Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:20:38+02:00
Commit #79 ยท 17 snippets
CLAUDE.md | 6 ++++-- scripts/train.py | 12 +++++++----- src/train/config.py | 43 ++++++++++++++++++++++++++++++++++++------- test/test_harness_smoke.py | 34 ++++++++++++++++++++++++++++++++-- 4 files changed, 79 insertions(+), 16 deletions(-)
| 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys | 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys |
| 6 | raise, so config typos fail fast instead of silently training with defaults. | 6 | raise, so config typos fail fast instead of silently training with defaults. |
| 7 | 7 | ||
| 8 | Adding a config key = adding one field with its default to the model below. | 8 | Adding a config key = adding one field with its default to the model below. |
| 9 | |||
| 10 | Instances are frozen and validated: derive a changed config with | ||
| 11 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips | ||
| 12 | validation and the fleet coercion matrix. | ||
| 9 | """ | 13 | """ |
| 10 | import logging | 14 | from collections.abc import Mapping |
| 11 | from pathlib import Path | 15 | from pathlib import Path |
| 12 | from typing import Literal | 16 | from typing import Any, Literal |
| 13 | 17 | ||
| 14 | import pydantic | 18 | import pydantic |
| 15 | from iolabs.common import config_loader | 19 | from iolabs.common import config_loader |
| 16 | from iolabs_ml_harness import config as harness_config | 20 | from iolabs_ml_harness import config as harness_config |
| 17 | 21 | ||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 20 | 22 | ||
| 21 | class ConfigError(config_loader.ConfigError): | 23 | class ConfigError(config_loader.ConfigError): |
| 22 | """Raised when a guardrail harness config holds unknown keys or bad values.""" | 24 | """Raised when a guardrail harness config holds unknown keys or bad values.""" |
| 23 | 25 | ||
| 24 | 26 | ||
| 25 | class PairSpec(config_loader.ConfigModel): | 27 | class PairSpec(harness_config.SectionModel): |
| 26 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 28 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 27 | images: str | 29 | images: str |
| 28 | masks: str | 30 | masks: str |
| 29 | 31 | ||
| 30 | 32 | ||
| 31 | class DataConfig(config_loader.ConfigModel): | 33 | class DataConfig(harness_config.SectionModel): |
| 32 | """Guardrail dataset selection and crop/loader sizing.""" | 34 | """Guardrail dataset selection and crop/loader sizing.""" |
| 33 | source: Literal["synthetic", "pairs"] = "synthetic" | 35 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 34 | pairs: list[PairSpec] = [] | 36 | pairs: list[PairSpec] = [] |
| 35 | crop_size: int = pydantic.Field(default=256, gt=0) | 37 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 39 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) | 41 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) |
| 40 | synthetic_seed: int = 20260707 | 42 | synthetic_seed: int = 20260707 |
| 41 | 43 | ||
| 42 | 44 | ||
| 43 | class HarnessConfig(config_loader.ConfigModel): | 45 | class HarnessConfig(harness_config.SectionModel): |
| 44 | """Top-level training config: one YAML file, one instance.""" | 46 | """Top-level training config: one YAML file, one instance.""" |
| 45 | experiment: str = "experiment" | 47 | experiment: str = "experiment" |
| 46 | seed: int = 1337 | 48 | seed: int = 1337 |
| 47 | data: DataConfig = DataConfig() | 49 | data: DataConfig = DataConfig() |
| 67 | """ | 69 | """ |
| 68 | raw = harness_config.load_yaml_mapping(path) | 70 | raw = harness_config.load_yaml_mapping(path) |
| 69 | return config_loader.validate_config( | 71 | return config_loader.validate_config( |
| 70 | cls, raw, context=str(path), error_cls=ConfigError) | 72 | cls, raw, context=str(path), error_cls=ConfigError) |
| 73 | |||
| 74 | |||
| 75 | def with_overrides(cfg: HarnessConfig, | ||
| 76 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: | ||
| 77 | """Returns a re-validated copy of ``cfg`` with per-section overrides merged in. | ||
| 78 | |||
| 79 | ``model_copy(update=...)`` would store the values unchecked, so a | ||
| 80 | ``--max-epochs -2`` would survive the ``ge=-1`` bound. Round-tripping through | ||
| 81 | the model keeps CLI overrides on exactly the path YAML values take. | ||
| 82 | |||
| 83 | Args: | ||
| 84 | cfg: The config to derive from; never mutated. | ||
| 85 | sections: Section name -> field name -> override value. Empty sections | ||
| 86 | are ignored. | ||
| 87 | |||
| 88 | Returns: | ||
| 89 | ``cfg`` itself when no override is given, otherwise a validated copy. | ||
| 90 | |||
| 91 | Raises: | ||
| 92 | ConfigError: An override value is invalid for its declared field. | ||
| 93 | """ | ||
| 94 | applied = {name: dict(values) for name, values in sections.items() if values} | ||
| 95 | if not applied: | ||
| 96 | return cfg | ||
| 97 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) | ||
| 98 | return config_loader.validate_config( | ||
| 99 | type(cfg), merged, context="CLI overrides", error_cls=ConfigError) |
| 48 | def apply_cli_overrides(cfg: config.HarnessConfig, | 48 | def apply_cli_overrides(cfg: config.HarnessConfig, |
| 49 | args: argparse.Namespace) -> config.HarnessConfig: | 49 | args: argparse.Namespace) -> config.HarnessConfig: |
| 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. | 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. |
| 51 | 51 | ||
| 52 | Config models are frozen, so the overrides are applied by copying each | 52 | Config models are frozen, so the overrides are re-validated into a copy |
| 53 | touched section instead of assigning to it. | 53 | instead of being assigned onto ``cfg``. |
| 54 | 54 | ||
| 55 | Args: | 55 | Args: |
| 56 | cfg: The config parsed from the YAML file. | 56 | cfg: The config parsed from the YAML file. |
| 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. | 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. |
| 58 | 58 | ||
| 59 | Returns: | 59 | Returns: |
| 60 | ``cfg`` itself when no override was given, otherwise an updated copy. | 60 | ``cfg`` itself when no override was given, otherwise an updated copy. |
| 61 | |||
| 62 | Raises: | ||
| 63 | config.ConfigError: An override value is invalid for its field, e.g. | ||
| 64 | ``--max-epochs -2`` or ``--batch-size 0``. | ||
| 61 | """ | 65 | """ |
| 62 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} |
| 63 | if args.max_epochs is not None: | 67 | if args.max_epochs is not None: |
| 64 | sections["train"]["max_epochs"] = args.max_epochs | 68 | sections["train"]["max_epochs"] = args.max_epochs |
| 67 | if args.model: | 71 | if args.model: |
| 68 | sections["model"]["name"] = args.model | 72 | sections["model"]["name"] = args.model |
| 69 | if args.encoder: | 73 | if args.encoder: |
| 70 | sections["model"]["encoder_name"] = args.encoder | 74 | sections["model"]["encoder_name"] = args.encoder |
| 71 | updates = {name: getattr(cfg, name).model_copy(update=values) | 75 | return config.with_overrides(cfg, sections) |
| 72 | for name, values in sections.items() if values} | ||
| 73 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 74 | 76 | ||
| 75 | 77 | ||
| 76 | def main() -> None: | 78 | def main() -> None: |
| 77 | args = parse_args() | 79 | args = parse_args() |
| 23 | from iolabs_ml_harness.trainer import build_trainer # noqa: E402 | 23 | from iolabs_ml_harness.trainer import build_trainer # noqa: E402 |
| 24 | 24 | ||
| 25 | from src.dataset.rasters import index_raster_pairs # noqa: E402 | 25 | from src.dataset.rasters import index_raster_pairs # noqa: E402 |
| 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 | 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 |
| 27 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import ConfigError, DataConfig, HarnessConfig # noqa: E402 |
| 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 | 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 |
| 29 | 29 | ||
| 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] | 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] |
| 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") | 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") |
| 65 | cfg = HarnessConfig.from_yaml(good) | 65 | cfg = HarnessConfig.from_yaml(good) |
| 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 67 | bad = tmp_path / "bad.yaml" | 67 | bad = tmp_path / "bad.yaml" |
| 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key | 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key |
| 69 | with pytest.raises(ValueError, match="soruce"): | 69 | with pytest.raises(ConfigError, match="soruce"): |
| 70 | HarnessConfig.from_yaml(bad) | 70 | HarnessConfig.from_yaml(bad) |
| 71 | 71 | ||
| 72 | 72 | ||
| 73 | def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None: | ||
| 74 | """A bare ``data:`` line parses to None and must mean "all defaults".""" | ||
| 75 | path = tmp_path / "nulls.yaml" | ||
| 76 | path.write_text("experiment: t\ndata:\nmodel:\nloss:\n args:\n") | ||
| 77 | cfg = HarnessConfig.from_yaml(path) | ||
| 78 | assert cfg.data.crop_size == 256 and cfg.data.source == "synthetic" | ||
| 79 | assert cfg.model.name == "unet" and cfg.loss.args == {} | ||
| 80 | |||
| 81 | |||
| 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: | ||
| 83 | path = tmp_path / "bad.yaml" | ||
| 84 | path.write_text("train:\n max_epochs: -2\n") | ||
| 85 | with pytest.raises(ConfigError, match="max_epochs"): | ||
| 86 | HarnessConfig.from_yaml(path) | ||
| 87 | |||
| 88 | |||
| 73 | def test_synthetic_dataset_is_deterministic() -> None: | 89 | def test_synthetic_dataset_is_deterministic() -> None: |
| 74 | ds_a = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) | 90 | ds_a = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) |
| 75 | ds_b = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) | 91 | ds_b = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) |
| 76 | for i in range(4): | 92 | for i in range(4): |
| 174 | 190 | ||
| 175 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) | 191 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) |
| 176 | 192 | ||
| 177 | assert updated.train.max_epochs == 0 | 193 | assert updated.train.max_epochs == 0 |
| 194 | |||
| 195 | |||
| 196 | @pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}]) | ||
| 197 | def test_apply_cli_overrides_validates_like_the_yaml_path(flags: dict) -> None: | ||
| 198 | """CLI overrides must hit the same bounds as values coming from YAML.""" | ||
| 199 | with pytest.raises(ConfigError): | ||
| 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) | ||
| 201 | |||
| 202 | |||
| 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: | ||
| 204 | updated = _train_script().apply_cli_overrides( | ||
| 205 | HarnessConfig(), _args(max_epochs="7")) | ||
| 206 | |||
| 207 | assert updated.train.max_epochs == 7 |
| 49 | (unknown keys rejected, values coerced by the shared fleet matrix, instances | 49 | (unknown keys rejected, values coerced by the shared fleet matrix, instances |
| 50 | frozen). **Adding a config key = adding one field with its default to the model | 50 | frozen). **Adding a config key = adding one field with its default to the model |
| 51 | in `src/train/config.py`** โ nothing else. `HarnessConfig.from_yaml` raises | 51 | in `src/train/config.py`** โ nothing else. `HarnessConfig.from_yaml` raises |
| 52 | `config.ConfigError`, which derives from `ValueError`. Because the models are | 52 | `config.ConfigError`, which derives from `ValueError`. Because the models are |
| 53 | frozen, CLI overrides copy sections (`apply_cli_overrides` in | 53 | frozen, CLI overrides go through `config.with_overrides` (used by |
| 54 | `scripts/train.py`) instead of assigning to them. | 54 | `apply_cli_overrides` in `scripts/train.py`), which re-validates the merged |
| 55 | config โ never `model_copy(update=...)`, which stores values unchecked. A bare | ||
| 56 | `data:` / `model:` line (YAML `null`) means "use the defaults". | ||
| 55 | 57 | ||
| 56 | ## Environment | 58 | ## Environment |
| 57 | 59 | ||
| 58 | uv-managed: `uv sync`. Extras: | 60 | uv-managed: `uv sync`. Extras: |
| 48 | def apply_cli_overrides(cfg: config.HarnessConfig, | 48 | def apply_cli_overrides(cfg: config.HarnessConfig, |
| 49 | args: argparse.Namespace) -> config.HarnessConfig: | 49 | args: argparse.Namespace) -> config.HarnessConfig: |
| 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. | 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. |
| 51 | 51 | ||
| 52 | Config models are frozen, so the overrides are applied by copying each | 52 | Config models are frozen, so the overrides are re-validated into a copy |
| 53 | touched section instead of assigning to it. | 53 | instead of being assigned onto ``cfg``. |
| 54 | 54 | ||
| 55 | Args: | 55 | Args: |
| 56 | cfg: The config parsed from the YAML file. | 56 | cfg: The config parsed from the YAML file. |
| 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. | 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. |
| 58 | 58 | ||
| 59 | Returns: | 59 | Returns: |
| 60 | ``cfg`` itself when no override was given, otherwise an updated copy. | 60 | ``cfg`` itself when no override was given, otherwise an updated copy. |
| 61 | |||
| 62 | Raises: | ||
| 63 | config.ConfigError: An override value is invalid for its field, e.g. | ||
| 64 | ``--max-epochs -2`` or ``--batch-size 0``. | ||
| 61 | """ | 65 | """ |
| 62 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} |
| 63 | if args.max_epochs is not None: | 67 | if args.max_epochs is not None: |
| 64 | sections["train"]["max_epochs"] = args.max_epochs | 68 | sections["train"]["max_epochs"] = args.max_epochs |
| 67 | if args.model: | 71 | if args.model: |
| 68 | sections["model"]["name"] = args.model | 72 | sections["model"]["name"] = args.model |
| 69 | if args.encoder: | 73 | if args.encoder: |
| 70 | sections["model"]["encoder_name"] = args.encoder | 74 | sections["model"]["encoder_name"] = args.encoder |
| 71 | updates = {name: getattr(cfg, name).model_copy(update=values) | 75 | return config.with_overrides(cfg, sections) |
| 72 | for name, values in sections.items() if values} | ||
| 73 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 74 | 76 | ||
| 75 | 77 | ||
| 76 | def main() -> None: | 78 | def main() -> None: |
| 77 | args = parse_args() | 79 | args = parse_args() |
| 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys | 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys |
| 6 | raise, so config typos fail fast instead of silently training with defaults. | 6 | raise, so config typos fail fast instead of silently training with defaults. |
| 7 | 7 | ||
| 8 | Adding a config key = adding one field with its default to the model below. | 8 | Adding a config key = adding one field with its default to the model below. |
| 9 | |||
| 10 | Instances are frozen and validated: derive a changed config with | ||
| 11 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips | ||
| 12 | validation and the fleet coercion matrix. | ||
| 9 | """ | 13 | """ |
| 10 | import logging | 14 | from collections.abc import Mapping |
| 11 | from pathlib import Path | 15 | from pathlib import Path |
| 12 | from typing import Literal | 16 | from typing import Any, Literal |
| 13 | 17 | ||
| 14 | import pydantic | 18 | import pydantic |
| 15 | from iolabs.common import config_loader | 19 | from iolabs.common import config_loader |
| 16 | from iolabs_ml_harness import config as harness_config | 20 | from iolabs_ml_harness import config as harness_config |
| 17 | 21 | ||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 20 | 22 | ||
| 21 | class ConfigError(config_loader.ConfigError): | 23 | class ConfigError(config_loader.ConfigError): |
| 22 | """Raised when a guardrail harness config holds unknown keys or bad values.""" | 24 | """Raised when a guardrail harness config holds unknown keys or bad values.""" |
| 23 | 25 | ||
| 24 | 26 | ||
| 25 | class PairSpec(config_loader.ConfigModel): | 27 | class PairSpec(harness_config.SectionModel): |
| 26 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 28 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 27 | images: str | 29 | images: str |
| 28 | masks: str | 30 | masks: str |
| 29 | 31 | ||
| 30 | 32 | ||
| 31 | class DataConfig(config_loader.ConfigModel): | 33 | class DataConfig(harness_config.SectionModel): |
| 32 | """Guardrail dataset selection and crop/loader sizing.""" | 34 | """Guardrail dataset selection and crop/loader sizing.""" |
| 33 | source: Literal["synthetic", "pairs"] = "synthetic" | 35 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 34 | pairs: list[PairSpec] = [] | 36 | pairs: list[PairSpec] = [] |
| 35 | crop_size: int = pydantic.Field(default=256, gt=0) | 37 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 39 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) | 41 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) |
| 40 | synthetic_seed: int = 20260707 | 42 | synthetic_seed: int = 20260707 |
| 41 | 43 | ||
| 42 | 44 | ||
| 43 | class HarnessConfig(config_loader.ConfigModel): | 45 | class HarnessConfig(harness_config.SectionModel): |
| 44 | """Top-level training config: one YAML file, one instance.""" | 46 | """Top-level training config: one YAML file, one instance.""" |
| 45 | experiment: str = "experiment" | 47 | experiment: str = "experiment" |
| 46 | seed: int = 1337 | 48 | seed: int = 1337 |
| 47 | data: DataConfig = DataConfig() | 49 | data: DataConfig = DataConfig() |
| 67 | """ | 69 | """ |
| 68 | raw = harness_config.load_yaml_mapping(path) | 70 | raw = harness_config.load_yaml_mapping(path) |
| 69 | return config_loader.validate_config( | 71 | return config_loader.validate_config( |
| 70 | cls, raw, context=str(path), error_cls=ConfigError) | 72 | cls, raw, context=str(path), error_cls=ConfigError) |
| 73 | |||
| 74 | |||
| 75 | def with_overrides(cfg: HarnessConfig, | ||
| 76 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: | ||
| 77 | """Returns a re-validated copy of ``cfg`` with per-section overrides merged in. | ||
| 78 | |||
| 79 | ``model_copy(update=...)`` would store the values unchecked, so a | ||
| 80 | ``--max-epochs -2`` would survive the ``ge=-1`` bound. Round-tripping through | ||
| 81 | the model keeps CLI overrides on exactly the path YAML values take. | ||
| 82 | |||
| 83 | Args: | ||
| 84 | cfg: The config to derive from; never mutated. | ||
| 85 | sections: Section name -> field name -> override value. Empty sections | ||
| 86 | are ignored. | ||
| 87 | |||
| 88 | Returns: | ||
| 89 | ``cfg`` itself when no override is given, otherwise a validated copy. | ||
| 90 | |||
| 91 | Raises: | ||
| 92 | ConfigError: An override value is invalid for its declared field. | ||
| 93 | """ | ||
| 94 | applied = {name: dict(values) for name, values in sections.items() if values} | ||
| 95 | if not applied: | ||
| 96 | return cfg | ||
| 97 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) | ||
| 98 | return config_loader.validate_config( | ||
| 99 | type(cfg), merged, context="CLI overrides", error_cls=ConfigError) |
| 23 | from iolabs_ml_harness.trainer import build_trainer # noqa: E402 | 23 | from iolabs_ml_harness.trainer import build_trainer # noqa: E402 |
| 24 | 24 | ||
| 25 | from src.dataset.rasters import index_raster_pairs # noqa: E402 | 25 | from src.dataset.rasters import index_raster_pairs # noqa: E402 |
| 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 | 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 |
| 27 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import ConfigError, DataConfig, HarnessConfig # noqa: E402 |
| 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 | 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 |
| 29 | 29 | ||
| 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] | 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] |
| 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") | 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") |
| 65 | cfg = HarnessConfig.from_yaml(good) | 65 | cfg = HarnessConfig.from_yaml(good) |
| 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 67 | bad = tmp_path / "bad.yaml" | 67 | bad = tmp_path / "bad.yaml" |
| 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key | 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key |
| 69 | with pytest.raises(ValueError, match="soruce"): | 69 | with pytest.raises(ConfigError, match="soruce"): |
| 70 | HarnessConfig.from_yaml(bad) | 70 | HarnessConfig.from_yaml(bad) |
| 71 | 71 | ||
| 72 | 72 | ||
| 73 | def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None: | ||
| 74 | """A bare ``data:`` line parses to None and must mean "all defaults".""" | ||
| 75 | path = tmp_path / "nulls.yaml" | ||
| 76 | path.write_text("experiment: t\ndata:\nmodel:\nloss:\n args:\n") | ||
| 77 | cfg = HarnessConfig.from_yaml(path) | ||
| 78 | assert cfg.data.crop_size == 256 and cfg.data.source == "synthetic" | ||
| 79 | assert cfg.model.name == "unet" and cfg.loss.args == {} | ||
| 80 | |||
| 81 | |||
| 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: | ||
| 83 | path = tmp_path / "bad.yaml" | ||
| 84 | path.write_text("train:\n max_epochs: -2\n") | ||
| 85 | with pytest.raises(ConfigError, match="max_epochs"): | ||
| 86 | HarnessConfig.from_yaml(path) | ||
| 87 | |||
| 88 | |||
| 73 | def test_synthetic_dataset_is_deterministic() -> None: | 89 | def test_synthetic_dataset_is_deterministic() -> None: |
| 74 | ds_a = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) | 90 | ds_a = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) |
| 75 | ds_b = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) | 91 | ds_b = SyntheticGuardrailDataset(n_tiles=4, crop_size=64, seed=7, train=True) |
| 76 | for i in range(4): | 92 | for i in range(4): |
| 174 | 190 | ||
| 175 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) | 191 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) |
| 176 | 192 | ||
| 177 | assert updated.train.max_epochs == 0 | 193 | assert updated.train.max_epochs == 0 |
| 194 | |||
| 195 | |||
| 196 | @pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}]) | ||
| 197 | def test_apply_cli_overrides_validates_like_the_yaml_path(flags: dict) -> None: | ||
| 198 | """CLI overrides must hit the same bounds as values coming from YAML.""" | ||
| 199 | with pytest.raises(ConfigError): | ||
| 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) | ||
| 201 | |||
| 202 | |||
| 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: | ||
| 204 | updated = _train_script().apply_cli_overrides( | ||
| 205 | HarnessConfig(), _args(max_epochs="7")) | ||
| 206 | |||
| 207 | assert updated.train.max_epochs == 7 |
nullconfig sections fall back to section defaults (pre-migration behaviour), re-validation instead ofmodel_copy(update=), doc/test parity with the packaged JSON.