Back to report index

mlsegmentation 5e76e63: AI3D-379 Align config module with fleet pattern

Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:40:19+02:00

Commit #74 ยท 271 snippets

 CLAUDE.md                              |   2 +-
 README.md                              |  21 +++++
 frameworks/emission_check.py           |   8 +-
 frameworks/run_provenance.py           |   4 +-
 frameworks/runner_options.py           |  11 +--
 scripts/evaluate.py                    |  13 ++-
 scripts/ingest_preannotations.py       |   4 +-
 scripts/prepare_dataset.py             |   4 +-
 scripts/train.py                       |   6 +-
 scripts/verify_adapter_roundtrip.py    |   4 +-
 scripts/voxel_oracle.py                |   7 +-
 src/train/__init__.py                  |   5 +-
 src/train/config.py                    |  71 ++++++++-------
 src/train/config_rules.py              |  62 ++++++-------
 src/train/config_schema.py             |  30 ++++---
 src/train/config_sections.py           |  58 ++++++++++---
 src/train/config_study.py              |  41 +++++----
 src/train/config_values.py             |  48 ++++++-----
 src/train/dispatch.py                  |   4 +-
 tests/test_a1_recap_wiring.py          |   4 +-
 tests/test_checkpoint_seam.py          |   4 +-
 tests/test_config.py                   | 153 ++++++++++++++++++---------------
 tests/test_dispatch_cells.py           |  19 ++--
 tests/test_dispatch_environment.py     |   6 +-
 tests/test_framework_run_provenance.py |   4 +-
 tests/test_study_expansion.py          |  38 ++++----
 tests/test_voxel_oracle.py             |  20 ++---
 27 files changed, 379 insertions(+), 272 deletions(-)
