Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:36:44+02:00
Commit #80 ยท 21 snippets
README.md | 14 ++++++++ scripts/train.py | 2 +- src/train/config.py | 39 +++++++++++++-------- test/test_config.py | 87 ++++++++++++++++++++++++++++++++++++++++++++++ test/test_harness_smoke.py | 8 ++--- 5 files changed, 131 insertions(+), 19 deletions(-)
| 1 | """Task-specific config composed over the shared harness package. | 1 | """Guardrail training-harness config, composed over the shared harness package. |
| 2 | 2 | ||
| 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig`` | 3 | The schema is `HarnessConfig` (a `config_loader.ConfigModel` via |
| 4 | is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` | 4 | ``iolabs_ml_harness.config.SectionModel``), mirroring ``configs/*.yaml`` key for |
| 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys | 5 | key. ``DataConfig`` is guardrail-specific (dataset/synthetic split); the |
| 6 | raise, so config typos fail fast instead of silently training with defaults. | 6 | ``model``/``loss``/``train`` sections are the generic models from |
| 7 | ``iolabs_ml_harness.config``. | ||
| 7 | 8 | ||
| 8 | Adding a config key = adding one field with its default to the model below. | 9 | Adding a config key means adding the field to the model and the same key to the |
| 10 | experiment YAML -- nothing else. Unknown keys are rejected. | ||
| 9 | 11 | ||
| 10 | Instances are frozen and validated: derive a changed config with | 12 | Instances are frozen and validated: derive a changed config with |
| 11 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips | 13 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips |
| 12 | validation and the fleet coercion matrix. | 14 | validation and the fleet coercion matrix. |
| 13 | """ | 15 | """ |
| 16 | import logging | ||
| 14 | from collections.abc import Mapping | 17 | from collections.abc import Mapping |
| 15 | from pathlib import Path | 18 | from pathlib import Path |
| 16 | from typing import Any, Literal | 19 | from typing import Any, Literal |
| 17 | 20 | ||
| 18 | import pydantic | 21 | import pydantic |
| 19 | from iolabs.common import config_loader | 22 | from iolabs.common import config_loader |
| 20 | from iolabs_ml_harness import config as harness_config | 23 | from iolabs_ml_harness import config as harness_config |
| 21 | 24 | ||
| 25 | logger = logging.getLogger(__name__) | ||
| 22 | 26 | ||
| 23 | class ConfigError(config_loader.ConfigError): | 27 | _CONTEXT = "guardrail harness config" |
| 24 | """Raised when a guardrail harness config holds unknown keys or bad values.""" | 28 | _OVERRIDE_CONTEXT = f"{_CONTEXT} CLI overrides" |
| 29 | |||
| 30 | |||
| 31 | class HarnessConfigError(config_loader.ConfigError): | ||
| 32 | """Raised when guardrail harness config contains unsupported keys or values.""" | ||
| 25 | 33 | ||
| 26 | 34 | ||
| 27 | class PairSpec(harness_config.SectionModel): | 35 | class PairSpec(harness_config.SectionModel): |
| 28 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 36 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 32 | 40 | ||
| 33 | class DataConfig(harness_config.SectionModel): | 41 | class DataConfig(harness_config.SectionModel): |
| 34 | """Guardrail dataset selection and crop/loader sizing.""" | 42 | """Guardrail dataset selection and crop/loader sizing.""" |
| 35 | source: Literal["synthetic", "pairs"] = "synthetic" | 43 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 36 | pairs: list[PairSpec] = [] | 44 | pairs: tuple[PairSpec, ...] = () |
| 37 | crop_size: int = pydantic.Field(default=256, gt=0) | 45 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 38 | batch_size: int = pydantic.Field(default=8, gt=0) | 46 | batch_size: int = pydantic.Field(default=8, gt=0) |
| 39 | num_workers: int = pydantic.Field(default=4, ge=0) | 47 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 40 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) | 48 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 62 | The validated, frozen config. | 70 | The validated, frozen config. |
| 63 | 71 | ||
| 64 | Raises: | 72 | Raises: |
| 65 | FileNotFoundError: If ``path`` does not exist. | 73 | FileNotFoundError: If ``path`` does not exist. |
| 66 | ConfigError: If the document is not a mapping, holds an unknown key, | 74 | HarnessConfigError: If the document is not a mapping, holds an unknown key, |
| 67 | or holds a value invalid for its field. Derives from | 75 | or holds a value invalid for its field. Derives from |
| 68 | ``ValueError``. | 76 | ``ValueError``. |
| 69 | """ | 77 | """ |
| 70 | raw = harness_config.load_yaml_mapping(path) | 78 | raw = harness_config.load_yaml_mapping(path) |
| 71 | return config_loader.validate_config( | 79 | cfg = config_loader.validate_config( |
| 72 | cls, raw, context=str(path), error_cls=ConfigError) | 80 | cls, raw, context=f"{_CONTEXT} {path}", error_cls=HarnessConfigError) |
| 81 | logger.info("Config file applied: %s", path) | ||
| 82 | return cfg | ||
| 73 | 83 | ||
| 74 | 84 | ||
| 75 | def with_overrides(cfg: HarnessConfig, | 85 | def with_overrides(cfg: HarnessConfig, |
| 76 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: | 86 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: |
| 88 | Returns: | 98 | Returns: |
| 89 | ``cfg`` itself when no override is given, otherwise a validated copy. | 99 | ``cfg`` itself when no override is given, otherwise a validated copy. |
| 90 | 100 | ||
| 91 | Raises: | 101 | Raises: |
| 92 | ConfigError: An override value is invalid for its declared field. | 102 | HarnessConfigError: An override value is invalid for its declared field. |
| 93 | """ | 103 | """ |
| 94 | applied = {name: dict(values) for name, values in sections.items() if values} | 104 | applied = {name: dict(values) for name, values in sections.items() if values} |
| 95 | if not applied: | 105 | if not applied: |
| 96 | return cfg | 106 | return cfg |
| 107 | logger.info("Config overrides applied: %s", ", ".join(sorted(applied))) | ||
| 97 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) | 108 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) |
| 98 | return config_loader.validate_config( | 109 | return config_loader.validate_config( |
| 99 | type(cfg), merged, context="CLI overrides", error_cls=ConfigError) | 110 | type(cfg), merged, context=_OVERRIDE_CONTEXT, error_cls=HarnessConfigError) |
| 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 | 61 | ||
| 62 | Raises: | 62 | Raises: |
| 63 | config.ConfigError: An override value is invalid for its field, e.g. | 63 | config.HarnessConfigError: An override value is invalid for its field, e.g. |
| 64 | ``--max-epochs -2`` or ``--batch-size 0``. | 64 | ``--max-epochs -2`` or ``--batch-size 0``. |
| 65 | """ | 65 | """ |
| 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} |
| 67 | if args.max_epochs is not None: | 67 | if args.max_epochs is not None: |
| 1 | """Config-schema tests for the guardrail training harness. | ||
| 2 | |||
| 3 | Guards the fleet invariants: one package-prefixed error class, unknown keys | ||
| 4 | rejected at every nesting level, packaged experiment YAML in sync with the | ||
| 5 | model, and CLI overrides taking exactly the YAML validation path. | ||
| 6 | """ | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import pytest | ||
| 10 | |||
| 11 | pytest.importorskip("iolabs_ml_harness") | ||
| 12 | |||
| 13 | from iolabs.common import config_loader # noqa: E402 | ||
| 14 | |||
| 15 | from src.train import config # noqa: E402 | ||
| 16 | |||
| 17 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 18 | _PACKAGED_YAML = _REPO_ROOT / "configs" / "unet_baseline.yaml" | ||
| 19 | |||
| 20 | |||
| 21 | def _write(tmp_path: Path, text: str) -> Path: | ||
| 22 | path = tmp_path / "cfg.yaml" | ||
| 23 | path.write_text(text) | ||
| 24 | return path | ||
| 25 | |||
| 26 | |||
| 27 | def test_error_class_is_config_error() -> None: | ||
| 28 | assert issubclass(config.HarnessConfigError, config_loader.ConfigError) | ||
| 29 | assert issubclass(config.HarnessConfigError, ValueError) | ||
| 30 | |||
| 31 | |||
| 32 | def test_packaged_yaml_keys_match_the_model(tmp_path: Path) -> None: | ||
| 33 | """Every key/value in the shipped experiment YAML survives validation.""" | ||
| 34 | raw = config.harness_config.load_yaml_mapping(_PACKAGED_YAML) | ||
| 35 | dumped = config.HarnessConfig.from_yaml(_PACKAGED_YAML).model_dump() | ||
| 36 | |||
| 37 | for section, value in raw.items(): | ||
| 38 | if isinstance(value, dict): | ||
| 39 | for key, item in value.items(): | ||
| 40 | assert dumped[section][key] == item, f"{section}.{key}" | ||
| 41 | else: | ||
| 42 | assert dumped[section] == value, section | ||
| 43 | |||
| 44 | |||
| 45 | def test_model_defaults_are_a_valid_config() -> None: | ||
| 46 | cfg = config.HarnessConfig() | ||
| 47 | |||
| 48 | assert cfg.data.source == "synthetic" and cfg.data.pairs == () | ||
| 49 | assert config.HarnessConfig(**cfg.model_dump()) == cfg | ||
| 50 | |||
| 51 | |||
| 52 | def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None: | ||
| 53 | with pytest.raises(config.HarnessConfigError, match="experimnet"): | ||
| 54 | config.HarnessConfig.from_yaml(_write(tmp_path, "experimnet: t\n")) | ||
| 55 | |||
| 56 | |||
| 57 | def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None: | ||
| 58 | with pytest.raises(config.HarnessConfigError, match="soruce"): | ||
| 59 | config.HarnessConfig.from_yaml( | ||
| 60 | _write(tmp_path, "data:\n soruce: synthetic\n")) | ||
| 61 | |||
| 62 | |||
| 63 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 64 | cfg = config.HarnessConfig() | ||
| 65 | |||
| 66 | updated = config.with_overrides(cfg, {"data": {"batch_size": 2}}) | ||
| 67 | |||
| 68 | assert updated.data.batch_size == 2 | ||
| 69 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 70 | assert updated.model == cfg.model | ||
| 71 | assert cfg.data.batch_size == 8 # frozen original untouched | ||
| 72 | |||
| 73 | |||
| 74 | def test_empty_overrides_return_the_same_instance() -> None: | ||
| 75 | cfg = config.HarnessConfig() | ||
| 76 | |||
| 77 | assert config.with_overrides(cfg, {"data": {}, "train": {}}) is cfg | ||
| 78 | |||
| 79 | |||
| 80 | def test_override_coercion_and_rejection() -> None: | ||
| 81 | coerced = config.with_overrides( | ||
| 82 | config.HarnessConfig(), {"train": {"max_epochs": "7"}}) | ||
| 83 | assert coerced.train.max_epochs == 7 | ||
| 84 | |||
| 85 | with pytest.raises(config.HarnessConfigError, match="max_epochs"): | ||
| 86 | config.with_overrides( | ||
| 87 | config.HarnessConfig(), {"train": {"max_epochs": -2}}) | ||
| 0 |
| 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 ConfigError, DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import DataConfig, HarnessConfig, HarnessConfigError # 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(ConfigError, match="soruce"): | 69 | with pytest.raises(HarnessConfigError, 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: | 73 | def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None: |
| 81 | 81 | ||
| 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: | 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: |
| 83 | path = tmp_path / "bad.yaml" | 83 | path = tmp_path / "bad.yaml" |
| 84 | path.write_text("train:\n max_epochs: -2\n") | 84 | path.write_text("train:\n max_epochs: -2\n") |
| 85 | with pytest.raises(ConfigError, match="max_epochs"): | 85 | with pytest.raises(HarnessConfigError, match="max_epochs"): |
| 86 | HarnessConfig.from_yaml(path) | 86 | HarnessConfig.from_yaml(path) |
| 87 | 87 | ||
| 88 | 88 | ||
| 89 | def test_synthetic_dataset_is_deterministic() -> None: | 89 | def test_synthetic_dataset_is_deterministic() -> None: |
| 195 | 195 | ||
| 196 | @pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}]) | 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: | 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.""" | 198 | """CLI overrides must hit the same bounds as values coming from YAML.""" |
| 199 | with pytest.raises(ConfigError): | 199 | with pytest.raises(HarnessConfigError): |
| 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) | 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) |
| 201 | 201 | ||
| 202 | 202 | ||
| 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: | 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: |
| 50 | uv run python scripts/train.py --config configs/<experiment>.yaml | 50 | uv run python scripts/train.py --config configs/<experiment>.yaml |
| 51 | uv run tensorboard --logdir runs | 51 | uv run tensorboard --logdir runs |
| 52 | ``` | 52 | ``` |
| 53 | 53 | ||
| 54 | ### Configuration | ||
| 55 | |||
| 56 | Defaults live in `configs/<experiment>.yaml` (baseline: `configs/unet_baseline.yaml`). | ||
| 57 | The schema is `HarnessConfig` in `src/train/config.py` (a | ||
| 58 | `config_loader.ConfigModel` via `iolabs_ml_harness.config.SectionModel`); nested | ||
| 59 | YAML sections (`data`, `model`, `loss`, `train`) are nested models and unknown | ||
| 60 | keys are rejected. **To add a config key: add the field (with its type, default | ||
| 61 | and any `Field` range) to the model and the same key with the same default to | ||
| 62 | the YAML โ nothing else.** `HarnessConfig.from_yaml` and `with_overrides` return | ||
| 63 | the frozen `HarnessConfig`; the `model`/`loss`/`train` sections come from | ||
| 64 | `iolabs_ml_harness.config`. Runtime overrides come from the `scripts/train.py` | ||
| 65 | CLI flags (`--max-epochs`, `--batch-size`, `--model`, `--encoder`), never | ||
| 66 | repo-local JSON. | ||
| 67 | |||
| 54 | ## Status | 68 | ## Status |
| 55 | 69 | ||
| 56 | Reviving a stale job. Decisions from the 2026-07-07 review (see | 70 | Reviving a stale job. Decisions from the 2026-07-07 review (see |
| 57 | `docs/plans/guardrail-ml-harness-20260707.html`): | 71 | `docs/plans/guardrail-ml-harness-20260707.html`): |
| 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 | 61 | ||
| 62 | Raises: | 62 | Raises: |
| 63 | config.ConfigError: An override value is invalid for its field, e.g. | 63 | config.HarnessConfigError: An override value is invalid for its field, e.g. |
| 64 | ``--max-epochs -2`` or ``--batch-size 0``. | 64 | ``--max-epochs -2`` or ``--batch-size 0``. |
| 65 | """ | 65 | """ |
| 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | 66 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} |
| 67 | if args.max_epochs is not None: | 67 | if args.max_epochs is not None: |
| 1 | """Task-specific config composed over the shared harness package. | 1 | """Guardrail training-harness config, composed over the shared harness package. |
| 2 | 2 | ||
| 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig`` | 3 | The schema is `HarnessConfig` (a `config_loader.ConfigModel` via |
| 4 | is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` | 4 | ``iolabs_ml_harness.config.SectionModel``), mirroring ``configs/*.yaml`` key for |
| 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys | 5 | key. ``DataConfig`` is guardrail-specific (dataset/synthetic split); the |
| 6 | raise, so config typos fail fast instead of silently training with defaults. | 6 | ``model``/``loss``/``train`` sections are the generic models from |
| 7 | ``iolabs_ml_harness.config``. | ||
| 7 | 8 | ||
| 8 | Adding a config key = adding one field with its default to the model below. | 9 | Adding a config key means adding the field to the model and the same key to the |
| 10 | experiment YAML -- nothing else. Unknown keys are rejected. | ||
| 9 | 11 | ||
| 10 | Instances are frozen and validated: derive a changed config with | 12 | Instances are frozen and validated: derive a changed config with |
| 11 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips | 13 | :func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips |
| 12 | validation and the fleet coercion matrix. | 14 | validation and the fleet coercion matrix. |
| 13 | """ | 15 | """ |
| 16 | import logging | ||
| 14 | from collections.abc import Mapping | 17 | from collections.abc import Mapping |
| 15 | from pathlib import Path | 18 | from pathlib import Path |
| 16 | from typing import Any, Literal | 19 | from typing import Any, Literal |
| 17 | 20 | ||
| 18 | import pydantic | 21 | import pydantic |
| 19 | from iolabs.common import config_loader | 22 | from iolabs.common import config_loader |
| 20 | from iolabs_ml_harness import config as harness_config | 23 | from iolabs_ml_harness import config as harness_config |
| 21 | 24 | ||
| 25 | logger = logging.getLogger(__name__) | ||
| 22 | 26 | ||
| 23 | class ConfigError(config_loader.ConfigError): | 27 | _CONTEXT = "guardrail harness config" |
| 24 | """Raised when a guardrail harness config holds unknown keys or bad values.""" | 28 | _OVERRIDE_CONTEXT = f"{_CONTEXT} CLI overrides" |
| 29 | |||
| 30 | |||
| 31 | class HarnessConfigError(config_loader.ConfigError): | ||
| 32 | """Raised when guardrail harness config contains unsupported keys or values.""" | ||
| 25 | 33 | ||
| 26 | 34 | ||
| 27 | class PairSpec(harness_config.SectionModel): | 35 | class PairSpec(harness_config.SectionModel): |
| 28 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 36 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 32 | 40 | ||
| 33 | class DataConfig(harness_config.SectionModel): | 41 | class DataConfig(harness_config.SectionModel): |
| 34 | """Guardrail dataset selection and crop/loader sizing.""" | 42 | """Guardrail dataset selection and crop/loader sizing.""" |
| 35 | source: Literal["synthetic", "pairs"] = "synthetic" | 43 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 36 | pairs: list[PairSpec] = [] | 44 | pairs: tuple[PairSpec, ...] = () |
| 37 | crop_size: int = pydantic.Field(default=256, gt=0) | 45 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 38 | batch_size: int = pydantic.Field(default=8, gt=0) | 46 | batch_size: int = pydantic.Field(default=8, gt=0) |
| 39 | num_workers: int = pydantic.Field(default=4, ge=0) | 47 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 40 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) | 48 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 62 | The validated, frozen config. | 70 | The validated, frozen config. |
| 63 | 71 | ||
| 64 | Raises: | 72 | Raises: |
| 65 | FileNotFoundError: If ``path`` does not exist. | 73 | FileNotFoundError: If ``path`` does not exist. |
| 66 | ConfigError: If the document is not a mapping, holds an unknown key, | 74 | HarnessConfigError: If the document is not a mapping, holds an unknown key, |
| 67 | or holds a value invalid for its field. Derives from | 75 | or holds a value invalid for its field. Derives from |
| 68 | ``ValueError``. | 76 | ``ValueError``. |
| 69 | """ | 77 | """ |
| 70 | raw = harness_config.load_yaml_mapping(path) | 78 | raw = harness_config.load_yaml_mapping(path) |
| 71 | return config_loader.validate_config( | 79 | cfg = config_loader.validate_config( |
| 72 | cls, raw, context=str(path), error_cls=ConfigError) | 80 | cls, raw, context=f"{_CONTEXT} {path}", error_cls=HarnessConfigError) |
| 81 | logger.info("Config file applied: %s", path) | ||
| 82 | return cfg | ||
| 73 | 83 | ||
| 74 | 84 | ||
| 75 | def with_overrides(cfg: HarnessConfig, | 85 | def with_overrides(cfg: HarnessConfig, |
| 76 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: | 86 | sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig: |
| 88 | Returns: | 98 | Returns: |
| 89 | ``cfg`` itself when no override is given, otherwise a validated copy. | 99 | ``cfg`` itself when no override is given, otherwise a validated copy. |
| 90 | 100 | ||
| 91 | Raises: | 101 | Raises: |
| 92 | ConfigError: An override value is invalid for its declared field. | 102 | HarnessConfigError: An override value is invalid for its declared field. |
| 93 | """ | 103 | """ |
| 94 | applied = {name: dict(values) for name, values in sections.items() if values} | 104 | applied = {name: dict(values) for name, values in sections.items() if values} |
| 95 | if not applied: | 105 | if not applied: |
| 96 | return cfg | 106 | return cfg |
| 107 | logger.info("Config overrides applied: %s", ", ".join(sorted(applied))) | ||
| 97 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) | 108 | merged = config_loader.deep_merge_dicts(cfg.model_dump(), applied) |
| 98 | return config_loader.validate_config( | 109 | return config_loader.validate_config( |
| 99 | type(cfg), merged, context="CLI overrides", error_cls=ConfigError) | 110 | type(cfg), merged, context=_OVERRIDE_CONTEXT, error_cls=HarnessConfigError) |
| 1 | """Config-schema tests for the guardrail training harness. | ||
| 2 | |||
| 3 | Guards the fleet invariants: one package-prefixed error class, unknown keys | ||
| 4 | rejected at every nesting level, packaged experiment YAML in sync with the | ||
| 5 | model, and CLI overrides taking exactly the YAML validation path. | ||
| 6 | """ | ||
| 7 | from pathlib import Path | ||
| 8 | |||
| 9 | import pytest | ||
| 10 | |||
| 11 | pytest.importorskip("iolabs_ml_harness") | ||
| 12 | |||
| 13 | from iolabs.common import config_loader # noqa: E402 | ||
| 14 | |||
| 15 | from src.train import config # noqa: E402 | ||
| 16 | |||
| 17 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 18 | _PACKAGED_YAML = _REPO_ROOT / "configs" / "unet_baseline.yaml" | ||
| 19 | |||
| 20 | |||
| 21 | def _write(tmp_path: Path, text: str) -> Path: | ||
| 22 | path = tmp_path / "cfg.yaml" | ||
| 23 | path.write_text(text) | ||
| 24 | return path | ||
| 25 | |||
| 26 | |||
| 27 | def test_error_class_is_config_error() -> None: | ||
| 28 | assert issubclass(config.HarnessConfigError, config_loader.ConfigError) | ||
| 29 | assert issubclass(config.HarnessConfigError, ValueError) | ||
| 30 | |||
| 31 | |||
| 32 | def test_packaged_yaml_keys_match_the_model(tmp_path: Path) -> None: | ||
| 33 | """Every key/value in the shipped experiment YAML survives validation.""" | ||
| 34 | raw = config.harness_config.load_yaml_mapping(_PACKAGED_YAML) | ||
| 35 | dumped = config.HarnessConfig.from_yaml(_PACKAGED_YAML).model_dump() | ||
| 36 | |||
| 37 | for section, value in raw.items(): | ||
| 38 | if isinstance(value, dict): | ||
| 39 | for key, item in value.items(): | ||
| 40 | assert dumped[section][key] == item, f"{section}.{key}" | ||
| 41 | else: | ||
| 42 | assert dumped[section] == value, section | ||
| 43 | |||
| 44 | |||
| 45 | def test_model_defaults_are_a_valid_config() -> None: | ||
| 46 | cfg = config.HarnessConfig() | ||
| 47 | |||
| 48 | assert cfg.data.source == "synthetic" and cfg.data.pairs == () | ||
| 49 | assert config.HarnessConfig(**cfg.model_dump()) == cfg | ||
| 50 | |||
| 51 | |||
| 52 | def test_unknown_top_level_key_is_rejected(tmp_path: Path) -> None: | ||
| 53 | with pytest.raises(config.HarnessConfigError, match="experimnet"): | ||
| 54 | config.HarnessConfig.from_yaml(_write(tmp_path, "experimnet: t\n")) | ||
| 55 | |||
| 56 | |||
| 57 | def test_unknown_nested_key_is_rejected(tmp_path: Path) -> None: | ||
| 58 | with pytest.raises(config.HarnessConfigError, match="soruce"): | ||
| 59 | config.HarnessConfig.from_yaml( | ||
| 60 | _write(tmp_path, "data:\n soruce: synthetic\n")) | ||
| 61 | |||
| 62 | |||
| 63 | def test_overrides_deep_merge_onto_defaults() -> None: | ||
| 64 | cfg = config.HarnessConfig() | ||
| 65 | |||
| 66 | updated = config.with_overrides(cfg, {"data": {"batch_size": 2}}) | ||
| 67 | |||
| 68 | assert updated.data.batch_size == 2 | ||
| 69 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 70 | assert updated.model == cfg.model | ||
| 71 | assert cfg.data.batch_size == 8 # frozen original untouched | ||
| 72 | |||
| 73 | |||
| 74 | def test_empty_overrides_return_the_same_instance() -> None: | ||
| 75 | cfg = config.HarnessConfig() | ||
| 76 | |||
| 77 | assert config.with_overrides(cfg, {"data": {}, "train": {}}) is cfg | ||
| 78 | |||
| 79 | |||
| 80 | def test_override_coercion_and_rejection() -> None: | ||
| 81 | coerced = config.with_overrides( | ||
| 82 | config.HarnessConfig(), {"train": {"max_epochs": "7"}}) | ||
| 83 | assert coerced.train.max_epochs == 7 | ||
| 84 | |||
| 85 | with pytest.raises(config.HarnessConfigError, match="max_epochs"): | ||
| 86 | config.with_overrides( | ||
| 87 | config.HarnessConfig(), {"train": {"max_epochs": -2}}) | ||
| 0 |
| 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 ConfigError, DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import DataConfig, HarnessConfig, HarnessConfigError # 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(ConfigError, match="soruce"): | 69 | with pytest.raises(HarnessConfigError, 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: | 73 | def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None: |
| 81 | 81 | ||
| 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: | 82 | def test_config_rejects_out_of_range_values(tmp_path: Path) -> None: |
| 83 | path = tmp_path / "bad.yaml" | 83 | path = tmp_path / "bad.yaml" |
| 84 | path.write_text("train:\n max_epochs: -2\n") | 84 | path.write_text("train:\n max_epochs: -2\n") |
| 85 | with pytest.raises(ConfigError, match="max_epochs"): | 85 | with pytest.raises(HarnessConfigError, match="max_epochs"): |
| 86 | HarnessConfig.from_yaml(path) | 86 | HarnessConfig.from_yaml(path) |
| 87 | 87 | ||
| 88 | 88 | ||
| 89 | def test_synthetic_dataset_is_deterministic() -> None: | 89 | def test_synthetic_dataset_is_deterministic() -> None: |
| 195 | 195 | ||
| 196 | @pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}]) | 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: | 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.""" | 198 | """CLI overrides must hit the same bounds as values coming from YAML.""" |
| 199 | with pytest.raises(ConfigError): | 199 | with pytest.raises(HarnessConfigError): |
| 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) | 200 | _train_script().apply_cli_overrides(HarnessConfig(), _args(**flags)) |
| 201 | 201 | ||
| 202 | 202 | ||
| 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: | 203 | def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.