Importance #1: src/train/config_rules.py @@ -126,16 +126,16 @@
126 raw: Raw mapping the configuration was parsed from, used to check the126 raw: Raw mapping the configuration was parsed from, used to check the
127 declared study overrides against the document's own leaves.127 declared study overrides against the document's own leaves.
128128
129 Raises:129 Raises:
130 ConfigError: If a cross-section, identity, or ontology rule fails.130 ExperimentConfigError: If a cross-section, identity, or ontology rule fails.
131 """131 """
132 if config.schema_version != 1:132 if config.schema_version != 1:
133 raise config_values.ConfigError(133 raise config_values.ExperimentConfigError(
134 f"Unsupported schema_version {config.schema_version}"134 f"Unsupported schema_version {config.schema_version}"
135 )135 )
136 if not _EXPERIMENT_ID_PATTERN.fullmatch(config.experiment.id):136 if not _EXPERIMENT_ID_PATTERN.fullmatch(config.experiment.id):
137 raise config_values.ConfigError(137 raise config_values.ExperimentConfigError(
138 f"Invalid experiment.id {config.experiment.id!r}"138 f"Invalid experiment.id {config.experiment.id!r}"
139 )139 )
140 ontology = _load_task_ontology(config)140 ontology = _load_task_ontology(config)
141 _validate_task_against_ontology(config, ontology)141 _validate_task_against_ontology(config, ontology)
Importance #2: src/train/config_rules.py @@ -212,35 +212,35 @@
212 unknown = sorted(212 unknown = sorted(
213 set(gate.params.minimum_purity_by_class) - set(ontology.class_names)213 set(gate.params.minimum_purity_by_class) - set(ontology.class_names)
214 )214 )
215 if unknown:215 if unknown:
216 raise config_values.ConfigError(216 raise config_values.ExperimentConfigError(
217 f"gate {gate.name}.minimum_purity_by_class contains unknown "217 f"gate {gate.name}.minimum_purity_by_class contains unknown "
218 f"ontology classes {unknown} for ontology {ontology.name}"218 f"ontology classes {unknown} for ontology {ontology.name}"
219 )219 )
220 gate_types = {gate.type for gate in config.experiment.gates if gate.required}220 gate_types = {gate.type for gate in config.experiment.gates if gate.required}
221 if (221 if (
222 config.experiment.status == "template-only"222 config.experiment.status == "template-only"
223 and "implementation_ticket" not in gate_types223 and "implementation_ticket" not in gate_types
224 ):224 ):
225 raise config_values.ConfigError(225 raise config_values.ExperimentConfigError(
226 "template-only experiments require an implementation_ticket gate"226 "template-only experiments require an implementation_ticket gate"
227 )227 )
228 if config.experiment.status == "gated-later" and not gate_types:228 if config.experiment.status == "gated-later" and not gate_types:
229 raise config_values.ConfigError(229 raise config_values.ExperimentConfigError(
230 "gated-later experiments require at least one required gate"230 "gated-later experiments require at least one required gate"
231 )231 )
232 if config.experiment.status == "implement-now" and config.experiment.id not in {232 if config.experiment.status == "implement-now" and config.experiment.id not in {
233 "E1",233 "E1",
234 "E2",234 "E2",
235 }:235 }:
236 raise config_values.ConfigError(236 raise config_values.ExperimentConfigError(
237 "Only E1 and E2 are implement-now in schema version 1"237 "Only E1 and E2 are implement-now in schema version 1"
238 )238 )
239239
240240
241def _load_task_ontology(config: config_sections.HarnessConfig) -> Ontology:241def _load_task_ontology(config: config_sections.HarnessConfig) -> Ontology:
242 """Load the ontology the config declares, failing closed as a ConfigError.242 """Load the ontology the config declares, failing closed as a ExperimentConfigError.
243243
244 Args:244 Args:
245 config: Parsed configuration whose ``task.ontology`` path is resolved245 config: Parsed configuration whose ``task.ontology`` path is resolved
246 relative to the repository root.246 relative to the repository root.
Importance #3: src/train/config_sections.py @@ -154,21 +156,25 @@
154 @classmethod156 @classmethod
155 def _emit_is_supported(cls, value: tuple[str, ...]) -> tuple[str, ...]:157 def _emit_is_supported(cls, value: tuple[str, ...]) -> tuple[str, ...]:
156 """Reject unknown output formats and a missing canonical emission."""158 """Reject unknown output formats and a missing canonical emission."""
157 if set(value) - {"canonical", "spt", "pointcept"}:159 if set(value) - {"canonical", "spt", "pointcept"}:
158 raise config_values.ConfigError(160 raise config_values.ExperimentConfigError(
159 "adapter.emit contains an unsupported output format"161 "adapter.emit contains an unsupported output format"
160 )162 )
161 if "canonical" not in value:163 if "canonical" not in value:
162 raise config_values.ConfigError("adapter.emit must include canonical")164 raise config_values.ExperimentConfigError(
165 "adapter.emit must include canonical"
166 )
163 return value167 return value
164168
165 @pydantic.field_validator("canonical_version")169 @pydantic.field_validator("canonical_version")
166 @classmethod170 @classmethod
167 def _canonical_version_is_one(cls, value: int) -> int:171 def _canonical_version_is_one(cls, value: int) -> int:
168 """Freeze the canonical dataset version at 1."""172 """Freeze the canonical dataset version at 1."""
169 if value != 1:173 if value != 1:
170 raise config_values.ConfigError("adapter.canonical_version must be 1")174 raise config_values.ExperimentConfigError(
175 "adapter.canonical_version must be 1"
176 )
171 return value177 return value
172178
173179
174class ModelConfig(config_schema.StrictConfigModel):180class ModelConfig(config_schema.StrictConfigModel):
Importance #4: src/train/config.py @@ -1,16 +1,20 @@
1"""Strict experiment configuration schema for corridor segmentation studies.1"""Strict experiment configuration schema for corridor segmentation studies.
22
3The schema itself is a pydantic model tree built on3The schema is a pydantic model tree on
4:class:`iolabs.common.config_loader.ConfigModel`: :mod:`src.train.config_schema`4:class:`iolabs.common.config_loader.ConfigModel`, split by section because it
5holds the experiment/gate/study models, :mod:`src.train.config_sections` the5exceeds one module: :mod:`src.train.config_schema` holds the experiment, gate,
6data, model, training, and evaluation blocks. This module is the entry point6and study models, :mod:`src.train.config_sections` the data, model, training,
7every script imports: it loads one YAML document, validates it, applies the7and evaluation blocks, :mod:`src.train.config_values` the strict YAML leaf
8rules of :mod:`src.train.config_rules`, and expands a study into re-validated8typing, :mod:`src.train.config_rules` the cross-section rules, and
9cells with :mod:`src.train.config_study`.9:mod:`src.train.config_study` the study algebra. This module is the entry point
1010every script imports: :meth:`HarnessConfig.from_yaml` loads and validates one
11Adding a configuration key means adding a field to its model (and to the YAML11YAML document, :func:`expand_study` expands a study into re-validated cells,
12documents under ``configs/``); nothing else has to be touched.12and both raise :class:`ExperimentConfigError`.
13
14Adding a configuration key means adding the field to its model and the same key
15to the YAML documents under ``configs/`` -- nothing else. Unknown keys are
16rejected.
13"""17"""
1418
15from __future__ import annotations19from __future__ import annotations
1620
Importance #5: src/train/config.py @@ -85,9 +89,9 @@
85 TilingConfig,89 TilingConfig,
86 TrainConfig,90 TrainConfig,
87 VisualizationConfig,91 VisualizationConfig,
88)92)
89from src.train.config_values import ConfigError, OverrideValue, Scalar93from src.train.config_values import ExperimentConfigError, OverrideValue, Scalar
9094
91logger = logging.getLogger(__name__)95logger = logging.getLogger(__name__)
9296
93_CONTEXT = "experiment config"97_CONTEXT = "experiment config"
Importance #6: src/train/config.py @@ -98,17 +102,17 @@
98 "ArtifactExistsParams",102 "ArtifactExistsParams",
99 "BootstrapConfig",103 "BootstrapConfig",
100 "CheckpointPolicyGate",104 "CheckpointPolicyGate",
101 "CheckpointPolicyParams",105 "CheckpointPolicyParams",
102 "ConfigError",
103 "ContinuityConfig",106 "ContinuityConfig",
104 "ContrastConfig",107 "ContrastConfig",
105 "CorridorSelectionConfig",108 "CorridorSelectionConfig",
106 "DataAvailableGate",109 "DataAvailableGate",
107 "DataAvailableParams",110 "DataAvailableParams",
108 "DataConfig",111 "DataConfig",
109 "EvaluationConfig",112 "EvaluationConfig",
110 "ExperimentConfig",113 "ExperimentConfig",
114 "ExperimentConfigError",
111 "ExperimentStatus",115 "ExperimentStatus",
112 "FeatureConfig",116 "FeatureConfig",
113 "GateBase",117 "GateBase",
114 "GateConfig",118 "GateConfig",
Importance #7: src/train/config.py @@ -148,9 +152,8 @@
148 "VariantConfig",152 "VariantConfig",
149 "VisualizationConfig",153 "VisualizationConfig",
150 "expand_study",154 "expand_study",
151 "is_canonical_metric",155 "is_canonical_metric",
152 "load_config",
153 "resolve_ontology_path",156 "resolve_ontology_path",
154]157]
155158
156159
Importance #8: src/train/config.py @@ -174,30 +177,36 @@
174 document: str177 document: str
175 config: HarnessConfig178 config: HarnessConfig
176179
177180
178def load_config(path: str | Path) -> HarnessConfig:181def _load_yaml_document(path: str | Path) -> HarnessConfig:
179 """Load and strictly validate one E1--E14 experiment YAML.182 """Read, strictly validate, and rule-check one experiment YAML.
183
184 Implementation of :meth:`HarnessConfig.from_yaml`, which is the public
185 entry point; it lives here because the loading pipeline needs
186 :mod:`src.train.config_rules` and :mod:`src.train.config_study`.
180187
181 Args:188 Args:
182 path: Repository-relative or absolute YAML path.189 path: Repository-relative or absolute YAML path.
183190
184 Returns:191 Returns:
185 Fully typed immutable configuration.192 Fully typed immutable configuration.
186193
187 Raises:194 Raises:
188 ConfigError: If loading, strict parsing, or validation fails.195 ExperimentConfigError: If loading, strict parsing, or validation fails.
189 """196 """
190 config_path = Path(path)197 config_path = Path(path)
191 try:198 try:
192 payload = config_path.read_bytes()199 payload = config_path.read_bytes()
193 raw = yaml.safe_load(payload.decode("utf-8"))200 raw = yaml.safe_load(payload.decode("utf-8"))
194 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:201 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
195 raise ConfigError(f"Cannot load config {config_path}: {exc}") from exc202 raise ExperimentConfigError(
203 f"Cannot load config {config_path}: {exc}"
204 ) from exc
196 try:205 try:
197 return _build_config(raw, config_path, hashlib.sha256(payload).hexdigest())206 return _build_config(raw, config_path, hashlib.sha256(payload).hexdigest())
198 except ConfigError as exc:207 except ExperimentConfigError as exc:
199 raise ConfigError(f"{config_path}: {exc}") from exc208 raise ExperimentConfigError(f"{config_path}: {exc}") from exc
200209
201210
202def expand_study(config: HarnessConfig) -> tuple[StudyCell, ...]:211def expand_study(config: HarnessConfig) -> tuple[StudyCell, ...]:
203 """Expand a typed study definition into ordered, re-validated cells.212 """Expand a typed study definition into ordered, re-validated cells.
Importance #9: src/train/config.py @@ -206,31 +215,31 @@
206 per declared variant, ``matrix`` the deterministic cross product of its215 per declared variant, ``matrix`` the deterministic cross product of its
207 axes after ``include``/``exclude``, and ``sweep`` the deterministic216 axes after ``include``/``exclude``, and ``sweep`` the deterministic
208 enumeration of its typed parameters under the study ``seed``. Every cell's217 enumeration of its typed parameters under the study ``seed``. Every cell's
209 overrides are re-applied to the raw mapping and re-validated through218 overrides are re-applied to the raw mapping and re-validated through
210 :func:`load_config`'s machinery, so each cell carries its own SHA-256.219 :meth:`HarnessConfig.from_yaml`'s machinery, so each cell carries its own SHA-256.
211220
212 Args:221 Args:
213 config: Strictly parsed experiment configuration.222 config: Strictly parsed experiment configuration.
214223
215 Returns:224 Returns:
216 Deterministically ordered study cells, never empty.225 Deterministically ordered study cells, never empty.
217226
218 Raises:227 Raises:
219 ConfigError: If the study payload, an override, a cell identity, or a228 ExperimentConfigError: If the study payload, an override, a cell identity, or a
220 resolved cell configuration violates the strict schema.229 resolved cell configuration violates the strict schema.
221 """230 """
222 raw = _raw_document(config)231 raw = _raw_document(config)
223 definitions = config_study.cell_definitions(config)232 definitions = config_study.cell_definitions(config)
224 if not definitions:233 if not definitions:
225 raise ConfigError(234 raise ExperimentConfigError(
226 f"{config.experiment.id} study kind {config.study.kind} expanded to "235 f"{config.experiment.id} study kind {config.study.kind} expanded to "
227 "no cells"236 "no cells"
228 )237 )
229 identities = [cell_id for cell_id, _ in definitions]238 identities = [cell_id for cell_id, _ in definitions]
230 duplicates = sorted({item for item in identities if identities.count(item) > 1})239 duplicates = sorted({item for item in identities if identities.count(item) > 1})
231 if duplicates:240 if duplicates:
232 raise ConfigError(241 raise ExperimentConfigError(
233 f"{config.experiment.id} study has duplicate cell IDs {duplicates}"242 f"{config.experiment.id} study has duplicate cell IDs {duplicates}"
234 )243 )
235 cells: list[StudyCell] = []244 cells: list[StudyCell] = []
236 for index, (cell_id, overrides) in enumerate(definitions):245 for index, (cell_id, overrides) in enumerate(definitions):
Importance #10: src/train/config.py @@ -239,10 +248,12 @@
239 document = yaml.safe_dump(cell_raw, sort_keys=True, default_flow_style=False)248 document = yaml.safe_dump(cell_raw, sort_keys=True, default_flow_style=False)
240 digest = hashlib.sha256(document.encode("utf-8")).hexdigest()249 digest = hashlib.sha256(document.encode("utf-8")).hexdigest()
241 try:250 try:
242 cell_config = _build_config(cell_raw, config.source_path, digest)251 cell_config = _build_config(cell_raw, config.source_path, digest)
243 except ConfigError as exc:252 except ExperimentConfigError as exc:
244 raise ConfigError(f"{config.source_path} cell {cell_id!r}: {exc}") from exc253 raise ExperimentConfigError(
254 f"{config.source_path} cell {cell_id!r}: {exc}"
255 ) from exc
245 run_name = (256 run_name = (
246 config.experiment.id257 config.experiment.id
247 if config.study.kind == "single"258 if config.study.kind == "single"
248 else f"{config.experiment.id}_{cell_id}"259 else f"{config.experiment.id}_{cell_id}"
Importance #11: src/train/config.py @@ -263,9 +274,9 @@
263def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:274def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:
264 """Validate one raw document into a configuration carrying its digest."""275 """Validate one raw document into a configuration carrying its digest."""
265 root = config_values.mapping(raw, str(config_path))276 root = config_values.mapping(raw, str(config_path))
266 config = config_loader.validate_config(277 config = config_loader.validate_config(
267 HarnessConfig, dict(root), context=_CONTEXT, error_cls=ConfigError278 HarnessConfig, dict(root), context=_CONTEXT, error_cls=ExperimentConfigError
268 )279 )
269 config.attach_provenance(config_path, sha256, copy.deepcopy(dict(root)))280 config.attach_provenance(config_path, sha256, copy.deepcopy(dict(root)))
270 config_rules.validate_config(config, root)281 config_rules.validate_config(config, root)
271 return config282 return config
Importance #12: src/train/config.py @@ -274,24 +285,24 @@
274def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:285def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:
275 """Return the raw mapping a configuration was validated from.286 """Return the raw mapping a configuration was validated from.
276287
277 Args:288 Args:
278 config: Configuration produced by :func:`load_config` or289 config: Configuration produced by :meth:`HarnessConfig.from_yaml`
279 :func:`expand_study`.290 or :func:`expand_study`.
280291
281 Returns:292 Returns:
282 The stashed raw mapping, or a fresh parse of ``config.source_path``.293 The stashed raw mapping, or a fresh parse of ``config.source_path``.
283294
284 Raises:295 Raises:
285 ConfigError: If the source file must be re-read and cannot be parsed.296 ExperimentConfigError: If the source file must be re-read and cannot be parsed.
286 """297 """
287 stashed = config._raw_document298 stashed = config._raw_document
288 if stashed is not None:299 if stashed is not None:
289 return stashed300 return stashed
290 try:301 try:
291 payload = config.source_path.read_bytes()302 payload = config.source_path.read_bytes()
292 raw = yaml.safe_load(payload.decode("utf-8"))303 raw = yaml.safe_load(payload.decode("utf-8"))
293 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:304 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
294 raise ConfigError(305 raise ExperimentConfigError(
295 f"Cannot re-read config {config.source_path}: {exc}"306 f"Cannot re-read config {config.source_path}: {exc}"
296 ) from exc307 ) from exc
297 return config_values.mapping(raw, str(config.source_path))308 return config_values.mapping(raw, str(config.source_path))
Importance #13: src/train/config_rules.py @@ -88,14 +88,14 @@
88 Returns:88 Returns:
89 An absolute, existing ontology path.89 An absolute, existing ontology path.
9090
91 Raises:91 Raises:
92 ConfigError: If no candidate path exists.92 ExperimentConfigError: If no candidate path exists.
93 """93 """
94 declared = config.task.ontology94 declared = config.task.ontology
95 if declared.is_absolute():95 if declared.is_absolute():
96 if not declared.is_file():96 if not declared.is_file():
97 raise config_values.ConfigError(97 raise config_values.ExperimentConfigError(
98 f"{config.source_path}: task.ontology {declared.as_posix()} "98 f"{config.source_path}: task.ontology {declared.as_posix()} "
99 f"does not exist"99 f"does not exist"
100 )100 )
101 return declared101 return declared
Importance #14: src/train/config_rules.py @@ -108,9 +108,9 @@
108 return candidate.resolve()108 return candidate.resolve()
109 searched = ", ".join(109 searched = ", ".join(
110 sorted({candidate.parent.as_posix() for candidate in candidates})110 sorted({candidate.parent.as_posix() for candidate in candidates})
111 )111 )
112 raise config_values.ConfigError(112 raise config_values.ExperimentConfigError(
113 f"{config.source_path}: task.ontology {declared.as_posix()} was not "113 f"{config.source_path}: task.ontology {declared.as_posix()} was not "
114 f"found relative to any parent of the config or to the working "114 f"found relative to any parent of the config or to the working "
115 f"directory {Path.cwd().as_posix()}; searched {searched}"115 f"directory {Path.cwd().as_posix()}; searched {searched}"
116 )116 )
Importance #15: src/train/config_rules.py @@ -149,18 +149,18 @@
149 """Check the rules that tie a section to the declared model framework."""149 """Check the rules that tie a section to the declared model framework."""
150 if config.model.framework in {"spt", "pointcept"} and (150 if config.model.framework in {"spt", "pointcept"} and (
151 _VIZ_TRAIN_KEYS & config.train.model_fields_set151 _VIZ_TRAIN_KEYS & config.train.model_fields_set
152 ):152 ):
153 raise config_values.ConfigError(153 raise config_values.ExperimentConfigError(
154 "train.viz_every_n_epochs and train.viz_samples are forbidden for "154 "train.viz_every_n_epochs and train.viz_samples are forbidden for "
155 "external frameworks"155 "external frameworks"
156 )156 )
157 if config.model.framework == "pointcept" and config.runtime.flash_attention:157 if config.model.framework == "pointcept" and config.runtime.flash_attention:
158 raise config_values.ConfigError(158 raise config_values.ExperimentConfigError(
159 "Pointcept PTv3/LitePT configurations must keep FlashAttention disabled"159 "Pointcept PTv3/LitePT configurations must keep FlashAttention disabled"
160 )160 )
161 if config.visualization is not None and config.model.framework != "pointcept":161 if config.visualization is not None and config.model.framework != "pointcept":
162 raise config_values.ConfigError(162 raise config_values.ExperimentConfigError(
163 "visualization is only supported for model.framework pointcept; "163 "visualization is only supported for model.framework pointcept; "
164 f"{config.model.framework} configs must omit the block"164 f"{config.model.framework} configs must omit the block"
165 )165 )
166166
Importance #16: src/train/config_rules.py @@ -170,36 +170,36 @@
170) -> None:170) -> None:
171 """Check every declared metric tag against the ontology's namespace."""171 """Check every declared metric tag against the ontology's namespace."""
172 for metric in config.experiment.deciding_metrics:172 for metric in config.experiment.deciding_metrics:
173 if not is_canonical_metric(metric, ontology=ontology):173 if not is_canonical_metric(metric, ontology=ontology):
174 raise config_values.ConfigError(174 raise config_values.ExperimentConfigError(
175 "experiment.deciding_metrics contains non-canonical metric "175 "experiment.deciding_metrics contains non-canonical metric "
176 f"{metric!r} for ontology {ontology.name}"176 f"{metric!r} for ontology {ontology.name}"
177 )177 )
178 for metric in (config.train.monitor, config.train.early_stop_monitor):178 for metric in (config.train.monitor, config.train.early_stop_monitor):
179 if not is_canonical_metric(metric, ontology=ontology):179 if not is_canonical_metric(metric, ontology=ontology):
180 raise config_values.ConfigError(180 raise config_values.ExperimentConfigError(
181 f"train monitor {metric!r} is outside the canonical namespace "181 f"train monitor {metric!r} is outside the canonical namespace "
182 f"of ontology {ontology.name}"182 f"of ontology {ontology.name}"
183 )183 )
184 unknown_floor_classes = sorted(184 unknown_floor_classes = sorted(
185 set(config.evaluation.precision_floors) - set(ontology.class_names)185 set(config.evaluation.precision_floors) - set(ontology.class_names)
186 )186 )
187 if unknown_floor_classes:187 if unknown_floor_classes:
188 raise config_values.ConfigError(188 raise config_values.ExperimentConfigError(
189 "evaluation.precision_floors has unknown classes "189 "evaluation.precision_floors has unknown classes "
190 f"{unknown_floor_classes} for ontology {ontology.name}"190 f"{unknown_floor_classes} for ontology {ontology.name}"
191 )191 )
192 for contrast in config.study.contrasts:192 for contrast in config.study.contrasts:
193 if not is_canonical_metric(contrast.metric, ontology=ontology):193 if not is_canonical_metric(contrast.metric, ontology=ontology):
194 raise config_values.ConfigError(194 raise config_values.ExperimentConfigError(
195 f"study contrast metric {contrast.metric!r} is not canonical "195 f"study contrast metric {contrast.metric!r} is not canonical "
196 f"for ontology {ontology.name}"196 f"for ontology {ontology.name}"
197 )197 )
198 if config.study.sweep is not None and not is_canonical_metric(198 if config.study.sweep is not None and not is_canonical_metric(
199 config.study.sweep.objective, ontology=ontology199 config.study.sweep.objective, ontology=ontology
200 ):200 ):
201 raise config_values.ConfigError(201 raise config_values.ExperimentConfigError(
202 f"study.sweep.objective must be canonical for ontology {ontology.name}"202 f"study.sweep.objective must be canonical for ontology {ontology.name}"
203 )203 )
204204
205205
Importance #17: src/train/config_rules.py @@ -248,15 +248,15 @@
248 Returns:248 Returns:
249 The validated ontology every other contract is checked against.249 The validated ontology every other contract is checked against.
250250
251 Raises:251 Raises:
252 ConfigError: If the ontology cannot be located, loaded, or is invalid.252 ExperimentConfigError: If the ontology cannot be located, loaded, or is invalid.
253 """253 """
254 resolved = resolve_ontology_path(config)254 resolved = resolve_ontology_path(config)
255 try:255 try:
256 return load_ontology(resolved)256 return load_ontology(resolved)
257 except OntologyError as exc:257 except OntologyError as exc:
258 raise config_values.ConfigError(258 raise config_values.ExperimentConfigError(
259 f"task.ontology {config.task.ontology.as_posix()} is not a valid "259 f"task.ontology {config.task.ontology.as_posix()} is not a valid "
260 f"ontology: {exc}"260 f"ontology: {exc}"
261 ) from exc261 ) from exc
262262
Importance #18: src/train/config_rules.py @@ -270,25 +270,25 @@
270 config: Parsed configuration.270 config: Parsed configuration.
271 ontology: Ontology loaded from ``task.ontology``.271 ontology: Ontology loaded from ``task.ontology``.
272272
273 Raises:273 Raises:
274 ConfigError: If any task, evaluation, model, or loss class contract274 ExperimentConfigError: If any task, evaluation, model, or loss class contract
275 disagrees with the loaded ontology.275 disagrees with the loaded ontology.
276 """276 """
277 task = config.task277 task = config.task
278 if task.num_classes != ontology.num_predicted_classes:278 if task.num_classes != ontology.num_predicted_classes:
279 raise config_values.ConfigError(279 raise config_values.ExperimentConfigError(
280 f"task.num_classes {task.num_classes} must equal ontology "280 f"task.num_classes {task.num_classes} must equal ontology "
281 f"{ontology.name} num_predicted_classes "281 f"{ontology.name} num_predicted_classes "
282 f"{ontology.num_predicted_classes}"282 f"{ontology.num_predicted_classes}"
283 )283 )
284 if task.ignore_index != ontology.void_id:284 if task.ignore_index != ontology.void_id:
285 raise config_values.ConfigError(285 raise config_values.ExperimentConfigError(
286 f"task.ignore_index {task.ignore_index} must equal ontology "286 f"task.ignore_index {task.ignore_index} must equal ontology "
287 f"{ontology.name} void ID {ontology.void_id}"287 f"{ontology.name} void ID {ontology.void_id}"
288 )288 )
289 if task.classes_of_interest != ontology.interest_ids:289 if task.classes_of_interest != ontology.interest_ids:
290 raise config_values.ConfigError(290 raise config_values.ExperimentConfigError(
291 f"task.classes_of_interest {list(task.classes_of_interest)} must "291 f"task.classes_of_interest {list(task.classes_of_interest)} must "
292 f"equal ontology {ontology.name} interest IDs "292 f"equal ontology {ontology.name} interest IDs "
293 f"{list(ontology.interest_ids)}"293 f"{list(ontology.interest_ids)}"
294 )294 )
Importance #19: src/train/config_rules.py @@ -297,31 +297,31 @@
297 )297 )
298 if len(set(task.linear_classes)) != len(task.linear_classes) or set(298 if len(set(task.linear_classes)) != len(task.linear_classes) or set(
299 task.linear_classes299 task.linear_classes
300 ) != set(linear_names):300 ) != set(linear_names):
301 raise config_values.ConfigError(301 raise config_values.ExperimentConfigError(
302 f"task.linear_classes {list(task.linear_classes)} must be exactly "302 f"task.linear_classes {list(task.linear_classes)} must be exactly "
303 f"the linear classes {list(linear_names)} of ontology "303 f"the linear classes {list(linear_names)} of ontology "
304 f"{ontology.name}"304 f"{ontology.name}"
305 )305 )
306 profiles = config.evaluation.object_matching.cluster_profiles306 profiles = config.evaluation.object_matching.cluster_profiles
307 if profiles != task.ontology:307 if profiles != task.ontology:
308 raise config_values.ConfigError(308 raise config_values.ExperimentConfigError(
309 "evaluation.object_matching.cluster_profiles "309 "evaluation.object_matching.cluster_profiles "
310 f"{profiles.as_posix()} must be the task ontology "310 f"{profiles.as_posix()} must be the task ontology "
311 f"{task.ontology.as_posix()}"311 f"{task.ontology.as_posix()}"
312 )312 )
313 if "num_classes" in config.model.args:313 if "num_classes" in config.model.args:
314 declared = config.model.args["num_classes"]314 declared = config.model.args["num_classes"]
315 if declared != task.num_classes:315 if declared != task.num_classes:
316 raise config_values.ConfigError(316 raise config_values.ExperimentConfigError(
317 f"model.args.num_classes {declared!r} must equal "317 f"model.args.num_classes {declared!r} must equal "
318 f"task.num_classes {task.num_classes}"318 f"task.num_classes {task.num_classes}"
319 )319 )
320 if "ignore_index" in config.loss.args:320 if "ignore_index" in config.loss.args:
321 declared = config.loss.args["ignore_index"]321 declared = config.loss.args["ignore_index"]
322 if declared != task.ignore_index:322 if declared != task.ignore_index:
323 raise config_values.ConfigError(323 raise config_values.ExperimentConfigError(
324 f"loss.args.ignore_index {declared!r} must equal "324 f"loss.args.ignore_index {declared!r} must equal "
325 f"task.ignore_index {task.ignore_index}"325 f"task.ignore_index {task.ignore_index}"
326 )326 )
327327
Importance #20: src/train/config_rules.py @@ -352,13 +352,13 @@
352 )352 )
353 for overrides in override_groups:353 for overrides in override_groups:
354 for path, value in overrides.items():354 for path, value in overrides.items():
355 if path.startswith("study.") or path not in leaves:355 if path.startswith("study.") or path not in leaves:
356 raise config_values.ConfigError(356 raise config_values.ExperimentConfigError(
357 f"Study override path {path!r} is not a declared scalar/list leaf"357 f"Study override path {path!r} is not a declared scalar/list leaf"
358 )358 )
359 expected = leaves[path]359 expected = leaves[path]
360 if not config_values.same_leaf_type(expected, value):360 if not config_values.same_leaf_type(expected, value):
361 raise config_values.ConfigError(361 raise config_values.ExperimentConfigError(
362 f"Study override {path!r} has incompatible value {value!r}; "362 f"Study override {path!r} has incompatible value {value!r}; "
363 f"expected type of {expected!r}"363 f"expected type of {expected!r}"
364 )364 )
Importance #21: src/train/config_schema.py @@ -250,9 +250,9 @@
250 ) -> Mapping[str, tuple[config_values.OverrideValue, ...]]:250 ) -> Mapping[str, tuple[config_values.OverrideValue, ...]]:
251 """Reject an axis that declares no value."""251 """Reject an axis that declares no value."""
252 for path, values in value.items():252 for path, values in value.items():
253 if not values:253 if not values:
254 raise config_values.ConfigError(254 raise config_values.ExperimentConfigError(
255 f"study.matrix.axes.{path} cannot be empty"255 f"study.matrix.axes.{path} cannot be empty"
256 )256 )
257 return value257 return value
258258
Importance #22: src/train/config_schema.py @@ -268,21 +268,23 @@
268 @pydantic.model_validator(mode="after")268 @pydantic.model_validator(mode="after")
269 def _bounds_are_complete(self) -> SweepParameterConfig:269 def _bounds_are_complete(self) -> SweepParameterConfig:
270 """Reject a parameter that is neither finite nor fully bounded."""270 """Reject a parameter that is neither finite nor fully bounded."""
271 if self.values is not None and not self.values:271 if self.values is not None and not self.values:
272 raise config_values.ConfigError("values cannot be empty")272 raise config_values.ExperimentConfigError("values cannot be empty")
273 if self.values is None and (273 if self.values is None and (
274 self.minimum is None or self.maximum is None or self.distribution is None274 self.minimum is None or self.maximum is None or self.distribution is None
275 ):275 ):
276 raise config_values.ConfigError(276 raise config_values.ExperimentConfigError(
277 "requires values or minimum/maximum/distribution"277 "requires values or minimum/maximum/distribution"
278 )278 )
279 if (279 if (
280 self.minimum is not None280 self.minimum is not None
281 and self.maximum is not None281 and self.maximum is not None
282 and self.minimum >= self.maximum282 and self.minimum >= self.maximum
283 ):283 ):
284 raise config_values.ConfigError("minimum must be smaller than maximum")284 raise config_values.ExperimentConfigError(
285 "minimum must be smaller than maximum"
286 )
285 return self287 return self
286288
287289
288class SweepConfig(StrictConfigModel):290class SweepConfig(StrictConfigModel):
Importance #23: src/train/config_schema.py @@ -299,9 +301,11 @@
299 cls, value: Mapping[str, SweepParameterConfig]301 cls, value: Mapping[str, SweepParameterConfig]
300 ) -> Mapping[str, SweepParameterConfig]:302 ) -> Mapping[str, SweepParameterConfig]:
301 """Reject a sweep that declares no parameter."""303 """Reject a sweep that declares no parameter."""
302 if not value:304 if not value:
303 raise config_values.ConfigError("study.sweep.parameters cannot be empty")305 raise config_values.ExperimentConfigError(
306 "study.sweep.parameters cannot be empty"
307 )
304 return value308 return value
305309
306310
307class ContrastConfig(StrictConfigModel):311class ContrastConfig(StrictConfigModel):
Importance #24: src/train/config_schema.py @@ -327,25 +331,31 @@
327 """Reject a study whose kind and payload disagree."""331 """Reject a study whose kind and payload disagree."""
328 if self.kind == "single" and (332 if self.kind == "single" and (
329 self.variants or self.matrix is not None or self.sweep is not None333 self.variants or self.matrix is not None or self.sweep is not None
330 ):334 ):
331 raise config_values.ConfigError(335 raise config_values.ExperimentConfigError(
332 "study.kind single cannot carry variants, matrix, or sweep"336 "study.kind single cannot carry variants, matrix, or sweep"
333 )337 )
334 if self.kind == "variants" and (338 if self.kind == "variants" and (
335 not self.variants or self.matrix is not None or self.sweep is not None339 not self.variants or self.matrix is not None or self.sweep is not None
336 ):340 ):
337 raise config_values.ConfigError(341 raise config_values.ExperimentConfigError(
338 "study.kind variants requires only a non-empty variants payload"342 "study.kind variants requires only a non-empty variants payload"
339 )343 )
340 if self.kind == "matrix" and (344 if self.kind == "matrix" and (
341 self.variants or self.matrix is None or self.sweep is not None345 self.variants or self.matrix is None or self.sweep is not None
342 ):346 ):
343 raise config_values.ConfigError("study.kind matrix requires only matrix")347 raise config_values.ExperimentConfigError(
348 "study.kind matrix requires only matrix"
349 )
344 if self.kind == "sweep" and (350 if self.kind == "sweep" and (
345 self.variants or self.matrix is not None or self.sweep is None351 self.variants or self.matrix is not None or self.sweep is None
346 ):352 ):
347 raise config_values.ConfigError("study.kind sweep requires only sweep")353 raise config_values.ExperimentConfigError(
354 "study.kind sweep requires only sweep"
355 )
348 identities = [item.id for item in self.variants]356 identities = [item.id for item in self.variants]
349 if len(set(identities)) != len(identities):357 if len(set(identities)) != len(identities):
350 raise config_values.ConfigError("study.variants contains duplicate IDs")358 raise config_values.ExperimentConfigError(
359 "study.variants contains duplicate IDs"
360 )
351 return self361 return self
Importance #25: src/train/config_sections.py @@ -65,13 +65,13 @@
65 @pydantic.model_validator(mode="after")65 @pydantic.model_validator(mode="after")
66 def _fuse_config_matches_mode(self) -> LabelSourceConfig:66 def _fuse_config_matches_mode(self) -> LabelSourceConfig:
67 """Tie the fusion config to the declared label mode."""67 """Tie the fusion config to the declared label mode."""
68 if self.mode == "regenerate_full_resolution" and self.fuse_config is None:68 if self.mode == "regenerate_full_resolution" and self.fuse_config is None:
69 raise config_values.ConfigError(69 raise config_values.ExperimentConfigError(
70 "regenerate_full_resolution requires data.label_source.fuse_config"70 "regenerate_full_resolution requires data.label_source.fuse_config"
71 )71 )
72 if self.mode == "artifact" and self.fuse_config is not None:72 if self.mode == "artifact" and self.fuse_config is not None:
73 raise config_values.ConfigError(73 raise config_values.ExperimentConfigError(
74 "artifact label mode requires data.label_source.fuse_config: null"74 "artifact label mode requires data.label_source.fuse_config: null"
75 )75 )
76 return self76 return self
7777
Importance #26: src/train/config_sections.py @@ -104,9 +104,11 @@
104 @pydantic.model_validator(mode="after")104 @pydantic.model_validator(mode="after")
105 def _overlap_fits_in_a_tile(self) -> TilingConfig:105 def _overlap_fits_in_a_tile(self) -> TilingConfig:
106 """Reject an overlap that is not shorter than the tile."""106 """Reject an overlap that is not shorter than the tile."""
107 if self.overlap_m >= self.length_m:107 if self.overlap_m >= self.length_m:
108 raise config_values.ConfigError("tiling requires 0 <= overlap_m < length_m")108 raise config_values.ExperimentConfigError(
109 "tiling requires 0 <= overlap_m < length_m"
110 )
109 return self111 return self
110112
111113
112class DataConfig(config_schema.StrictConfigModel):114class DataConfig(config_schema.StrictConfigModel):
Importance #27: src/train/config_sections.py @@ -204,13 +210,13 @@
204 def _external_models_pin_their_checkout(self) -> ModelConfig:210 def _external_models_pin_their_checkout(self) -> ModelConfig:
205 """Tie the external checkout variables to the declared framework."""211 """Tie the external checkout variables to the declared framework."""
206 if self.framework == "cpu":212 if self.framework == "cpu":
207 if self.checkout_env is not None or self.commit_env is not None:213 if self.checkout_env is not None or self.commit_env is not None:
208 raise config_values.ConfigError(214 raise config_values.ExperimentConfigError(
209 "CPU experiments cannot declare external checkout variables"215 "CPU experiments cannot declare external checkout variables"
210 )216 )
211 elif not (self.base_config and self.checkout_env and self.commit_env):217 elif not (self.base_config and self.checkout_env and self.commit_env):
212 raise config_values.ConfigError(218 raise config_values.ExperimentConfigError(
213 "External models require base_config, checkout_env, and commit_env"219 "External models require base_config, checkout_env, and commit_env"
214 )220 )
215 return self221 return self
216222
Importance #28: src/train/config_sections.py @@ -300,9 +306,9 @@
300 self.delta_quality is None306 self.delta_quality is None
301 or self.delta_fp_per_km is None307 or self.delta_fp_per_km is None
302 or not self.superiority_conditions308 or not self.superiority_conditions
303 ):309 ):
304 raise config_values.ConfigError(310 raise config_values.ExperimentConfigError(
305 "enabled promotion requires non-null margins and superiority "311 "enabled promotion requires non-null margins and superiority "
306 "conditions"312 "conditions"
307 )313 )
308 return self314 return self
Importance #29: src/train/config_sections.py @@ -325,18 +331,18 @@
325 def _floors_are_fractions(cls, value: Mapping[str, float]) -> Mapping[str, float]:331 def _floors_are_fractions(cls, value: Mapping[str, float]) -> Mapping[str, float]:
326 """Reject a precision floor outside the unit interval."""332 """Reject a precision floor outside the unit interval."""
327 for name, floor in value.items():333 for name, floor in value.items():
328 if not 0.0 <= floor <= 1.0:334 if not 0.0 <= floor <= 1.0:
329 raise config_values.ConfigError(335 raise config_values.ExperimentConfigError(
330 f"evaluation precision floor for {name} must be in [0, 1]"336 f"evaluation precision floor for {name} must be in [0, 1]"
331 )337 )
332 return value338 return value
333339
334 @pydantic.model_validator(mode="after")340 @pydantic.model_validator(mode="after")
335 def _promotion_test_is_promotable(self) -> EvaluationConfig:341 def _promotion_test_is_promotable(self) -> EvaluationConfig:
336 """Reject a locked-test split whose promotion protocol is disabled."""342 """Reject a locked-test split whose promotion protocol is disabled."""
337 if self.split == "promotion_test" and not self.promotion.enabled:343 if self.split == "promotion_test" and not self.promotion.enabled:
338 raise config_values.ConfigError(344 raise config_values.ExperimentConfigError(
339 "promotion_test evaluation requires evaluation.promotion.enabled"345 "promotion_test evaluation requires evaluation.promotion.enabled"
340 )346 )
341 return self347 return self
342348
Importance #30: src/train/config_sections.py @@ -376,13 +382,15 @@
376 def _tiles_select_something(self) -> VisualizationConfig:382 def _tiles_select_something(self) -> VisualizationConfig:
377 """Reject an empty or non-positive tile selection."""383 """Reject an empty or non-positive tile selection."""
378 if isinstance(self.masks_tiles, int):384 if isinstance(self.masks_tiles, int):
379 if self.masks_tiles < 1:385 if self.masks_tiles < 1:
380 raise config_values.ConfigError(386 raise config_values.ExperimentConfigError(
381 "visualization.masks_tiles must be >= 1"387 "visualization.masks_tiles must be >= 1"
382 )388 )
383 elif not self.masks_tiles:389 elif not self.masks_tiles:
384 raise config_values.ConfigError("visualization.masks_tiles cannot be empty")390 raise config_values.ExperimentConfigError(
391 "visualization.masks_tiles cannot be empty"
392 )
385 return self393 return self
386394
387395
388class HarnessConfig(config_schema.StrictConfigModel):396class HarnessConfig(config_schema.StrictConfigModel):
Importance #31: src/train/config_sections.py @@ -414,8 +422,32 @@
414 if isinstance(data, Mapping) and data.get("visualization", False) is None:422 if isinstance(data, Mapping) and data.get("visualization", False) is None:
415 raise ValueError("visualization must be a mapping")423 raise ValueError("visualization must be a mapping")
416 return data424 return data
417425
426 @classmethod
427 def from_yaml(cls, path: str | Path) -> HarnessConfig:
428 """Load and strictly validate one E1--E14 experiment YAML.
429
430 This is the harness entry point every script and test uses; the
431 document read, strict validation, and the cross-section rules of
432 :mod:`src.train.config_rules` all run here.
433
434 Args:
435 path: Repository-relative or absolute YAML path.
436
437 Returns:
438 Fully typed immutable configuration carrying its own SHA-256.
439
440 Raises:
441 ExperimentConfigError: If loading, strict parsing, or validation
442 fails.
443 """
444 # Deferred: the loading pipeline lives in the entry-point module, which
445 # imports this one for its models.
446 from src.train import config
447
448 return config._load_yaml_document(path)
449
418 @property450 @property
419 def source_path(self) -> Path:451 def source_path(self) -> Path:
420 """Path of the YAML document this configuration was parsed from."""452 """Path of the YAML document this configuration was parsed from."""
421 return self._source_path453 return self._source_path
Importance #32: src/train/config_values.py @@ -1,12 +1,14 @@
1"""Strict YAML value typing shared by the experiment configuration models.1"""Strict YAML value typing shared by the experiment configuration models.
22
3The experiment YAML is a fail-closed contract: a value must already carry its3The experiment YAML is a fail-closed contract: a value must already carry its
4declared type, so ``"50"`` is not a float, ``1`` is not a boolean, and ``3.0``4declared type, so ``"50"`` is not a float, ``1`` is not a boolean, and ``3.0``
5is not an integer. That is deliberately stricter than the fleet coercion5is not an integer. That is deliberately stricter than the fleet coercion matrix
6matrix of :func:`iolabs.common.config_loader.coerce_config_value`, so the6of :func:`iolabs.common.config_loader.coerce_to_field_type`, and it is the one
7models in :mod:`src.train.config_schema` route every field through7sanctioned opt-out from it: the models in :mod:`src.train.config_schema` route
8:func:`typed_value` instead of the inherited coercion.8every field through :func:`typed_value` instead of the inherited coercion. Do
9not copy this into a packaged pipeline config -- it holds only for this
10YAML experiment contract.
9"""11"""
1012
11from __future__ import annotations13from __future__ import annotations
1214
Importance #33: src/train/config_values.py @@ -23,10 +25,10 @@
23Scalar: TypeAlias = str | int | float | bool | None25Scalar: TypeAlias = str | int | float | bool | None
24OverrideValue: TypeAlias = Scalar | list[Scalar]26OverrideValue: TypeAlias = Scalar | list[Scalar]
2527
2628
27class ConfigError(config_loader.ConfigError):29class ExperimentConfigError(config_loader.ConfigError):
28 """Raised when an experiment configuration violates its strict schema."""30 """Raised when experiment config contains unsupported keys or values."""
2931
3032
31def freeze_value(value: Any) -> Any:33def freeze_value(value: Any) -> Any:
32 """Return *value* with every nested mapping wrapped read-only.34 """Return *value* with every nested mapping wrapped read-only.
Importance #34: src/train/config_values.py @@ -89,9 +91,9 @@
89 Returns:91 Returns:
90 The value converted to the declared type.92 The value converted to the declared type.
9193
92 Raises:94 Raises:
93 ConfigError: If the value does not match the declared type.95 ExperimentConfigError: If the value does not match the declared type.
94 """96 """
95 if annotation is None or annotation is Any:97 if annotation is None or annotation is Any:
96 return value98 return value
97 origin = get_origin(annotation)99 origin = get_origin(annotation)
Importance #35: src/train/config_values.py @@ -135,81 +137,85 @@
135 optional = type(None) in members137 optional = type(None) in members
136 if value is None:138 if value is None:
137 if optional:139 if optional:
138 return None140 return None
139 raise ConfigError(f"{where} must not be null")141 raise ExperimentConfigError(f"{where} must not be null")
140 candidates = [item for item in members if item is not type(None)]142 candidates = [item for item in members if item is not type(None)]
141 if len(candidates) == 1:143 if len(candidates) == 1:
142 return typed_value(candidates[0], value, where)144 return typed_value(candidates[0], value, where)
143 for candidate in candidates:145 for candidate in candidates:
144 try:146 try:
145 return typed_value(candidate, value, where)147 return typed_value(candidate, value, where)
146 except ConfigError:148 except ExperimentConfigError:
147 continue149 continue
148 names = sorted(getattr(item, "__name__", str(item)) for item in candidates)150 names = sorted(getattr(item, "__name__", str(item)) for item in candidates)
149 raise ConfigError(151 raise ExperimentConfigError(
150 f"{where} must be one of {names}, got {type(value).__name__} {value!r}"152 f"{where} must be one of {names}, got {type(value).__name__} {value!r}"
151 )153 )
152154
153155
154def mapping(value: Any, where: str) -> Mapping[str, Any]:156def mapping(value: Any, where: str) -> Mapping[str, Any]:
155 """Return *value* as a string-keyed mapping or raise."""157 """Return *value* as a string-keyed mapping or raise."""
156 if not isinstance(value, Mapping):158 if not isinstance(value, Mapping):
157 raise ConfigError(f"{where} must be a mapping")159 raise ExperimentConfigError(f"{where} must be a mapping")
158 if any(not isinstance(key, str) for key in value):160 if any(not isinstance(key, str) for key in value):
159 raise ConfigError(f"{where} keys must be strings")161 raise ExperimentConfigError(f"{where} keys must be strings")
160 return value162 return value
161163
162164
163def sequence(value: Any, where: str) -> Sequence[Any]:165def sequence(value: Any, where: str) -> Sequence[Any]:
164 """Return *value* as a non-string sequence or raise."""166 """Return *value* as a non-string sequence or raise."""
165 if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):167 if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
166 raise ConfigError(f"{where} must be a sequence")168 raise ExperimentConfigError(f"{where} must be a sequence")
167 return value169 return value
168170
169171
170def string(value: Any, where: str) -> str:172def string(value: Any, where: str) -> str:
171 """Return *value* as a non-empty string or raise."""173 """Return *value* as a non-empty string or raise."""
172 if not isinstance(value, str) or not value.strip():174 if not isinstance(value, str) or not value.strip():
173 raise ConfigError(f"{where} must be a non-empty string")175 raise ExperimentConfigError(f"{where} must be a non-empty string")
174 return value176 return value
175177
176178
177def integer(value: Any, where: str) -> int:179def integer(value: Any, where: str) -> int:
178 """Return *value* as an integer, rejecting booleans, or raise."""180 """Return *value* as an integer, rejecting booleans, or raise."""
179 if isinstance(value, bool) or not isinstance(value, int):181 if isinstance(value, bool) or not isinstance(value, int):
180 raise ConfigError(f"{where} must be an integer")182 raise ExperimentConfigError(f"{where} must be an integer")
181 return value183 return value
182184
183185
184def number(value: Any, where: str) -> float:186def number(value: Any, where: str) -> float:
185 """Return *value* as a float, rejecting booleans, or raise."""187 """Return *value* as a float, rejecting booleans, or raise."""
186 if isinstance(value, bool) or not isinstance(value, (int, float)):188 if isinstance(value, bool) or not isinstance(value, (int, float)):
187 raise ConfigError(f"{where} must be numeric")189 raise ExperimentConfigError(f"{where} must be numeric")
188 return float(value)190 return float(value)
189191
190192
191def boolean(value: Any, where: str) -> bool:193def boolean(value: Any, where: str) -> bool:
192 """Return *value* as a boolean or raise."""194 """Return *value* as a boolean or raise."""
193 if not isinstance(value, bool):195 if not isinstance(value, bool):
194 raise ConfigError(f"{where} must be boolean")196 raise ExperimentConfigError(f"{where} must be boolean")
195 return value197 return value
196198
197199
198def path(value: Any, where: str) -> Path:200def path(value: Any, where: str) -> Path:
199 """Return *value* as a repository-relative path or raise."""201 """Return *value* as a repository-relative path or raise."""
200 text = string(value, where)202 text = string(value, where)
201 parsed = Path(text)203 parsed = Path(text)
202 if parsed.is_absolute():204 if parsed.is_absolute():
203 raise ConfigError(f"{where} must be repository-relative, got {parsed}")205 raise ExperimentConfigError(
206 f"{where} must be repository-relative, got {parsed}"
207 )
204 return parsed208 return parsed
205209
206210
207def choice(value: Any, choices: set[str], where: str) -> str:211def choice(value: Any, choices: set[str], where: str) -> str:
208 """Return *value* when it is one of the allowed string choices."""212 """Return *value* when it is one of the allowed string choices."""
209 text = string(value, where)213 text = string(value, where)
210 if text not in choices:214 if text not in choices:
211 raise ConfigError(f"{where} must be one of {sorted(choices)}, got {text!r}")215 raise ExperimentConfigError(
216 f"{where} must be one of {sorted(choices)}, got {text!r}"
217 )
212 return text218 return text
213219
214220
215def check_keys(221def check_keys(
Importance #36: src/train/config_values.py @@ -226,9 +232,9 @@
226 allowed: Keys that may be present.232 allowed: Keys that may be present.
227 where: Dotted key path used in error messages.233 where: Dotted key path used in error messages.
228234
229 Raises:235 Raises:
230 ConfigError: If a key is missing or unknown.236 ExperimentConfigError: If a key is missing or unknown.
231 """237 """
232 missing = sorted(required - set(value))238 missing = sorted(required - set(value))
233 unknown = sorted(set(value) - allowed)239 unknown = sorted(set(value) - allowed)
234 if missing or unknown:240 if missing or unknown:
Importance #37: src/train/config_values.py @@ -236,9 +242,9 @@
236 if missing:242 if missing:
237 details.append(f"missing {missing}")243 details.append(f"missing {missing}")
238 if unknown:244 if unknown:
239 details.append(f"unknown {unknown}")245 details.append(f"unknown {unknown}")
240 raise ConfigError(f"{where} has " + " and ".join(details))246 raise ExperimentConfigError(f"{where} has " + " and ".join(details))
241247
242248
243def leaf_values(value: Any, prefix: str = "") -> dict[str, OverrideValue]:249def leaf_values(value: Any, prefix: str = "") -> dict[str, OverrideValue]:
244 """Return every dotted scalar/list leaf of a raw configuration mapping.250 """Return every dotted scalar/list leaf of a raw configuration mapping.
Importance #38: frameworks/emission_check.py @@ -23,9 +23,9 @@
23from pathlib import Path23from pathlib import Path
24from typing import Any24from typing import Any
2525
26from src.contracts.ontology import Ontology, OntologyError, load_ontology26from src.contracts.ontology import Ontology, OntologyError, load_ontology
27from src.train.config import ConfigError, load_config, resolve_ontology_path27from src.train.config import ExperimentConfigError, HarnessConfig, resolve_ontology_path
2828
29POINTCEPT_FRAMEWORK = "pointcept"29POINTCEPT_FRAMEWORK = "pointcept"
30SPT_FRAMEWORK = "spt"30SPT_FRAMEWORK = "spt"
31SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)31SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)
Importance #39: frameworks/emission_check.py @@ -171,17 +171,17 @@
171171
172 Raises:172 Raises:
173 EmissionCheckError: If the framework is unsupported, a narrowing is173 EmissionCheckError: If the framework is unsupported, a narrowing is
174 requested for SPT, or the emission disagrees with the ontology.174 requested for SPT, or the emission disagrees with the ontology.
175 ConfigError: If the harness YAML is malformed.175 ExperimentConfigError: If the harness YAML is malformed.
176 OntologyError: If the declared ontology cannot be loaded.176 OntologyError: If the declared ontology cannot be loaded.
177 """177 """
178 if framework not in SUPPORTED_FRAMEWORKS:178 if framework not in SUPPORTED_FRAMEWORKS:
179 raise EmissionCheckError(179 raise EmissionCheckError(
180 f"{config_path}: unsupported framework {framework!r}; expected one "180 f"{config_path}: unsupported framework {framework!r}; expected one "
181 f"of {list(SUPPORTED_FRAMEWORKS)}"181 f"of {list(SUPPORTED_FRAMEWORKS)}"
182 )182 )
183 config = load_config(config_path)183 config = HarnessConfig.from_yaml(config_path)
184 ontology = load_ontology(resolve_ontology_path(config))184 ontology = load_ontology(resolve_ontology_path(config))
185 lane = Path(processed_root) if processed_root else config.data.processed_root185 lane = Path(processed_root) if processed_root else config.data.processed_root
186 if framework == SPT_FRAMEWORK:186 if framework == SPT_FRAMEWORK:
187 if corridors:187 if corridors:
Importance #40: frameworks/emission_check.py @@ -245,9 +245,9 @@
245 processed_root=args.processed_root,245 processed_root=args.processed_root,
246 corridors=tuple(args.corridor),246 corridors=tuple(args.corridor),
247 )247 )
248 except (248 except (
249 ConfigError,249 ExperimentConfigError,
250 EmissionCheckError,250 EmissionCheckError,
251 OntologyError,251 OntologyError,
252 OSError,252 OSError,
253 ) as exc:253 ) as exc:
Importance #41: frameworks/run_provenance.py @@ -33,9 +33,9 @@
33 SplitTier,33 SplitTier,
34 authorize_split_access,34 authorize_split_access,
35 load_split_manifest,35 load_split_manifest,
36)36)
37from src.train.config import HarnessConfig, load_config, resolve_ontology_path37from src.train.config import HarnessConfig, resolve_ontology_path
3838
39STARTED = "started"39STARTED = "started"
40COMPLETED = "completed"40COMPLETED = "completed"
41PROVENANCE_FILENAMES: dict[str, str] = {41PROVENANCE_FILENAMES: dict[str, str] = {
Importance #42: frameworks/run_provenance.py @@ -417,9 +417,9 @@
417 Raises:417 Raises:
418 RunProvenanceError: If the run cannot prove its required evidence.418 RunProvenanceError: If the run cannot prove its required evidence.
419 """419 """
420 args = build_parser().parse_args(argv)420 args = build_parser().parse_args(argv)
421 config = load_config(args.config)421 config = HarnessConfig.from_yaml(args.config)
422 split_manifest = load_split_manifest(422 split_manifest = load_split_manifest(
423 config.data.split_manifest,423 config.data.split_manifest,
424 ontology=load_ontology(resolve_ontology_path(config)),424 ontology=load_ontology(resolve_ontology_path(config)),
425 )425 )
Importance #43: frameworks/runner_options.py @@ -1,9 +1,10 @@
1"""Emit ontology-derived CLI overrides for the SPT and Pointcept runners.1"""Emit ontology-derived CLI overrides for the SPT and Pointcept runners.
22
3The harness experiment YAML is the source of truth for class count, void ID,3The harness experiment YAML is the source of truth for class count, void ID,
4and predicted class names. This module loads that YAML with the same4and predicted class names. This module loads that YAML with the same
5:func:`src.train.config.load_config` / :func:`src.contracts.ontology.load_ontology`5:meth:`src.train.config.HarnessConfig.from_yaml` /
6:func:`src.contracts.ontology.load_ontology`
6path the rest of the harness uses, then prints one ``KEY=VALUE`` override per7path the rest of the harness uses, then prints one ``KEY=VALUE`` override per
7line. The shell runners append those lines to the framework command:8line. The shell runners append those lines to the framework command:
89
9* Pointcept consumes them as ``--options`` tokens (``DictAction`` then10* Pointcept consumes them as ``--options`` tokens (``DictAction`` then
Importance #44: frameworks/runner_options.py @@ -28,9 +29,9 @@
28from collections.abc import Sequence29from collections.abc import Sequence
29from pathlib import Path30from pathlib import Path
3031
31from src.contracts.ontology import Ontology, OntologyError, load_ontology32from src.contracts.ontology import Ontology, OntologyError, load_ontology
32from src.train.config import ConfigError, load_config, resolve_ontology_path33from src.train.config import ExperimentConfigError, HarnessConfig, resolve_ontology_path
3334
34POINTCEPT_FRAMEWORK = "pointcept"35POINTCEPT_FRAMEWORK = "pointcept"
35SPT_FRAMEWORK = "spt"36SPT_FRAMEWORK = "spt"
36SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)37SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)
Importance #45: frameworks/runner_options.py @@ -91,17 +92,17 @@
91 One ``KEY=VALUE`` override per tuple element.92 One ``KEY=VALUE`` override per tuple element.
9293
93 Raises:94 Raises:
94 RunnerOptionsError: If ``framework`` is unsupported.95 RunnerOptionsError: If ``framework`` is unsupported.
95 ConfigError: If the harness YAML is malformed.96 ExperimentConfigError: If the harness YAML is malformed.
96 OntologyError: If the declared ontology cannot be loaded.97 OntologyError: If the declared ontology cannot be loaded.
97 """98 """
98 if framework not in SUPPORTED_FRAMEWORKS:99 if framework not in SUPPORTED_FRAMEWORKS:
99 raise RunnerOptionsError(100 raise RunnerOptionsError(
100 f"{config_path}: unsupported framework {framework!r}; expected "101 f"{config_path}: unsupported framework {framework!r}; expected "
101 f"one of {list(SUPPORTED_FRAMEWORKS)}"102 f"one of {list(SUPPORTED_FRAMEWORKS)}"
102 )103 )
103 config = load_config(config_path)104 config = HarnessConfig.from_yaml(config_path)
104 ontology = load_ontology(resolve_ontology_path(config))105 ontology = load_ontology(resolve_ontology_path(config))
105 if framework == SPT_FRAMEWORK:106 if framework == SPT_FRAMEWORK:
106 return spt_overrides(ontology)107 return spt_overrides(ontology)
107 return pointcept_overrides(ontology)108 return pointcept_overrides(ontology)
Importance #46: frameworks/runner_options.py @@ -139,9 +140,9 @@
139 """140 """
140 args = build_parser().parse_args(argv)141 args = build_parser().parse_args(argv)
141 try:142 try:
142 lines = collect_overrides(args.config, args.framework)143 lines = collect_overrides(args.config, args.framework)
143 except (ConfigError, OntologyError, RunnerOptionsError, OSError) as exc:144 except (ExperimentConfigError, OntologyError, RunnerOptionsError, OSError) as exc:
144 print(f"{args.config}: {exc}", file=sys.stderr)145 print(f"{args.config}: {exc}", file=sys.stderr)
145 return 1146 return 1
146 if not lines:147 if not lines:
147 print(148 print(
Importance #47: scripts/evaluate.py @@ -40,12 +40,11 @@
40 continuity_metrics,40 continuity_metrics,
41 object_metrics,41 object_metrics,
42)42)
43from src.train.config import (43from src.train.config import (
44 ConfigError,44 ExperimentConfigError,
45 HarnessConfig,45 HarnessConfig,
46 is_canonical_metric,46 is_canonical_metric,
47 load_config,
48 resolve_ontology_path,47 resolve_ontology_path,
49)48)
5049
5150
Importance #48: scripts/evaluate.py @@ -127,9 +126,9 @@
127 Zero after reports and finalized provenance are written.126 Zero after reports and finalized provenance are written.
128 """127 """
129 args = build_parser().parse_args(argv)128 args = build_parser().parse_args(argv)
130 try:129 try:
131 config = load_config(args.config)130 config = HarnessConfig.from_yaml(args.config)
132 if config.model.framework not in {"spt", "pointcept"}:131 if config.model.framework not in {"spt", "pointcept"}:
133 raise EvaluationError(132 raise EvaluationError(
134 f"{config.experiment.id} framework {config.model.framework} "133 f"{config.experiment.id} framework {config.model.framework} "
135 "has no checkpoint evaluator"134 "has no checkpoint evaluator"
Importance #49: scripts/evaluate.py @@ -313,9 +312,15 @@
313 },312 },
314 )313 )
315 write_corridor_run_provenance(run_dir / "provenance.json", final_provenance)314 write_corridor_run_provenance(run_dir / "provenance.json", final_provenance)
316 return 0315 return 0
317 except (ConfigError, EvaluationError, OSError, RuntimeError, ValueError) as exc:316 except (
317 ExperimentConfigError,
318 EvaluationError,
319 OSError,
320 RuntimeError,
321 ValueError,
322 ) as exc:
318 raise SystemExit(str(exc)) from exc323 raise SystemExit(str(exc)) from exc
319324
320325
321@dataclass(frozen=True)326@dataclass(frozen=True)
Importance #50: scripts/ingest_preannotations.py @@ -24,9 +24,9 @@
24 PreannotationError,24 PreannotationError,
25 load_preannotation,25 load_preannotation,
26 select_preannotation,26 select_preannotation,
27)27)
28from src.train.config import load_config, resolve_ontology_path28from src.train.config import HarnessConfig, resolve_ontology_path
2929
3030
31def build_parser() -> argparse.ArgumentParser:31def build_parser() -> argparse.ArgumentParser:
32 """Build the frozen preannotation-ingest CLI parser.32 """Build the frozen preannotation-ingest CLI parser.
Importance #51: scripts/ingest_preannotations.py @@ -57,9 +57,9 @@
57 Process exit status.57 Process exit status.
58 """58 """
59 args = build_parser().parse_args(argv)59 args = build_parser().parse_args(argv)
60 try:60 try:
61 config = load_config(args.config)61 config = HarnessConfig.from_yaml(args.config)
62 ontology = load_ontology(resolve_ontology_path(config))62 ontology = load_ontology(resolve_ontology_path(config))
63 split_manifest = load_split_manifest(63 split_manifest = load_split_manifest(
64 config.data.split_manifest, ontology=ontology64 config.data.split_manifest, ontology=ontology
65 )65 )
Importance #52: scripts/prepare_dataset.py @@ -50,9 +50,9 @@
50 validate_boundary_las_codes,50 validate_boundary_las_codes,
51 write_normalization,51 write_normalization,
52)52)
53from src.dataset.features import expand_feature_columns53from src.dataset.features import expand_feature_columns
54from src.train.config import load_config, resolve_ontology_path54from src.train.config import HarnessConfig, resolve_ontology_path
5555
5656
57def build_parser() -> argparse.ArgumentParser:57def build_parser() -> argparse.ArgumentParser:
58 """Build the frozen dataset-preparation CLI parser.58 """Build the frozen dataset-preparation CLI parser.
Importance #53: scripts/prepare_dataset.py @@ -88,9 +88,9 @@
88 Process exit status.88 Process exit status.
89 """89 """
90 args = build_parser().parse_args(argv)90 args = build_parser().parse_args(argv)
91 try:91 try:
92 config = load_config(args.config)92 config = HarnessConfig.from_yaml(args.config)
93 ontology = load_ontology(resolve_ontology_path(config))93 ontology = load_ontology(resolve_ontology_path(config))
94 split_manifest = load_split_manifest(94 split_manifest = load_split_manifest(
95 config.data.split_manifest, ontology=ontology95 config.data.split_manifest, ontology=ontology
96 )96 )
Importance #54: scripts/train.py @@ -7,9 +7,9 @@
7import logging7import logging
8from collections.abc import Sequence8from collections.abc import Sequence
9from pathlib import Path9from pathlib import Path
1010
11from src.train.config import ConfigError, load_config11from src.train.config import ExperimentConfigError, HarnessConfig
12from src.train.dispatch import (12from src.train.dispatch import (
13 DispatchError,13 DispatchError,
14 DispatchRequest,14 DispatchRequest,
15 dispatch_experiment,15 dispatch_experiment,
Importance #55: scripts/train.py @@ -55,9 +55,9 @@
55 """55 """
56 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")56 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
57 args = build_parser().parse_args(argv)57 args = build_parser().parse_args(argv)
58 try:58 try:
59 config = load_config(args.config)59 config = HarnessConfig.from_yaml(args.config)
60 cells = plan_experiment(config)60 cells = plan_experiment(config)
61 LOGGER.info(61 LOGGER.info(
62 "%s study kind %s expands to %d cell(s): %s",62 "%s study kind %s expands to %d cell(s): %s",
63 config.experiment.id,63 config.experiment.id,
Importance #56: scripts/train.py @@ -73,9 +73,9 @@
73 log_dir=args.log_dir,73 log_dir=args.log_dir,
74 num_workers=args.num_workers,74 num_workers=args.num_workers,
75 )75 )
76 return dispatch_experiment(config, request)76 return dispatch_experiment(config, request)
77 except (ConfigError, DispatchError) as exc:77 except (ExperimentConfigError, DispatchError) as exc:
78 raise SystemExit(str(exc)) from exc78 raise SystemExit(str(exc)) from exc
7979
8080
81if __name__ == "__main__":81if __name__ == "__main__":
Importance #57: scripts/verify_adapter_roundtrip.py @@ -14,9 +14,9 @@
14from src.adapters.pointcept import load_pointcept_identity14from src.adapters.pointcept import load_pointcept_identity
15from src.adapters.remap import TilePrediction, blend_and_remap15from src.adapters.remap import TilePrediction, blend_and_remap
16from src.adapters.spt import load_spt_identity16from src.adapters.spt import load_spt_identity
17from src.dataset.canonical import CanonicalCloud, read_canonical_tile17from src.dataset.canonical import CanonicalCloud, read_canonical_tile
18from src.train.config import load_config18from src.train.config import HarnessConfig
1919
20DEFAULT_FRAME_TOLERANCE_MM = 0.620DEFAULT_FRAME_TOLERANCE_MM = 0.6
21"""Tolerated frame reconstruction error.21"""Tolerated frame reconstruction error.
2222
Importance #58: scripts/verify_adapter_roundtrip.py @@ -61,9 +61,9 @@
61 if not np.isfinite(args.frame_tolerance_mm) or args.frame_tolerance_mm <= 0.0:61 if not np.isfinite(args.frame_tolerance_mm) or args.frame_tolerance_mm <= 0.0:
62 raise ValueError(62 raise ValueError(
63 f"--frame-tolerance-mm must be positive, got {args.frame_tolerance_mm}"63 f"--frame-tolerance-mm must be positive, got {args.frame_tolerance_mm}"
64 )64 )
65 config = load_config(args.config)65 config = HarnessConfig.from_yaml(args.config)
66 # Per-corridor adapter manifests live in the canonical lane66 # Per-corridor adapter manifests live in the canonical lane
67 # (<canonical_root>/canonical/<corridor>.adapter.json); the legacy67 # (<canonical_root>/canonical/<corridor>.adapter.json); the legacy
68 # top-level location is still accepted so older lanes verify.68 # top-level location is still accepted so older lanes verify.
69 canonical_lane = config.data.canonical_root / "canonical"69 canonical_lane = config.data.canonical_root / "canonical"
Importance #59: scripts/voxel_oracle.py @@ -32,11 +32,10 @@
32 validate_full_resolution_manifest,32 validate_full_resolution_manifest,
33 write_oracle_outputs,33 write_oracle_outputs,
34)34)
35from src.train.config import (35from src.train.config import (
36 ConfigError,36 ExperimentConfigError,
37 HarnessConfig,37 HarnessConfig,
38 load_config,
39 resolve_ontology_path,38 resolve_ontology_path,
40)39)
4140
42LOGGER = logging.getLogger(__name__)41LOGGER = logging.getLogger(__name__)
Importance #60: scripts/voxel_oracle.py @@ -82,9 +81,9 @@
82 """81 """
83 args = build_parser().parse_args(argv)82 args = build_parser().parse_args(argv)
84 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")83 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
85 try:84 try:
86 config = load_config(args.config)85 config = HarnessConfig.from_yaml(args.config)
87 if config.experiment.id != "E2":86 if config.experiment.id != "E2":
88 raise VoxelOracleError(87 raise VoxelOracleError(
89 f"scripts/voxel_oracle.py accepts E2 only, got {config.experiment.id}"88 f"scripts/voxel_oracle.py accepts E2 only, got {config.experiment.id}"
90 )89 )
Importance #61: scripts/voxel_oracle.py @@ -159,9 +158,9 @@
159 )158 )
160 LOGGER.info("Wrote E2 products: %s", ", ".join(str(path) for path in paths))159 LOGGER.info("Wrote E2 products: %s", ", ".join(str(path) for path in paths))
161 return 0160 return 0
162 except (161 except (
163 ConfigError,162 ExperimentConfigError,
164 SplitAccessError,163 SplitAccessError,
165 VoxelOracleError,164 VoxelOracleError,
166 OSError,165 OSError,
167 ValueError,166 ValueError,
Importance #62: src/train/__init__.py @@ -1,13 +1,12 @@
1"""Public strict configuration and experiment-dispatch interfaces."""1"""Public strict configuration and experiment-dispatch interfaces."""
22
3from src.train.config import ConfigError, HarnessConfig, TrainConfig, load_config3from src.train.config import ExperimentConfigError, HarnessConfig, TrainConfig
4from src.train.dispatch import DispatchError, dispatch_experiment4from src.train.dispatch import DispatchError, dispatch_experiment
55
6__all__ = [6__all__ = [
7 "ConfigError",
8 "DispatchError",7 "DispatchError",
8 "ExperimentConfigError",
9 "HarnessConfig",9 "HarnessConfig",
10 "TrainConfig",10 "TrainConfig",
11 "dispatch_experiment",11 "dispatch_experiment",
12 "load_config",
13]12]
Importance #63: src/train/config_study.py @@ -36,28 +36,28 @@
36 Returns:36 Returns:
37 The deterministic cell definitions of the declared study kind.37 The deterministic cell definitions of the declared study kind.
3838
39 Raises:39 Raises:
40 ConfigError: If the study payload of the declared kind is absent or40 ExperimentConfigError: If the study payload of the declared kind is absent or
41 expands to nothing.41 expands to nothing.
42 """42 """
43 study = config.study43 study = config.study
44 if study.kind == "single":44 if study.kind == "single":
45 return ((config.experiment.id, MappingProxyType({})),)45 return ((config.experiment.id, MappingProxyType({})),)
46 if study.kind == "variants":46 if study.kind == "variants":
47 if not study.variants:47 if not study.variants:
48 raise config_values.ConfigError(48 raise config_values.ExperimentConfigError(
49 f"{config.experiment.id} study.kind variants has no variants payload"49 f"{config.experiment.id} study.kind variants has no variants payload"
50 )50 )
51 return tuple((item.id, item.overrides) for item in study.variants)51 return tuple((item.id, item.overrides) for item in study.variants)
52 if study.kind == "matrix":52 if study.kind == "matrix":
53 if study.matrix is None:53 if study.matrix is None:
54 raise config_values.ConfigError(54 raise config_values.ExperimentConfigError(
55 f"{config.experiment.id} study.kind matrix has no matrix payload"55 f"{config.experiment.id} study.kind matrix has no matrix payload"
56 )56 )
57 return matrix_cells(study.matrix)57 return matrix_cells(study.matrix)
58 if study.sweep is None:58 if study.sweep is None:
59 raise config_values.ConfigError(59 raise config_values.ExperimentConfigError(
60 f"{config.experiment.id} study.kind sweep has no sweep payload"60 f"{config.experiment.id} study.kind sweep has no sweep payload"
61 )61 )
62 return sweep_cells(study.sweep, config.seed)62 return sweep_cells(study.sweep, config.seed)
6363
Importance #64: src/train/config_study.py @@ -68,9 +68,9 @@
68 unknown = sorted(68 unknown = sorted(
69 {path for item in matrix.exclude for path in item} - set(axis_paths)69 {path for item in matrix.exclude for path in item} - set(axis_paths)
70 )70 )
71 if unknown:71 if unknown:
72 raise config_values.ConfigError(72 raise config_values.ExperimentConfigError(
73 f"study.matrix.exclude references non-axis paths {unknown}"73 f"study.matrix.exclude references non-axis paths {unknown}"
74 )74 )
75 definitions: list[CellDefinition] = []75 definitions: list[CellDefinition] = []
76 excluded = [0] * len(matrix.exclude)76 excluded = [0] * len(matrix.exclude)
Importance #65: src/train/config_study.py @@ -84,19 +84,19 @@
84 if not dropped:84 if not dropped:
85 definitions.append((cell_id(overrides), MappingProxyType(overrides)))85 definitions.append((cell_id(overrides), MappingProxyType(overrides)))
86 for index, count in enumerate(excluded):86 for index, count in enumerate(excluded):
87 if not count:87 if not count:
88 raise config_values.ConfigError(88 raise config_values.ExperimentConfigError(
89 f"study.matrix.exclude[{index}] matches no matrix cell"89 f"study.matrix.exclude[{index}] matches no matrix cell"
90 )90 )
91 for index, item in enumerate(matrix.include):91 for index, item in enumerate(matrix.include):
92 if not item:92 if not item:
93 raise config_values.ConfigError(93 raise config_values.ExperimentConfigError(
94 f"study.matrix.include[{index}] cannot be empty"94 f"study.matrix.include[{index}] cannot be empty"
95 )95 )
96 definitions.append((cell_id(item), item))96 definitions.append((cell_id(item), item))
97 if not definitions:97 if not definitions:
98 raise config_values.ConfigError("study.matrix excludes every cell")98 raise config_values.ExperimentConfigError("study.matrix excludes every cell")
99 return tuple(definitions)99 return tuple(definitions)
100100
101101
102def sweep_cells(102def sweep_cells(
Importance #66: src/train/config_study.py @@ -108,9 +108,9 @@
108 unbounded = sorted(108 unbounded = sorted(
109 path for path, item in sweep.parameters.items() if item.values is None109 path for path, item in sweep.parameters.items() if item.values is None
110 )110 )
111 if unbounded:111 if unbounded:
112 raise config_values.ConfigError(112 raise config_values.ExperimentConfigError(
113 f"study.sweep.method grid requires explicit values for {unbounded}"113 f"study.sweep.method grid requires explicit values for {unbounded}"
114 )114 )
115 definitions: list[CellDefinition] = []115 definitions: list[CellDefinition] = []
116 for combination in itertools.product(116 for combination in itertools.product(
Importance #67: src/train/config_study.py @@ -136,9 +136,9 @@
136 ),136 ),
137 )137 )
138 for index in range(sweep.budget)138 for index in range(sweep.budget)
139 )139 )
140 raise config_values.ConfigError(140 raise config_values.ExperimentConfigError(
141 f"study.sweep.method {sweep.method!r} is not implemented; supported "141 f"study.sweep.method {sweep.method!r} is not implemented; supported "
142 "methods are grid and random"142 "methods are grid and random"
143 )143 )
144144
Importance #68: src/train/config_study.py @@ -151,23 +151,23 @@
151 """Draw one deterministic value for a sweep parameter."""151 """Draw one deterministic value for a sweep parameter."""
152 if parameter.values is not None:152 if parameter.values is not None:
153 return parameter.values[generator.randrange(len(parameter.values))]153 return parameter.values[generator.randrange(len(parameter.values))]
154 if parameter.minimum is None or parameter.maximum is None:154 if parameter.minimum is None or parameter.maximum is None:
155 raise config_values.ConfigError(155 raise config_values.ExperimentConfigError(
156 f"{where} requires minimum and maximum for sampling"156 f"{where} requires minimum and maximum for sampling"
157 )157 )
158 if parameter.distribution == "uniform":158 if parameter.distribution == "uniform":
159 drawn = generator.uniform(parameter.minimum, parameter.maximum)159 drawn = generator.uniform(parameter.minimum, parameter.maximum)
160 elif parameter.distribution == "log_uniform":160 elif parameter.distribution == "log_uniform":
161 if parameter.minimum <= 0.0:161 if parameter.minimum <= 0.0:
162 raise config_values.ConfigError(162 raise config_values.ExperimentConfigError(
163 f"{where}.minimum must be positive for log_uniform"163 f"{where}.minimum must be positive for log_uniform"
164 )164 )
165 drawn = math.exp(165 drawn = math.exp(
166 generator.uniform(math.log(parameter.minimum), math.log(parameter.maximum))166 generator.uniform(math.log(parameter.minimum), math.log(parameter.maximum))
167 )167 )
168 else:168 else:
169 raise config_values.ConfigError(169 raise config_values.ExperimentConfigError(
170 f"{where}.distribution {parameter.distribution!r} is not implemented; "170 f"{where}.distribution {parameter.distribution!r} is not implemented; "
171 "supported distributions are uniform and log_uniform"171 "supported distributions are uniform and log_uniform"
172 )172 )
173 return float(f"{drawn:.6g}")173 return float(f"{drawn:.6g}")
Importance #69: src/train/config_study.py @@ -175,9 +175,11 @@
175175
176def cell_id(overrides: Mapping[str, config_values.OverrideValue]) -> str:176def cell_id(overrides: Mapping[str, config_values.OverrideValue]) -> str:
177 """Derive a stable, filesystem-safe identity from a cell's overrides."""177 """Derive a stable, filesystem-safe identity from a cell's overrides."""
178 if not overrides:178 if not overrides:
179 raise config_values.ConfigError("A study cell requires at least one override")179 raise config_values.ExperimentConfigError(
180 "A study cell requires at least one override"
181 )
180 names = [path.rsplit(".", 1)[-1] for path in overrides]182 names = [path.rsplit(".", 1)[-1] for path in overrides]
181 if len(set(names)) != len(names):183 if len(set(names)) != len(names):
182 names = [path.replace(".", "_") for path in overrides]184 names = [path.replace(".", "_") for path in overrides]
183 return "__".join(185 return "__".join(
Importance #70: src/train/config_study.py @@ -216,21 +218,22 @@
216 Returns:218 Returns:
217 A deep copy of ``raw`` carrying the overridden leaves.219 A deep copy of ``raw`` carrying the overridden leaves.
218220
219 Raises:221 Raises:
220 ConfigError: If a path is not a declared leaf or the value type differs.222 ExperimentConfigError: If a path is not a declared leaf or the value
223 type differs.
221 """224 """
222 result = copy.deepcopy(dict(raw))225 result = copy.deepcopy(dict(raw))
223 leaves = config_values.leaf_values(raw)226 leaves = config_values.leaf_values(raw)
224 for path in sorted(overrides):227 for path in sorted(overrides):
225 value = overrides[path]228 value = overrides[path]
226 if path.startswith("study.") or path not in leaves:229 if path.startswith("study.") or path not in leaves:
227 raise config_values.ConfigError(230 raise config_values.ExperimentConfigError(
228 f"{where} override path {path!r} is not a declared scalar/list leaf"231 f"{where} override path {path!r} is not a declared scalar/list leaf"
229 )232 )
230 expected = leaves[path]233 expected = leaves[path]
231 if not config_values.same_leaf_type(expected, value):234 if not config_values.same_leaf_type(expected, value):
232 raise config_values.ConfigError(235 raise config_values.ExperimentConfigError(
233 f"{where} override {path!r} has incompatible value {value!r}; "236 f"{where} override {path!r} has incompatible value {value!r}; "
234 f"expected type of {expected!r}"237 f"expected type of {expected!r}"
235 )238 )
236 _set_leaf(result, path, config_values.coerce_leaf(expected, value), where)239 _set_leaf(result, path, config_values.coerce_leaf(expected, value), where)
Importance #71: src/train/config_study.py @@ -247,13 +250,13 @@
247 segments = path.split(".")250 segments = path.split(".")
248 node: Any = target251 node: Any = target
249 for segment in segments[:-1]:252 for segment in segments[:-1]:
250 if not isinstance(node, dict) or segment not in node:253 if not isinstance(node, dict) or segment not in node:
251 raise config_values.ConfigError(254 raise config_values.ExperimentConfigError(
252 f"{where} override path {path!r} is not addressable"255 f"{where} override path {path!r} is not addressable"
253 )256 )
254 node = node[segment]257 node = node[segment]
255 if not isinstance(node, dict) or segments[-1] not in node:258 if not isinstance(node, dict) or segments[-1] not in node:
256 raise config_values.ConfigError(259 raise config_values.ExperimentConfigError(
257 f"{where} override path {path!r} is not addressable"260 f"{where} override path {path!r} is not addressable"
258 )261 )
259 node[segments[-1]] = value262 node[segments[-1]] = value
Importance #72: src/train/dispatch.py @@ -14,10 +14,10 @@
1414
15from src.train.config import (15from src.train.config import (
16 ArtifactExistsParams,16 ArtifactExistsParams,
17 CheckpointPolicyParams,17 CheckpointPolicyParams,
18 ConfigError,
19 DataAvailableParams,18 DataAvailableParams,
19 ExperimentConfigError,
20 GateConfig,20 GateConfig,
21 HarnessConfig,21 HarnessConfig,
22 HumanWorkflowParams,22 HumanWorkflowParams,
23 ImplementationTicketParams,23 ImplementationTicketParams,
Importance #73: src/train/dispatch.py @@ -105,9 +105,9 @@
105 DispatchError: If the study definition cannot be expanded strictly.105 DispatchError: If the study definition cannot be expanded strictly.
106 """106 """
107 try:107 try:
108 return expand_study(config)108 return expand_study(config)
109 except ConfigError as exc:109 except ExperimentConfigError as exc:
110 raise DispatchError(110 raise DispatchError(
111 f"{config.experiment.id} study expansion failed: {exc}"111 f"{config.experiment.id} study expansion failed: {exc}"
112 ) from exc112 ) from exc
113113
Importance #74: tests/test_a1_recap_wiring.py @@ -5,9 +5,9 @@
5from pathlib import Path5from pathlib import Path
66
7from src.contracts.ontology import load_ontology7from src.contracts.ontology import load_ontology
8from src.contracts.splits import SplitTier, load_split_manifest8from src.contracts.splits import SplitTier, load_split_manifest
9from src.train.config import load_config9from src.train.config import HarnessConfig
1010
11CONFIG_PATH = Path("configs/dev/a1_recap_segment_085.yaml")11CONFIG_PATH = Path("configs/dev/a1_recap_segment_085.yaml")
12ONTOLOGY_PATH = Path("configs/contracts/ontology_v2.yaml")12ONTOLOGY_PATH = Path("configs/contracts/ontology_v2.yaml")
13SPLIT_PATH = Path("configs/contracts/corridor_splits_a1_recap_v1.yaml")13SPLIT_PATH = Path("configs/contracts/corridor_splits_a1_recap_v1.yaml")
Importance #75: tests/test_a1_recap_wiring.py @@ -30,9 +30,9 @@
30 assert manifest.supported_interest_ids[SplitTier.TRAIN] == tuple(range(9))30 assert manifest.supported_interest_ids[SplitTier.TRAIN] == tuple(range(9))
3131
3232
33def test_recap_config_points_at_the_recap_lanes_with_rgb_and_intensity() -> None:33def test_recap_config_points_at_the_recap_lanes_with_rgb_and_intensity() -> None:
34 config = load_config(CONFIG_PATH)34 config = HarnessConfig.from_yaml(CONFIG_PATH)
3535
36 assert config.data.split_manifest == SPLIT_PATH36 assert config.data.split_manifest == SPLIT_PATH
37 assert config.data.root == Path("data/00_external/a1_recap_v1")37 assert config.data.root == Path("data/00_external/a1_recap_v1")
38 assert config.data.corridors.include == (CORRIDOR_ID,)38 assert config.data.corridors.include == (CORRIDOR_ID,)
Importance #76: tests/test_checkpoint_seam.py @@ -41,9 +41,9 @@
41 SplitTier,41 SplitTier,
42 authorize_split_access,42 authorize_split_access,
43 load_split_manifest,43 load_split_manifest,
44)44)
45from src.train.config import HarnessConfig, load_config45from src.train.config import HarnessConfig
4646
47CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")47CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")
48POLICY_PATH = Path("configs/contracts/checkpoint_policy.yaml")48POLICY_PATH = Path("configs/contracts/checkpoint_policy.yaml")
49GRID_ORIGIN = [10.0, -20.0, 5.0]49GRID_ORIGIN = [10.0, -20.0, 5.0]
Importance #77: tests/test_checkpoint_seam.py @@ -145,9 +145,9 @@
145145
146 Returns:146 Returns:
147 The E1 configuration with a writable canonical root.147 The E1 configuration with a writable canonical root.
148 """148 """
149 config = load_config(CONFIG_PATH)149 config = HarnessConfig.from_yaml(CONFIG_PATH)
150 canonical_root = tmp_path / "canonical_root"150 canonical_root = tmp_path / "canonical_root"
151 (canonical_root / "canonical").mkdir(parents=True, exist_ok=True)151 (canonical_root / "canonical").mkdir(parents=True, exist_ok=True)
152 for corridor_id in config.data.corridors.include:152 for corridor_id in config.data.corridors.include:
153 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(153 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(
Importance #78: tests/test_config.py @@ -6,14 +6,15 @@
6from typing import Any6from typing import Any
77
8import pytest8import pytest
9import yaml9import yaml
10from iolabs.common import config_loader
1011
11from src.contracts.ontology import load_ontology12from src.contracts.ontology import load_ontology
12from src.train.config import (13from src.train.config import (
13 ConfigError,14 ExperimentConfigError,
15 HarnessConfig,
14 is_canonical_metric,16 is_canonical_metric,
15 load_config,
16 resolve_ontology_path,17 resolve_ontology_path,
17)18)
18from src.train.dispatch import DispatchError, dispatch_experiment19from src.train.dispatch import DispatchError, dispatch_experiment
1920
Importance #79: tests/test_config.py @@ -24,9 +25,9 @@
2425
2526
26def test_all_fourteen_configs_parse_with_frozen_statuses() -> None:27def test_all_fourteen_configs_parse_with_frozen_statuses() -> None:
27 """Every experiment parses and matches the section 6 status matrix."""28 """Every experiment parses and matches the section 6 status matrix."""
28 configs = [load_config(path) for path in CONFIGS]29 configs = [HarnessConfig.from_yaml(path) for path in CONFIGS]
29 assert [item.experiment.id for item in configs] == [30 assert [item.experiment.id for item in configs] == [
30 f"E{index}" for index in range(1, 15)31 f"E{index}" for index in range(1, 15)
31 ]32 ]
32 assert [item.experiment.status for item in configs] == [33 assert [item.experiment.status for item in configs] == [
Importance #80: tests/test_config.py @@ -52,16 +53,32 @@
52 "sweep",53 "sweep",
53 }54 }
5455
5556
57def test_error_class_is_config_error() -> None:
58 """The package error class is a fleet config error and a ValueError."""
59 assert issubclass(ExperimentConfigError, config_loader.ConfigError)
60 assert issubclass(ExperimentConfigError, ValueError)
61
62
63def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None:
64 """Unknown keys fail at the document root."""
65 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
66 raw["surprise_section"] = {"enabled": True}
67 path = tmp_path / "unknown_root.yaml"
68 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
69 with pytest.raises(ExperimentConfigError, match="surprise_section"):
70 HarnessConfig.from_yaml(path)
71
72
56def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:73def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:
57 """Unknown keys fail at nested section boundaries."""74 """Unknown keys fail at nested section boundaries."""
58 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))75 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
59 raw["train"]["surprise_callback"] = True76 raw["train"]["surprise_callback"] = True
60 path = tmp_path / "unknown.yaml"77 path = tmp_path / "unknown.yaml"
61 path.write_text(yaml.safe_dump(raw), encoding="utf-8")78 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
62 with pytest.raises(ConfigError, match="surprise_callback"):79 with pytest.raises(ExperimentConfigError, match="surprise_callback"):
63 load_config(path)80 HarnessConfig.from_yaml(path)
6481
6582
66def test_incompatible_study_override_is_rejected(tmp_path: Path) -> None:83def test_incompatible_study_override_is_rejected(tmp_path: Path) -> None:
67 """Dotted overrides must name an existing leaf and preserve its type."""84 """Dotted overrides must name an existing leaf and preserve its type."""
Importance #81: tests/test_config.py @@ -70,29 +87,29 @@
70 "adapter.spt.voxel_m": "two centimetres"87 "adapter.spt.voxel_m": "two centimetres"
71 }88 }
72 path = tmp_path / "bad_override.yaml"89 path = tmp_path / "bad_override.yaml"
73 path.write_text(yaml.safe_dump(raw), encoding="utf-8")90 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
74 with pytest.raises(ConfigError, match="incompatible value"):91 with pytest.raises(ExperimentConfigError, match="incompatible value"):
75 load_config(path)92 HarnessConfig.from_yaml(path)
7693
7794
78def test_external_framework_visualizer_fields_are_forbidden(tmp_path: Path) -> None:95def test_external_framework_visualizer_fields_are_forbidden(tmp_path: Path) -> None:
79 """SPT/Pointcept YAML cannot configure image-loop visualization callbacks."""96 """SPT/Pointcept YAML cannot configure image-loop visualization callbacks."""
80 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))97 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
81 raw["train"]["viz_samples"] = 498 raw["train"]["viz_samples"] = 4
82 path = tmp_path / "external_viz.yaml"99 path = tmp_path / "external_viz.yaml"
83 path.write_text(yaml.safe_dump(raw), encoding="utf-8")100 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
84 with pytest.raises(ConfigError, match="forbidden"):101 with pytest.raises(ExperimentConfigError, match="forbidden"):
85 load_config(path)102 HarnessConfig.from_yaml(path)
86103
87104
88def test_visualization_block_parses_int_and_named_tiles(tmp_path: Path) -> None:105def test_visualization_block_parses_int_and_named_tiles(tmp_path: Path) -> None:
89 """A valid visualization block is optional and strictly typed."""106 """A valid visualization block is optional and strictly typed."""
90 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))107 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
91 raw["visualization"] = {"masks_every_n_epochs": 2, "masks_tiles": 3}108 raw["visualization"] = {"masks_every_n_epochs": 2, "masks_tiles": 3}
92 path = tmp_path / "viz.yaml"109 path = tmp_path / "viz.yaml"
93 path.write_text(yaml.safe_dump(raw), encoding="utf-8")110 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
94 config = load_config(path)111 config = HarnessConfig.from_yaml(path)
95 assert config.visualization is not None112 assert config.visualization is not None
96 assert config.visualization.masks_every_n_epochs == 2113 assert config.visualization.masks_every_n_epochs == 2
97 assert config.visualization.masks_tiles == 3114 assert config.visualization.masks_tiles == 3
98115
Importance #82: tests/test_config.py @@ -100,15 +117,15 @@
100 "masks_every_n_epochs": 1,117 "masks_every_n_epochs": 1,
101 "masks_tiles": ["tile_a", "tile_b"],118 "masks_tiles": ["tile_a", "tile_b"],
102 }119 }
103 path.write_text(yaml.safe_dump(raw), encoding="utf-8")120 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
104 named = load_config(path)121 named = HarnessConfig.from_yaml(path)
105 assert named.visualization is not None122 assert named.visualization is not None
106 assert named.visualization.masks_tiles == ("tile_a", "tile_b")123 assert named.visualization.masks_tiles == ("tile_a", "tile_b")
107124
108 raw["visualization"] = {"masks_every_n_epochs": 4}125 raw["visualization"] = {"masks_every_n_epochs": 4}
109 path.write_text(yaml.safe_dump(raw), encoding="utf-8")126 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
110 defaulted = load_config(path)127 defaulted = HarnessConfig.from_yaml(path)
111 assert defaulted.visualization is not None128 assert defaulted.visualization is not None
112 assert defaulted.visualization.masks_tiles == 2129 assert defaulted.visualization.masks_tiles == 2
113130
114131
Importance #83: tests/test_config.py @@ -121,35 +138,35 @@
121 "surprise": True,138 "surprise": True,
122 }139 }
123 path = tmp_path / "viz_unknown.yaml"140 path = tmp_path / "viz_unknown.yaml"
124 path.write_text(yaml.safe_dump(raw), encoding="utf-8")141 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
125 with pytest.raises(ConfigError, match="visualization.*surprise"):142 with pytest.raises(ExperimentConfigError, match="visualization.*surprise"):
126 load_config(path)143 HarnessConfig.from_yaml(path)
127144
128145
129def test_visualization_every_n_epochs_zero_is_rejected(tmp_path: Path) -> None:146def test_visualization_every_n_epochs_zero_is_rejected(tmp_path: Path) -> None:
130 """masks_every_n_epochs must be >= 1 when the block is present."""147 """masks_every_n_epochs must be >= 1 when the block is present."""
131 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))148 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
132 raw["visualization"] = {"masks_every_n_epochs": 0, "masks_tiles": 2}149 raw["visualization"] = {"masks_every_n_epochs": 0, "masks_tiles": 2}
133 path = tmp_path / "viz_zero.yaml"150 path = tmp_path / "viz_zero.yaml"
134 path.write_text(yaml.safe_dump(raw), encoding="utf-8")151 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
135 with pytest.raises(ConfigError, match="masks_every_n_epochs"):152 with pytest.raises(ExperimentConfigError, match="masks_every_n_epochs"):
136 load_config(path)153 HarnessConfig.from_yaml(path)
137154
138155
139def test_visualization_empty_tiles_are_rejected(tmp_path: Path) -> None:156def test_visualization_empty_tiles_are_rejected(tmp_path: Path) -> None:
140 """An empty tile list is not a valid selection."""157 """An empty tile list is not a valid selection."""
141 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))158 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
142 raw["visualization"] = {"masks_every_n_epochs": 1, "masks_tiles": []}159 raw["visualization"] = {"masks_every_n_epochs": 1, "masks_tiles": []}
143 path = tmp_path / "viz_empty.yaml"160 path = tmp_path / "viz_empty.yaml"
144 path.write_text(yaml.safe_dump(raw), encoding="utf-8")161 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
145 with pytest.raises(ConfigError, match="masks_tiles"):162 with pytest.raises(ExperimentConfigError, match="masks_tiles"):
146 load_config(path)163 HarnessConfig.from_yaml(path)
147164
148165
149def test_absent_visualization_block_is_none() -> None:166def test_absent_visualization_block_is_none() -> None:
150 """Omitting visualization leaves the feature off."""167 """Omitting visualization leaves the feature off."""
151 config = load_config(CONFIGS[1])168 config = HarnessConfig.from_yaml(CONFIGS[1])
152 assert config.visualization is None169 assert config.visualization is None
153170
154171
155def test_visualization_requires_pointcept_framework(tmp_path: Path) -> None:172def test_visualization_requires_pointcept_framework(tmp_path: Path) -> None:
Importance #84: tests/test_config.py @@ -158,20 +175,22 @@
158 raw = yaml.safe_load(CONFIGS[index].read_text(encoding="utf-8"))175 raw = yaml.safe_load(CONFIGS[index].read_text(encoding="utf-8"))
159 raw["visualization"] = {"masks_every_n_epochs": 2}176 raw["visualization"] = {"masks_every_n_epochs": 2}
160 path = tmp_path / f"viz_wrong_framework_{index}.yaml"177 path = tmp_path / f"viz_wrong_framework_{index}.yaml"
161 path.write_text(yaml.safe_dump(raw), encoding="utf-8")178 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
162 with pytest.raises(ConfigError, match="only supported for.*pointcept"):179 with pytest.raises(
163 load_config(path)180 ExperimentConfigError, match="only supported for.*pointcept"
181 ):
182 HarnessConfig.from_yaml(path)
164183
165184
166def test_noncanonical_deciding_metric_is_rejected(tmp_path: Path) -> None:185def test_noncanonical_deciding_metric_is_rejected(tmp_path: Path) -> None:
167 """Decision metrics cannot silently depend on an unlogged tag."""186 """Decision metrics cannot silently depend on an unlogged tag."""
168 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))187 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
169 raw["experiment"]["deciding_metrics"] = ["val/mystery"]188 raw["experiment"]["deciding_metrics"] = ["val/mystery"]
170 path = tmp_path / "metric.yaml"189 path = tmp_path / "metric.yaml"
171 path.write_text(yaml.safe_dump(raw), encoding="utf-8")190 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
172 with pytest.raises(ConfigError, match="non-canonical"):191 with pytest.raises(ExperimentConfigError, match="non-canonical"):
173 load_config(path)192 HarnessConfig.from_yaml(path)
174193
175194
176@pytest.mark.parametrize(195@pytest.mark.parametrize(
177 ("section", "key", "value", "expected"),196 ("section", "key", "value", "expected"),
Importance #85: tests/test_config.py @@ -209,10 +228,10 @@
209 node[key] = value228 node[key] = value
210 path = tmp_path / "scalar.yaml"229 path = tmp_path / "scalar.yaml"
211 path.write_text(yaml.safe_dump(raw), encoding="utf-8")230 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
212 dotted = ".".join((*section, key))231 dotted = ".".join((*section, key))
213 with pytest.raises(ConfigError) as failure:232 with pytest.raises(ExperimentConfigError) as failure:
214 load_config(path)233 HarnessConfig.from_yaml(path)
215 message = str(failure.value)234 message = str(failure.value)
216 assert str(path) in message235 assert str(path) in message
217 assert dotted in message236 assert dotted in message
218 assert expected in message237 assert expected in message
Importance #86: tests/test_config.py @@ -232,29 +251,29 @@
232 pytest.skip("The reference gate declares no string parameter")251 pytest.skip("The reference gate declares no string parameter")
233 params[key] = 3252 params[key] = 3
234 path = tmp_path / "gate.yaml"253 path = tmp_path / "gate.yaml"
235 path.write_text(yaml.safe_dump(raw), encoding="utf-8")254 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
236 with pytest.raises(ConfigError, match=f"params.{key}"):255 with pytest.raises(ExperimentConfigError, match=f"params.{key}"):
237 load_config(path)256 HarnessConfig.from_yaml(path)
238257
239258
240def test_template_dispatch_names_implementation_ticket() -> None:259def test_template_dispatch_names_implementation_ticket() -> None:
241 """A template fails before runner work and identifies its exact ticket."""260 """A template fails before runner work and identifies its exact ticket."""
242 config = load_config("configs/e03_feature_ablation.yaml")261 config = HarnessConfig.from_yaml("configs/e03_feature_ablation.yaml")
243 with pytest.raises(DispatchError, match="AI3D-MLSEG-E3"):262 with pytest.raises(DispatchError, match="AI3D-MLSEG-E3"):
244 dispatch_experiment(config)263 dispatch_experiment(config)
245264
246265
247def test_gated_dispatch_names_missing_artifact() -> None:266def test_gated_dispatch_names_missing_artifact() -> None:
248 """A gated experiment reports its first unmet prerequisite."""267 """A gated experiment reports its first unmet prerequisite."""
249 config = load_config("configs/e07_ezsp_scale.yaml")268 config = HarnessConfig.from_yaml("configs/e07_ezsp_scale.yaml")
250 with pytest.raises(DispatchError, match="data_contract_acceptance.json"):269 with pytest.raises(DispatchError, match="data_contract_acceptance.json"):
251 dispatch_experiment(config)270 dispatch_experiment(config)
252271
253272
254def test_e1_dispatch_names_partition_oracle_report() -> None:273def test_e1_dispatch_names_partition_oracle_report() -> None:
255 """E1 cannot launch without the partition-purity report."""274 """E1 cannot launch without the partition-purity report."""
256 config = load_config("configs/e01_spt_pilot.yaml")275 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
257 with pytest.raises(DispatchError, match="partition_oracle.json"):276 with pytest.raises(DispatchError, match="partition_oracle.json"):
258 dispatch_experiment(config)277 dispatch_experiment(config)
259278
260279
Importance #87: tests/test_config.py @@ -263,9 +282,9 @@
263) -> None:282) -> None:
264 """An external experiment names every unset framework variable."""283 """An external experiment names every unset framework variable."""
265 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):284 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):
266 monkeypatch.delenv(name, raising=False)285 monkeypatch.delenv(name, raising=False)
267 config = load_config("configs/e01_spt_pilot.yaml")286 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
268 with pytest.raises(DispatchError, match="SPT_ROOT, SPT_COMMIT, SPT_PYTHON"):287 with pytest.raises(DispatchError, match="SPT_ROOT, SPT_COMMIT, SPT_PYTHON"):
269 dispatch_experiment(config)288 dispatch_experiment(config)
270289
271290
Importance #88: tests/test_config.py @@ -276,9 +295,9 @@
276 checkout = _fake_spt_checkout(tmp_path)295 checkout = _fake_spt_checkout(tmp_path)
277 monkeypatch.setenv("SPT_ROOT", str(checkout))296 monkeypatch.setenv("SPT_ROOT", str(checkout))
278 monkeypatch.setenv("SPT_COMMIT", "0" * 40)297 monkeypatch.setenv("SPT_COMMIT", "0" * 40)
279 monkeypatch.setenv("SPT_PYTHON", str(_fake_interpreter(tmp_path)))298 monkeypatch.setenv("SPT_PYTHON", str(_fake_interpreter(tmp_path)))
280 config = load_config("configs/e01_spt_pilot.yaml")299 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
281 with pytest.raises(DispatchError) as failure:300 with pytest.raises(DispatchError) as failure:
282 dispatch_experiment(config)301 dispatch_experiment(config)
283 assert "framework_environment" not in str(failure.value)302 assert "framework_environment" not in str(failure.value)
284303
Importance #89: tests/test_config.py @@ -305,9 +324,9 @@
305) -> None:324) -> None:
306 """CPU experiments never demand external checkout variables."""325 """CPU experiments never demand external checkout variables."""
307 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):326 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):
308 monkeypatch.delenv(name, raising=False)327 monkeypatch.delenv(name, raising=False)
309 config = load_config("configs/e02_voxel_survival_oracle.yaml")328 config = HarnessConfig.from_yaml("configs/e02_voxel_survival_oracle.yaml")
310 with pytest.raises(DispatchError) as failure:329 with pytest.raises(DispatchError) as failure:
311 dispatch_experiment(config)330 dispatch_experiment(config)
312 assert "framework_environment" not in str(failure.value)331 assert "framework_environment" not in str(failure.value)
313332
Importance #90: tests/test_config.py @@ -354,9 +373,9 @@
354373
355def test_v2_recap_config_loads_against_ontology_v2(tmp_path: Path) -> None:374def test_v2_recap_config_loads_against_ontology_v2(tmp_path: Path) -> None:
356 """A v2 task block aligned with recap_semantics_v2 is accepted."""375 """A v2 task block aligned with recap_semantics_v2 is accepted."""
357 path = _write_config(tmp_path, _v2_recap_document(), "v2.yaml")376 path = _write_config(tmp_path, _v2_recap_document(), "v2.yaml")
358 config = load_config(path)377 config = HarnessConfig.from_yaml(path)
359 assert config.task.ontology == ONTOLOGY_V2_PATH378 assert config.task.ontology == ONTOLOGY_V2_PATH
360 assert config.task.num_classes == 11379 assert config.task.num_classes == 11
361 assert config.task.ignore_index == 11380 assert config.task.ignore_index == 11
362 assert config.task.classes_of_interest == tuple(range(9))381 assert config.task.classes_of_interest == tuple(range(9))
Importance #91: tests/test_config.py @@ -378,10 +397,10 @@
378 """task.num_classes must equal ontology.num_predicted_classes."""397 """task.num_classes must equal ontology.num_predicted_classes."""
379 raw = _v2_recap_document()398 raw = _v2_recap_document()
380 raw["task"]["num_classes"] = 9399 raw["task"]["num_classes"] = 9
381 path = _write_config(tmp_path, raw, "v2_num_classes.yaml")400 path = _write_config(tmp_path, raw, "v2_num_classes.yaml")
382 with pytest.raises(ConfigError, match="num_predicted_classes"):401 with pytest.raises(ExperimentConfigError, match="num_predicted_classes"):
383 load_config(path)402 HarnessConfig.from_yaml(path)
384403
385404
386def test_v2_config_rejects_incomplete_classes_of_interest(405def test_v2_config_rejects_incomplete_classes_of_interest(
387 tmp_path: Path,406 tmp_path: Path,
Importance #92: tests/test_config.py @@ -389,10 +408,10 @@
389 """task.classes_of_interest must equal ontology.interest_ids."""408 """task.classes_of_interest must equal ontology.interest_ids."""
390 raw = _v2_recap_document()409 raw = _v2_recap_document()
391 raw["task"]["classes_of_interest"] = list(range(8))410 raw["task"]["classes_of_interest"] = list(range(8))
392 path = _write_config(tmp_path, raw, "v2_interest.yaml")411 path = _write_config(tmp_path, raw, "v2_interest.yaml")
393 with pytest.raises(ConfigError, match="classes_of_interest"):412 with pytest.raises(ExperimentConfigError, match="classes_of_interest"):
394 load_config(path)413 HarnessConfig.from_yaml(path)
395414
396415
397def test_v2_config_rejects_v1_linear_class_names(tmp_path: Path) -> None:416def test_v2_config_rejects_v1_linear_class_names(tmp_path: Path) -> None:
398 """task.linear_classes must be the linear names of the loaded ontology."""417 """task.linear_classes must be the linear names of the loaded ontology."""
Importance #93: tests/test_config.py @@ -402,28 +421,28 @@
402 "wall_noise_barrier",421 "wall_noise_barrier",
403 "fence_gate",422 "fence_gate",
404 ]423 ]
405 path = _write_config(tmp_path, raw, "v2_linear.yaml")424 path = _write_config(tmp_path, raw, "v2_linear.yaml")
406 with pytest.raises(ConfigError, match="linear_classes"):425 with pytest.raises(ExperimentConfigError, match="linear_classes"):
407 load_config(path)426 HarnessConfig.from_yaml(path)
408427
409428
410def test_v2_config_rejects_v1_precision_floor_class(tmp_path: Path) -> None:429def test_v2_config_rejects_v1_precision_floor_class(tmp_path: Path) -> None:
411 """precision_floors keys must be predicted names of the loaded ontology."""430 """precision_floors keys must be predicted names of the loaded ontology."""
412 raw = _v2_recap_document()431 raw = _v2_recap_document()
413 raw["evaluation"]["precision_floors"] = {"sign_gantry": 0.9}432 raw["evaluation"]["precision_floors"] = {"sign_gantry": 0.9}
414 path = _write_config(tmp_path, raw, "v2_floors.yaml")433 path = _write_config(tmp_path, raw, "v2_floors.yaml")
415 with pytest.raises(ConfigError, match="sign_gantry"):434 with pytest.raises(ExperimentConfigError, match="sign_gantry"):
416 load_config(path)435 HarnessConfig.from_yaml(path)
417436
418437
419def test_v2_config_rejects_model_num_classes_mismatch(tmp_path: Path) -> None:438def test_v2_config_rejects_model_num_classes_mismatch(tmp_path: Path) -> None:
420 """model.args.num_classes must equal task.num_classes when present."""439 """model.args.num_classes must equal task.num_classes when present."""
421 raw = _v2_recap_document()440 raw = _v2_recap_document()
422 raw["model"]["args"]["num_classes"] = 9441 raw["model"]["args"]["num_classes"] = 9
423 path = _write_config(tmp_path, raw, "v2_model_classes.yaml")442 path = _write_config(tmp_path, raw, "v2_model_classes.yaml")
424 with pytest.raises(ConfigError, match="model.args.num_classes"):443 with pytest.raises(ExperimentConfigError, match="model.args.num_classes"):
425 load_config(path)444 HarnessConfig.from_yaml(path)
426445
427446
428def test_macro_interest_all9_is_canonical_only_under_v2() -> None:447def test_macro_interest_all9_is_canonical_only_under_v2() -> None:
429 """v2 macros use all9; the frozen v1 all8 tag is not canonical under v2."""448 """v2 macros use all9; the frozen v1 all8 tag is not canonical under v2."""
Importance #94: tests/test_config.py @@ -442,19 +461,19 @@
442 """A v1 ontology has no delineator class, so the monitor is non-canonical."""461 """A v1 ontology has no delineator class, so the monitor is non-canonical."""
443 raw = yaml.safe_load(A1_SMOKE_CONFIG.read_text(encoding="utf-8"))462 raw = yaml.safe_load(A1_SMOKE_CONFIG.read_text(encoding="utf-8"))
444 raw["train"]["monitor"] = "val/iou_delineator"463 raw["train"]["monitor"] = "val/iou_delineator"
445 path = _write_config(tmp_path, raw, "v1_delineator.yaml")464 path = _write_config(tmp_path, raw, "v1_delineator.yaml")
446 with pytest.raises(ConfigError, match="delineator"):465 with pytest.raises(ExperimentConfigError, match="delineator"):
447 load_config(path)466 HarnessConfig.from_yaml(path)
448467
449468
450def test_v2_config_rejects_loss_ignore_index_mismatch(tmp_path: Path) -> None:469def test_v2_config_rejects_loss_ignore_index_mismatch(tmp_path: Path) -> None:
451 """loss.args.ignore_index must equal task.ignore_index when present."""470 """loss.args.ignore_index must equal task.ignore_index when present."""
452 raw = _v2_recap_document()471 raw = _v2_recap_document()
453 raw["loss"]["args"]["ignore_index"] = 9472 raw["loss"]["args"]["ignore_index"] = 9
454 path = _write_config(tmp_path, raw, "v2_loss_ignore_index.yaml")473 path = _write_config(tmp_path, raw, "v2_loss_ignore_index.yaml")
455 with pytest.raises(ConfigError, match="loss.args.ignore_index"):474 with pytest.raises(ExperimentConfigError, match="loss.args.ignore_index"):
456 load_config(path)475 HarnessConfig.from_yaml(path)
457476
458477
459def test_v2_config_rejects_cluster_profiles_from_another_ontology(478def test_v2_config_rejects_cluster_profiles_from_another_ontology(
460 tmp_path: Path,479 tmp_path: Path,
Importance #95: tests/test_config.py @@ -464,19 +483,19 @@
464 raw["evaluation"]["object_matching"]["cluster_profiles"] = (483 raw["evaluation"]["object_matching"]["cluster_profiles"] = (
465 "configs/contracts/ontology_v1.yaml"484 "configs/contracts/ontology_v1.yaml"
466 )485 )
467 path = _write_config(tmp_path, raw, "v2_cluster_profiles.yaml")486 path = _write_config(tmp_path, raw, "v2_cluster_profiles.yaml")
468 with pytest.raises(ConfigError, match="cluster_profiles"):487 with pytest.raises(ExperimentConfigError, match="cluster_profiles"):
469 load_config(path)488 HarnessConfig.from_yaml(path)
470489
471490
472def test_v2_config_accepts_all9_deciding_metric(tmp_path: Path) -> None:491def test_v2_config_accepts_all9_deciding_metric(tmp_path: Path) -> None:
473 """The structural macro suffix follows the ontology's interest count."""492 """The structural macro suffix follows the ontology's interest count."""
474 raw = _v2_recap_document()493 raw = _v2_recap_document()
475 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all9"]494 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all9"]
476 path = _write_config(tmp_path, raw, "v2_all9_metric.yaml")495 path = _write_config(tmp_path, raw, "v2_all9_metric.yaml")
477496
478 config = load_config(path)497 config = HarnessConfig.from_yaml(path)
479498
480 assert config.experiment.deciding_metrics == ("val/iou_macro_interest_all9",)499 assert config.experiment.deciding_metrics == ("val/iou_macro_interest_all9",)
481500
482501
Importance #96: tests/test_config.py @@ -484,10 +503,10 @@
484 """The frozen v1 macro tag is not canonical for a nine-interest ontology."""503 """The frozen v1 macro tag is not canonical for a nine-interest ontology."""
485 raw = _v2_recap_document()504 raw = _v2_recap_document()
486 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all8"]505 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all8"]
487 path = _write_config(tmp_path, raw, "v2_all8_metric.yaml")506 path = _write_config(tmp_path, raw, "v2_all8_metric.yaml")
488 with pytest.raises(ConfigError, match="deciding_metrics"):507 with pytest.raises(ExperimentConfigError, match="deciding_metrics"):
489 load_config(path)508 HarnessConfig.from_yaml(path)
490509
491510
492def test_v2_gate_rejects_purity_class_from_another_ontology(511def test_v2_gate_rejects_purity_class_from_another_ontology(
493 tmp_path: Path,512 tmp_path: Path,
Importance #97: tests/test_config.py @@ -505,10 +524,10 @@
505 },524 },
506 }525 }
507 ]526 ]
508 path = _write_config(tmp_path, raw, "v2_gate_purity.yaml")527 path = _write_config(tmp_path, raw, "v2_gate_purity.yaml")
509 with pytest.raises(ConfigError, match="sign_gantry"):528 with pytest.raises(ExperimentConfigError, match="sign_gantry"):
510 load_config(path)529 HarnessConfig.from_yaml(path)
511530
512531
513def test_v2_gate_accepts_purity_class_of_the_task_ontology(532def test_v2_gate_accepts_purity_class_of_the_task_ontology(
514 tmp_path: Path,533 tmp_path: Path,
Importance #98: tests/test_config.py @@ -527,9 +546,9 @@
527 }546 }
528 ]547 ]
529 path = _write_config(tmp_path, raw, "v2_gate_purity_ok.yaml")548 path = _write_config(tmp_path, raw, "v2_gate_purity_ok.yaml")
530549
531 config = load_config(path)550 config = HarnessConfig.from_yaml(path)
532551
533 assert config.experiment.gates[0].params.minimum_purity_by_class == {552 assert config.experiment.gates[0].params.minimum_purity_by_class == {
534 "delineator": 0.8553 "delineator": 0.8
535 }554 }
Importance #99: tests/test_config.py @@ -545,11 +564,11 @@
545 raw["task"]["ontology"] = broken.name564 raw["task"]["ontology"] = broken.name
546 raw["evaluation"]["object_matching"]["cluster_profiles"] = broken.name565 raw["evaluation"]["object_matching"]["cluster_profiles"] = broken.name
547 path = _write_config(tmp_path, raw, "broken_ontology_config.yaml")566 path = _write_config(tmp_path, raw, "broken_ontology_config.yaml")
548 with pytest.raises(567 with pytest.raises(
549 ConfigError, match=r"task\.ontology .* is not a valid ontology"568 ExperimentConfigError, match=r"task\.ontology .* is not a valid ontology"
550 ):569 ):
551 load_config(path)570 HarnessConfig.from_yaml(path)
552571
553572
554def test_missing_task_ontology_names_the_working_directory(573def test_missing_task_ontology_names_the_working_directory(
555 tmp_path: Path,574 tmp_path: Path,
Importance #100: tests/test_config.py @@ -560,10 +579,10 @@
560 raw["evaluation"]["object_matching"]["cluster_profiles"] = (579 raw["evaluation"]["object_matching"]["cluster_profiles"] = (
561 "configs/contracts/ontology_absent.yaml"580 "configs/contracts/ontology_absent.yaml"
562 )581 )
563 path = _write_config(tmp_path, raw, "absent_ontology_config.yaml")582 path = _write_config(tmp_path, raw, "absent_ontology_config.yaml")
564 with pytest.raises(ConfigError, match="was not found relative to"):583 with pytest.raises(ExperimentConfigError, match="was not found relative to"):
565 load_config(path)584 HarnessConfig.from_yaml(path)
566585
567586
568def test_task_ontology_resolves_inside_the_configs_own_repository(587def test_task_ontology_resolves_inside_the_configs_own_repository(
569 tmp_path: Path, monkeypatch: pytest.MonkeyPatch588 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
Importance #101: tests/test_config.py @@ -589,9 +608,9 @@
589 ONTOLOGY_V2_PATH.read_text(encoding="utf-8"), encoding="utf-8"608 ONTOLOGY_V2_PATH.read_text(encoding="utf-8"), encoding="utf-8"
590 )609 )
591 monkeypatch.chdir(tmp_path / "elsewhere")610 monkeypatch.chdir(tmp_path / "elsewhere")
592611
593 config = load_config(config_path)612 config = HarnessConfig.from_yaml(config_path)
594613
595 assert resolve_ontology_path(config) == repo_contracts / "ontology_v1.yaml"614 assert resolve_ontology_path(config) == repo_contracts / "ontology_v1.yaml"
596 assert config.task.num_classes == 9615 assert config.task.num_classes == 9
597 assert config.task.ignore_index == 9616 assert config.task.ignore_index == 9
Importance #102: tests/test_config.py @@ -603,9 +622,9 @@
603 """task.ontology also resolves against the config's own repository root."""622 """task.ontology also resolves against the config's own repository root."""
604 config_path = Path("configs/e01_spt_pilot.yaml").resolve()623 config_path = Path("configs/e01_spt_pilot.yaml").resolve()
605 monkeypatch.chdir(tmp_path)624 monkeypatch.chdir(tmp_path)
606625
607 config = load_config(config_path)626 config = HarnessConfig.from_yaml(config_path)
608627
609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")628 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")
610 assert config.task.num_classes == 9629 assert config.task.num_classes == 9
611630
Importance #103: tests/test_config.py @@ -617,25 +636,25 @@
617 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))636 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
618 raw["__sha256__"] = "deadbeef"637 raw["__sha256__"] = "deadbeef"
619 path = tmp_path / "smuggled.yaml"638 path = tmp_path / "smuggled.yaml"
620 path.write_text(yaml.safe_dump(raw), encoding="utf-8")639 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
621 with pytest.raises(ConfigError, match="Unknown.*__sha256__"):640 with pytest.raises(ExperimentConfigError, match="Unknown.*__sha256__"):
622 load_config(path)641 HarnessConfig.from_yaml(path)
623642
624643
625def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:644def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:
626 """An explicitly null visualization block is a typo, not an omission."""645 """An explicitly null visualization block is a typo, not an omission."""
627 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))646 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
628 raw["visualization"] = None647 raw["visualization"] = None
629 path = tmp_path / "viz_null.yaml"648 path = tmp_path / "viz_null.yaml"
630 path.write_text(yaml.safe_dump(raw), encoding="utf-8")649 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
631 with pytest.raises(ConfigError, match="visualization must be a mapping"):650 with pytest.raises(ExperimentConfigError, match="visualization must be a mapping"):
632 load_config(path)651 HarnessConfig.from_yaml(path)
633652
634653
635def test_mapping_sections_are_read_only() -> None:654def test_mapping_sections_are_read_only() -> None:
636 """Frozen configs hand out read-only mappings, not mutable dicts."""655 """Frozen configs hand out read-only mappings, not mutable dicts."""
637 config = load_config(CONFIGS[0])656 config = HarnessConfig.from_yaml(CONFIGS[0])
638 with pytest.raises(TypeError):657 with pytest.raises(TypeError):
639 config.model.args["num_classes"] = 99 # type: ignore[index]658 config.model.args["num_classes"] = 99 # type: ignore[index]
640 with pytest.raises(TypeError):659 with pytest.raises(TypeError):
641 config.loss.args["gamma"] = 0.0 # type: ignore[index]660 config.loss.args["gamma"] = 0.0 # type: ignore[index]
Importance #104: tests/test_dispatch_cells.py @@ -10,9 +10,9 @@
1010
11import pytest11import pytest
12import yaml12import yaml
1313
14from src.train.config import load_config14from src.train.config import HarnessConfig
15from src.train.dispatch import DispatchError, DispatchRequest, dispatch_experiment15from src.train.dispatch import DispatchError, DispatchRequest, dispatch_experiment
1616
17BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")17BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
18CONTRACTS_DIR = Path("configs/contracts")18CONTRACTS_DIR = Path("configs/contracts")
Importance #105: tests/test_dispatch_cells.py @@ -39,9 +39,10 @@
39 raw["experiment"]["gates"] = []39 raw["experiment"]["gates"] = []
40 raw["model"]["runner"] = "stub_runner.py"40 raw["model"]["runner"] = "stub_runner.py"
41 raw["study"] = study41 raw["study"] = study
42 (tmp_path / "stub_runner.py").write_text(STUB_RUNNER, encoding="utf-8")42 (tmp_path / "stub_runner.py").write_text(STUB_RUNNER, encoding="utf-8")
43 # load_config resolves task.ontology against the working directory, so the43 # HarnessConfig.from_yaml resolves task.ontology against the working
44 # directory, so the
44 # throwaway root needs the frozen contracts the config names.45 # throwaway root needs the frozen contracts the config names.
45 shutil.copytree(CONTRACTS_DIR, tmp_path / CONTRACTS_DIR, dirs_exist_ok=True)46 shutil.copytree(CONTRACTS_DIR, tmp_path / CONTRACTS_DIR, dirs_exist_ok=True)
46 config_path = tmp_path / "experiment.yaml"47 config_path = tmp_path / "experiment.yaml"
47 config_path.write_text(yaml.safe_dump(raw), encoding="utf-8")48 config_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
Importance #106: tests/test_dispatch_cells.py @@ -81,9 +82,9 @@
81 log_path = tmp_path / "invocations.jsonl"82 log_path = tmp_path / "invocations.jsonl"
82 monkeypatch.setenv("STUB_LOG", str(log_path))83 monkeypatch.setenv("STUB_LOG", str(log_path))
83 request = DispatchRequest(log_dir=tmp_path / "runs")84 request = DispatchRequest(log_dir=tmp_path / "runs")
84 assert dispatch_experiment(85 assert dispatch_experiment(
85 load_config(config_path), request, repository_root=root86 HarnessConfig.from_yaml(config_path), request, repository_root=root
86 ) == 087 ) == 0
87 records = _records(log_path)88 records = _records(log_path)
88 assert [record["cell"] for record in records] == ["coarse", "fine"]89 assert [record["cell"] for record in records] == ["coarse", "fine"]
89 assert [record["index"] for record in records] == ["0", "1"]90 assert [record["index"] for record in records] == ["0", "1"]
Importance #107: tests/test_dispatch_cells.py @@ -106,9 +107,9 @@
106 """Every cell run directory carries identity, overrides, and its SHA-256."""107 """Every cell run directory carries identity, overrides, and its SHA-256."""
107 root, config_path = _repository(tmp_path, _variants_study())108 root, config_path = _repository(tmp_path, _variants_study())
108 monkeypatch.setenv("STUB_LOG", str(tmp_path / "invocations.jsonl"))109 monkeypatch.setenv("STUB_LOG", str(tmp_path / "invocations.jsonl"))
109 dispatch_experiment(110 dispatch_experiment(
110 load_config(config_path),111 HarnessConfig.from_yaml(config_path),
111 DispatchRequest(log_dir=tmp_path / "runs"),112 DispatchRequest(log_dir=tmp_path / "runs"),
112 repository_root=root,113 repository_root=root,
113 )114 )
114 provenance = json.loads(115 provenance = json.loads(
Importance #108: tests/test_dispatch_cells.py @@ -138,9 +139,9 @@
138 root, config_path = _repository(tmp_path, study)139 root, config_path = _repository(tmp_path, study)
139 log_path = tmp_path / "invocations.jsonl"140 log_path = tmp_path / "invocations.jsonl"
140 monkeypatch.setenv("STUB_LOG", str(log_path))141 monkeypatch.setenv("STUB_LOG", str(log_path))
141 dispatch_experiment(142 dispatch_experiment(
142 load_config(config_path),143 HarnessConfig.from_yaml(config_path),
143 DispatchRequest(log_dir=tmp_path / "runs"),144 DispatchRequest(log_dir=tmp_path / "runs"),
144 repository_root=root,145 repository_root=root,
145 )146 )
146 records = _records(log_path)147 records = _records(log_path)
Importance #109: tests/test_dispatch_cells.py @@ -158,9 +159,9 @@
158 monkeypatch.setenv("STUB_LOG", str(log_path))159 monkeypatch.setenv("STUB_LOG", str(log_path))
159 monkeypatch.setenv("STUB_EXIT_CODE", "3")160 monkeypatch.setenv("STUB_EXIT_CODE", "3")
160 with pytest.raises(DispatchError, match="cell coarse"):161 with pytest.raises(DispatchError, match="cell coarse"):
161 dispatch_experiment(162 dispatch_experiment(
162 load_config(config_path),163 HarnessConfig.from_yaml(config_path),
163 DispatchRequest(log_dir=tmp_path / "runs"),164 DispatchRequest(log_dir=tmp_path / "runs"),
164 repository_root=root,165 repository_root=root,
165 )166 )
166 assert len(_records(log_path)) == 1167 assert len(_records(log_path)) == 1
Importance #110: tests/test_dispatch_cells.py @@ -172,9 +173,9 @@
172 """DispatchRequest narrowing is forwarded unchanged to each cell."""173 """DispatchRequest narrowing is forwarded unchanged to each cell."""
173 root, config_path = _repository(tmp_path, _variants_study())174 root, config_path = _repository(tmp_path, _variants_study())
174 log_path = tmp_path / "invocations.jsonl"175 log_path = tmp_path / "invocations.jsonl"
175 monkeypatch.setenv("STUB_LOG", str(log_path))176 monkeypatch.setenv("STUB_LOG", str(log_path))
176 config = load_config(config_path)177 config = HarnessConfig.from_yaml(config_path)
177 request = DispatchRequest(178 request = DispatchRequest(
178 corridors=(config.data.corridors.include[0],),179 corridors=(config.data.corridors.include[0],),
179 fast_dev_run=True,180 fast_dev_run=True,
180 num_workers=2,181 num_workers=2,
Importance #111: tests/test_dispatch_cells.py @@ -194,9 +195,9 @@
194 """A training run needs both folds, so --split fails early and by name."""195 """A training run needs both folds, so --split fails early and by name."""
195 root, config_path = _repository(tmp_path, _variants_study())196 root, config_path = _repository(tmp_path, _variants_study())
196 with pytest.raises(DispatchError, match="refuses --split"):197 with pytest.raises(DispatchError, match="refuses --split"):
197 dispatch_experiment(198 dispatch_experiment(
198 load_config(config_path),199 HarnessConfig.from_yaml(config_path),
199 DispatchRequest(splits=("train",)),200 DispatchRequest(splits=("train",)),
200 repository_root=root,201 repository_root=root,
201 )202 )
202203
Importance #112: tests/test_dispatch_cells.py @@ -236,8 +237,8 @@
236 """Study expansion never relaxes the narrowing contract."""237 """Study expansion never relaxes the narrowing contract."""
237 root, config_path = _repository(tmp_path, _variants_study())238 root, config_path = _repository(tmp_path, _variants_study())
238 with pytest.raises(DispatchError, match="promotion"):239 with pytest.raises(DispatchError, match="promotion"):
239 dispatch_experiment(240 dispatch_experiment(
240 load_config(config_path),241 HarnessConfig.from_yaml(config_path),
241 DispatchRequest(splits=("promotion_test",)),242 DispatchRequest(splits=("promotion_test",)),
242 repository_root=root,243 repository_root=root,
243 )244 )
Importance #113: tests/test_dispatch_environment.py @@ -5,9 +5,9 @@
5from pathlib import Path5from pathlib import Path
66
7import pytest7import pytest
88
9from src.train.config import load_config9from src.train.config import HarnessConfig
10from src.train.dispatch import DispatchError, dispatch_experiment10from src.train.dispatch import DispatchError, dispatch_experiment
1111
12SPT_CONFIG = Path("configs/e01_spt_pilot.yaml")12SPT_CONFIG = Path("configs/e01_spt_pilot.yaml")
13CPU_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")13CPU_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
Importance #114: tests/test_dispatch_environment.py @@ -32,9 +32,9 @@
3232
3333
34def _dispatch_message() -> str:34def _dispatch_message() -> str:
35 """Dispatch the SPT pilot and return its refusal message."""35 """Dispatch the SPT pilot and return its refusal message."""
36 config = load_config(SPT_CONFIG)36 config = HarnessConfig.from_yaml(SPT_CONFIG)
37 with pytest.raises(DispatchError) as failure:37 with pytest.raises(DispatchError) as failure:
38 dispatch_experiment(config)38 dispatch_experiment(config)
39 return str(failure.value)39 return str(failure.value)
4040
Importance #115: tests/test_dispatch_environment.py @@ -157,8 +157,8 @@
157 monkeypatch: pytest.MonkeyPatch,157 monkeypatch: pytest.MonkeyPatch,
158) -> None:158) -> None:
159 """CPU experiments never consult the external framework environment."""159 """CPU experiments never consult the external framework environment."""
160 monkeypatch.setenv("SPT_ROOT", "/definitely/not/a/checkout")160 monkeypatch.setenv("SPT_ROOT", "/definitely/not/a/checkout")
161 config = load_config(CPU_CONFIG)161 config = HarnessConfig.from_yaml(CPU_CONFIG)
162 with pytest.raises(DispatchError) as failure:162 with pytest.raises(DispatchError) as failure:
163 dispatch_experiment(config)163 dispatch_experiment(config)
164 assert "framework_environment" not in str(failure.value)164 assert "framework_environment" not in str(failure.value)
Importance #116: tests/test_framework_run_provenance.py @@ -24,9 +24,9 @@
24 SplitTier,24 SplitTier,
25 authorize_split_access,25 authorize_split_access,
26 load_split_manifest,26 load_split_manifest,
27)27)
28from src.train.config import HarnessConfig, load_config28from src.train.config import HarnessConfig
2929
30CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")30CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")
31GRID_ORIGIN = [10.0, -20.0, 5.0]31GRID_ORIGIN = [10.0, -20.0, 5.0]
3232
Importance #117: tests/test_framework_run_provenance.py @@ -59,9 +59,9 @@
5959
6060
61def _config_with_prepared_corridors(tmp_path: Path) -> HarnessConfig:61def _config_with_prepared_corridors(tmp_path: Path) -> HarnessConfig:
62 """Return the E1 config pointed at synthetic adapter manifests."""62 """Return the E1 config pointed at synthetic adapter manifests."""
63 config = load_config(CONFIG_PATH)63 config = HarnessConfig.from_yaml(CONFIG_PATH)
64 canonical_root = tmp_path / "canonical_root"64 canonical_root = tmp_path / "canonical_root"
65 (canonical_root / "canonical").mkdir(parents=True)65 (canonical_root / "canonical").mkdir(parents=True)
66 for corridor_id in config.data.corridors.include:66 for corridor_id in config.data.corridors.include:
67 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(67 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(
Importance #118: tests/test_study_expansion.py @@ -7,9 +7,9 @@
77
8import pytest8import pytest
9import yaml9import yaml
1010
11from src.train.config import ConfigError, expand_study, load_config11from src.train.config import ExperimentConfigError, HarnessConfig, expand_study
1212
13BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")13BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
14SINGLE_CONFIG = Path("configs/e01_spt_pilot.yaml")14SINGLE_CONFIG = Path("configs/e01_spt_pilot.yaml")
1515
Importance #119: tests/test_study_expansion.py @@ -34,9 +34,9 @@
3434
3535
36def test_variants_study_expands_to_one_cell_per_variant() -> None:36def test_variants_study_expands_to_one_cell_per_variant() -> None:
37 """Every declared variant becomes its own resolved, hashed configuration."""37 """Every declared variant becomes its own resolved, hashed configuration."""
38 config = load_config(BASE_CONFIG)38 config = HarnessConfig.from_yaml(BASE_CONFIG)
39 cells = expand_study(config)39 cells = expand_study(config)
40 assert [cell.id for cell in cells] == [40 assert [cell.id for cell in cells] == [
41 variant.id for variant in config.study.variants41 variant.id for variant in config.study.variants
42 ]42 ]
Importance #120: tests/test_study_expansion.py @@ -49,9 +49,9 @@
4949
5050
51def test_single_study_expands_to_exactly_one_identity_cell() -> None:51def test_single_study_expands_to_exactly_one_identity_cell() -> None:
52 """A single study runs once and changes nothing in the resolved config."""52 """A single study runs once and changes nothing in the resolved config."""
53 config = load_config(SINGLE_CONFIG)53 config = HarnessConfig.from_yaml(SINGLE_CONFIG)
54 cells = expand_study(config)54 cells = expand_study(config)
55 assert len(cells) == 155 assert len(cells) == 1
56 assert cells[0].overrides == {}56 assert cells[0].overrides == {}
57 assert cells[0].id == config.experiment.id57 assert cells[0].id == config.experiment.id
Importance #121: tests/test_study_expansion.py @@ -65,11 +65,11 @@
6565
6666
67def test_cell_order_and_hashes_are_deterministic() -> None:67def test_cell_order_and_hashes_are_deterministic() -> None:
68 """Repeated expansion yields identical identities, order, and hashes."""68 """Repeated expansion yields identical identities, order, and hashes."""
69 config = load_config(BASE_CONFIG)69 config = HarnessConfig.from_yaml(BASE_CONFIG)
70 first = expand_study(config)70 first = expand_study(config)
71 second = expand_study(load_config(BASE_CONFIG))71 second = expand_study(HarnessConfig.from_yaml(BASE_CONFIG))
72 assert [cell.id for cell in first] == [cell.id for cell in second]72 assert [cell.id for cell in first] == [cell.id for cell in second]
73 assert [cell.config.sha256 for cell in first] == [73 assert [cell.config.sha256 for cell in first] == [
74 cell.config.sha256 for cell in second74 cell.config.sha256 for cell in second
75 ]75 ]
Importance #122: tests/test_study_expansion.py @@ -82,10 +82,10 @@
82 {"id": "typo", "overrides": {"adapter.spt.voxel_metres": 0.02}}82 {"id": "typo", "overrides": {"adapter.spt.voxel_metres": 0.02}}
83 ]83 ]
84 raw["study"]["contrasts"] = []84 raw["study"]["contrasts"] = []
85 path = _write(raw, tmp_path / "unknown_override.yaml")85 path = _write(raw, tmp_path / "unknown_override.yaml")
86 with pytest.raises(ConfigError, match="adapter.spt.voxel_metres"):86 with pytest.raises(ExperimentConfigError, match="adapter.spt.voxel_metres"):
87 load_config(path)87 HarnessConfig.from_yaml(path)
8888
8989
90def test_duplicate_variant_cell_ids_are_rejected(tmp_path: Path) -> None:90def test_duplicate_variant_cell_ids_are_rejected(tmp_path: Path) -> None:
91 """Two cells cannot share an identity."""91 """Two cells cannot share an identity."""
Importance #123: tests/test_study_expansion.py @@ -95,10 +95,10 @@
95 {"id": "twin", "overrides": {"data.tiling.min_points": 2}},95 {"id": "twin", "overrides": {"data.tiling.min_points": 2}},
96 ]96 ]
97 raw["study"]["contrasts"] = []97 raw["study"]["contrasts"] = []
98 path = _write(raw, tmp_path / "duplicate_ids.yaml")98 path = _write(raw, tmp_path / "duplicate_ids.yaml")
99 with pytest.raises(ConfigError, match="duplicate"):99 with pytest.raises(ExperimentConfigError, match="duplicate"):
100 load_config(path)100 HarnessConfig.from_yaml(path)
101101
102102
103def test_kind_without_its_payload_is_rejected(tmp_path: Path) -> None:103def test_kind_without_its_payload_is_rejected(tmp_path: Path) -> None:
104 """A discriminated kind whose payload is absent never expands silently."""104 """A discriminated kind whose payload is absent never expands silently."""
Importance #124: tests/test_study_expansion.py @@ -110,10 +110,10 @@
110 "sweep": None,110 "sweep": None,
111 "contrasts": [],111 "contrasts": [],
112 }112 }
113 path = _write(raw, tmp_path / "empty_variants.yaml")113 path = _write(raw, tmp_path / "empty_variants.yaml")
114 with pytest.raises(ConfigError, match="variants"):114 with pytest.raises(ExperimentConfigError, match="variants"):
115 load_config(path)115 HarnessConfig.from_yaml(path)
116116
117117
118def test_matrix_study_expands_cross_product_minus_exclusions(118def test_matrix_study_expands_cross_product_minus_exclusions(
119 tmp_path: Path,119 tmp_path: Path,
Importance #125: tests/test_study_expansion.py @@ -135,9 +135,9 @@
135 },135 },
136 "sweep": None,136 "sweep": None,
137 "contrasts": [],137 "contrasts": [],
138 }138 }
139 config = load_config(_write(raw, tmp_path / "matrix.yaml"))139 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "matrix.yaml"))
140 cells = expand_study(config)140 cells = expand_study(config)
141 assert [cell.id for cell in cells] == [141 assert [cell.id for cell in cells] == [
142 "min_points-1__samples-500",142 "min_points-1__samples-500",
143 "min_points-2__samples-500",143 "min_points-2__samples-500",
Importance #126: tests/test_study_expansion.py @@ -161,10 +161,10 @@
161 },161 },
162 "sweep": None,162 "sweep": None,
163 "contrasts": [],163 "contrasts": [],
164 }164 }
165 config = load_config(_write(raw, tmp_path / "stale_exclude.yaml"))165 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "stale_exclude.yaml"))
166 with pytest.raises(ConfigError, match="matches no matrix cell"):166 with pytest.raises(ExperimentConfigError, match="matches no matrix cell"):
167 expand_study(config)167 expand_study(config)
168168
169169
170def test_grid_sweep_expands_deterministically_within_budget(170def test_grid_sweep_expands_deterministically_within_budget(
Importance #127: tests/test_study_expansion.py @@ -183,9 +183,9 @@
183 "objective": "val/iou_macro_interest",183 "objective": "val/iou_macro_interest",
184 },184 },
185 "contrasts": [],185 "contrasts": [],
186 }186 }
187 config = load_config(_write(raw, tmp_path / "grid_sweep.yaml"))187 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "grid_sweep.yaml"))
188 cells = expand_study(config)188 cells = expand_study(config)
189 assert [cell.id for cell in cells] == ["min_points-1", "min_points-2"]189 assert [cell.id for cell in cells] == ["min_points-1", "min_points-2"]
190 assert [cell.config.data.tiling.min_points for cell in cells] == [1, 2]190 assert [cell.config.data.tiling.min_points for cell in cells] == [1, 2]
191191
Importance #128: tests/test_study_expansion.py @@ -212,9 +212,9 @@
212 "objective": "val/iou_macro_interest",212 "objective": "val/iou_macro_interest",
213 },213 },
214 "contrasts": [],214 "contrasts": [],
215 }215 }
216 config = load_config(_write(raw, tmp_path / "random_sweep.yaml"))216 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "random_sweep.yaml"))
217 first = expand_study(config)217 first = expand_study(config)
218 second = expand_study(config)218 second = expand_study(config)
219 assert [cell.id for cell in first] == ["sample_000", "sample_001", "sample_002"]219 assert [cell.id for cell in first] == ["sample_000", "sample_001", "sample_002"]
220 assert [220 assert [
Importance #129: tests/test_study_expansion.py @@ -237,17 +237,17 @@
237 "objective": "val/iou_macro_interest",237 "objective": "val/iou_macro_interest",
238 },238 },
239 "contrasts": [],239 "contrasts": [],
240 }240 }
241 config = load_config(_write(raw, tmp_path / "bayes_sweep.yaml"))241 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "bayes_sweep.yaml"))
242 with pytest.raises(ConfigError, match="bayesian"):242 with pytest.raises(ExperimentConfigError, match="bayesian"):
243 expand_study(config)243 expand_study(config)
244244
245245
246def test_every_repository_experiment_expands() -> None:246def test_every_repository_experiment_expands() -> None:
247 """All E1-E14 studies expand to at least one strictly validated cell."""247 """All E1-E14 studies expand to at least one strictly validated cell."""
248 for path in sorted(Path("configs").glob("e*.yaml")):248 for path in sorted(Path("configs").glob("e*.yaml")):
249 config = load_config(path)249 config = HarnessConfig.from_yaml(path)
250 cells = expand_study(config)250 cells = expand_study(config)
251 assert cells251 assert cells
252 assert len({cell.id for cell in cells}) == len(cells)252 assert len({cell.id for cell in cells}) == len(cells)
253 if config.study.kind == "single":253 if config.study.kind == "single":
Importance #130: tests/test_voxel_oracle.py @@ -760,22 +760,22 @@
760760
761761
762def test_voxel_sizes_come_from_the_study_variants() -> None:762def test_voxel_sizes_come_from_the_study_variants() -> None:
763 """The study variants are the single source of the analysed grid sizes."""763 """The study variants are the single source of the analysed grid sizes."""
764 from src.train.config import load_config764 from src.train.config import HarnessConfig
765765
766 module = _load_oracle_script()766 module = _load_oracle_script()
767767
768 assert module._voxel_sizes(load_config(E2_CONFIG), Path(E2_CONFIG)) == (768 assert module._voxel_sizes(HarnessConfig.from_yaml(E2_CONFIG), Path(E2_CONFIG)) == (
769 0.02,769 0.02,
770 0.03,770 0.03,
771 0.05,771 0.05,
772 )772 )
773773
774774
775def test_config_without_variant_voxel_sizes_fails_closed(tmp_path: Path) -> None:775def test_config_without_variant_voxel_sizes_fails_closed(tmp_path: Path) -> None:
776 """A config declaring no variant voxel size refuses to run E2."""776 """A config declaring no variant voxel size refuses to run E2."""
777 from src.train.config import load_config777 from src.train.config import HarnessConfig
778778
779 module = _load_oracle_script()779 module = _load_oracle_script()
780780
781 def _drop_variants(raw: dict[str, Any]) -> None:781 def _drop_variants(raw: dict[str, Any]) -> None:
Importance #131: tests/test_voxel_oracle.py @@ -785,14 +785,14 @@
785785
786 path = _config_variant(tmp_path, _drop_variants)786 path = _config_variant(tmp_path, _drop_variants)
787787
788 with pytest.raises(VoxelOracleError, match="adapter.spt.voxel_m"):788 with pytest.raises(VoxelOracleError, match="adapter.spt.voxel_m"):
789 module._voxel_sizes(load_config(path), path)789 module._voxel_sizes(HarnessConfig.from_yaml(path), path)
790790
791791
792def test_restated_model_voxel_sizes_must_agree(tmp_path: Path) -> None:792def test_restated_model_voxel_sizes_must_agree(tmp_path: Path) -> None:
793 """A duplicated ``model.args.voxel_sizes_m`` must match or fail closed."""793 """A duplicated ``model.args.voxel_sizes_m`` must match or fail closed."""
794 from src.train.config import load_config794 from src.train.config import HarnessConfig
795795
796 module = _load_oracle_script()796 module = _load_oracle_script()
797797
798 def _restate(raw: dict[str, Any]) -> None:798 def _restate(raw: dict[str, Any]) -> None:
Importance #132: tests/test_voxel_oracle.py @@ -800,9 +800,9 @@
800800
801 path = _config_variant(tmp_path, _restate)801 path = _config_variant(tmp_path, _restate)
802802
803 with pytest.raises(VoxelOracleError, match="voxel_sizes_m"):803 with pytest.raises(VoxelOracleError, match="voxel_sizes_m"):
804 module._voxel_sizes(load_config(path), path)804 module._voxel_sizes(HarnessConfig.from_yaml(path), path)
805805
806806
807def test_required_output_formats_are_written(tmp_path: Path) -> None:807def test_required_output_formats_are_written(tmp_path: Path) -> None:
808 """E2 always writes CSV, JSON, and Markdown products."""808 """E2 always writes CSV, JSON, and Markdown products."""
Importance #133: tests/test_voxel_oracle.py @@ -852,12 +852,12 @@
852852
853853
854def test_dispatched_cell_analyses_only_its_own_voxel_size() -> None:854def test_dispatched_cell_analyses_only_its_own_voxel_size() -> None:
855 """Dispatch runs E2 once per cell, so a cell must not re-run the sweep."""855 """Dispatch runs E2 once per cell, so a cell must not re-run the sweep."""
856 from src.train.config import load_config856 from src.train.config import HarnessConfig
857857
858 module = _load_oracle_script()858 module = _load_oracle_script()
859 config = load_config(E2_CONFIG)859 config = HarnessConfig.from_yaml(E2_CONFIG)
860860
861 assert module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_2cm", 0)) == (861 assert module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_2cm", 0)) == (
862 0.02,862 0.02,
863 )863 )
Importance #134: tests/test_voxel_oracle.py @@ -872,12 +872,12 @@
872872
873873
874def test_unknown_dispatched_cell_fails_closed() -> None:874def test_unknown_dispatched_cell_fails_closed() -> None:
875 """An unknown or misplaced study cell is refused by name."""875 """An unknown or misplaced study cell is refused by name."""
876 from src.train.config import load_config876 from src.train.config import HarnessConfig
877877
878 module = _load_oracle_script()878 module = _load_oracle_script()
879 config = load_config(E2_CONFIG)879 config = HarnessConfig.from_yaml(E2_CONFIG)
880880
881 with pytest.raises(VoxelOracleError, match="voxel_9cm"):881 with pytest.raises(VoxelOracleError, match="voxel_9cm"):
882 module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_9cm", 0))882 module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_9cm", 0))
883 with pytest.raises(VoxelOracleError, match="position 1"):883 with pytest.raises(VoxelOracleError, match="position 1"):
Importance #135: CLAUDE.md @@ -18,9 +18,9 @@
18## Data and experiments18## Data and experiments
1919
20- Keep source and generated datasets under the DVC lanes in `data/`; never commit data products directly.20- Keep source and generated datasets under the DVC lanes in `data/`; never commit data products directly.
21- Every experiment is configured by one strict YAML file in `configs/`.21- Every experiment is configured by one strict YAML file in `configs/`.
22- The configuration schema is a pydantic model tree on `iolabs.common.config_loader.ConfigModel`: `src/train/config_schema.py` (experiment, gates, study), `src/train/config_sections.py` (task, data, adapter, model, train, evaluation, runtime, provenance). Adding a key means adding a typed field to its model and to the YAML documents under `configs/`; nothing else. Cross-section and ontology-dependent rules live in `src/train/config_rules.py`, study expansion in `src/train/config_study.py`, and the strict YAML leaf typing in `src/train/config_values.py`. `src/train/config.py` stays the only import site (`load_config`, `expand_study`, `ConfigError`, the model classes).22- The configuration schema is a pydantic model tree on `iolabs.common.config_loader.ConfigModel`: `src/train/config_schema.py` (experiment, gates, study), `src/train/config_sections.py` (task, data, adapter, model, train, evaluation, runtime, provenance). Adding a key means adding a typed field to its model and to the YAML documents under `configs/`; nothing else. Cross-section and ontology-dependent rules live in `src/train/config_rules.py`, study expansion in `src/train/config_study.py`, and the strict YAML leaf typing in `src/train/config_values.py`. `src/train/config.py` stays the only import site (`HarnessConfig.from_yaml`, `expand_study`, `ExperimentConfigError`, the model classes).
23- Class counts come from the experiment's ontology (`task.ontology`): the model predicts train IDs `0..N-1` and ID `N` is void with no output channel (v1: N=9, roadside classes; v2: N=11, the ReCap annotation classes with junk merged into unclassified/void). Never hard-code a class count.23- Class counts come from the experiment's ontology (`task.ontology`): the model predicts train IDs `0..N-1` and ID `N` is void with no output channel (v1: N=9, roadside classes; v2: N=11, the ReCap annotation classes with junk merged into unclassified/void). Never hard-code a class count.
24- Boundary LAS codes belong to the code space the ontology, export map, and artifact all declare (`las_code_space`): `seg3d` is the legacy upstream space (v1), `asprs` is ASPRS LAS 1.4 and the ReCap lane (v2). Never read a LAS code without its space; resolve it through `registry_for(space)` in `src/contracts/las_codes.py`.24- Boundary LAS codes belong to the code space the ontology, export map, and artifact all declare (`las_code_space`): `seg3d` is the legacy upstream space (v1), `asprs` is ASPRS LAS 1.4 and the ReCap lane (v2). Never read a LAS code without its space; resolve it through `registry_for(space)` in `src/contracts/las_codes.py`.
25- Never train on, evaluate on, or inspect `promotion_test` data without the command's explicit promotion authorization arguments.25- Never train on, evaluate on, or inspect `promotion_test` data without the command's explicit promotion authorization arguments.
26- E2 accepts only canonical data whose manifest proves full-resolution, in-memory `FuseResult.classification` provenance.26- E2 accepts only canonical data whose manifest proves full-resolution, in-memory `FuseResult.classification` provenance.
Importance #136: README.md @@ -14,8 +14,29 @@
14```14```
1515
16The first command installs the CPU preparation/E2 environment. The `ml` extra adds Torch, Lightning, TensorBoard, torchmetrics, and `iolabs-ml-harness`; it still does not install SPT, Pointcept, spconv, or FlashAttention. For shared-harness development, run `scripts/dev/use_local_mlharness.sh`; a later `uv sync --extra ml` restores the locked source.16The first command installs the CPU preparation/E2 environment. The `ml` extra adds Torch, Lightning, TensorBoard, torchmetrics, and `iolabs-ml-harness`; it still does not install SPT, Pointcept, spconv, or FlashAttention. For shared-harness development, run `scripts/dev/use_local_mlharness.sh`; a later `uv sync --extra ml` restores the locked source.
1717
18## Configuration
19
20Every experiment is one strict YAML document under `configs/`. The schema is
21`HarnessConfig` in `src/train/config.py` (a `config_loader.ConfigModel` through the
22repository-local `StrictConfigModel`); nested YAML blocks are nested models and unknown
23keys are rejected. **To add a config key: add the field (with its type, default and any
24`Field` range) to its model and the same key to the YAML documents under `configs/` --
25nothing else.** `HarnessConfig.from_yaml(path)` returns the frozen `HarnessConfig`,
26`expand_study(config)` returns its re-validated study cells, and both raise
27`ExperimentConfigError`.
28
29The schema exceeds one module, so it is split by section (fleet rule "field declarations
30only"): `src/train/config_schema.py` (experiment, gates, study), `src/train/config_sections.py`
31(task, data, adapter, model, train, evaluation, runtime, provenance), `src/train/config_values.py`
32(strict YAML leaf typing), `src/train/config_rules.py` (cross-section and ontology rules),
33`src/train/config_study.py` (study algebra). `src/train/config.py` stays the only import
34site. Leaf typing is deliberately stricter than the fleet coercion matrix -- `"50"` is not
35a float and `1` is not a boolean -- the sanctioned opt-out for this YAML experiment
36contract, documented in `src/train/config_values.py`. Runtime narrowing comes from CLI
37arguments and study overrides, never from repo-local JSON.
38
18## E2: voxel-survival oracle39## E2: voxel-survival oracle
1940
20E2 is local-first and CPU-capable. It must run only after canonical preparation regenerates and stages the full-resolution in-memory `FuseResult.classification`; written seg3d/ReCap representative clouds are rejected as oracle input.41E2 is local-first and CPU-capable. It must run only after canonical preparation regenerates and stages the full-resolution in-memory `FuseResult.classification`; written seg3d/ReCap representative clouds are rejected as oracle input.
2142
Importance #137: README.md @@ -14,8 +14,29 @@
14```14```
1515
16The first command installs the CPU preparation/E2 environment. The `ml` extra adds Torch, Lightning, TensorBoard, torchmetrics, and `iolabs-ml-harness`; it still does not install SPT, Pointcept, spconv, or FlashAttention. For shared-harness development, run `scripts/dev/use_local_mlharness.sh`; a later `uv sync --extra ml` restores the locked source.16The first command installs the CPU preparation/E2 environment. The `ml` extra adds Torch, Lightning, TensorBoard, torchmetrics, and `iolabs-ml-harness`; it still does not install SPT, Pointcept, spconv, or FlashAttention. For shared-harness development, run `scripts/dev/use_local_mlharness.sh`; a later `uv sync --extra ml` restores the locked source.
1717
18## Configuration
19
20Every experiment is one strict YAML document under `configs/`. The schema is
21`HarnessConfig` in `src/train/config.py` (a `config_loader.ConfigModel` through the
22repository-local `StrictConfigModel`); nested YAML blocks are nested models and unknown
23keys are rejected. **To add a config key: add the field (with its type, default and any
24`Field` range) to its model and the same key to the YAML documents under `configs/` --
25nothing else.** `HarnessConfig.from_yaml(path)` returns the frozen `HarnessConfig`,
26`expand_study(config)` returns its re-validated study cells, and both raise
27`ExperimentConfigError`.
28
29The schema exceeds one module, so it is split by section (fleet rule "field declarations
30only"): `src/train/config_schema.py` (experiment, gates, study), `src/train/config_sections.py`
31(task, data, adapter, model, train, evaluation, runtime, provenance), `src/train/config_values.py`
32(strict YAML leaf typing), `src/train/config_rules.py` (cross-section and ontology rules),
33`src/train/config_study.py` (study algebra). `src/train/config.py` stays the only import
34site. Leaf typing is deliberately stricter than the fleet coercion matrix -- `"50"` is not
35a float and `1` is not a boolean -- the sanctioned opt-out for this YAML experiment
36contract, documented in `src/train/config_values.py`. Runtime narrowing comes from CLI
37arguments and study overrides, never from repo-local JSON.
38
18## E2: voxel-survival oracle39## E2: voxel-survival oracle
1940
20E2 is local-first and CPU-capable. It must run only after canonical preparation regenerates and stages the full-resolution in-memory `FuseResult.classification`; written seg3d/ReCap representative clouds are rejected as oracle input.41E2 is local-first and CPU-capable. It must run only after canonical preparation regenerates and stages the full-resolution in-memory `FuseResult.classification`; written seg3d/ReCap representative clouds are rejected as oracle input.
2142
Importance #138: frameworks/emission_check.py @@ -23,9 +23,9 @@
23from pathlib import Path23from pathlib import Path
24from typing import Any24from typing import Any
2525
26from src.contracts.ontology import Ontology, OntologyError, load_ontology26from src.contracts.ontology import Ontology, OntologyError, load_ontology
27from src.train.config import ConfigError, load_config, resolve_ontology_path27from src.train.config import ExperimentConfigError, HarnessConfig, resolve_ontology_path
2828
29POINTCEPT_FRAMEWORK = "pointcept"29POINTCEPT_FRAMEWORK = "pointcept"
30SPT_FRAMEWORK = "spt"30SPT_FRAMEWORK = "spt"
31SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)31SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)
Importance #139: frameworks/emission_check.py @@ -171,17 +171,17 @@
171171
172 Raises:172 Raises:
173 EmissionCheckError: If the framework is unsupported, a narrowing is173 EmissionCheckError: If the framework is unsupported, a narrowing is
174 requested for SPT, or the emission disagrees with the ontology.174 requested for SPT, or the emission disagrees with the ontology.
175 ConfigError: If the harness YAML is malformed.175 ExperimentConfigError: If the harness YAML is malformed.
176 OntologyError: If the declared ontology cannot be loaded.176 OntologyError: If the declared ontology cannot be loaded.
177 """177 """
178 if framework not in SUPPORTED_FRAMEWORKS:178 if framework not in SUPPORTED_FRAMEWORKS:
179 raise EmissionCheckError(179 raise EmissionCheckError(
180 f"{config_path}: unsupported framework {framework!r}; expected one "180 f"{config_path}: unsupported framework {framework!r}; expected one "
181 f"of {list(SUPPORTED_FRAMEWORKS)}"181 f"of {list(SUPPORTED_FRAMEWORKS)}"
182 )182 )
183 config = load_config(config_path)183 config = HarnessConfig.from_yaml(config_path)
184 ontology = load_ontology(resolve_ontology_path(config))184 ontology = load_ontology(resolve_ontology_path(config))
185 lane = Path(processed_root) if processed_root else config.data.processed_root185 lane = Path(processed_root) if processed_root else config.data.processed_root
186 if framework == SPT_FRAMEWORK:186 if framework == SPT_FRAMEWORK:
187 if corridors:187 if corridors:
Importance #140: frameworks/emission_check.py @@ -245,9 +245,9 @@
245 processed_root=args.processed_root,245 processed_root=args.processed_root,
246 corridors=tuple(args.corridor),246 corridors=tuple(args.corridor),
247 )247 )
248 except (248 except (
249 ConfigError,249 ExperimentConfigError,
250 EmissionCheckError,250 EmissionCheckError,
251 OntologyError,251 OntologyError,
252 OSError,252 OSError,
253 ) as exc:253 ) as exc:
Importance #141: frameworks/run_provenance.py @@ -33,9 +33,9 @@
33 SplitTier,33 SplitTier,
34 authorize_split_access,34 authorize_split_access,
35 load_split_manifest,35 load_split_manifest,
36)36)
37from src.train.config import HarnessConfig, load_config, resolve_ontology_path37from src.train.config import HarnessConfig, resolve_ontology_path
3838
39STARTED = "started"39STARTED = "started"
40COMPLETED = "completed"40COMPLETED = "completed"
41PROVENANCE_FILENAMES: dict[str, str] = {41PROVENANCE_FILENAMES: dict[str, str] = {
Importance #142: frameworks/run_provenance.py @@ -417,9 +417,9 @@
417 Raises:417 Raises:
418 RunProvenanceError: If the run cannot prove its required evidence.418 RunProvenanceError: If the run cannot prove its required evidence.
419 """419 """
420 args = build_parser().parse_args(argv)420 args = build_parser().parse_args(argv)
421 config = load_config(args.config)421 config = HarnessConfig.from_yaml(args.config)
422 split_manifest = load_split_manifest(422 split_manifest = load_split_manifest(
423 config.data.split_manifest,423 config.data.split_manifest,
424 ontology=load_ontology(resolve_ontology_path(config)),424 ontology=load_ontology(resolve_ontology_path(config)),
425 )425 )
Importance #143: frameworks/runner_options.py @@ -1,9 +1,10 @@
1"""Emit ontology-derived CLI overrides for the SPT and Pointcept runners.1"""Emit ontology-derived CLI overrides for the SPT and Pointcept runners.
22
3The harness experiment YAML is the source of truth for class count, void ID,3The harness experiment YAML is the source of truth for class count, void ID,
4and predicted class names. This module loads that YAML with the same4and predicted class names. This module loads that YAML with the same
5:func:`src.train.config.load_config` / :func:`src.contracts.ontology.load_ontology`5:meth:`src.train.config.HarnessConfig.from_yaml` /
6:func:`src.contracts.ontology.load_ontology`
6path the rest of the harness uses, then prints one ``KEY=VALUE`` override per7path the rest of the harness uses, then prints one ``KEY=VALUE`` override per
7line. The shell runners append those lines to the framework command:8line. The shell runners append those lines to the framework command:
89
9* Pointcept consumes them as ``--options`` tokens (``DictAction`` then10* Pointcept consumes them as ``--options`` tokens (``DictAction`` then
Importance #144: frameworks/runner_options.py @@ -28,9 +29,9 @@
28from collections.abc import Sequence29from collections.abc import Sequence
29from pathlib import Path30from pathlib import Path
3031
31from src.contracts.ontology import Ontology, OntologyError, load_ontology32from src.contracts.ontology import Ontology, OntologyError, load_ontology
32from src.train.config import ConfigError, load_config, resolve_ontology_path33from src.train.config import ExperimentConfigError, HarnessConfig, resolve_ontology_path
3334
34POINTCEPT_FRAMEWORK = "pointcept"35POINTCEPT_FRAMEWORK = "pointcept"
35SPT_FRAMEWORK = "spt"36SPT_FRAMEWORK = "spt"
36SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)37SUPPORTED_FRAMEWORKS = (POINTCEPT_FRAMEWORK, SPT_FRAMEWORK)
Importance #145: frameworks/runner_options.py @@ -91,17 +92,17 @@
91 One ``KEY=VALUE`` override per tuple element.92 One ``KEY=VALUE`` override per tuple element.
9293
93 Raises:94 Raises:
94 RunnerOptionsError: If ``framework`` is unsupported.95 RunnerOptionsError: If ``framework`` is unsupported.
95 ConfigError: If the harness YAML is malformed.96 ExperimentConfigError: If the harness YAML is malformed.
96 OntologyError: If the declared ontology cannot be loaded.97 OntologyError: If the declared ontology cannot be loaded.
97 """98 """
98 if framework not in SUPPORTED_FRAMEWORKS:99 if framework not in SUPPORTED_FRAMEWORKS:
99 raise RunnerOptionsError(100 raise RunnerOptionsError(
100 f"{config_path}: unsupported framework {framework!r}; expected "101 f"{config_path}: unsupported framework {framework!r}; expected "
101 f"one of {list(SUPPORTED_FRAMEWORKS)}"102 f"one of {list(SUPPORTED_FRAMEWORKS)}"
102 )103 )
103 config = load_config(config_path)104 config = HarnessConfig.from_yaml(config_path)
104 ontology = load_ontology(resolve_ontology_path(config))105 ontology = load_ontology(resolve_ontology_path(config))
105 if framework == SPT_FRAMEWORK:106 if framework == SPT_FRAMEWORK:
106 return spt_overrides(ontology)107 return spt_overrides(ontology)
107 return pointcept_overrides(ontology)108 return pointcept_overrides(ontology)
Importance #146: frameworks/runner_options.py @@ -139,9 +140,9 @@
139 """140 """
140 args = build_parser().parse_args(argv)141 args = build_parser().parse_args(argv)
141 try:142 try:
142 lines = collect_overrides(args.config, args.framework)143 lines = collect_overrides(args.config, args.framework)
143 except (ConfigError, OntologyError, RunnerOptionsError, OSError) as exc:144 except (ExperimentConfigError, OntologyError, RunnerOptionsError, OSError) as exc:
144 print(f"{args.config}: {exc}", file=sys.stderr)145 print(f"{args.config}: {exc}", file=sys.stderr)
145 return 1146 return 1
146 if not lines:147 if not lines:
147 print(148 print(
Importance #147: scripts/evaluate.py @@ -40,12 +40,11 @@
40 continuity_metrics,40 continuity_metrics,
41 object_metrics,41 object_metrics,
42)42)
43from src.train.config import (43from src.train.config import (
44 ConfigError,44 ExperimentConfigError,
45 HarnessConfig,45 HarnessConfig,
46 is_canonical_metric,46 is_canonical_metric,
47 load_config,
48 resolve_ontology_path,47 resolve_ontology_path,
49)48)
5049
5150
Importance #148: scripts/evaluate.py @@ -127,9 +126,9 @@
127 Zero after reports and finalized provenance are written.126 Zero after reports and finalized provenance are written.
128 """127 """
129 args = build_parser().parse_args(argv)128 args = build_parser().parse_args(argv)
130 try:129 try:
131 config = load_config(args.config)130 config = HarnessConfig.from_yaml(args.config)
132 if config.model.framework not in {"spt", "pointcept"}:131 if config.model.framework not in {"spt", "pointcept"}:
133 raise EvaluationError(132 raise EvaluationError(
134 f"{config.experiment.id} framework {config.model.framework} "133 f"{config.experiment.id} framework {config.model.framework} "
135 "has no checkpoint evaluator"134 "has no checkpoint evaluator"
Importance #149: scripts/evaluate.py @@ -313,9 +312,15 @@
313 },312 },
314 )313 )
315 write_corridor_run_provenance(run_dir / "provenance.json", final_provenance)314 write_corridor_run_provenance(run_dir / "provenance.json", final_provenance)
316 return 0315 return 0
317 except (ConfigError, EvaluationError, OSError, RuntimeError, ValueError) as exc:316 except (
317 ExperimentConfigError,
318 EvaluationError,
319 OSError,
320 RuntimeError,
321 ValueError,
322 ) as exc:
318 raise SystemExit(str(exc)) from exc323 raise SystemExit(str(exc)) from exc
319324
320325
321@dataclass(frozen=True)326@dataclass(frozen=True)
Importance #150: scripts/ingest_preannotations.py @@ -24,9 +24,9 @@
24 PreannotationError,24 PreannotationError,
25 load_preannotation,25 load_preannotation,
26 select_preannotation,26 select_preannotation,
27)27)
28from src.train.config import load_config, resolve_ontology_path28from src.train.config import HarnessConfig, resolve_ontology_path
2929
3030
31def build_parser() -> argparse.ArgumentParser:31def build_parser() -> argparse.ArgumentParser:
32 """Build the frozen preannotation-ingest CLI parser.32 """Build the frozen preannotation-ingest CLI parser.
Importance #151: scripts/ingest_preannotations.py @@ -57,9 +57,9 @@
57 Process exit status.57 Process exit status.
58 """58 """
59 args = build_parser().parse_args(argv)59 args = build_parser().parse_args(argv)
60 try:60 try:
61 config = load_config(args.config)61 config = HarnessConfig.from_yaml(args.config)
62 ontology = load_ontology(resolve_ontology_path(config))62 ontology = load_ontology(resolve_ontology_path(config))
63 split_manifest = load_split_manifest(63 split_manifest = load_split_manifest(
64 config.data.split_manifest, ontology=ontology64 config.data.split_manifest, ontology=ontology
65 )65 )
Importance #152: scripts/prepare_dataset.py @@ -50,9 +50,9 @@
50 validate_boundary_las_codes,50 validate_boundary_las_codes,
51 write_normalization,51 write_normalization,
52)52)
53from src.dataset.features import expand_feature_columns53from src.dataset.features import expand_feature_columns
54from src.train.config import load_config, resolve_ontology_path54from src.train.config import HarnessConfig, resolve_ontology_path
5555
5656
57def build_parser() -> argparse.ArgumentParser:57def build_parser() -> argparse.ArgumentParser:
58 """Build the frozen dataset-preparation CLI parser.58 """Build the frozen dataset-preparation CLI parser.
Importance #153: scripts/prepare_dataset.py @@ -88,9 +88,9 @@
88 Process exit status.88 Process exit status.
89 """89 """
90 args = build_parser().parse_args(argv)90 args = build_parser().parse_args(argv)
91 try:91 try:
92 config = load_config(args.config)92 config = HarnessConfig.from_yaml(args.config)
93 ontology = load_ontology(resolve_ontology_path(config))93 ontology = load_ontology(resolve_ontology_path(config))
94 split_manifest = load_split_manifest(94 split_manifest = load_split_manifest(
95 config.data.split_manifest, ontology=ontology95 config.data.split_manifest, ontology=ontology
96 )96 )
Importance #154: scripts/train.py @@ -7,9 +7,9 @@
7import logging7import logging
8from collections.abc import Sequence8from collections.abc import Sequence
9from pathlib import Path9from pathlib import Path
1010
11from src.train.config import ConfigError, load_config11from src.train.config import ExperimentConfigError, HarnessConfig
12from src.train.dispatch import (12from src.train.dispatch import (
13 DispatchError,13 DispatchError,
14 DispatchRequest,14 DispatchRequest,
15 dispatch_experiment,15 dispatch_experiment,
Importance #155: scripts/train.py @@ -55,9 +55,9 @@
55 """55 """
56 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")56 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
57 args = build_parser().parse_args(argv)57 args = build_parser().parse_args(argv)
58 try:58 try:
59 config = load_config(args.config)59 config = HarnessConfig.from_yaml(args.config)
60 cells = plan_experiment(config)60 cells = plan_experiment(config)
61 LOGGER.info(61 LOGGER.info(
62 "%s study kind %s expands to %d cell(s): %s",62 "%s study kind %s expands to %d cell(s): %s",
63 config.experiment.id,63 config.experiment.id,
Importance #156: scripts/train.py @@ -73,9 +73,9 @@
73 log_dir=args.log_dir,73 log_dir=args.log_dir,
74 num_workers=args.num_workers,74 num_workers=args.num_workers,
75 )75 )
76 return dispatch_experiment(config, request)76 return dispatch_experiment(config, request)
77 except (ConfigError, DispatchError) as exc:77 except (ExperimentConfigError, DispatchError) as exc:
78 raise SystemExit(str(exc)) from exc78 raise SystemExit(str(exc)) from exc
7979
8080
81if __name__ == "__main__":81if __name__ == "__main__":
Importance #157: scripts/verify_adapter_roundtrip.py @@ -14,9 +14,9 @@
14from src.adapters.pointcept import load_pointcept_identity14from src.adapters.pointcept import load_pointcept_identity
15from src.adapters.remap import TilePrediction, blend_and_remap15from src.adapters.remap import TilePrediction, blend_and_remap
16from src.adapters.spt import load_spt_identity16from src.adapters.spt import load_spt_identity
17from src.dataset.canonical import CanonicalCloud, read_canonical_tile17from src.dataset.canonical import CanonicalCloud, read_canonical_tile
18from src.train.config import load_config18from src.train.config import HarnessConfig
1919
20DEFAULT_FRAME_TOLERANCE_MM = 0.620DEFAULT_FRAME_TOLERANCE_MM = 0.6
21"""Tolerated frame reconstruction error.21"""Tolerated frame reconstruction error.
2222
Importance #158: scripts/verify_adapter_roundtrip.py @@ -61,9 +61,9 @@
61 if not np.isfinite(args.frame_tolerance_mm) or args.frame_tolerance_mm <= 0.0:61 if not np.isfinite(args.frame_tolerance_mm) or args.frame_tolerance_mm <= 0.0:
62 raise ValueError(62 raise ValueError(
63 f"--frame-tolerance-mm must be positive, got {args.frame_tolerance_mm}"63 f"--frame-tolerance-mm must be positive, got {args.frame_tolerance_mm}"
64 )64 )
65 config = load_config(args.config)65 config = HarnessConfig.from_yaml(args.config)
66 # Per-corridor adapter manifests live in the canonical lane66 # Per-corridor adapter manifests live in the canonical lane
67 # (<canonical_root>/canonical/<corridor>.adapter.json); the legacy67 # (<canonical_root>/canonical/<corridor>.adapter.json); the legacy
68 # top-level location is still accepted so older lanes verify.68 # top-level location is still accepted so older lanes verify.
69 canonical_lane = config.data.canonical_root / "canonical"69 canonical_lane = config.data.canonical_root / "canonical"
Importance #159: scripts/voxel_oracle.py @@ -32,11 +32,10 @@
32 validate_full_resolution_manifest,32 validate_full_resolution_manifest,
33 write_oracle_outputs,33 write_oracle_outputs,
34)34)
35from src.train.config import (35from src.train.config import (
36 ConfigError,36 ExperimentConfigError,
37 HarnessConfig,37 HarnessConfig,
38 load_config,
39 resolve_ontology_path,38 resolve_ontology_path,
40)39)
4140
42LOGGER = logging.getLogger(__name__)41LOGGER = logging.getLogger(__name__)
Importance #160: scripts/voxel_oracle.py @@ -82,9 +81,9 @@
82 """81 """
83 args = build_parser().parse_args(argv)82 args = build_parser().parse_args(argv)
84 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")83 logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
85 try:84 try:
86 config = load_config(args.config)85 config = HarnessConfig.from_yaml(args.config)
87 if config.experiment.id != "E2":86 if config.experiment.id != "E2":
88 raise VoxelOracleError(87 raise VoxelOracleError(
89 f"scripts/voxel_oracle.py accepts E2 only, got {config.experiment.id}"88 f"scripts/voxel_oracle.py accepts E2 only, got {config.experiment.id}"
90 )89 )
Importance #161: scripts/voxel_oracle.py @@ -159,9 +158,9 @@
159 )158 )
160 LOGGER.info("Wrote E2 products: %s", ", ".join(str(path) for path in paths))159 LOGGER.info("Wrote E2 products: %s", ", ".join(str(path) for path in paths))
161 return 0160 return 0
162 except (161 except (
163 ConfigError,162 ExperimentConfigError,
164 SplitAccessError,163 SplitAccessError,
165 VoxelOracleError,164 VoxelOracleError,
166 OSError,165 OSError,
167 ValueError,166 ValueError,
Importance #162: src/train/__init__.py @@ -1,13 +1,12 @@
1"""Public strict configuration and experiment-dispatch interfaces."""1"""Public strict configuration and experiment-dispatch interfaces."""
22
3from src.train.config import ConfigError, HarnessConfig, TrainConfig, load_config3from src.train.config import ExperimentConfigError, HarnessConfig, TrainConfig
4from src.train.dispatch import DispatchError, dispatch_experiment4from src.train.dispatch import DispatchError, dispatch_experiment
55
6__all__ = [6__all__ = [
7 "ConfigError",
8 "DispatchError",7 "DispatchError",
8 "ExperimentConfigError",
9 "HarnessConfig",9 "HarnessConfig",
10 "TrainConfig",10 "TrainConfig",
11 "dispatch_experiment",11 "dispatch_experiment",
12 "load_config",
13]12]
Importance #163: src/train/config.py @@ -1,16 +1,20 @@
1"""Strict experiment configuration schema for corridor segmentation studies.1"""Strict experiment configuration schema for corridor segmentation studies.
22
3The schema itself is a pydantic model tree built on3The schema is a pydantic model tree on
4:class:`iolabs.common.config_loader.ConfigModel`: :mod:`src.train.config_schema`4:class:`iolabs.common.config_loader.ConfigModel`, split by section because it
5holds the experiment/gate/study models, :mod:`src.train.config_sections` the5exceeds one module: :mod:`src.train.config_schema` holds the experiment, gate,
6data, model, training, and evaluation blocks. This module is the entry point6and study models, :mod:`src.train.config_sections` the data, model, training,
7every script imports: it loads one YAML document, validates it, applies the7and evaluation blocks, :mod:`src.train.config_values` the strict YAML leaf
8rules of :mod:`src.train.config_rules`, and expands a study into re-validated8typing, :mod:`src.train.config_rules` the cross-section rules, and
9cells with :mod:`src.train.config_study`.9:mod:`src.train.config_study` the study algebra. This module is the entry point
1010every script imports: :meth:`HarnessConfig.from_yaml` loads and validates one
11Adding a configuration key means adding a field to its model (and to the YAML11YAML document, :func:`expand_study` expands a study into re-validated cells,
12documents under ``configs/``); nothing else has to be touched.12and both raise :class:`ExperimentConfigError`.
13
14Adding a configuration key means adding the field to its model and the same key
15to the YAML documents under ``configs/`` -- nothing else. Unknown keys are
16rejected.
13"""17"""
1418
15from __future__ import annotations19from __future__ import annotations
1620
Importance #164: src/train/config.py @@ -85,9 +89,9 @@
85 TilingConfig,89 TilingConfig,
86 TrainConfig,90 TrainConfig,
87 VisualizationConfig,91 VisualizationConfig,
88)92)
89from src.train.config_values import ConfigError, OverrideValue, Scalar93from src.train.config_values import ExperimentConfigError, OverrideValue, Scalar
9094
91logger = logging.getLogger(__name__)95logger = logging.getLogger(__name__)
9296
93_CONTEXT = "experiment config"97_CONTEXT = "experiment config"
Importance #165: src/train/config.py @@ -98,17 +102,17 @@
98 "ArtifactExistsParams",102 "ArtifactExistsParams",
99 "BootstrapConfig",103 "BootstrapConfig",
100 "CheckpointPolicyGate",104 "CheckpointPolicyGate",
101 "CheckpointPolicyParams",105 "CheckpointPolicyParams",
102 "ConfigError",
103 "ContinuityConfig",106 "ContinuityConfig",
104 "ContrastConfig",107 "ContrastConfig",
105 "CorridorSelectionConfig",108 "CorridorSelectionConfig",
106 "DataAvailableGate",109 "DataAvailableGate",
107 "DataAvailableParams",110 "DataAvailableParams",
108 "DataConfig",111 "DataConfig",
109 "EvaluationConfig",112 "EvaluationConfig",
110 "ExperimentConfig",113 "ExperimentConfig",
114 "ExperimentConfigError",
111 "ExperimentStatus",115 "ExperimentStatus",
112 "FeatureConfig",116 "FeatureConfig",
113 "GateBase",117 "GateBase",
114 "GateConfig",118 "GateConfig",
Importance #166: src/train/config.py @@ -148,9 +152,8 @@
148 "VariantConfig",152 "VariantConfig",
149 "VisualizationConfig",153 "VisualizationConfig",
150 "expand_study",154 "expand_study",
151 "is_canonical_metric",155 "is_canonical_metric",
152 "load_config",
153 "resolve_ontology_path",156 "resolve_ontology_path",
154]157]
155158
156159
Importance #167: src/train/config.py @@ -174,30 +177,36 @@
174 document: str177 document: str
175 config: HarnessConfig178 config: HarnessConfig
176179
177180
178def load_config(path: str | Path) -> HarnessConfig:181def _load_yaml_document(path: str | Path) -> HarnessConfig:
179 """Load and strictly validate one E1--E14 experiment YAML.182 """Read, strictly validate, and rule-check one experiment YAML.
183
184 Implementation of :meth:`HarnessConfig.from_yaml`, which is the public
185 entry point; it lives here because the loading pipeline needs
186 :mod:`src.train.config_rules` and :mod:`src.train.config_study`.
180187
181 Args:188 Args:
182 path: Repository-relative or absolute YAML path.189 path: Repository-relative or absolute YAML path.
183190
184 Returns:191 Returns:
185 Fully typed immutable configuration.192 Fully typed immutable configuration.
186193
187 Raises:194 Raises:
188 ConfigError: If loading, strict parsing, or validation fails.195 ExperimentConfigError: If loading, strict parsing, or validation fails.
189 """196 """
190 config_path = Path(path)197 config_path = Path(path)
191 try:198 try:
192 payload = config_path.read_bytes()199 payload = config_path.read_bytes()
193 raw = yaml.safe_load(payload.decode("utf-8"))200 raw = yaml.safe_load(payload.decode("utf-8"))
194 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:201 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
195 raise ConfigError(f"Cannot load config {config_path}: {exc}") from exc202 raise ExperimentConfigError(
203 f"Cannot load config {config_path}: {exc}"
204 ) from exc
196 try:205 try:
197 return _build_config(raw, config_path, hashlib.sha256(payload).hexdigest())206 return _build_config(raw, config_path, hashlib.sha256(payload).hexdigest())
198 except ConfigError as exc:207 except ExperimentConfigError as exc:
199 raise ConfigError(f"{config_path}: {exc}") from exc208 raise ExperimentConfigError(f"{config_path}: {exc}") from exc
200209
201210
202def expand_study(config: HarnessConfig) -> tuple[StudyCell, ...]:211def expand_study(config: HarnessConfig) -> tuple[StudyCell, ...]:
203 """Expand a typed study definition into ordered, re-validated cells.212 """Expand a typed study definition into ordered, re-validated cells.
Importance #168: src/train/config.py @@ -206,31 +215,31 @@
206 per declared variant, ``matrix`` the deterministic cross product of its215 per declared variant, ``matrix`` the deterministic cross product of its
207 axes after ``include``/``exclude``, and ``sweep`` the deterministic216 axes after ``include``/``exclude``, and ``sweep`` the deterministic
208 enumeration of its typed parameters under the study ``seed``. Every cell's217 enumeration of its typed parameters under the study ``seed``. Every cell's
209 overrides are re-applied to the raw mapping and re-validated through218 overrides are re-applied to the raw mapping and re-validated through
210 :func:`load_config`'s machinery, so each cell carries its own SHA-256.219 :meth:`HarnessConfig.from_yaml`'s machinery, so each cell carries its own SHA-256.
211220
212 Args:221 Args:
213 config: Strictly parsed experiment configuration.222 config: Strictly parsed experiment configuration.
214223
215 Returns:224 Returns:
216 Deterministically ordered study cells, never empty.225 Deterministically ordered study cells, never empty.
217226
218 Raises:227 Raises:
219 ConfigError: If the study payload, an override, a cell identity, or a228 ExperimentConfigError: If the study payload, an override, a cell identity, or a
220 resolved cell configuration violates the strict schema.229 resolved cell configuration violates the strict schema.
221 """230 """
222 raw = _raw_document(config)231 raw = _raw_document(config)
223 definitions = config_study.cell_definitions(config)232 definitions = config_study.cell_definitions(config)
224 if not definitions:233 if not definitions:
225 raise ConfigError(234 raise ExperimentConfigError(
226 f"{config.experiment.id} study kind {config.study.kind} expanded to "235 f"{config.experiment.id} study kind {config.study.kind} expanded to "
227 "no cells"236 "no cells"
228 )237 )
229 identities = [cell_id for cell_id, _ in definitions]238 identities = [cell_id for cell_id, _ in definitions]
230 duplicates = sorted({item for item in identities if identities.count(item) > 1})239 duplicates = sorted({item for item in identities if identities.count(item) > 1})
231 if duplicates:240 if duplicates:
232 raise ConfigError(241 raise ExperimentConfigError(
233 f"{config.experiment.id} study has duplicate cell IDs {duplicates}"242 f"{config.experiment.id} study has duplicate cell IDs {duplicates}"
234 )243 )
235 cells: list[StudyCell] = []244 cells: list[StudyCell] = []
236 for index, (cell_id, overrides) in enumerate(definitions):245 for index, (cell_id, overrides) in enumerate(definitions):
Importance #169: src/train/config.py @@ -239,10 +248,12 @@
239 document = yaml.safe_dump(cell_raw, sort_keys=True, default_flow_style=False)248 document = yaml.safe_dump(cell_raw, sort_keys=True, default_flow_style=False)
240 digest = hashlib.sha256(document.encode("utf-8")).hexdigest()249 digest = hashlib.sha256(document.encode("utf-8")).hexdigest()
241 try:250 try:
242 cell_config = _build_config(cell_raw, config.source_path, digest)251 cell_config = _build_config(cell_raw, config.source_path, digest)
243 except ConfigError as exc:252 except ExperimentConfigError as exc:
244 raise ConfigError(f"{config.source_path} cell {cell_id!r}: {exc}") from exc253 raise ExperimentConfigError(
254 f"{config.source_path} cell {cell_id!r}: {exc}"
255 ) from exc
245 run_name = (256 run_name = (
246 config.experiment.id257 config.experiment.id
247 if config.study.kind == "single"258 if config.study.kind == "single"
248 else f"{config.experiment.id}_{cell_id}"259 else f"{config.experiment.id}_{cell_id}"
Importance #170: src/train/config.py @@ -263,9 +274,9 @@
263def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:274def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:
264 """Validate one raw document into a configuration carrying its digest."""275 """Validate one raw document into a configuration carrying its digest."""
265 root = config_values.mapping(raw, str(config_path))276 root = config_values.mapping(raw, str(config_path))
266 config = config_loader.validate_config(277 config = config_loader.validate_config(
267 HarnessConfig, dict(root), context=_CONTEXT, error_cls=ConfigError278 HarnessConfig, dict(root), context=_CONTEXT, error_cls=ExperimentConfigError
268 )279 )
269 config.attach_provenance(config_path, sha256, copy.deepcopy(dict(root)))280 config.attach_provenance(config_path, sha256, copy.deepcopy(dict(root)))
270 config_rules.validate_config(config, root)281 config_rules.validate_config(config, root)
271 return config282 return config
Importance #171: src/train/config.py @@ -274,24 +285,24 @@
274def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:285def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:
275 """Return the raw mapping a configuration was validated from.286 """Return the raw mapping a configuration was validated from.
276287
277 Args:288 Args:
278 config: Configuration produced by :func:`load_config` or289 config: Configuration produced by :meth:`HarnessConfig.from_yaml`
279 :func:`expand_study`.290 or :func:`expand_study`.
280291
281 Returns:292 Returns:
282 The stashed raw mapping, or a fresh parse of ``config.source_path``.293 The stashed raw mapping, or a fresh parse of ``config.source_path``.
283294
284 Raises:295 Raises:
285 ConfigError: If the source file must be re-read and cannot be parsed.296 ExperimentConfigError: If the source file must be re-read and cannot be parsed.
286 """297 """
287 stashed = config._raw_document298 stashed = config._raw_document
288 if stashed is not None:299 if stashed is not None:
289 return stashed300 return stashed
290 try:301 try:
291 payload = config.source_path.read_bytes()302 payload = config.source_path.read_bytes()
292 raw = yaml.safe_load(payload.decode("utf-8"))303 raw = yaml.safe_load(payload.decode("utf-8"))
293 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:304 except (OSError, UnicodeDecodeError, yaml.YAMLError) as exc:
294 raise ConfigError(305 raise ExperimentConfigError(
295 f"Cannot re-read config {config.source_path}: {exc}"306 f"Cannot re-read config {config.source_path}: {exc}"
296 ) from exc307 ) from exc
297 return config_values.mapping(raw, str(config.source_path))308 return config_values.mapping(raw, str(config.source_path))
Importance #172: src/train/config_rules.py @@ -88,14 +88,14 @@
88 Returns:88 Returns:
89 An absolute, existing ontology path.89 An absolute, existing ontology path.
9090
91 Raises:91 Raises:
92 ConfigError: If no candidate path exists.92 ExperimentConfigError: If no candidate path exists.
93 """93 """
94 declared = config.task.ontology94 declared = config.task.ontology
95 if declared.is_absolute():95 if declared.is_absolute():
96 if not declared.is_file():96 if not declared.is_file():
97 raise config_values.ConfigError(97 raise config_values.ExperimentConfigError(
98 f"{config.source_path}: task.ontology {declared.as_posix()} "98 f"{config.source_path}: task.ontology {declared.as_posix()} "
99 f"does not exist"99 f"does not exist"
100 )100 )
101 return declared101 return declared
Importance #173: src/train/config_rules.py @@ -108,9 +108,9 @@
108 return candidate.resolve()108 return candidate.resolve()
109 searched = ", ".join(109 searched = ", ".join(
110 sorted({candidate.parent.as_posix() for candidate in candidates})110 sorted({candidate.parent.as_posix() for candidate in candidates})
111 )111 )
112 raise config_values.ConfigError(112 raise config_values.ExperimentConfigError(
113 f"{config.source_path}: task.ontology {declared.as_posix()} was not "113 f"{config.source_path}: task.ontology {declared.as_posix()} was not "
114 f"found relative to any parent of the config or to the working "114 f"found relative to any parent of the config or to the working "
115 f"directory {Path.cwd().as_posix()}; searched {searched}"115 f"directory {Path.cwd().as_posix()}; searched {searched}"
116 )116 )
Importance #174: src/train/config_rules.py @@ -126,16 +126,16 @@
126 raw: Raw mapping the configuration was parsed from, used to check the126 raw: Raw mapping the configuration was parsed from, used to check the
127 declared study overrides against the document's own leaves.127 declared study overrides against the document's own leaves.
128128
129 Raises:129 Raises:
130 ConfigError: If a cross-section, identity, or ontology rule fails.130 ExperimentConfigError: If a cross-section, identity, or ontology rule fails.
131 """131 """
132 if config.schema_version != 1:132 if config.schema_version != 1:
133 raise config_values.ConfigError(133 raise config_values.ExperimentConfigError(
134 f"Unsupported schema_version {config.schema_version}"134 f"Unsupported schema_version {config.schema_version}"
135 )135 )
136 if not _EXPERIMENT_ID_PATTERN.fullmatch(config.experiment.id):136 if not _EXPERIMENT_ID_PATTERN.fullmatch(config.experiment.id):
137 raise config_values.ConfigError(137 raise config_values.ExperimentConfigError(
138 f"Invalid experiment.id {config.experiment.id!r}"138 f"Invalid experiment.id {config.experiment.id!r}"
139 )139 )
140 ontology = _load_task_ontology(config)140 ontology = _load_task_ontology(config)
141 _validate_task_against_ontology(config, ontology)141 _validate_task_against_ontology(config, ontology)
Importance #175: src/train/config_rules.py @@ -149,18 +149,18 @@
149 """Check the rules that tie a section to the declared model framework."""149 """Check the rules that tie a section to the declared model framework."""
150 if config.model.framework in {"spt", "pointcept"} and (150 if config.model.framework in {"spt", "pointcept"} and (
151 _VIZ_TRAIN_KEYS & config.train.model_fields_set151 _VIZ_TRAIN_KEYS & config.train.model_fields_set
152 ):152 ):
153 raise config_values.ConfigError(153 raise config_values.ExperimentConfigError(
154 "train.viz_every_n_epochs and train.viz_samples are forbidden for "154 "train.viz_every_n_epochs and train.viz_samples are forbidden for "
155 "external frameworks"155 "external frameworks"
156 )156 )
157 if config.model.framework == "pointcept" and config.runtime.flash_attention:157 if config.model.framework == "pointcept" and config.runtime.flash_attention:
158 raise config_values.ConfigError(158 raise config_values.ExperimentConfigError(
159 "Pointcept PTv3/LitePT configurations must keep FlashAttention disabled"159 "Pointcept PTv3/LitePT configurations must keep FlashAttention disabled"
160 )160 )
161 if config.visualization is not None and config.model.framework != "pointcept":161 if config.visualization is not None and config.model.framework != "pointcept":
162 raise config_values.ConfigError(162 raise config_values.ExperimentConfigError(
163 "visualization is only supported for model.framework pointcept; "163 "visualization is only supported for model.framework pointcept; "
164 f"{config.model.framework} configs must omit the block"164 f"{config.model.framework} configs must omit the block"
165 )165 )
166166
Importance #176: src/train/config_rules.py @@ -170,36 +170,36 @@
170) -> None:170) -> None:
171 """Check every declared metric tag against the ontology's namespace."""171 """Check every declared metric tag against the ontology's namespace."""
172 for metric in config.experiment.deciding_metrics:172 for metric in config.experiment.deciding_metrics:
173 if not is_canonical_metric(metric, ontology=ontology):173 if not is_canonical_metric(metric, ontology=ontology):
174 raise config_values.ConfigError(174 raise config_values.ExperimentConfigError(
175 "experiment.deciding_metrics contains non-canonical metric "175 "experiment.deciding_metrics contains non-canonical metric "
176 f"{metric!r} for ontology {ontology.name}"176 f"{metric!r} for ontology {ontology.name}"
177 )177 )
178 for metric in (config.train.monitor, config.train.early_stop_monitor):178 for metric in (config.train.monitor, config.train.early_stop_monitor):
179 if not is_canonical_metric(metric, ontology=ontology):179 if not is_canonical_metric(metric, ontology=ontology):
180 raise config_values.ConfigError(180 raise config_values.ExperimentConfigError(
181 f"train monitor {metric!r} is outside the canonical namespace "181 f"train monitor {metric!r} is outside the canonical namespace "
182 f"of ontology {ontology.name}"182 f"of ontology {ontology.name}"
183 )183 )
184 unknown_floor_classes = sorted(184 unknown_floor_classes = sorted(
185 set(config.evaluation.precision_floors) - set(ontology.class_names)185 set(config.evaluation.precision_floors) - set(ontology.class_names)
186 )186 )
187 if unknown_floor_classes:187 if unknown_floor_classes:
188 raise config_values.ConfigError(188 raise config_values.ExperimentConfigError(
189 "evaluation.precision_floors has unknown classes "189 "evaluation.precision_floors has unknown classes "
190 f"{unknown_floor_classes} for ontology {ontology.name}"190 f"{unknown_floor_classes} for ontology {ontology.name}"
191 )191 )
192 for contrast in config.study.contrasts:192 for contrast in config.study.contrasts:
193 if not is_canonical_metric(contrast.metric, ontology=ontology):193 if not is_canonical_metric(contrast.metric, ontology=ontology):
194 raise config_values.ConfigError(194 raise config_values.ExperimentConfigError(
195 f"study contrast metric {contrast.metric!r} is not canonical "195 f"study contrast metric {contrast.metric!r} is not canonical "
196 f"for ontology {ontology.name}"196 f"for ontology {ontology.name}"
197 )197 )
198 if config.study.sweep is not None and not is_canonical_metric(198 if config.study.sweep is not None and not is_canonical_metric(
199 config.study.sweep.objective, ontology=ontology199 config.study.sweep.objective, ontology=ontology
200 ):200 ):
201 raise config_values.ConfigError(201 raise config_values.ExperimentConfigError(
202 f"study.sweep.objective must be canonical for ontology {ontology.name}"202 f"study.sweep.objective must be canonical for ontology {ontology.name}"
203 )203 )
204204
205205
Importance #177: src/train/config_rules.py @@ -212,35 +212,35 @@
212 unknown = sorted(212 unknown = sorted(
213 set(gate.params.minimum_purity_by_class) - set(ontology.class_names)213 set(gate.params.minimum_purity_by_class) - set(ontology.class_names)
214 )214 )
215 if unknown:215 if unknown:
216 raise config_values.ConfigError(216 raise config_values.ExperimentConfigError(
217 f"gate {gate.name}.minimum_purity_by_class contains unknown "217 f"gate {gate.name}.minimum_purity_by_class contains unknown "
218 f"ontology classes {unknown} for ontology {ontology.name}"218 f"ontology classes {unknown} for ontology {ontology.name}"
219 )219 )
220 gate_types = {gate.type for gate in config.experiment.gates if gate.required}220 gate_types = {gate.type for gate in config.experiment.gates if gate.required}
221 if (221 if (
222 config.experiment.status == "template-only"222 config.experiment.status == "template-only"
223 and "implementation_ticket" not in gate_types223 and "implementation_ticket" not in gate_types
224 ):224 ):
225 raise config_values.ConfigError(225 raise config_values.ExperimentConfigError(
226 "template-only experiments require an implementation_ticket gate"226 "template-only experiments require an implementation_ticket gate"
227 )227 )
228 if config.experiment.status == "gated-later" and not gate_types:228 if config.experiment.status == "gated-later" and not gate_types:
229 raise config_values.ConfigError(229 raise config_values.ExperimentConfigError(
230 "gated-later experiments require at least one required gate"230 "gated-later experiments require at least one required gate"
231 )231 )
232 if config.experiment.status == "implement-now" and config.experiment.id not in {232 if config.experiment.status == "implement-now" and config.experiment.id not in {
233 "E1",233 "E1",
234 "E2",234 "E2",
235 }:235 }:
236 raise config_values.ConfigError(236 raise config_values.ExperimentConfigError(
237 "Only E1 and E2 are implement-now in schema version 1"237 "Only E1 and E2 are implement-now in schema version 1"
238 )238 )
239239
240240
241def _load_task_ontology(config: config_sections.HarnessConfig) -> Ontology:241def _load_task_ontology(config: config_sections.HarnessConfig) -> Ontology:
242 """Load the ontology the config declares, failing closed as a ConfigError.242 """Load the ontology the config declares, failing closed as a ExperimentConfigError.
243243
244 Args:244 Args:
245 config: Parsed configuration whose ``task.ontology`` path is resolved245 config: Parsed configuration whose ``task.ontology`` path is resolved
246 relative to the repository root.246 relative to the repository root.
Importance #178: src/train/config_rules.py @@ -248,15 +248,15 @@
248 Returns:248 Returns:
249 The validated ontology every other contract is checked against.249 The validated ontology every other contract is checked against.
250250
251 Raises:251 Raises:
252 ConfigError: If the ontology cannot be located, loaded, or is invalid.252 ExperimentConfigError: If the ontology cannot be located, loaded, or is invalid.
253 """253 """
254 resolved = resolve_ontology_path(config)254 resolved = resolve_ontology_path(config)
255 try:255 try:
256 return load_ontology(resolved)256 return load_ontology(resolved)
257 except OntologyError as exc:257 except OntologyError as exc:
258 raise config_values.ConfigError(258 raise config_values.ExperimentConfigError(
259 f"task.ontology {config.task.ontology.as_posix()} is not a valid "259 f"task.ontology {config.task.ontology.as_posix()} is not a valid "
260 f"ontology: {exc}"260 f"ontology: {exc}"
261 ) from exc261 ) from exc
262262
Importance #179: src/train/config_rules.py @@ -270,25 +270,25 @@
270 config: Parsed configuration.270 config: Parsed configuration.
271 ontology: Ontology loaded from ``task.ontology``.271 ontology: Ontology loaded from ``task.ontology``.
272272
273 Raises:273 Raises:
274 ConfigError: If any task, evaluation, model, or loss class contract274 ExperimentConfigError: If any task, evaluation, model, or loss class contract
275 disagrees with the loaded ontology.275 disagrees with the loaded ontology.
276 """276 """
277 task = config.task277 task = config.task
278 if task.num_classes != ontology.num_predicted_classes:278 if task.num_classes != ontology.num_predicted_classes:
279 raise config_values.ConfigError(279 raise config_values.ExperimentConfigError(
280 f"task.num_classes {task.num_classes} must equal ontology "280 f"task.num_classes {task.num_classes} must equal ontology "
281 f"{ontology.name} num_predicted_classes "281 f"{ontology.name} num_predicted_classes "
282 f"{ontology.num_predicted_classes}"282 f"{ontology.num_predicted_classes}"
283 )283 )
284 if task.ignore_index != ontology.void_id:284 if task.ignore_index != ontology.void_id:
285 raise config_values.ConfigError(285 raise config_values.ExperimentConfigError(
286 f"task.ignore_index {task.ignore_index} must equal ontology "286 f"task.ignore_index {task.ignore_index} must equal ontology "
287 f"{ontology.name} void ID {ontology.void_id}"287 f"{ontology.name} void ID {ontology.void_id}"
288 )288 )
289 if task.classes_of_interest != ontology.interest_ids:289 if task.classes_of_interest != ontology.interest_ids:
290 raise config_values.ConfigError(290 raise config_values.ExperimentConfigError(
291 f"task.classes_of_interest {list(task.classes_of_interest)} must "291 f"task.classes_of_interest {list(task.classes_of_interest)} must "
292 f"equal ontology {ontology.name} interest IDs "292 f"equal ontology {ontology.name} interest IDs "
293 f"{list(ontology.interest_ids)}"293 f"{list(ontology.interest_ids)}"
294 )294 )
Importance #180: src/train/config_rules.py @@ -297,31 +297,31 @@
297 )297 )
298 if len(set(task.linear_classes)) != len(task.linear_classes) or set(298 if len(set(task.linear_classes)) != len(task.linear_classes) or set(
299 task.linear_classes299 task.linear_classes
300 ) != set(linear_names):300 ) != set(linear_names):
301 raise config_values.ConfigError(301 raise config_values.ExperimentConfigError(
302 f"task.linear_classes {list(task.linear_classes)} must be exactly "302 f"task.linear_classes {list(task.linear_classes)} must be exactly "
303 f"the linear classes {list(linear_names)} of ontology "303 f"the linear classes {list(linear_names)} of ontology "
304 f"{ontology.name}"304 f"{ontology.name}"
305 )305 )
306 profiles = config.evaluation.object_matching.cluster_profiles306 profiles = config.evaluation.object_matching.cluster_profiles
307 if profiles != task.ontology:307 if profiles != task.ontology:
308 raise config_values.ConfigError(308 raise config_values.ExperimentConfigError(
309 "evaluation.object_matching.cluster_profiles "309 "evaluation.object_matching.cluster_profiles "
310 f"{profiles.as_posix()} must be the task ontology "310 f"{profiles.as_posix()} must be the task ontology "
311 f"{task.ontology.as_posix()}"311 f"{task.ontology.as_posix()}"
312 )312 )
313 if "num_classes" in config.model.args:313 if "num_classes" in config.model.args:
314 declared = config.model.args["num_classes"]314 declared = config.model.args["num_classes"]
315 if declared != task.num_classes:315 if declared != task.num_classes:
316 raise config_values.ConfigError(316 raise config_values.ExperimentConfigError(
317 f"model.args.num_classes {declared!r} must equal "317 f"model.args.num_classes {declared!r} must equal "
318 f"task.num_classes {task.num_classes}"318 f"task.num_classes {task.num_classes}"
319 )319 )
320 if "ignore_index" in config.loss.args:320 if "ignore_index" in config.loss.args:
321 declared = config.loss.args["ignore_index"]321 declared = config.loss.args["ignore_index"]
322 if declared != task.ignore_index:322 if declared != task.ignore_index:
323 raise config_values.ConfigError(323 raise config_values.ExperimentConfigError(
324 f"loss.args.ignore_index {declared!r} must equal "324 f"loss.args.ignore_index {declared!r} must equal "
325 f"task.ignore_index {task.ignore_index}"325 f"task.ignore_index {task.ignore_index}"
326 )326 )
327327
Importance #181: src/train/config_rules.py @@ -352,13 +352,13 @@
352 )352 )
353 for overrides in override_groups:353 for overrides in override_groups:
354 for path, value in overrides.items():354 for path, value in overrides.items():
355 if path.startswith("study.") or path not in leaves:355 if path.startswith("study.") or path not in leaves:
356 raise config_values.ConfigError(356 raise config_values.ExperimentConfigError(
357 f"Study override path {path!r} is not a declared scalar/list leaf"357 f"Study override path {path!r} is not a declared scalar/list leaf"
358 )358 )
359 expected = leaves[path]359 expected = leaves[path]
360 if not config_values.same_leaf_type(expected, value):360 if not config_values.same_leaf_type(expected, value):
361 raise config_values.ConfigError(361 raise config_values.ExperimentConfigError(
362 f"Study override {path!r} has incompatible value {value!r}; "362 f"Study override {path!r} has incompatible value {value!r}; "
363 f"expected type of {expected!r}"363 f"expected type of {expected!r}"
364 )364 )
Importance #182: src/train/config_schema.py @@ -250,9 +250,9 @@
250 ) -> Mapping[str, tuple[config_values.OverrideValue, ...]]:250 ) -> Mapping[str, tuple[config_values.OverrideValue, ...]]:
251 """Reject an axis that declares no value."""251 """Reject an axis that declares no value."""
252 for path, values in value.items():252 for path, values in value.items():
253 if not values:253 if not values:
254 raise config_values.ConfigError(254 raise config_values.ExperimentConfigError(
255 f"study.matrix.axes.{path} cannot be empty"255 f"study.matrix.axes.{path} cannot be empty"
256 )256 )
257 return value257 return value
258258
Importance #183: src/train/config_schema.py @@ -268,21 +268,23 @@
268 @pydantic.model_validator(mode="after")268 @pydantic.model_validator(mode="after")
269 def _bounds_are_complete(self) -> SweepParameterConfig:269 def _bounds_are_complete(self) -> SweepParameterConfig:
270 """Reject a parameter that is neither finite nor fully bounded."""270 """Reject a parameter that is neither finite nor fully bounded."""
271 if self.values is not None and not self.values:271 if self.values is not None and not self.values:
272 raise config_values.ConfigError("values cannot be empty")272 raise config_values.ExperimentConfigError("values cannot be empty")
273 if self.values is None and (273 if self.values is None and (
274 self.minimum is None or self.maximum is None or self.distribution is None274 self.minimum is None or self.maximum is None or self.distribution is None
275 ):275 ):
276 raise config_values.ConfigError(276 raise config_values.ExperimentConfigError(
277 "requires values or minimum/maximum/distribution"277 "requires values or minimum/maximum/distribution"
278 )278 )
279 if (279 if (
280 self.minimum is not None280 self.minimum is not None
281 and self.maximum is not None281 and self.maximum is not None
282 and self.minimum >= self.maximum282 and self.minimum >= self.maximum
283 ):283 ):
284 raise config_values.ConfigError("minimum must be smaller than maximum")284 raise config_values.ExperimentConfigError(
285 "minimum must be smaller than maximum"
286 )
285 return self287 return self
286288
287289
288class SweepConfig(StrictConfigModel):290class SweepConfig(StrictConfigModel):
Importance #184: src/train/config_schema.py @@ -299,9 +301,11 @@
299 cls, value: Mapping[str, SweepParameterConfig]301 cls, value: Mapping[str, SweepParameterConfig]
300 ) -> Mapping[str, SweepParameterConfig]:302 ) -> Mapping[str, SweepParameterConfig]:
301 """Reject a sweep that declares no parameter."""303 """Reject a sweep that declares no parameter."""
302 if not value:304 if not value:
303 raise config_values.ConfigError("study.sweep.parameters cannot be empty")305 raise config_values.ExperimentConfigError(
306 "study.sweep.parameters cannot be empty"
307 )
304 return value308 return value
305309
306310
307class ContrastConfig(StrictConfigModel):311class ContrastConfig(StrictConfigModel):
Importance #185: src/train/config_schema.py @@ -327,25 +331,31 @@
327 """Reject a study whose kind and payload disagree."""331 """Reject a study whose kind and payload disagree."""
328 if self.kind == "single" and (332 if self.kind == "single" and (
329 self.variants or self.matrix is not None or self.sweep is not None333 self.variants or self.matrix is not None or self.sweep is not None
330 ):334 ):
331 raise config_values.ConfigError(335 raise config_values.ExperimentConfigError(
332 "study.kind single cannot carry variants, matrix, or sweep"336 "study.kind single cannot carry variants, matrix, or sweep"
333 )337 )
334 if self.kind == "variants" and (338 if self.kind == "variants" and (
335 not self.variants or self.matrix is not None or self.sweep is not None339 not self.variants or self.matrix is not None or self.sweep is not None
336 ):340 ):
337 raise config_values.ConfigError(341 raise config_values.ExperimentConfigError(
338 "study.kind variants requires only a non-empty variants payload"342 "study.kind variants requires only a non-empty variants payload"
339 )343 )
340 if self.kind == "matrix" and (344 if self.kind == "matrix" and (
341 self.variants or self.matrix is None or self.sweep is not None345 self.variants or self.matrix is None or self.sweep is not None
342 ):346 ):
343 raise config_values.ConfigError("study.kind matrix requires only matrix")347 raise config_values.ExperimentConfigError(
348 "study.kind matrix requires only matrix"
349 )
344 if self.kind == "sweep" and (350 if self.kind == "sweep" and (
345 self.variants or self.matrix is not None or self.sweep is None351 self.variants or self.matrix is not None or self.sweep is None
346 ):352 ):
347 raise config_values.ConfigError("study.kind sweep requires only sweep")353 raise config_values.ExperimentConfigError(
354 "study.kind sweep requires only sweep"
355 )
348 identities = [item.id for item in self.variants]356 identities = [item.id for item in self.variants]
349 if len(set(identities)) != len(identities):357 if len(set(identities)) != len(identities):
350 raise config_values.ConfigError("study.variants contains duplicate IDs")358 raise config_values.ExperimentConfigError(
359 "study.variants contains duplicate IDs"
360 )
351 return self361 return self
Importance #186: src/train/config_sections.py @@ -65,13 +65,13 @@
65 @pydantic.model_validator(mode="after")65 @pydantic.model_validator(mode="after")
66 def _fuse_config_matches_mode(self) -> LabelSourceConfig:66 def _fuse_config_matches_mode(self) -> LabelSourceConfig:
67 """Tie the fusion config to the declared label mode."""67 """Tie the fusion config to the declared label mode."""
68 if self.mode == "regenerate_full_resolution" and self.fuse_config is None:68 if self.mode == "regenerate_full_resolution" and self.fuse_config is None:
69 raise config_values.ConfigError(69 raise config_values.ExperimentConfigError(
70 "regenerate_full_resolution requires data.label_source.fuse_config"70 "regenerate_full_resolution requires data.label_source.fuse_config"
71 )71 )
72 if self.mode == "artifact" and self.fuse_config is not None:72 if self.mode == "artifact" and self.fuse_config is not None:
73 raise config_values.ConfigError(73 raise config_values.ExperimentConfigError(
74 "artifact label mode requires data.label_source.fuse_config: null"74 "artifact label mode requires data.label_source.fuse_config: null"
75 )75 )
76 return self76 return self
7777
Importance #187: src/train/config_sections.py @@ -104,9 +104,11 @@
104 @pydantic.model_validator(mode="after")104 @pydantic.model_validator(mode="after")
105 def _overlap_fits_in_a_tile(self) -> TilingConfig:105 def _overlap_fits_in_a_tile(self) -> TilingConfig:
106 """Reject an overlap that is not shorter than the tile."""106 """Reject an overlap that is not shorter than the tile."""
107 if self.overlap_m >= self.length_m:107 if self.overlap_m >= self.length_m:
108 raise config_values.ConfigError("tiling requires 0 <= overlap_m < length_m")108 raise config_values.ExperimentConfigError(
109 "tiling requires 0 <= overlap_m < length_m"
110 )
109 return self111 return self
110112
111113
112class DataConfig(config_schema.StrictConfigModel):114class DataConfig(config_schema.StrictConfigModel):
Importance #188: src/train/config_sections.py @@ -154,21 +156,25 @@
154 @classmethod156 @classmethod
155 def _emit_is_supported(cls, value: tuple[str, ...]) -> tuple[str, ...]:157 def _emit_is_supported(cls, value: tuple[str, ...]) -> tuple[str, ...]:
156 """Reject unknown output formats and a missing canonical emission."""158 """Reject unknown output formats and a missing canonical emission."""
157 if set(value) - {"canonical", "spt", "pointcept"}:159 if set(value) - {"canonical", "spt", "pointcept"}:
158 raise config_values.ConfigError(160 raise config_values.ExperimentConfigError(
159 "adapter.emit contains an unsupported output format"161 "adapter.emit contains an unsupported output format"
160 )162 )
161 if "canonical" not in value:163 if "canonical" not in value:
162 raise config_values.ConfigError("adapter.emit must include canonical")164 raise config_values.ExperimentConfigError(
165 "adapter.emit must include canonical"
166 )
163 return value167 return value
164168
165 @pydantic.field_validator("canonical_version")169 @pydantic.field_validator("canonical_version")
166 @classmethod170 @classmethod
167 def _canonical_version_is_one(cls, value: int) -> int:171 def _canonical_version_is_one(cls, value: int) -> int:
168 """Freeze the canonical dataset version at 1."""172 """Freeze the canonical dataset version at 1."""
169 if value != 1:173 if value != 1:
170 raise config_values.ConfigError("adapter.canonical_version must be 1")174 raise config_values.ExperimentConfigError(
175 "adapter.canonical_version must be 1"
176 )
171 return value177 return value
172178
173179
174class ModelConfig(config_schema.StrictConfigModel):180class ModelConfig(config_schema.StrictConfigModel):
Importance #189: src/train/config_sections.py @@ -204,13 +210,13 @@
204 def _external_models_pin_their_checkout(self) -> ModelConfig:210 def _external_models_pin_their_checkout(self) -> ModelConfig:
205 """Tie the external checkout variables to the declared framework."""211 """Tie the external checkout variables to the declared framework."""
206 if self.framework == "cpu":212 if self.framework == "cpu":
207 if self.checkout_env is not None or self.commit_env is not None:213 if self.checkout_env is not None or self.commit_env is not None:
208 raise config_values.ConfigError(214 raise config_values.ExperimentConfigError(
209 "CPU experiments cannot declare external checkout variables"215 "CPU experiments cannot declare external checkout variables"
210 )216 )
211 elif not (self.base_config and self.checkout_env and self.commit_env):217 elif not (self.base_config and self.checkout_env and self.commit_env):
212 raise config_values.ConfigError(218 raise config_values.ExperimentConfigError(
213 "External models require base_config, checkout_env, and commit_env"219 "External models require base_config, checkout_env, and commit_env"
214 )220 )
215 return self221 return self
216222
Importance #190: src/train/config_sections.py @@ -300,9 +306,9 @@
300 self.delta_quality is None306 self.delta_quality is None
301 or self.delta_fp_per_km is None307 or self.delta_fp_per_km is None
302 or not self.superiority_conditions308 or not self.superiority_conditions
303 ):309 ):
304 raise config_values.ConfigError(310 raise config_values.ExperimentConfigError(
305 "enabled promotion requires non-null margins and superiority "311 "enabled promotion requires non-null margins and superiority "
306 "conditions"312 "conditions"
307 )313 )
308 return self314 return self
Importance #191: src/train/config_sections.py @@ -325,18 +331,18 @@
325 def _floors_are_fractions(cls, value: Mapping[str, float]) -> Mapping[str, float]:331 def _floors_are_fractions(cls, value: Mapping[str, float]) -> Mapping[str, float]:
326 """Reject a precision floor outside the unit interval."""332 """Reject a precision floor outside the unit interval."""
327 for name, floor in value.items():333 for name, floor in value.items():
328 if not 0.0 <= floor <= 1.0:334 if not 0.0 <= floor <= 1.0:
329 raise config_values.ConfigError(335 raise config_values.ExperimentConfigError(
330 f"evaluation precision floor for {name} must be in [0, 1]"336 f"evaluation precision floor for {name} must be in [0, 1]"
331 )337 )
332 return value338 return value
333339
334 @pydantic.model_validator(mode="after")340 @pydantic.model_validator(mode="after")
335 def _promotion_test_is_promotable(self) -> EvaluationConfig:341 def _promotion_test_is_promotable(self) -> EvaluationConfig:
336 """Reject a locked-test split whose promotion protocol is disabled."""342 """Reject a locked-test split whose promotion protocol is disabled."""
337 if self.split == "promotion_test" and not self.promotion.enabled:343 if self.split == "promotion_test" and not self.promotion.enabled:
338 raise config_values.ConfigError(344 raise config_values.ExperimentConfigError(
339 "promotion_test evaluation requires evaluation.promotion.enabled"345 "promotion_test evaluation requires evaluation.promotion.enabled"
340 )346 )
341 return self347 return self
342348
Importance #192: src/train/config_sections.py @@ -376,13 +382,15 @@
376 def _tiles_select_something(self) -> VisualizationConfig:382 def _tiles_select_something(self) -> VisualizationConfig:
377 """Reject an empty or non-positive tile selection."""383 """Reject an empty or non-positive tile selection."""
378 if isinstance(self.masks_tiles, int):384 if isinstance(self.masks_tiles, int):
379 if self.masks_tiles < 1:385 if self.masks_tiles < 1:
380 raise config_values.ConfigError(386 raise config_values.ExperimentConfigError(
381 "visualization.masks_tiles must be >= 1"387 "visualization.masks_tiles must be >= 1"
382 )388 )
383 elif not self.masks_tiles:389 elif not self.masks_tiles:
384 raise config_values.ConfigError("visualization.masks_tiles cannot be empty")390 raise config_values.ExperimentConfigError(
391 "visualization.masks_tiles cannot be empty"
392 )
385 return self393 return self
386394
387395
388class HarnessConfig(config_schema.StrictConfigModel):396class HarnessConfig(config_schema.StrictConfigModel):
Importance #193: src/train/config_sections.py @@ -414,8 +422,32 @@
414 if isinstance(data, Mapping) and data.get("visualization", False) is None:422 if isinstance(data, Mapping) and data.get("visualization", False) is None:
415 raise ValueError("visualization must be a mapping")423 raise ValueError("visualization must be a mapping")
416 return data424 return data
417425
426 @classmethod
427 def from_yaml(cls, path: str | Path) -> HarnessConfig:
428 """Load and strictly validate one E1--E14 experiment YAML.
429
430 This is the harness entry point every script and test uses; the
431 document read, strict validation, and the cross-section rules of
432 :mod:`src.train.config_rules` all run here.
433
434 Args:
435 path: Repository-relative or absolute YAML path.
436
437 Returns:
438 Fully typed immutable configuration carrying its own SHA-256.
439
440 Raises:
441 ExperimentConfigError: If loading, strict parsing, or validation
442 fails.
443 """
444 # Deferred: the loading pipeline lives in the entry-point module, which
445 # imports this one for its models.
446 from src.train import config
447
448 return config._load_yaml_document(path)
449
418 @property450 @property
419 def source_path(self) -> Path:451 def source_path(self) -> Path:
420 """Path of the YAML document this configuration was parsed from."""452 """Path of the YAML document this configuration was parsed from."""
421 return self._source_path453 return self._source_path
Importance #194: src/train/config_study.py @@ -36,28 +36,28 @@
36 Returns:36 Returns:
37 The deterministic cell definitions of the declared study kind.37 The deterministic cell definitions of the declared study kind.
3838
39 Raises:39 Raises:
40 ConfigError: If the study payload of the declared kind is absent or40 ExperimentConfigError: If the study payload of the declared kind is absent or
41 expands to nothing.41 expands to nothing.
42 """42 """
43 study = config.study43 study = config.study
44 if study.kind == "single":44 if study.kind == "single":
45 return ((config.experiment.id, MappingProxyType({})),)45 return ((config.experiment.id, MappingProxyType({})),)
46 if study.kind == "variants":46 if study.kind == "variants":
47 if not study.variants:47 if not study.variants:
48 raise config_values.ConfigError(48 raise config_values.ExperimentConfigError(
49 f"{config.experiment.id} study.kind variants has no variants payload"49 f"{config.experiment.id} study.kind variants has no variants payload"
50 )50 )
51 return tuple((item.id, item.overrides) for item in study.variants)51 return tuple((item.id, item.overrides) for item in study.variants)
52 if study.kind == "matrix":52 if study.kind == "matrix":
53 if study.matrix is None:53 if study.matrix is None:
54 raise config_values.ConfigError(54 raise config_values.ExperimentConfigError(
55 f"{config.experiment.id} study.kind matrix has no matrix payload"55 f"{config.experiment.id} study.kind matrix has no matrix payload"
56 )56 )
57 return matrix_cells(study.matrix)57 return matrix_cells(study.matrix)
58 if study.sweep is None:58 if study.sweep is None:
59 raise config_values.ConfigError(59 raise config_values.ExperimentConfigError(
60 f"{config.experiment.id} study.kind sweep has no sweep payload"60 f"{config.experiment.id} study.kind sweep has no sweep payload"
61 )61 )
62 return sweep_cells(study.sweep, config.seed)62 return sweep_cells(study.sweep, config.seed)
6363
Importance #195: src/train/config_study.py @@ -68,9 +68,9 @@
68 unknown = sorted(68 unknown = sorted(
69 {path for item in matrix.exclude for path in item} - set(axis_paths)69 {path for item in matrix.exclude for path in item} - set(axis_paths)
70 )70 )
71 if unknown:71 if unknown:
72 raise config_values.ConfigError(72 raise config_values.ExperimentConfigError(
73 f"study.matrix.exclude references non-axis paths {unknown}"73 f"study.matrix.exclude references non-axis paths {unknown}"
74 )74 )
75 definitions: list[CellDefinition] = []75 definitions: list[CellDefinition] = []
76 excluded = [0] * len(matrix.exclude)76 excluded = [0] * len(matrix.exclude)
Importance #196: src/train/config_study.py @@ -84,19 +84,19 @@
84 if not dropped:84 if not dropped:
85 definitions.append((cell_id(overrides), MappingProxyType(overrides)))85 definitions.append((cell_id(overrides), MappingProxyType(overrides)))
86 for index, count in enumerate(excluded):86 for index, count in enumerate(excluded):
87 if not count:87 if not count:
88 raise config_values.ConfigError(88 raise config_values.ExperimentConfigError(
89 f"study.matrix.exclude[{index}] matches no matrix cell"89 f"study.matrix.exclude[{index}] matches no matrix cell"
90 )90 )
91 for index, item in enumerate(matrix.include):91 for index, item in enumerate(matrix.include):
92 if not item:92 if not item:
93 raise config_values.ConfigError(93 raise config_values.ExperimentConfigError(
94 f"study.matrix.include[{index}] cannot be empty"94 f"study.matrix.include[{index}] cannot be empty"
95 )95 )
96 definitions.append((cell_id(item), item))96 definitions.append((cell_id(item), item))
97 if not definitions:97 if not definitions:
98 raise config_values.ConfigError("study.matrix excludes every cell")98 raise config_values.ExperimentConfigError("study.matrix excludes every cell")
99 return tuple(definitions)99 return tuple(definitions)
100100
101101
102def sweep_cells(102def sweep_cells(
Importance #197: src/train/config_study.py @@ -108,9 +108,9 @@
108 unbounded = sorted(108 unbounded = sorted(
109 path for path, item in sweep.parameters.items() if item.values is None109 path for path, item in sweep.parameters.items() if item.values is None
110 )110 )
111 if unbounded:111 if unbounded:
112 raise config_values.ConfigError(112 raise config_values.ExperimentConfigError(
113 f"study.sweep.method grid requires explicit values for {unbounded}"113 f"study.sweep.method grid requires explicit values for {unbounded}"
114 )114 )
115 definitions: list[CellDefinition] = []115 definitions: list[CellDefinition] = []
116 for combination in itertools.product(116 for combination in itertools.product(
Importance #198: src/train/config_study.py @@ -136,9 +136,9 @@
136 ),136 ),
137 )137 )
138 for index in range(sweep.budget)138 for index in range(sweep.budget)
139 )139 )
140 raise config_values.ConfigError(140 raise config_values.ExperimentConfigError(
141 f"study.sweep.method {sweep.method!r} is not implemented; supported "141 f"study.sweep.method {sweep.method!r} is not implemented; supported "
142 "methods are grid and random"142 "methods are grid and random"
143 )143 )
144144
Importance #199: src/train/config_study.py @@ -151,23 +151,23 @@
151 """Draw one deterministic value for a sweep parameter."""151 """Draw one deterministic value for a sweep parameter."""
152 if parameter.values is not None:152 if parameter.values is not None:
153 return parameter.values[generator.randrange(len(parameter.values))]153 return parameter.values[generator.randrange(len(parameter.values))]
154 if parameter.minimum is None or parameter.maximum is None:154 if parameter.minimum is None or parameter.maximum is None:
155 raise config_values.ConfigError(155 raise config_values.ExperimentConfigError(
156 f"{where} requires minimum and maximum for sampling"156 f"{where} requires minimum and maximum for sampling"
157 )157 )
158 if parameter.distribution == "uniform":158 if parameter.distribution == "uniform":
159 drawn = generator.uniform(parameter.minimum, parameter.maximum)159 drawn = generator.uniform(parameter.minimum, parameter.maximum)
160 elif parameter.distribution == "log_uniform":160 elif parameter.distribution == "log_uniform":
161 if parameter.minimum <= 0.0:161 if parameter.minimum <= 0.0:
162 raise config_values.ConfigError(162 raise config_values.ExperimentConfigError(
163 f"{where}.minimum must be positive for log_uniform"163 f"{where}.minimum must be positive for log_uniform"
164 )164 )
165 drawn = math.exp(165 drawn = math.exp(
166 generator.uniform(math.log(parameter.minimum), math.log(parameter.maximum))166 generator.uniform(math.log(parameter.minimum), math.log(parameter.maximum))
167 )167 )
168 else:168 else:
169 raise config_values.ConfigError(169 raise config_values.ExperimentConfigError(
170 f"{where}.distribution {parameter.distribution!r} is not implemented; "170 f"{where}.distribution {parameter.distribution!r} is not implemented; "
171 "supported distributions are uniform and log_uniform"171 "supported distributions are uniform and log_uniform"
172 )172 )
173 return float(f"{drawn:.6g}")173 return float(f"{drawn:.6g}")
Importance #200: src/train/config_study.py @@ -175,9 +175,11 @@
175175
176def cell_id(overrides: Mapping[str, config_values.OverrideValue]) -> str:176def cell_id(overrides: Mapping[str, config_values.OverrideValue]) -> str:
177 """Derive a stable, filesystem-safe identity from a cell's overrides."""177 """Derive a stable, filesystem-safe identity from a cell's overrides."""
178 if not overrides:178 if not overrides:
179 raise config_values.ConfigError("A study cell requires at least one override")179 raise config_values.ExperimentConfigError(
180 "A study cell requires at least one override"
181 )
180 names = [path.rsplit(".", 1)[-1] for path in overrides]182 names = [path.rsplit(".", 1)[-1] for path in overrides]
181 if len(set(names)) != len(names):183 if len(set(names)) != len(names):
182 names = [path.replace(".", "_") for path in overrides]184 names = [path.replace(".", "_") for path in overrides]
183 return "__".join(185 return "__".join(
Importance #201: src/train/config_study.py @@ -216,21 +218,22 @@
216 Returns:218 Returns:
217 A deep copy of ``raw`` carrying the overridden leaves.219 A deep copy of ``raw`` carrying the overridden leaves.
218220
219 Raises:221 Raises:
220 ConfigError: If a path is not a declared leaf or the value type differs.222 ExperimentConfigError: If a path is not a declared leaf or the value
223 type differs.
221 """224 """
222 result = copy.deepcopy(dict(raw))225 result = copy.deepcopy(dict(raw))
223 leaves = config_values.leaf_values(raw)226 leaves = config_values.leaf_values(raw)
224 for path in sorted(overrides):227 for path in sorted(overrides):
225 value = overrides[path]228 value = overrides[path]
226 if path.startswith("study.") or path not in leaves:229 if path.startswith("study.") or path not in leaves:
227 raise config_values.ConfigError(230 raise config_values.ExperimentConfigError(
228 f"{where} override path {path!r} is not a declared scalar/list leaf"231 f"{where} override path {path!r} is not a declared scalar/list leaf"
229 )232 )
230 expected = leaves[path]233 expected = leaves[path]
231 if not config_values.same_leaf_type(expected, value):234 if not config_values.same_leaf_type(expected, value):
232 raise config_values.ConfigError(235 raise config_values.ExperimentConfigError(
233 f"{where} override {path!r} has incompatible value {value!r}; "236 f"{where} override {path!r} has incompatible value {value!r}; "
234 f"expected type of {expected!r}"237 f"expected type of {expected!r}"
235 )238 )
236 _set_leaf(result, path, config_values.coerce_leaf(expected, value), where)239 _set_leaf(result, path, config_values.coerce_leaf(expected, value), where)
Importance #202: src/train/config_study.py @@ -247,13 +250,13 @@
247 segments = path.split(".")250 segments = path.split(".")
248 node: Any = target251 node: Any = target
249 for segment in segments[:-1]:252 for segment in segments[:-1]:
250 if not isinstance(node, dict) or segment not in node:253 if not isinstance(node, dict) or segment not in node:
251 raise config_values.ConfigError(254 raise config_values.ExperimentConfigError(
252 f"{where} override path {path!r} is not addressable"255 f"{where} override path {path!r} is not addressable"
253 )256 )
254 node = node[segment]257 node = node[segment]
255 if not isinstance(node, dict) or segments[-1] not in node:258 if not isinstance(node, dict) or segments[-1] not in node:
256 raise config_values.ConfigError(259 raise config_values.ExperimentConfigError(
257 f"{where} override path {path!r} is not addressable"260 f"{where} override path {path!r} is not addressable"
258 )261 )
259 node[segments[-1]] = value262 node[segments[-1]] = value
Importance #203: src/train/config_values.py @@ -1,12 +1,14 @@
1"""Strict YAML value typing shared by the experiment configuration models.1"""Strict YAML value typing shared by the experiment configuration models.
22
3The experiment YAML is a fail-closed contract: a value must already carry its3The experiment YAML is a fail-closed contract: a value must already carry its
4declared type, so ``"50"`` is not a float, ``1`` is not a boolean, and ``3.0``4declared type, so ``"50"`` is not a float, ``1`` is not a boolean, and ``3.0``
5is not an integer. That is deliberately stricter than the fleet coercion5is not an integer. That is deliberately stricter than the fleet coercion matrix
6matrix of :func:`iolabs.common.config_loader.coerce_config_value`, so the6of :func:`iolabs.common.config_loader.coerce_to_field_type`, and it is the one
7models in :mod:`src.train.config_schema` route every field through7sanctioned opt-out from it: the models in :mod:`src.train.config_schema` route
8:func:`typed_value` instead of the inherited coercion.8every field through :func:`typed_value` instead of the inherited coercion. Do
9not copy this into a packaged pipeline config -- it holds only for this
10YAML experiment contract.
9"""11"""
1012
11from __future__ import annotations13from __future__ import annotations
1214
Importance #204: src/train/config_values.py @@ -23,10 +25,10 @@
23Scalar: TypeAlias = str | int | float | bool | None25Scalar: TypeAlias = str | int | float | bool | None
24OverrideValue: TypeAlias = Scalar | list[Scalar]26OverrideValue: TypeAlias = Scalar | list[Scalar]
2527
2628
27class ConfigError(config_loader.ConfigError):29class ExperimentConfigError(config_loader.ConfigError):
28 """Raised when an experiment configuration violates its strict schema."""30 """Raised when experiment config contains unsupported keys or values."""
2931
3032
31def freeze_value(value: Any) -> Any:33def freeze_value(value: Any) -> Any:
32 """Return *value* with every nested mapping wrapped read-only.34 """Return *value* with every nested mapping wrapped read-only.
Importance #205: src/train/config_values.py @@ -89,9 +91,9 @@
89 Returns:91 Returns:
90 The value converted to the declared type.92 The value converted to the declared type.
9193
92 Raises:94 Raises:
93 ConfigError: If the value does not match the declared type.95 ExperimentConfigError: If the value does not match the declared type.
94 """96 """
95 if annotation is None or annotation is Any:97 if annotation is None or annotation is Any:
96 return value98 return value
97 origin = get_origin(annotation)99 origin = get_origin(annotation)
Importance #206: src/train/config_values.py @@ -135,81 +137,85 @@
135 optional = type(None) in members137 optional = type(None) in members
136 if value is None:138 if value is None:
137 if optional:139 if optional:
138 return None140 return None
139 raise ConfigError(f"{where} must not be null")141 raise ExperimentConfigError(f"{where} must not be null")
140 candidates = [item for item in members if item is not type(None)]142 candidates = [item for item in members if item is not type(None)]
141 if len(candidates) == 1:143 if len(candidates) == 1:
142 return typed_value(candidates[0], value, where)144 return typed_value(candidates[0], value, where)
143 for candidate in candidates:145 for candidate in candidates:
144 try:146 try:
145 return typed_value(candidate, value, where)147 return typed_value(candidate, value, where)
146 except ConfigError:148 except ExperimentConfigError:
147 continue149 continue
148 names = sorted(getattr(item, "__name__", str(item)) for item in candidates)150 names = sorted(getattr(item, "__name__", str(item)) for item in candidates)
149 raise ConfigError(151 raise ExperimentConfigError(
150 f"{where} must be one of {names}, got {type(value).__name__} {value!r}"152 f"{where} must be one of {names}, got {type(value).__name__} {value!r}"
151 )153 )
152154
153155
154def mapping(value: Any, where: str) -> Mapping[str, Any]:156def mapping(value: Any, where: str) -> Mapping[str, Any]:
155 """Return *value* as a string-keyed mapping or raise."""157 """Return *value* as a string-keyed mapping or raise."""
156 if not isinstance(value, Mapping):158 if not isinstance(value, Mapping):
157 raise ConfigError(f"{where} must be a mapping")159 raise ExperimentConfigError(f"{where} must be a mapping")
158 if any(not isinstance(key, str) for key in value):160 if any(not isinstance(key, str) for key in value):
159 raise ConfigError(f"{where} keys must be strings")161 raise ExperimentConfigError(f"{where} keys must be strings")
160 return value162 return value
161163
162164
163def sequence(value: Any, where: str) -> Sequence[Any]:165def sequence(value: Any, where: str) -> Sequence[Any]:
164 """Return *value* as a non-string sequence or raise."""166 """Return *value* as a non-string sequence or raise."""
165 if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):167 if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
166 raise ConfigError(f"{where} must be a sequence")168 raise ExperimentConfigError(f"{where} must be a sequence")
167 return value169 return value
168170
169171
170def string(value: Any, where: str) -> str:172def string(value: Any, where: str) -> str:
171 """Return *value* as a non-empty string or raise."""173 """Return *value* as a non-empty string or raise."""
172 if not isinstance(value, str) or not value.strip():174 if not isinstance(value, str) or not value.strip():
173 raise ConfigError(f"{where} must be a non-empty string")175 raise ExperimentConfigError(f"{where} must be a non-empty string")
174 return value176 return value
175177
176178
177def integer(value: Any, where: str) -> int:179def integer(value: Any, where: str) -> int:
178 """Return *value* as an integer, rejecting booleans, or raise."""180 """Return *value* as an integer, rejecting booleans, or raise."""
179 if isinstance(value, bool) or not isinstance(value, int):181 if isinstance(value, bool) or not isinstance(value, int):
180 raise ConfigError(f"{where} must be an integer")182 raise ExperimentConfigError(f"{where} must be an integer")
181 return value183 return value
182184
183185
184def number(value: Any, where: str) -> float:186def number(value: Any, where: str) -> float:
185 """Return *value* as a float, rejecting booleans, or raise."""187 """Return *value* as a float, rejecting booleans, or raise."""
186 if isinstance(value, bool) or not isinstance(value, (int, float)):188 if isinstance(value, bool) or not isinstance(value, (int, float)):
187 raise ConfigError(f"{where} must be numeric")189 raise ExperimentConfigError(f"{where} must be numeric")
188 return float(value)190 return float(value)
189191
190192
191def boolean(value: Any, where: str) -> bool:193def boolean(value: Any, where: str) -> bool:
192 """Return *value* as a boolean or raise."""194 """Return *value* as a boolean or raise."""
193 if not isinstance(value, bool):195 if not isinstance(value, bool):
194 raise ConfigError(f"{where} must be boolean")196 raise ExperimentConfigError(f"{where} must be boolean")
195 return value197 return value
196198
197199
198def path(value: Any, where: str) -> Path:200def path(value: Any, where: str) -> Path:
199 """Return *value* as a repository-relative path or raise."""201 """Return *value* as a repository-relative path or raise."""
200 text = string(value, where)202 text = string(value, where)
201 parsed = Path(text)203 parsed = Path(text)
202 if parsed.is_absolute():204 if parsed.is_absolute():
203 raise ConfigError(f"{where} must be repository-relative, got {parsed}")205 raise ExperimentConfigError(
206 f"{where} must be repository-relative, got {parsed}"
207 )
204 return parsed208 return parsed
205209
206210
207def choice(value: Any, choices: set[str], where: str) -> str:211def choice(value: Any, choices: set[str], where: str) -> str:
208 """Return *value* when it is one of the allowed string choices."""212 """Return *value* when it is one of the allowed string choices."""
209 text = string(value, where)213 text = string(value, where)
210 if text not in choices:214 if text not in choices:
211 raise ConfigError(f"{where} must be one of {sorted(choices)}, got {text!r}")215 raise ExperimentConfigError(
216 f"{where} must be one of {sorted(choices)}, got {text!r}"
217 )
212 return text218 return text
213219
214220
215def check_keys(221def check_keys(
Importance #207: src/train/config_values.py @@ -226,9 +232,9 @@
226 allowed: Keys that may be present.232 allowed: Keys that may be present.
227 where: Dotted key path used in error messages.233 where: Dotted key path used in error messages.
228234
229 Raises:235 Raises:
230 ConfigError: If a key is missing or unknown.236 ExperimentConfigError: If a key is missing or unknown.
231 """237 """
232 missing = sorted(required - set(value))238 missing = sorted(required - set(value))
233 unknown = sorted(set(value) - allowed)239 unknown = sorted(set(value) - allowed)
234 if missing or unknown:240 if missing or unknown:
Importance #208: src/train/config_values.py @@ -236,9 +242,9 @@
236 if missing:242 if missing:
237 details.append(f"missing {missing}")243 details.append(f"missing {missing}")
238 if unknown:244 if unknown:
239 details.append(f"unknown {unknown}")245 details.append(f"unknown {unknown}")
240 raise ConfigError(f"{where} has " + " and ".join(details))246 raise ExperimentConfigError(f"{where} has " + " and ".join(details))
241247
242248
243def leaf_values(value: Any, prefix: str = "") -> dict[str, OverrideValue]:249def leaf_values(value: Any, prefix: str = "") -> dict[str, OverrideValue]:
244 """Return every dotted scalar/list leaf of a raw configuration mapping.250 """Return every dotted scalar/list leaf of a raw configuration mapping.
Importance #209: src/train/dispatch.py @@ -14,10 +14,10 @@
1414
15from src.train.config import (15from src.train.config import (
16 ArtifactExistsParams,16 ArtifactExistsParams,
17 CheckpointPolicyParams,17 CheckpointPolicyParams,
18 ConfigError,
19 DataAvailableParams,18 DataAvailableParams,
19 ExperimentConfigError,
20 GateConfig,20 GateConfig,
21 HarnessConfig,21 HarnessConfig,
22 HumanWorkflowParams,22 HumanWorkflowParams,
23 ImplementationTicketParams,23 ImplementationTicketParams,
Importance #210: src/train/dispatch.py @@ -105,9 +105,9 @@
105 DispatchError: If the study definition cannot be expanded strictly.105 DispatchError: If the study definition cannot be expanded strictly.
106 """106 """
107 try:107 try:
108 return expand_study(config)108 return expand_study(config)
109 except ConfigError as exc:109 except ExperimentConfigError as exc:
110 raise DispatchError(110 raise DispatchError(
111 f"{config.experiment.id} study expansion failed: {exc}"111 f"{config.experiment.id} study expansion failed: {exc}"
112 ) from exc112 ) from exc
113113
Importance #211: tests/test_a1_recap_wiring.py @@ -5,9 +5,9 @@
5from pathlib import Path5from pathlib import Path
66
7from src.contracts.ontology import load_ontology7from src.contracts.ontology import load_ontology
8from src.contracts.splits import SplitTier, load_split_manifest8from src.contracts.splits import SplitTier, load_split_manifest
9from src.train.config import load_config9from src.train.config import HarnessConfig
1010
11CONFIG_PATH = Path("configs/dev/a1_recap_segment_085.yaml")11CONFIG_PATH = Path("configs/dev/a1_recap_segment_085.yaml")
12ONTOLOGY_PATH = Path("configs/contracts/ontology_v2.yaml")12ONTOLOGY_PATH = Path("configs/contracts/ontology_v2.yaml")
13SPLIT_PATH = Path("configs/contracts/corridor_splits_a1_recap_v1.yaml")13SPLIT_PATH = Path("configs/contracts/corridor_splits_a1_recap_v1.yaml")
Importance #212: tests/test_a1_recap_wiring.py @@ -30,9 +30,9 @@
30 assert manifest.supported_interest_ids[SplitTier.TRAIN] == tuple(range(9))30 assert manifest.supported_interest_ids[SplitTier.TRAIN] == tuple(range(9))
3131
3232
33def test_recap_config_points_at_the_recap_lanes_with_rgb_and_intensity() -> None:33def test_recap_config_points_at_the_recap_lanes_with_rgb_and_intensity() -> None:
34 config = load_config(CONFIG_PATH)34 config = HarnessConfig.from_yaml(CONFIG_PATH)
3535
36 assert config.data.split_manifest == SPLIT_PATH36 assert config.data.split_manifest == SPLIT_PATH
37 assert config.data.root == Path("data/00_external/a1_recap_v1")37 assert config.data.root == Path("data/00_external/a1_recap_v1")
38 assert config.data.corridors.include == (CORRIDOR_ID,)38 assert config.data.corridors.include == (CORRIDOR_ID,)
Importance #213: tests/test_checkpoint_seam.py @@ -41,9 +41,9 @@
41 SplitTier,41 SplitTier,
42 authorize_split_access,42 authorize_split_access,
43 load_split_manifest,43 load_split_manifest,
44)44)
45from src.train.config import HarnessConfig, load_config45from src.train.config import HarnessConfig
4646
47CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")47CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")
48POLICY_PATH = Path("configs/contracts/checkpoint_policy.yaml")48POLICY_PATH = Path("configs/contracts/checkpoint_policy.yaml")
49GRID_ORIGIN = [10.0, -20.0, 5.0]49GRID_ORIGIN = [10.0, -20.0, 5.0]
Importance #214: tests/test_checkpoint_seam.py @@ -145,9 +145,9 @@
145145
146 Returns:146 Returns:
147 The E1 configuration with a writable canonical root.147 The E1 configuration with a writable canonical root.
148 """148 """
149 config = load_config(CONFIG_PATH)149 config = HarnessConfig.from_yaml(CONFIG_PATH)
150 canonical_root = tmp_path / "canonical_root"150 canonical_root = tmp_path / "canonical_root"
151 (canonical_root / "canonical").mkdir(parents=True, exist_ok=True)151 (canonical_root / "canonical").mkdir(parents=True, exist_ok=True)
152 for corridor_id in config.data.corridors.include:152 for corridor_id in config.data.corridors.include:
153 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(153 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(
Importance #215: tests/test_config.py @@ -6,14 +6,15 @@
6from typing import Any6from typing import Any
77
8import pytest8import pytest
9import yaml9import yaml
10from iolabs.common import config_loader
1011
11from src.contracts.ontology import load_ontology12from src.contracts.ontology import load_ontology
12from src.train.config import (13from src.train.config import (
13 ConfigError,14 ExperimentConfigError,
15 HarnessConfig,
14 is_canonical_metric,16 is_canonical_metric,
15 load_config,
16 resolve_ontology_path,17 resolve_ontology_path,
17)18)
18from src.train.dispatch import DispatchError, dispatch_experiment19from src.train.dispatch import DispatchError, dispatch_experiment
1920
Importance #216: tests/test_config.py @@ -24,9 +25,9 @@
2425
2526
26def test_all_fourteen_configs_parse_with_frozen_statuses() -> None:27def test_all_fourteen_configs_parse_with_frozen_statuses() -> None:
27 """Every experiment parses and matches the section 6 status matrix."""28 """Every experiment parses and matches the section 6 status matrix."""
28 configs = [load_config(path) for path in CONFIGS]29 configs = [HarnessConfig.from_yaml(path) for path in CONFIGS]
29 assert [item.experiment.id for item in configs] == [30 assert [item.experiment.id for item in configs] == [
30 f"E{index}" for index in range(1, 15)31 f"E{index}" for index in range(1, 15)
31 ]32 ]
32 assert [item.experiment.status for item in configs] == [33 assert [item.experiment.status for item in configs] == [
Importance #217: tests/test_config.py @@ -52,16 +53,32 @@
52 "sweep",53 "sweep",
53 }54 }
5455
5556
57def test_error_class_is_config_error() -> None:
58 """The package error class is a fleet config error and a ValueError."""
59 assert issubclass(ExperimentConfigError, config_loader.ConfigError)
60 assert issubclass(ExperimentConfigError, ValueError)
61
62
63def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None:
64 """Unknown keys fail at the document root."""
65 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
66 raw["surprise_section"] = {"enabled": True}
67 path = tmp_path / "unknown_root.yaml"
68 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
69 with pytest.raises(ExperimentConfigError, match="surprise_section"):
70 HarnessConfig.from_yaml(path)
71
72
56def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:73def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None:
57 """Unknown keys fail at nested section boundaries."""74 """Unknown keys fail at nested section boundaries."""
58 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))75 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
59 raw["train"]["surprise_callback"] = True76 raw["train"]["surprise_callback"] = True
60 path = tmp_path / "unknown.yaml"77 path = tmp_path / "unknown.yaml"
61 path.write_text(yaml.safe_dump(raw), encoding="utf-8")78 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
62 with pytest.raises(ConfigError, match="surprise_callback"):79 with pytest.raises(ExperimentConfigError, match="surprise_callback"):
63 load_config(path)80 HarnessConfig.from_yaml(path)
6481
6582
66def test_incompatible_study_override_is_rejected(tmp_path: Path) -> None:83def test_incompatible_study_override_is_rejected(tmp_path: Path) -> None:
67 """Dotted overrides must name an existing leaf and preserve its type."""84 """Dotted overrides must name an existing leaf and preserve its type."""
Importance #218: tests/test_config.py @@ -70,29 +87,29 @@
70 "adapter.spt.voxel_m": "two centimetres"87 "adapter.spt.voxel_m": "two centimetres"
71 }88 }
72 path = tmp_path / "bad_override.yaml"89 path = tmp_path / "bad_override.yaml"
73 path.write_text(yaml.safe_dump(raw), encoding="utf-8")90 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
74 with pytest.raises(ConfigError, match="incompatible value"):91 with pytest.raises(ExperimentConfigError, match="incompatible value"):
75 load_config(path)92 HarnessConfig.from_yaml(path)
7693
7794
78def test_external_framework_visualizer_fields_are_forbidden(tmp_path: Path) -> None:95def test_external_framework_visualizer_fields_are_forbidden(tmp_path: Path) -> None:
79 """SPT/Pointcept YAML cannot configure image-loop visualization callbacks."""96 """SPT/Pointcept YAML cannot configure image-loop visualization callbacks."""
80 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))97 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
81 raw["train"]["viz_samples"] = 498 raw["train"]["viz_samples"] = 4
82 path = tmp_path / "external_viz.yaml"99 path = tmp_path / "external_viz.yaml"
83 path.write_text(yaml.safe_dump(raw), encoding="utf-8")100 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
84 with pytest.raises(ConfigError, match="forbidden"):101 with pytest.raises(ExperimentConfigError, match="forbidden"):
85 load_config(path)102 HarnessConfig.from_yaml(path)
86103
87104
88def test_visualization_block_parses_int_and_named_tiles(tmp_path: Path) -> None:105def test_visualization_block_parses_int_and_named_tiles(tmp_path: Path) -> None:
89 """A valid visualization block is optional and strictly typed."""106 """A valid visualization block is optional and strictly typed."""
90 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))107 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
91 raw["visualization"] = {"masks_every_n_epochs": 2, "masks_tiles": 3}108 raw["visualization"] = {"masks_every_n_epochs": 2, "masks_tiles": 3}
92 path = tmp_path / "viz.yaml"109 path = tmp_path / "viz.yaml"
93 path.write_text(yaml.safe_dump(raw), encoding="utf-8")110 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
94 config = load_config(path)111 config = HarnessConfig.from_yaml(path)
95 assert config.visualization is not None112 assert config.visualization is not None
96 assert config.visualization.masks_every_n_epochs == 2113 assert config.visualization.masks_every_n_epochs == 2
97 assert config.visualization.masks_tiles == 3114 assert config.visualization.masks_tiles == 3
98115
Importance #219: tests/test_config.py @@ -100,15 +117,15 @@
100 "masks_every_n_epochs": 1,117 "masks_every_n_epochs": 1,
101 "masks_tiles": ["tile_a", "tile_b"],118 "masks_tiles": ["tile_a", "tile_b"],
102 }119 }
103 path.write_text(yaml.safe_dump(raw), encoding="utf-8")120 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
104 named = load_config(path)121 named = HarnessConfig.from_yaml(path)
105 assert named.visualization is not None122 assert named.visualization is not None
106 assert named.visualization.masks_tiles == ("tile_a", "tile_b")123 assert named.visualization.masks_tiles == ("tile_a", "tile_b")
107124
108 raw["visualization"] = {"masks_every_n_epochs": 4}125 raw["visualization"] = {"masks_every_n_epochs": 4}
109 path.write_text(yaml.safe_dump(raw), encoding="utf-8")126 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
110 defaulted = load_config(path)127 defaulted = HarnessConfig.from_yaml(path)
111 assert defaulted.visualization is not None128 assert defaulted.visualization is not None
112 assert defaulted.visualization.masks_tiles == 2129 assert defaulted.visualization.masks_tiles == 2
113130
114131
Importance #220: tests/test_config.py @@ -121,35 +138,35 @@
121 "surprise": True,138 "surprise": True,
122 }139 }
123 path = tmp_path / "viz_unknown.yaml"140 path = tmp_path / "viz_unknown.yaml"
124 path.write_text(yaml.safe_dump(raw), encoding="utf-8")141 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
125 with pytest.raises(ConfigError, match="visualization.*surprise"):142 with pytest.raises(ExperimentConfigError, match="visualization.*surprise"):
126 load_config(path)143 HarnessConfig.from_yaml(path)
127144
128145
129def test_visualization_every_n_epochs_zero_is_rejected(tmp_path: Path) -> None:146def test_visualization_every_n_epochs_zero_is_rejected(tmp_path: Path) -> None:
130 """masks_every_n_epochs must be >= 1 when the block is present."""147 """masks_every_n_epochs must be >= 1 when the block is present."""
131 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))148 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
132 raw["visualization"] = {"masks_every_n_epochs": 0, "masks_tiles": 2}149 raw["visualization"] = {"masks_every_n_epochs": 0, "masks_tiles": 2}
133 path = tmp_path / "viz_zero.yaml"150 path = tmp_path / "viz_zero.yaml"
134 path.write_text(yaml.safe_dump(raw), encoding="utf-8")151 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
135 with pytest.raises(ConfigError, match="masks_every_n_epochs"):152 with pytest.raises(ExperimentConfigError, match="masks_every_n_epochs"):
136 load_config(path)153 HarnessConfig.from_yaml(path)
137154
138155
139def test_visualization_empty_tiles_are_rejected(tmp_path: Path) -> None:156def test_visualization_empty_tiles_are_rejected(tmp_path: Path) -> None:
140 """An empty tile list is not a valid selection."""157 """An empty tile list is not a valid selection."""
141 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))158 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
142 raw["visualization"] = {"masks_every_n_epochs": 1, "masks_tiles": []}159 raw["visualization"] = {"masks_every_n_epochs": 1, "masks_tiles": []}
143 path = tmp_path / "viz_empty.yaml"160 path = tmp_path / "viz_empty.yaml"
144 path.write_text(yaml.safe_dump(raw), encoding="utf-8")161 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
145 with pytest.raises(ConfigError, match="masks_tiles"):162 with pytest.raises(ExperimentConfigError, match="masks_tiles"):
146 load_config(path)163 HarnessConfig.from_yaml(path)
147164
148165
149def test_absent_visualization_block_is_none() -> None:166def test_absent_visualization_block_is_none() -> None:
150 """Omitting visualization leaves the feature off."""167 """Omitting visualization leaves the feature off."""
151 config = load_config(CONFIGS[1])168 config = HarnessConfig.from_yaml(CONFIGS[1])
152 assert config.visualization is None169 assert config.visualization is None
153170
154171
155def test_visualization_requires_pointcept_framework(tmp_path: Path) -> None:172def test_visualization_requires_pointcept_framework(tmp_path: Path) -> None:
Importance #221: tests/test_config.py @@ -158,20 +175,22 @@
158 raw = yaml.safe_load(CONFIGS[index].read_text(encoding="utf-8"))175 raw = yaml.safe_load(CONFIGS[index].read_text(encoding="utf-8"))
159 raw["visualization"] = {"masks_every_n_epochs": 2}176 raw["visualization"] = {"masks_every_n_epochs": 2}
160 path = tmp_path / f"viz_wrong_framework_{index}.yaml"177 path = tmp_path / f"viz_wrong_framework_{index}.yaml"
161 path.write_text(yaml.safe_dump(raw), encoding="utf-8")178 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
162 with pytest.raises(ConfigError, match="only supported for.*pointcept"):179 with pytest.raises(
163 load_config(path)180 ExperimentConfigError, match="only supported for.*pointcept"
181 ):
182 HarnessConfig.from_yaml(path)
164183
165184
166def test_noncanonical_deciding_metric_is_rejected(tmp_path: Path) -> None:185def test_noncanonical_deciding_metric_is_rejected(tmp_path: Path) -> None:
167 """Decision metrics cannot silently depend on an unlogged tag."""186 """Decision metrics cannot silently depend on an unlogged tag."""
168 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))187 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
169 raw["experiment"]["deciding_metrics"] = ["val/mystery"]188 raw["experiment"]["deciding_metrics"] = ["val/mystery"]
170 path = tmp_path / "metric.yaml"189 path = tmp_path / "metric.yaml"
171 path.write_text(yaml.safe_dump(raw), encoding="utf-8")190 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
172 with pytest.raises(ConfigError, match="non-canonical"):191 with pytest.raises(ExperimentConfigError, match="non-canonical"):
173 load_config(path)192 HarnessConfig.from_yaml(path)
174193
175194
176@pytest.mark.parametrize(195@pytest.mark.parametrize(
177 ("section", "key", "value", "expected"),196 ("section", "key", "value", "expected"),
Importance #222: tests/test_config.py @@ -209,10 +228,10 @@
209 node[key] = value228 node[key] = value
210 path = tmp_path / "scalar.yaml"229 path = tmp_path / "scalar.yaml"
211 path.write_text(yaml.safe_dump(raw), encoding="utf-8")230 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
212 dotted = ".".join((*section, key))231 dotted = ".".join((*section, key))
213 with pytest.raises(ConfigError) as failure:232 with pytest.raises(ExperimentConfigError) as failure:
214 load_config(path)233 HarnessConfig.from_yaml(path)
215 message = str(failure.value)234 message = str(failure.value)
216 assert str(path) in message235 assert str(path) in message
217 assert dotted in message236 assert dotted in message
218 assert expected in message237 assert expected in message
Importance #223: tests/test_config.py @@ -232,29 +251,29 @@
232 pytest.skip("The reference gate declares no string parameter")251 pytest.skip("The reference gate declares no string parameter")
233 params[key] = 3252 params[key] = 3
234 path = tmp_path / "gate.yaml"253 path = tmp_path / "gate.yaml"
235 path.write_text(yaml.safe_dump(raw), encoding="utf-8")254 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
236 with pytest.raises(ConfigError, match=f"params.{key}"):255 with pytest.raises(ExperimentConfigError, match=f"params.{key}"):
237 load_config(path)256 HarnessConfig.from_yaml(path)
238257
239258
240def test_template_dispatch_names_implementation_ticket() -> None:259def test_template_dispatch_names_implementation_ticket() -> None:
241 """A template fails before runner work and identifies its exact ticket."""260 """A template fails before runner work and identifies its exact ticket."""
242 config = load_config("configs/e03_feature_ablation.yaml")261 config = HarnessConfig.from_yaml("configs/e03_feature_ablation.yaml")
243 with pytest.raises(DispatchError, match="AI3D-MLSEG-E3"):262 with pytest.raises(DispatchError, match="AI3D-MLSEG-E3"):
244 dispatch_experiment(config)263 dispatch_experiment(config)
245264
246265
247def test_gated_dispatch_names_missing_artifact() -> None:266def test_gated_dispatch_names_missing_artifact() -> None:
248 """A gated experiment reports its first unmet prerequisite."""267 """A gated experiment reports its first unmet prerequisite."""
249 config = load_config("configs/e07_ezsp_scale.yaml")268 config = HarnessConfig.from_yaml("configs/e07_ezsp_scale.yaml")
250 with pytest.raises(DispatchError, match="data_contract_acceptance.json"):269 with pytest.raises(DispatchError, match="data_contract_acceptance.json"):
251 dispatch_experiment(config)270 dispatch_experiment(config)
252271
253272
254def test_e1_dispatch_names_partition_oracle_report() -> None:273def test_e1_dispatch_names_partition_oracle_report() -> None:
255 """E1 cannot launch without the partition-purity report."""274 """E1 cannot launch without the partition-purity report."""
256 config = load_config("configs/e01_spt_pilot.yaml")275 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
257 with pytest.raises(DispatchError, match="partition_oracle.json"):276 with pytest.raises(DispatchError, match="partition_oracle.json"):
258 dispatch_experiment(config)277 dispatch_experiment(config)
259278
260279
Importance #224: tests/test_config.py @@ -263,9 +282,9 @@
263) -> None:282) -> None:
264 """An external experiment names every unset framework variable."""283 """An external experiment names every unset framework variable."""
265 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):284 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):
266 monkeypatch.delenv(name, raising=False)285 monkeypatch.delenv(name, raising=False)
267 config = load_config("configs/e01_spt_pilot.yaml")286 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
268 with pytest.raises(DispatchError, match="SPT_ROOT, SPT_COMMIT, SPT_PYTHON"):287 with pytest.raises(DispatchError, match="SPT_ROOT, SPT_COMMIT, SPT_PYTHON"):
269 dispatch_experiment(config)288 dispatch_experiment(config)
270289
271290
Importance #225: tests/test_config.py @@ -276,9 +295,9 @@
276 checkout = _fake_spt_checkout(tmp_path)295 checkout = _fake_spt_checkout(tmp_path)
277 monkeypatch.setenv("SPT_ROOT", str(checkout))296 monkeypatch.setenv("SPT_ROOT", str(checkout))
278 monkeypatch.setenv("SPT_COMMIT", "0" * 40)297 monkeypatch.setenv("SPT_COMMIT", "0" * 40)
279 monkeypatch.setenv("SPT_PYTHON", str(_fake_interpreter(tmp_path)))298 monkeypatch.setenv("SPT_PYTHON", str(_fake_interpreter(tmp_path)))
280 config = load_config("configs/e01_spt_pilot.yaml")299 config = HarnessConfig.from_yaml("configs/e01_spt_pilot.yaml")
281 with pytest.raises(DispatchError) as failure:300 with pytest.raises(DispatchError) as failure:
282 dispatch_experiment(config)301 dispatch_experiment(config)
283 assert "framework_environment" not in str(failure.value)302 assert "framework_environment" not in str(failure.value)
284303
Importance #226: tests/test_config.py @@ -305,9 +324,9 @@
305) -> None:324) -> None:
306 """CPU experiments never demand external checkout variables."""325 """CPU experiments never demand external checkout variables."""
307 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):326 for name in ("SPT_ROOT", "SPT_COMMIT", "SPT_PYTHON"):
308 monkeypatch.delenv(name, raising=False)327 monkeypatch.delenv(name, raising=False)
309 config = load_config("configs/e02_voxel_survival_oracle.yaml")328 config = HarnessConfig.from_yaml("configs/e02_voxel_survival_oracle.yaml")
310 with pytest.raises(DispatchError) as failure:329 with pytest.raises(DispatchError) as failure:
311 dispatch_experiment(config)330 dispatch_experiment(config)
312 assert "framework_environment" not in str(failure.value)331 assert "framework_environment" not in str(failure.value)
313332
Importance #227: tests/test_config.py @@ -354,9 +373,9 @@
354373
355def test_v2_recap_config_loads_against_ontology_v2(tmp_path: Path) -> None:374def test_v2_recap_config_loads_against_ontology_v2(tmp_path: Path) -> None:
356 """A v2 task block aligned with recap_semantics_v2 is accepted."""375 """A v2 task block aligned with recap_semantics_v2 is accepted."""
357 path = _write_config(tmp_path, _v2_recap_document(), "v2.yaml")376 path = _write_config(tmp_path, _v2_recap_document(), "v2.yaml")
358 config = load_config(path)377 config = HarnessConfig.from_yaml(path)
359 assert config.task.ontology == ONTOLOGY_V2_PATH378 assert config.task.ontology == ONTOLOGY_V2_PATH
360 assert config.task.num_classes == 11379 assert config.task.num_classes == 11
361 assert config.task.ignore_index == 11380 assert config.task.ignore_index == 11
362 assert config.task.classes_of_interest == tuple(range(9))381 assert config.task.classes_of_interest == tuple(range(9))
Importance #228: tests/test_config.py @@ -378,10 +397,10 @@
378 """task.num_classes must equal ontology.num_predicted_classes."""397 """task.num_classes must equal ontology.num_predicted_classes."""
379 raw = _v2_recap_document()398 raw = _v2_recap_document()
380 raw["task"]["num_classes"] = 9399 raw["task"]["num_classes"] = 9
381 path = _write_config(tmp_path, raw, "v2_num_classes.yaml")400 path = _write_config(tmp_path, raw, "v2_num_classes.yaml")
382 with pytest.raises(ConfigError, match="num_predicted_classes"):401 with pytest.raises(ExperimentConfigError, match="num_predicted_classes"):
383 load_config(path)402 HarnessConfig.from_yaml(path)
384403
385404
386def test_v2_config_rejects_incomplete_classes_of_interest(405def test_v2_config_rejects_incomplete_classes_of_interest(
387 tmp_path: Path,406 tmp_path: Path,
Importance #229: tests/test_config.py @@ -389,10 +408,10 @@
389 """task.classes_of_interest must equal ontology.interest_ids."""408 """task.classes_of_interest must equal ontology.interest_ids."""
390 raw = _v2_recap_document()409 raw = _v2_recap_document()
391 raw["task"]["classes_of_interest"] = list(range(8))410 raw["task"]["classes_of_interest"] = list(range(8))
392 path = _write_config(tmp_path, raw, "v2_interest.yaml")411 path = _write_config(tmp_path, raw, "v2_interest.yaml")
393 with pytest.raises(ConfigError, match="classes_of_interest"):412 with pytest.raises(ExperimentConfigError, match="classes_of_interest"):
394 load_config(path)413 HarnessConfig.from_yaml(path)
395414
396415
397def test_v2_config_rejects_v1_linear_class_names(tmp_path: Path) -> None:416def test_v2_config_rejects_v1_linear_class_names(tmp_path: Path) -> None:
398 """task.linear_classes must be the linear names of the loaded ontology."""417 """task.linear_classes must be the linear names of the loaded ontology."""
Importance #230: tests/test_config.py @@ -402,28 +421,28 @@
402 "wall_noise_barrier",421 "wall_noise_barrier",
403 "fence_gate",422 "fence_gate",
404 ]423 ]
405 path = _write_config(tmp_path, raw, "v2_linear.yaml")424 path = _write_config(tmp_path, raw, "v2_linear.yaml")
406 with pytest.raises(ConfigError, match="linear_classes"):425 with pytest.raises(ExperimentConfigError, match="linear_classes"):
407 load_config(path)426 HarnessConfig.from_yaml(path)
408427
409428
410def test_v2_config_rejects_v1_precision_floor_class(tmp_path: Path) -> None:429def test_v2_config_rejects_v1_precision_floor_class(tmp_path: Path) -> None:
411 """precision_floors keys must be predicted names of the loaded ontology."""430 """precision_floors keys must be predicted names of the loaded ontology."""
412 raw = _v2_recap_document()431 raw = _v2_recap_document()
413 raw["evaluation"]["precision_floors"] = {"sign_gantry": 0.9}432 raw["evaluation"]["precision_floors"] = {"sign_gantry": 0.9}
414 path = _write_config(tmp_path, raw, "v2_floors.yaml")433 path = _write_config(tmp_path, raw, "v2_floors.yaml")
415 with pytest.raises(ConfigError, match="sign_gantry"):434 with pytest.raises(ExperimentConfigError, match="sign_gantry"):
416 load_config(path)435 HarnessConfig.from_yaml(path)
417436
418437
419def test_v2_config_rejects_model_num_classes_mismatch(tmp_path: Path) -> None:438def test_v2_config_rejects_model_num_classes_mismatch(tmp_path: Path) -> None:
420 """model.args.num_classes must equal task.num_classes when present."""439 """model.args.num_classes must equal task.num_classes when present."""
421 raw = _v2_recap_document()440 raw = _v2_recap_document()
422 raw["model"]["args"]["num_classes"] = 9441 raw["model"]["args"]["num_classes"] = 9
423 path = _write_config(tmp_path, raw, "v2_model_classes.yaml")442 path = _write_config(tmp_path, raw, "v2_model_classes.yaml")
424 with pytest.raises(ConfigError, match="model.args.num_classes"):443 with pytest.raises(ExperimentConfigError, match="model.args.num_classes"):
425 load_config(path)444 HarnessConfig.from_yaml(path)
426445
427446
428def test_macro_interest_all9_is_canonical_only_under_v2() -> None:447def test_macro_interest_all9_is_canonical_only_under_v2() -> None:
429 """v2 macros use all9; the frozen v1 all8 tag is not canonical under v2."""448 """v2 macros use all9; the frozen v1 all8 tag is not canonical under v2."""
Importance #231: tests/test_config.py @@ -442,19 +461,19 @@
442 """A v1 ontology has no delineator class, so the monitor is non-canonical."""461 """A v1 ontology has no delineator class, so the monitor is non-canonical."""
443 raw = yaml.safe_load(A1_SMOKE_CONFIG.read_text(encoding="utf-8"))462 raw = yaml.safe_load(A1_SMOKE_CONFIG.read_text(encoding="utf-8"))
444 raw["train"]["monitor"] = "val/iou_delineator"463 raw["train"]["monitor"] = "val/iou_delineator"
445 path = _write_config(tmp_path, raw, "v1_delineator.yaml")464 path = _write_config(tmp_path, raw, "v1_delineator.yaml")
446 with pytest.raises(ConfigError, match="delineator"):465 with pytest.raises(ExperimentConfigError, match="delineator"):
447 load_config(path)466 HarnessConfig.from_yaml(path)
448467
449468
450def test_v2_config_rejects_loss_ignore_index_mismatch(tmp_path: Path) -> None:469def test_v2_config_rejects_loss_ignore_index_mismatch(tmp_path: Path) -> None:
451 """loss.args.ignore_index must equal task.ignore_index when present."""470 """loss.args.ignore_index must equal task.ignore_index when present."""
452 raw = _v2_recap_document()471 raw = _v2_recap_document()
453 raw["loss"]["args"]["ignore_index"] = 9472 raw["loss"]["args"]["ignore_index"] = 9
454 path = _write_config(tmp_path, raw, "v2_loss_ignore_index.yaml")473 path = _write_config(tmp_path, raw, "v2_loss_ignore_index.yaml")
455 with pytest.raises(ConfigError, match="loss.args.ignore_index"):474 with pytest.raises(ExperimentConfigError, match="loss.args.ignore_index"):
456 load_config(path)475 HarnessConfig.from_yaml(path)
457476
458477
459def test_v2_config_rejects_cluster_profiles_from_another_ontology(478def test_v2_config_rejects_cluster_profiles_from_another_ontology(
460 tmp_path: Path,479 tmp_path: Path,
Importance #232: tests/test_config.py @@ -464,19 +483,19 @@
464 raw["evaluation"]["object_matching"]["cluster_profiles"] = (483 raw["evaluation"]["object_matching"]["cluster_profiles"] = (
465 "configs/contracts/ontology_v1.yaml"484 "configs/contracts/ontology_v1.yaml"
466 )485 )
467 path = _write_config(tmp_path, raw, "v2_cluster_profiles.yaml")486 path = _write_config(tmp_path, raw, "v2_cluster_profiles.yaml")
468 with pytest.raises(ConfigError, match="cluster_profiles"):487 with pytest.raises(ExperimentConfigError, match="cluster_profiles"):
469 load_config(path)488 HarnessConfig.from_yaml(path)
470489
471490
472def test_v2_config_accepts_all9_deciding_metric(tmp_path: Path) -> None:491def test_v2_config_accepts_all9_deciding_metric(tmp_path: Path) -> None:
473 """The structural macro suffix follows the ontology's interest count."""492 """The structural macro suffix follows the ontology's interest count."""
474 raw = _v2_recap_document()493 raw = _v2_recap_document()
475 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all9"]494 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all9"]
476 path = _write_config(tmp_path, raw, "v2_all9_metric.yaml")495 path = _write_config(tmp_path, raw, "v2_all9_metric.yaml")
477496
478 config = load_config(path)497 config = HarnessConfig.from_yaml(path)
479498
480 assert config.experiment.deciding_metrics == ("val/iou_macro_interest_all9",)499 assert config.experiment.deciding_metrics == ("val/iou_macro_interest_all9",)
481500
482501
Importance #233: tests/test_config.py @@ -484,10 +503,10 @@
484 """The frozen v1 macro tag is not canonical for a nine-interest ontology."""503 """The frozen v1 macro tag is not canonical for a nine-interest ontology."""
485 raw = _v2_recap_document()504 raw = _v2_recap_document()
486 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all8"]505 raw["experiment"]["deciding_metrics"] = ["val/iou_macro_interest_all8"]
487 path = _write_config(tmp_path, raw, "v2_all8_metric.yaml")506 path = _write_config(tmp_path, raw, "v2_all8_metric.yaml")
488 with pytest.raises(ConfigError, match="deciding_metrics"):507 with pytest.raises(ExperimentConfigError, match="deciding_metrics"):
489 load_config(path)508 HarnessConfig.from_yaml(path)
490509
491510
492def test_v2_gate_rejects_purity_class_from_another_ontology(511def test_v2_gate_rejects_purity_class_from_another_ontology(
493 tmp_path: Path,512 tmp_path: Path,
Importance #234: tests/test_config.py @@ -505,10 +524,10 @@
505 },524 },
506 }525 }
507 ]526 ]
508 path = _write_config(tmp_path, raw, "v2_gate_purity.yaml")527 path = _write_config(tmp_path, raw, "v2_gate_purity.yaml")
509 with pytest.raises(ConfigError, match="sign_gantry"):528 with pytest.raises(ExperimentConfigError, match="sign_gantry"):
510 load_config(path)529 HarnessConfig.from_yaml(path)
511530
512531
513def test_v2_gate_accepts_purity_class_of_the_task_ontology(532def test_v2_gate_accepts_purity_class_of_the_task_ontology(
514 tmp_path: Path,533 tmp_path: Path,
Importance #235: tests/test_config.py @@ -527,9 +546,9 @@
527 }546 }
528 ]547 ]
529 path = _write_config(tmp_path, raw, "v2_gate_purity_ok.yaml")548 path = _write_config(tmp_path, raw, "v2_gate_purity_ok.yaml")
530549
531 config = load_config(path)550 config = HarnessConfig.from_yaml(path)
532551
533 assert config.experiment.gates[0].params.minimum_purity_by_class == {552 assert config.experiment.gates[0].params.minimum_purity_by_class == {
534 "delineator": 0.8553 "delineator": 0.8
535 }554 }
Importance #236: tests/test_config.py @@ -545,11 +564,11 @@
545 raw["task"]["ontology"] = broken.name564 raw["task"]["ontology"] = broken.name
546 raw["evaluation"]["object_matching"]["cluster_profiles"] = broken.name565 raw["evaluation"]["object_matching"]["cluster_profiles"] = broken.name
547 path = _write_config(tmp_path, raw, "broken_ontology_config.yaml")566 path = _write_config(tmp_path, raw, "broken_ontology_config.yaml")
548 with pytest.raises(567 with pytest.raises(
549 ConfigError, match=r"task\.ontology .* is not a valid ontology"568 ExperimentConfigError, match=r"task\.ontology .* is not a valid ontology"
550 ):569 ):
551 load_config(path)570 HarnessConfig.from_yaml(path)
552571
553572
554def test_missing_task_ontology_names_the_working_directory(573def test_missing_task_ontology_names_the_working_directory(
555 tmp_path: Path,574 tmp_path: Path,
Importance #237: tests/test_config.py @@ -560,10 +579,10 @@
560 raw["evaluation"]["object_matching"]["cluster_profiles"] = (579 raw["evaluation"]["object_matching"]["cluster_profiles"] = (
561 "configs/contracts/ontology_absent.yaml"580 "configs/contracts/ontology_absent.yaml"
562 )581 )
563 path = _write_config(tmp_path, raw, "absent_ontology_config.yaml")582 path = _write_config(tmp_path, raw, "absent_ontology_config.yaml")
564 with pytest.raises(ConfigError, match="was not found relative to"):583 with pytest.raises(ExperimentConfigError, match="was not found relative to"):
565 load_config(path)584 HarnessConfig.from_yaml(path)
566585
567586
568def test_task_ontology_resolves_inside_the_configs_own_repository(587def test_task_ontology_resolves_inside_the_configs_own_repository(
569 tmp_path: Path, monkeypatch: pytest.MonkeyPatch588 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
Importance #238: tests/test_config.py @@ -589,9 +608,9 @@
589 ONTOLOGY_V2_PATH.read_text(encoding="utf-8"), encoding="utf-8"608 ONTOLOGY_V2_PATH.read_text(encoding="utf-8"), encoding="utf-8"
590 )609 )
591 monkeypatch.chdir(tmp_path / "elsewhere")610 monkeypatch.chdir(tmp_path / "elsewhere")
592611
593 config = load_config(config_path)612 config = HarnessConfig.from_yaml(config_path)
594613
595 assert resolve_ontology_path(config) == repo_contracts / "ontology_v1.yaml"614 assert resolve_ontology_path(config) == repo_contracts / "ontology_v1.yaml"
596 assert config.task.num_classes == 9615 assert config.task.num_classes == 9
597 assert config.task.ignore_index == 9616 assert config.task.ignore_index == 9
Importance #239: tests/test_config.py @@ -603,9 +622,9 @@
603 """task.ontology also resolves against the config's own repository root."""622 """task.ontology also resolves against the config's own repository root."""
604 config_path = Path("configs/e01_spt_pilot.yaml").resolve()623 config_path = Path("configs/e01_spt_pilot.yaml").resolve()
605 monkeypatch.chdir(tmp_path)624 monkeypatch.chdir(tmp_path)
606625
607 config = load_config(config_path)626 config = HarnessConfig.from_yaml(config_path)
608627
609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")628 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")
610 assert config.task.num_classes == 9629 assert config.task.num_classes == 9
611630
Importance #240: tests/test_config.py @@ -617,25 +636,25 @@
617 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))636 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
618 raw["__sha256__"] = "deadbeef"637 raw["__sha256__"] = "deadbeef"
619 path = tmp_path / "smuggled.yaml"638 path = tmp_path / "smuggled.yaml"
620 path.write_text(yaml.safe_dump(raw), encoding="utf-8")639 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
621 with pytest.raises(ConfigError, match="Unknown.*__sha256__"):640 with pytest.raises(ExperimentConfigError, match="Unknown.*__sha256__"):
622 load_config(path)641 HarnessConfig.from_yaml(path)
623642
624643
625def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:644def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:
626 """An explicitly null visualization block is a typo, not an omission."""645 """An explicitly null visualization block is a typo, not an omission."""
627 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))646 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
628 raw["visualization"] = None647 raw["visualization"] = None
629 path = tmp_path / "viz_null.yaml"648 path = tmp_path / "viz_null.yaml"
630 path.write_text(yaml.safe_dump(raw), encoding="utf-8")649 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
631 with pytest.raises(ConfigError, match="visualization must be a mapping"):650 with pytest.raises(ExperimentConfigError, match="visualization must be a mapping"):
632 load_config(path)651 HarnessConfig.from_yaml(path)
633652
634653
635def test_mapping_sections_are_read_only() -> None:654def test_mapping_sections_are_read_only() -> None:
636 """Frozen configs hand out read-only mappings, not mutable dicts."""655 """Frozen configs hand out read-only mappings, not mutable dicts."""
637 config = load_config(CONFIGS[0])656 config = HarnessConfig.from_yaml(CONFIGS[0])
638 with pytest.raises(TypeError):657 with pytest.raises(TypeError):
639 config.model.args["num_classes"] = 99 # type: ignore[index]658 config.model.args["num_classes"] = 99 # type: ignore[index]
640 with pytest.raises(TypeError):659 with pytest.raises(TypeError):
641 config.loss.args["gamma"] = 0.0 # type: ignore[index]660 config.loss.args["gamma"] = 0.0 # type: ignore[index]
Importance #241: tests/test_dispatch_cells.py @@ -10,9 +10,9 @@
1010
11import pytest11import pytest
12import yaml12import yaml
1313
14from src.train.config import load_config14from src.train.config import HarnessConfig
15from src.train.dispatch import DispatchError, DispatchRequest, dispatch_experiment15from src.train.dispatch import DispatchError, DispatchRequest, dispatch_experiment
1616
17BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")17BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
18CONTRACTS_DIR = Path("configs/contracts")18CONTRACTS_DIR = Path("configs/contracts")
Importance #242: tests/test_dispatch_cells.py @@ -39,9 +39,10 @@
39 raw["experiment"]["gates"] = []39 raw["experiment"]["gates"] = []
40 raw["model"]["runner"] = "stub_runner.py"40 raw["model"]["runner"] = "stub_runner.py"
41 raw["study"] = study41 raw["study"] = study
42 (tmp_path / "stub_runner.py").write_text(STUB_RUNNER, encoding="utf-8")42 (tmp_path / "stub_runner.py").write_text(STUB_RUNNER, encoding="utf-8")
43 # load_config resolves task.ontology against the working directory, so the43 # HarnessConfig.from_yaml resolves task.ontology against the working
44 # directory, so the
44 # throwaway root needs the frozen contracts the config names.45 # throwaway root needs the frozen contracts the config names.
45 shutil.copytree(CONTRACTS_DIR, tmp_path / CONTRACTS_DIR, dirs_exist_ok=True)46 shutil.copytree(CONTRACTS_DIR, tmp_path / CONTRACTS_DIR, dirs_exist_ok=True)
46 config_path = tmp_path / "experiment.yaml"47 config_path = tmp_path / "experiment.yaml"
47 config_path.write_text(yaml.safe_dump(raw), encoding="utf-8")48 config_path.write_text(yaml.safe_dump(raw), encoding="utf-8")
Importance #243: tests/test_dispatch_cells.py @@ -81,9 +82,9 @@
81 log_path = tmp_path / "invocations.jsonl"82 log_path = tmp_path / "invocations.jsonl"
82 monkeypatch.setenv("STUB_LOG", str(log_path))83 monkeypatch.setenv("STUB_LOG", str(log_path))
83 request = DispatchRequest(log_dir=tmp_path / "runs")84 request = DispatchRequest(log_dir=tmp_path / "runs")
84 assert dispatch_experiment(85 assert dispatch_experiment(
85 load_config(config_path), request, repository_root=root86 HarnessConfig.from_yaml(config_path), request, repository_root=root
86 ) == 087 ) == 0
87 records = _records(log_path)88 records = _records(log_path)
88 assert [record["cell"] for record in records] == ["coarse", "fine"]89 assert [record["cell"] for record in records] == ["coarse", "fine"]
89 assert [record["index"] for record in records] == ["0", "1"]90 assert [record["index"] for record in records] == ["0", "1"]
Importance #244: tests/test_dispatch_cells.py @@ -106,9 +107,9 @@
106 """Every cell run directory carries identity, overrides, and its SHA-256."""107 """Every cell run directory carries identity, overrides, and its SHA-256."""
107 root, config_path = _repository(tmp_path, _variants_study())108 root, config_path = _repository(tmp_path, _variants_study())
108 monkeypatch.setenv("STUB_LOG", str(tmp_path / "invocations.jsonl"))109 monkeypatch.setenv("STUB_LOG", str(tmp_path / "invocations.jsonl"))
109 dispatch_experiment(110 dispatch_experiment(
110 load_config(config_path),111 HarnessConfig.from_yaml(config_path),
111 DispatchRequest(log_dir=tmp_path / "runs"),112 DispatchRequest(log_dir=tmp_path / "runs"),
112 repository_root=root,113 repository_root=root,
113 )114 )
114 provenance = json.loads(115 provenance = json.loads(
Importance #245: tests/test_dispatch_cells.py @@ -138,9 +139,9 @@
138 root, config_path = _repository(tmp_path, study)139 root, config_path = _repository(tmp_path, study)
139 log_path = tmp_path / "invocations.jsonl"140 log_path = tmp_path / "invocations.jsonl"
140 monkeypatch.setenv("STUB_LOG", str(log_path))141 monkeypatch.setenv("STUB_LOG", str(log_path))
141 dispatch_experiment(142 dispatch_experiment(
142 load_config(config_path),143 HarnessConfig.from_yaml(config_path),
143 DispatchRequest(log_dir=tmp_path / "runs"),144 DispatchRequest(log_dir=tmp_path / "runs"),
144 repository_root=root,145 repository_root=root,
145 )146 )
146 records = _records(log_path)147 records = _records(log_path)
Importance #246: tests/test_dispatch_cells.py @@ -158,9 +159,9 @@
158 monkeypatch.setenv("STUB_LOG", str(log_path))159 monkeypatch.setenv("STUB_LOG", str(log_path))
159 monkeypatch.setenv("STUB_EXIT_CODE", "3")160 monkeypatch.setenv("STUB_EXIT_CODE", "3")
160 with pytest.raises(DispatchError, match="cell coarse"):161 with pytest.raises(DispatchError, match="cell coarse"):
161 dispatch_experiment(162 dispatch_experiment(
162 load_config(config_path),163 HarnessConfig.from_yaml(config_path),
163 DispatchRequest(log_dir=tmp_path / "runs"),164 DispatchRequest(log_dir=tmp_path / "runs"),
164 repository_root=root,165 repository_root=root,
165 )166 )
166 assert len(_records(log_path)) == 1167 assert len(_records(log_path)) == 1
Importance #247: tests/test_dispatch_cells.py @@ -172,9 +173,9 @@
172 """DispatchRequest narrowing is forwarded unchanged to each cell."""173 """DispatchRequest narrowing is forwarded unchanged to each cell."""
173 root, config_path = _repository(tmp_path, _variants_study())174 root, config_path = _repository(tmp_path, _variants_study())
174 log_path = tmp_path / "invocations.jsonl"175 log_path = tmp_path / "invocations.jsonl"
175 monkeypatch.setenv("STUB_LOG", str(log_path))176 monkeypatch.setenv("STUB_LOG", str(log_path))
176 config = load_config(config_path)177 config = HarnessConfig.from_yaml(config_path)
177 request = DispatchRequest(178 request = DispatchRequest(
178 corridors=(config.data.corridors.include[0],),179 corridors=(config.data.corridors.include[0],),
179 fast_dev_run=True,180 fast_dev_run=True,
180 num_workers=2,181 num_workers=2,
Importance #248: tests/test_dispatch_cells.py @@ -194,9 +195,9 @@
194 """A training run needs both folds, so --split fails early and by name."""195 """A training run needs both folds, so --split fails early and by name."""
195 root, config_path = _repository(tmp_path, _variants_study())196 root, config_path = _repository(tmp_path, _variants_study())
196 with pytest.raises(DispatchError, match="refuses --split"):197 with pytest.raises(DispatchError, match="refuses --split"):
197 dispatch_experiment(198 dispatch_experiment(
198 load_config(config_path),199 HarnessConfig.from_yaml(config_path),
199 DispatchRequest(splits=("train",)),200 DispatchRequest(splits=("train",)),
200 repository_root=root,201 repository_root=root,
201 )202 )
202203
Importance #249: tests/test_dispatch_cells.py @@ -236,8 +237,8 @@
236 """Study expansion never relaxes the narrowing contract."""237 """Study expansion never relaxes the narrowing contract."""
237 root, config_path = _repository(tmp_path, _variants_study())238 root, config_path = _repository(tmp_path, _variants_study())
238 with pytest.raises(DispatchError, match="promotion"):239 with pytest.raises(DispatchError, match="promotion"):
239 dispatch_experiment(240 dispatch_experiment(
240 load_config(config_path),241 HarnessConfig.from_yaml(config_path),
241 DispatchRequest(splits=("promotion_test",)),242 DispatchRequest(splits=("promotion_test",)),
242 repository_root=root,243 repository_root=root,
243 )244 )
Importance #250: tests/test_dispatch_environment.py @@ -5,9 +5,9 @@
5from pathlib import Path5from pathlib import Path
66
7import pytest7import pytest
88
9from src.train.config import load_config9from src.train.config import HarnessConfig
10from src.train.dispatch import DispatchError, dispatch_experiment10from src.train.dispatch import DispatchError, dispatch_experiment
1111
12SPT_CONFIG = Path("configs/e01_spt_pilot.yaml")12SPT_CONFIG = Path("configs/e01_spt_pilot.yaml")
13CPU_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")13CPU_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
Importance #251: tests/test_dispatch_environment.py @@ -32,9 +32,9 @@
3232
3333
34def _dispatch_message() -> str:34def _dispatch_message() -> str:
35 """Dispatch the SPT pilot and return its refusal message."""35 """Dispatch the SPT pilot and return its refusal message."""
36 config = load_config(SPT_CONFIG)36 config = HarnessConfig.from_yaml(SPT_CONFIG)
37 with pytest.raises(DispatchError) as failure:37 with pytest.raises(DispatchError) as failure:
38 dispatch_experiment(config)38 dispatch_experiment(config)
39 return str(failure.value)39 return str(failure.value)
4040
Importance #252: tests/test_dispatch_environment.py @@ -157,8 +157,8 @@
157 monkeypatch: pytest.MonkeyPatch,157 monkeypatch: pytest.MonkeyPatch,
158) -> None:158) -> None:
159 """CPU experiments never consult the external framework environment."""159 """CPU experiments never consult the external framework environment."""
160 monkeypatch.setenv("SPT_ROOT", "/definitely/not/a/checkout")160 monkeypatch.setenv("SPT_ROOT", "/definitely/not/a/checkout")
161 config = load_config(CPU_CONFIG)161 config = HarnessConfig.from_yaml(CPU_CONFIG)
162 with pytest.raises(DispatchError) as failure:162 with pytest.raises(DispatchError) as failure:
163 dispatch_experiment(config)163 dispatch_experiment(config)
164 assert "framework_environment" not in str(failure.value)164 assert "framework_environment" not in str(failure.value)
Importance #253: tests/test_framework_run_provenance.py @@ -24,9 +24,9 @@
24 SplitTier,24 SplitTier,
25 authorize_split_access,25 authorize_split_access,
26 load_split_manifest,26 load_split_manifest,
27)27)
28from src.train.config import HarnessConfig, load_config28from src.train.config import HarnessConfig
2929
30CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")30CONFIG_PATH = Path("configs/e01_spt_pilot.yaml")
31GRID_ORIGIN = [10.0, -20.0, 5.0]31GRID_ORIGIN = [10.0, -20.0, 5.0]
3232
Importance #254: tests/test_framework_run_provenance.py @@ -59,9 +59,9 @@
5959
6060
61def _config_with_prepared_corridors(tmp_path: Path) -> HarnessConfig:61def _config_with_prepared_corridors(tmp_path: Path) -> HarnessConfig:
62 """Return the E1 config pointed at synthetic adapter manifests."""62 """Return the E1 config pointed at synthetic adapter manifests."""
63 config = load_config(CONFIG_PATH)63 config = HarnessConfig.from_yaml(CONFIG_PATH)
64 canonical_root = tmp_path / "canonical_root"64 canonical_root = tmp_path / "canonical_root"
65 (canonical_root / "canonical").mkdir(parents=True)65 (canonical_root / "canonical").mkdir(parents=True)
66 for corridor_id in config.data.corridors.include:66 for corridor_id in config.data.corridors.include:
67 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(67 (canonical_root / "canonical" / f"{corridor_id}.adapter.json").write_text(
Importance #255: tests/test_study_expansion.py @@ -7,9 +7,9 @@
77
8import pytest8import pytest
9import yaml9import yaml
1010
11from src.train.config import ConfigError, expand_study, load_config11from src.train.config import ExperimentConfigError, HarnessConfig, expand_study
1212
13BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")13BASE_CONFIG = Path("configs/e02_voxel_survival_oracle.yaml")
14SINGLE_CONFIG = Path("configs/e01_spt_pilot.yaml")14SINGLE_CONFIG = Path("configs/e01_spt_pilot.yaml")
1515
Importance #256: tests/test_study_expansion.py @@ -34,9 +34,9 @@
3434
3535
36def test_variants_study_expands_to_one_cell_per_variant() -> None:36def test_variants_study_expands_to_one_cell_per_variant() -> None:
37 """Every declared variant becomes its own resolved, hashed configuration."""37 """Every declared variant becomes its own resolved, hashed configuration."""
38 config = load_config(BASE_CONFIG)38 config = HarnessConfig.from_yaml(BASE_CONFIG)
39 cells = expand_study(config)39 cells = expand_study(config)
40 assert [cell.id for cell in cells] == [40 assert [cell.id for cell in cells] == [
41 variant.id for variant in config.study.variants41 variant.id for variant in config.study.variants
42 ]42 ]
Importance #257: tests/test_study_expansion.py @@ -49,9 +49,9 @@
4949
5050
51def test_single_study_expands_to_exactly_one_identity_cell() -> None:51def test_single_study_expands_to_exactly_one_identity_cell() -> None:
52 """A single study runs once and changes nothing in the resolved config."""52 """A single study runs once and changes nothing in the resolved config."""
53 config = load_config(SINGLE_CONFIG)53 config = HarnessConfig.from_yaml(SINGLE_CONFIG)
54 cells = expand_study(config)54 cells = expand_study(config)
55 assert len(cells) == 155 assert len(cells) == 1
56 assert cells[0].overrides == {}56 assert cells[0].overrides == {}
57 assert cells[0].id == config.experiment.id57 assert cells[0].id == config.experiment.id
Importance #258: tests/test_study_expansion.py @@ -65,11 +65,11 @@
6565
6666
67def test_cell_order_and_hashes_are_deterministic() -> None:67def test_cell_order_and_hashes_are_deterministic() -> None:
68 """Repeated expansion yields identical identities, order, and hashes."""68 """Repeated expansion yields identical identities, order, and hashes."""
69 config = load_config(BASE_CONFIG)69 config = HarnessConfig.from_yaml(BASE_CONFIG)
70 first = expand_study(config)70 first = expand_study(config)
71 second = expand_study(load_config(BASE_CONFIG))71 second = expand_study(HarnessConfig.from_yaml(BASE_CONFIG))
72 assert [cell.id for cell in first] == [cell.id for cell in second]72 assert [cell.id for cell in first] == [cell.id for cell in second]
73 assert [cell.config.sha256 for cell in first] == [73 assert [cell.config.sha256 for cell in first] == [
74 cell.config.sha256 for cell in second74 cell.config.sha256 for cell in second
75 ]75 ]
Importance #259: tests/test_study_expansion.py @@ -82,10 +82,10 @@
82 {"id": "typo", "overrides": {"adapter.spt.voxel_metres": 0.02}}82 {"id": "typo", "overrides": {"adapter.spt.voxel_metres": 0.02}}
83 ]83 ]
84 raw["study"]["contrasts"] = []84 raw["study"]["contrasts"] = []
85 path = _write(raw, tmp_path / "unknown_override.yaml")85 path = _write(raw, tmp_path / "unknown_override.yaml")
86 with pytest.raises(ConfigError, match="adapter.spt.voxel_metres"):86 with pytest.raises(ExperimentConfigError, match="adapter.spt.voxel_metres"):
87 load_config(path)87 HarnessConfig.from_yaml(path)
8888
8989
90def test_duplicate_variant_cell_ids_are_rejected(tmp_path: Path) -> None:90def test_duplicate_variant_cell_ids_are_rejected(tmp_path: Path) -> None:
91 """Two cells cannot share an identity."""91 """Two cells cannot share an identity."""
Importance #260: tests/test_study_expansion.py @@ -95,10 +95,10 @@
95 {"id": "twin", "overrides": {"data.tiling.min_points": 2}},95 {"id": "twin", "overrides": {"data.tiling.min_points": 2}},
96 ]96 ]
97 raw["study"]["contrasts"] = []97 raw["study"]["contrasts"] = []
98 path = _write(raw, tmp_path / "duplicate_ids.yaml")98 path = _write(raw, tmp_path / "duplicate_ids.yaml")
99 with pytest.raises(ConfigError, match="duplicate"):99 with pytest.raises(ExperimentConfigError, match="duplicate"):
100 load_config(path)100 HarnessConfig.from_yaml(path)
101101
102102
103def test_kind_without_its_payload_is_rejected(tmp_path: Path) -> None:103def test_kind_without_its_payload_is_rejected(tmp_path: Path) -> None:
104 """A discriminated kind whose payload is absent never expands silently."""104 """A discriminated kind whose payload is absent never expands silently."""
Importance #261: tests/test_study_expansion.py @@ -110,10 +110,10 @@
110 "sweep": None,110 "sweep": None,
111 "contrasts": [],111 "contrasts": [],
112 }112 }
113 path = _write(raw, tmp_path / "empty_variants.yaml")113 path = _write(raw, tmp_path / "empty_variants.yaml")
114 with pytest.raises(ConfigError, match="variants"):114 with pytest.raises(ExperimentConfigError, match="variants"):
115 load_config(path)115 HarnessConfig.from_yaml(path)
116116
117117
118def test_matrix_study_expands_cross_product_minus_exclusions(118def test_matrix_study_expands_cross_product_minus_exclusions(
119 tmp_path: Path,119 tmp_path: Path,
Importance #262: tests/test_study_expansion.py @@ -135,9 +135,9 @@
135 },135 },
136 "sweep": None,136 "sweep": None,
137 "contrasts": [],137 "contrasts": [],
138 }138 }
139 config = load_config(_write(raw, tmp_path / "matrix.yaml"))139 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "matrix.yaml"))
140 cells = expand_study(config)140 cells = expand_study(config)
141 assert [cell.id for cell in cells] == [141 assert [cell.id for cell in cells] == [
142 "min_points-1__samples-500",142 "min_points-1__samples-500",
143 "min_points-2__samples-500",143 "min_points-2__samples-500",
Importance #263: tests/test_study_expansion.py @@ -161,10 +161,10 @@
161 },161 },
162 "sweep": None,162 "sweep": None,
163 "contrasts": [],163 "contrasts": [],
164 }164 }
165 config = load_config(_write(raw, tmp_path / "stale_exclude.yaml"))165 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "stale_exclude.yaml"))
166 with pytest.raises(ConfigError, match="matches no matrix cell"):166 with pytest.raises(ExperimentConfigError, match="matches no matrix cell"):
167 expand_study(config)167 expand_study(config)
168168
169169
170def test_grid_sweep_expands_deterministically_within_budget(170def test_grid_sweep_expands_deterministically_within_budget(
Importance #264: tests/test_study_expansion.py @@ -183,9 +183,9 @@
183 "objective": "val/iou_macro_interest",183 "objective": "val/iou_macro_interest",
184 },184 },
185 "contrasts": [],185 "contrasts": [],
186 }186 }
187 config = load_config(_write(raw, tmp_path / "grid_sweep.yaml"))187 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "grid_sweep.yaml"))
188 cells = expand_study(config)188 cells = expand_study(config)
189 assert [cell.id for cell in cells] == ["min_points-1", "min_points-2"]189 assert [cell.id for cell in cells] == ["min_points-1", "min_points-2"]
190 assert [cell.config.data.tiling.min_points for cell in cells] == [1, 2]190 assert [cell.config.data.tiling.min_points for cell in cells] == [1, 2]
191191
Importance #265: tests/test_study_expansion.py @@ -212,9 +212,9 @@
212 "objective": "val/iou_macro_interest",212 "objective": "val/iou_macro_interest",
213 },213 },
214 "contrasts": [],214 "contrasts": [],
215 }215 }
216 config = load_config(_write(raw, tmp_path / "random_sweep.yaml"))216 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "random_sweep.yaml"))
217 first = expand_study(config)217 first = expand_study(config)
218 second = expand_study(config)218 second = expand_study(config)
219 assert [cell.id for cell in first] == ["sample_000", "sample_001", "sample_002"]219 assert [cell.id for cell in first] == ["sample_000", "sample_001", "sample_002"]
220 assert [220 assert [
Importance #266: tests/test_study_expansion.py @@ -237,17 +237,17 @@
237 "objective": "val/iou_macro_interest",237 "objective": "val/iou_macro_interest",
238 },238 },
239 "contrasts": [],239 "contrasts": [],
240 }240 }
241 config = load_config(_write(raw, tmp_path / "bayes_sweep.yaml"))241 config = HarnessConfig.from_yaml(_write(raw, tmp_path / "bayes_sweep.yaml"))
242 with pytest.raises(ConfigError, match="bayesian"):242 with pytest.raises(ExperimentConfigError, match="bayesian"):
243 expand_study(config)243 expand_study(config)
244244
245245
246def test_every_repository_experiment_expands() -> None:246def test_every_repository_experiment_expands() -> None:
247 """All E1-E14 studies expand to at least one strictly validated cell."""247 """All E1-E14 studies expand to at least one strictly validated cell."""
248 for path in sorted(Path("configs").glob("e*.yaml")):248 for path in sorted(Path("configs").glob("e*.yaml")):
249 config = load_config(path)249 config = HarnessConfig.from_yaml(path)
250 cells = expand_study(config)250 cells = expand_study(config)
251 assert cells251 assert cells
252 assert len({cell.id for cell in cells}) == len(cells)252 assert len({cell.id for cell in cells}) == len(cells)
253 if config.study.kind == "single":253 if config.study.kind == "single":
Importance #267: tests/test_voxel_oracle.py @@ -760,22 +760,22 @@
760760
761761
762def test_voxel_sizes_come_from_the_study_variants() -> None:762def test_voxel_sizes_come_from_the_study_variants() -> None:
763 """The study variants are the single source of the analysed grid sizes."""763 """The study variants are the single source of the analysed grid sizes."""
764 from src.train.config import load_config764 from src.train.config import HarnessConfig
765765
766 module = _load_oracle_script()766 module = _load_oracle_script()
767767
768 assert module._voxel_sizes(load_config(E2_CONFIG), Path(E2_CONFIG)) == (768 assert module._voxel_sizes(HarnessConfig.from_yaml(E2_CONFIG), Path(E2_CONFIG)) == (
769 0.02,769 0.02,
770 0.03,770 0.03,
771 0.05,771 0.05,
772 )772 )
773773
774774
775def test_config_without_variant_voxel_sizes_fails_closed(tmp_path: Path) -> None:775def test_config_without_variant_voxel_sizes_fails_closed(tmp_path: Path) -> None:
776 """A config declaring no variant voxel size refuses to run E2."""776 """A config declaring no variant voxel size refuses to run E2."""
777 from src.train.config import load_config777 from src.train.config import HarnessConfig
778778
779 module = _load_oracle_script()779 module = _load_oracle_script()
780780
781 def _drop_variants(raw: dict[str, Any]) -> None:781 def _drop_variants(raw: dict[str, Any]) -> None:
Importance #268: tests/test_voxel_oracle.py @@ -785,14 +785,14 @@
785785
786 path = _config_variant(tmp_path, _drop_variants)786 path = _config_variant(tmp_path, _drop_variants)
787787
788 with pytest.raises(VoxelOracleError, match="adapter.spt.voxel_m"):788 with pytest.raises(VoxelOracleError, match="adapter.spt.voxel_m"):
789 module._voxel_sizes(load_config(path), path)789 module._voxel_sizes(HarnessConfig.from_yaml(path), path)
790790
791791
792def test_restated_model_voxel_sizes_must_agree(tmp_path: Path) -> None:792def test_restated_model_voxel_sizes_must_agree(tmp_path: Path) -> None:
793 """A duplicated ``model.args.voxel_sizes_m`` must match or fail closed."""793 """A duplicated ``model.args.voxel_sizes_m`` must match or fail closed."""
794 from src.train.config import load_config794 from src.train.config import HarnessConfig
795795
796 module = _load_oracle_script()796 module = _load_oracle_script()
797797
798 def _restate(raw: dict[str, Any]) -> None:798 def _restate(raw: dict[str, Any]) -> None:
Importance #269: tests/test_voxel_oracle.py @@ -800,9 +800,9 @@
800800
801 path = _config_variant(tmp_path, _restate)801 path = _config_variant(tmp_path, _restate)
802802
803 with pytest.raises(VoxelOracleError, match="voxel_sizes_m"):803 with pytest.raises(VoxelOracleError, match="voxel_sizes_m"):
804 module._voxel_sizes(load_config(path), path)804 module._voxel_sizes(HarnessConfig.from_yaml(path), path)
805805
806806
807def test_required_output_formats_are_written(tmp_path: Path) -> None:807def test_required_output_formats_are_written(tmp_path: Path) -> None:
808 """E2 always writes CSV, JSON, and Markdown products."""808 """E2 always writes CSV, JSON, and Markdown products."""
Importance #270: tests/test_voxel_oracle.py @@ -852,12 +852,12 @@
852852
853853
854def test_dispatched_cell_analyses_only_its_own_voxel_size() -> None:854def test_dispatched_cell_analyses_only_its_own_voxel_size() -> None:
855 """Dispatch runs E2 once per cell, so a cell must not re-run the sweep."""855 """Dispatch runs E2 once per cell, so a cell must not re-run the sweep."""
856 from src.train.config import load_config856 from src.train.config import HarnessConfig
857857
858 module = _load_oracle_script()858 module = _load_oracle_script()
859 config = load_config(E2_CONFIG)859 config = HarnessConfig.from_yaml(E2_CONFIG)
860860
861 assert module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_2cm", 0)) == (861 assert module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_2cm", 0)) == (
862 0.02,862 0.02,
863 )863 )
Importance #271: tests/test_voxel_oracle.py @@ -872,12 +872,12 @@
872872
873873
874def test_unknown_dispatched_cell_fails_closed() -> None:874def test_unknown_dispatched_cell_fails_closed() -> None:
875 """An unknown or misplaced study cell is refused by name."""875 """An unknown or misplaced study cell is refused by name."""
876 from src.train.config import load_config876 from src.train.config import HarnessConfig
877877
878 module = _load_oracle_script()878 module = _load_oracle_script()
879 config = load_config(E2_CONFIG)879 config = HarnessConfig.from_yaml(E2_CONFIG)
880880
881 with pytest.raises(VoxelOracleError, match="voxel_9cm"):881 with pytest.raises(VoxelOracleError, match="voxel_9cm"):
882 module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_9cm", 0))882 module._voxel_sizes(config, Path(E2_CONFIG), cell=("voxel_9cm", 0))
883 with pytest.raises(VoxelOracleError, match="position 1"):883 with pytest.raises(VoxelOracleError, match="position 1"):