Back to report index

guardraildetection (ML) 7a85e44: AI3D-379 Align config module with fleet pattern

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(-)
Importance #1: src/train/config.py @@ -1,28 +1,36 @@
1"""Task-specific config composed over the shared harness package.1"""Guardrail training-harness config, composed over the shared harness package.
22
3Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig``3The schema is `HarnessConfig` (a `config_loader.ConfigModel` via
4is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train``4``iolabs_ml_harness.config.SectionModel``), mirroring ``configs/*.yaml`` key for
5sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys5key. ``DataConfig`` is guardrail-specific (dataset/synthetic split); the
6raise, 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``.
78
8Adding a config key = adding one field with its default to the model below.9Adding a config key means adding the field to the model and the same key to the
10experiment YAML -- nothing else. Unknown keys are rejected.
911
10Instances are frozen and validated: derive a changed config with12Instances are frozen and validated: derive a changed config with
11:func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips13:func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips
12validation and the fleet coercion matrix.14validation and the fleet coercion matrix.
13"""15"""
16import logging
14from collections.abc import Mapping17from collections.abc import Mapping
15from pathlib import Path18from pathlib import Path
16from typing import Any, Literal19from typing import Any, Literal
1720
18import pydantic21import pydantic
19from iolabs.common import config_loader22from iolabs.common import config_loader
20from iolabs_ml_harness import config as harness_config23from iolabs_ml_harness import config as harness_config
2124
25logger = logging.getLogger(__name__)
2226
23class 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
31class HarnessConfigError(config_loader.ConfigError):
32 """Raised when guardrail harness config contains unsupported keys or values."""
2533
2634
27class PairSpec(harness_config.SectionModel):35class 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)."""
Importance #2: src/train/config.py @@ -32,9 +40,9 @@
3240
33class DataConfig(harness_config.SectionModel):41class 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)
Importance #3: src/train/config.py @@ -62,15 +70,17 @@
62 The validated, frozen config.70 The validated, frozen config.
6371
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 from75 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
7383
7484
75def with_overrides(cfg: HarnessConfig,85def with_overrides(cfg: HarnessConfig,
76 sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig:86 sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig:
Importance #4: src/train/config.py @@ -88,12 +98,13 @@
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.
90100
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 cfg106 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)
Importance #5: scripts/train.py @@ -59,9 +59,9 @@
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.
6161
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:
Importance #6: test/test_config.py @@ -0,0 +1,87 @@
1"""Config-schema tests for the guardrail training harness.
2
3Guards the fleet invariants: one package-prefixed error class, unknown keys
4rejected at every nesting level, packaged experiment YAML in sync with the
5model, and CLI overrides taking exactly the YAML validation path.
6"""
7from pathlib import Path
8
9import pytest
10
11pytest.importorskip("iolabs_ml_harness")
12
13from iolabs.common import config_loader # noqa: E402
14
15from 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
21def _write(tmp_path: Path, text: str) -> Path:
22 path = tmp_path / "cfg.yaml"
23 path.write_text(text)
24 return path
25
26
27def test_error_class_is_config_error() -> None:
28 assert issubclass(config.HarnessConfigError, config_loader.ConfigError)
29 assert issubclass(config.HarnessConfigError, ValueError)
30
31
32def 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
45def 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
52def 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
57def 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
63def 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
74def 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
80def 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
Importance #7: test/test_harness_smoke.py @@ -23,9 +23,9 @@
23from iolabs_ml_harness.trainer import build_trainer # noqa: E40223from iolabs_ml_harness.trainer import build_trainer # noqa: E402
2424
25from src.dataset.rasters import index_raster_pairs # noqa: E40225from src.dataset.rasters import index_raster_pairs # noqa: E402
26from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E40226from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402
27from src.train.config import ConfigError, DataConfig, HarnessConfig # noqa: E40227from src.train.config import DataConfig, HarnessConfig, HarnessConfigError # noqa: E402
28from src.train.datamodule import GuardrailDataModule # noqa: E40228from src.train.datamodule import GuardrailDataModule # noqa: E402
2929
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")
Importance #8: test/test_harness_smoke.py @@ -65,9 +65,9 @@
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 key68 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)
7171
7272
73def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None:73def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None:
Importance #9: test/test_harness_smoke.py @@ -81,9 +81,9 @@
8181
82def test_config_rejects_out_of_range_values(tmp_path: Path) -> None:82def 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)
8787
8888
89def test_synthetic_dataset_is_deterministic() -> None:89def test_synthetic_dataset_is_deterministic() -> None:
Importance #10: test/test_harness_smoke.py @@ -195,9 +195,9 @@
195195
196@pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}])196@pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}])
197def test_apply_cli_overrides_validates_like_the_yaml_path(flags: dict) -> None:197def 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))
201201
202202
203def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None:203def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None:
Importance #11: README.md @@ -50,8 +50,22 @@
50uv run python scripts/train.py --config configs/<experiment>.yaml50uv run python scripts/train.py --config configs/<experiment>.yaml
51uv run tensorboard --logdir runs51uv run tensorboard --logdir runs
52```52```
5353
54### Configuration
55
56Defaults live in `configs/<experiment>.yaml` (baseline: `configs/unet_baseline.yaml`).
57The schema is `HarnessConfig` in `src/train/config.py` (a
58`config_loader.ConfigModel` via `iolabs_ml_harness.config.SectionModel`); nested
59YAML sections (`data`, `model`, `loss`, `train`) are nested models and unknown
60keys are rejected. **To add a config key: add the field (with its type, default
61and any `Field` range) to the model and the same key with the same default to
62the YAML โ€” nothing else.** `HarnessConfig.from_yaml` and `with_overrides` return
63the frozen `HarnessConfig`; the `model`/`loss`/`train` sections come from
64`iolabs_ml_harness.config`. Runtime overrides come from the `scripts/train.py`
65CLI flags (`--max-epochs`, `--batch-size`, `--model`, `--encoder`), never
66repo-local JSON.
67
54## Status68## Status
5569
56Reviving a stale job. Decisions from the 2026-07-07 review (see70Reviving 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`):
Importance #12: scripts/train.py @@ -59,9 +59,9 @@
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.
6161
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:
Importance #13: src/train/config.py @@ -1,28 +1,36 @@
1"""Task-specific config composed over the shared harness package.1"""Guardrail training-harness config, composed over the shared harness package.
22
3Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig``3The schema is `HarnessConfig` (a `config_loader.ConfigModel` via
4is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train``4``iolabs_ml_harness.config.SectionModel``), mirroring ``configs/*.yaml`` key for
5sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys5key. ``DataConfig`` is guardrail-specific (dataset/synthetic split); the
6raise, 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``.
78
8Adding a config key = adding one field with its default to the model below.9Adding a config key means adding the field to the model and the same key to the
10experiment YAML -- nothing else. Unknown keys are rejected.
911
10Instances are frozen and validated: derive a changed config with12Instances are frozen and validated: derive a changed config with
11:func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips13:func:`with_overrides`, not ``model_copy(update=...)`` -- the latter skips
12validation and the fleet coercion matrix.14validation and the fleet coercion matrix.
13"""15"""
16import logging
14from collections.abc import Mapping17from collections.abc import Mapping
15from pathlib import Path18from pathlib import Path
16from typing import Any, Literal19from typing import Any, Literal
1720
18import pydantic21import pydantic
19from iolabs.common import config_loader22from iolabs.common import config_loader
20from iolabs_ml_harness import config as harness_config23from iolabs_ml_harness import config as harness_config
2124
25logger = logging.getLogger(__name__)
2226
23class 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
31class HarnessConfigError(config_loader.ConfigError):
32 """Raised when guardrail harness config contains unsupported keys or values."""
2533
2634
27class PairSpec(harness_config.SectionModel):35class 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)."""
Importance #14: src/train/config.py @@ -32,9 +40,9 @@
3240
33class DataConfig(harness_config.SectionModel):41class 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)
Importance #15: src/train/config.py @@ -62,15 +70,17 @@
62 The validated, frozen config.70 The validated, frozen config.
6371
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 from75 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
7383
7484
75def with_overrides(cfg: HarnessConfig,85def with_overrides(cfg: HarnessConfig,
76 sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig:86 sections: Mapping[str, Mapping[str, Any]]) -> HarnessConfig:
Importance #16: src/train/config.py @@ -88,12 +98,13 @@
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.
90100
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 cfg106 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)
Importance #17: test/test_config.py @@ -0,0 +1,87 @@
1"""Config-schema tests for the guardrail training harness.
2
3Guards the fleet invariants: one package-prefixed error class, unknown keys
4rejected at every nesting level, packaged experiment YAML in sync with the
5model, and CLI overrides taking exactly the YAML validation path.
6"""
7from pathlib import Path
8
9import pytest
10
11pytest.importorskip("iolabs_ml_harness")
12
13from iolabs.common import config_loader # noqa: E402
14
15from 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
21def _write(tmp_path: Path, text: str) -> Path:
22 path = tmp_path / "cfg.yaml"
23 path.write_text(text)
24 return path
25
26
27def test_error_class_is_config_error() -> None:
28 assert issubclass(config.HarnessConfigError, config_loader.ConfigError)
29 assert issubclass(config.HarnessConfigError, ValueError)
30
31
32def 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
45def 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
52def 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
57def 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
63def 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
74def 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
80def 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
Importance #18: test/test_harness_smoke.py @@ -23,9 +23,9 @@
23from iolabs_ml_harness.trainer import build_trainer # noqa: E40223from iolabs_ml_harness.trainer import build_trainer # noqa: E402
2424
25from src.dataset.rasters import index_raster_pairs # noqa: E40225from src.dataset.rasters import index_raster_pairs # noqa: E402
26from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E40226from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402
27from src.train.config import ConfigError, DataConfig, HarnessConfig # noqa: E40227from src.train.config import DataConfig, HarnessConfig, HarnessConfigError # noqa: E402
28from src.train.datamodule import GuardrailDataModule # noqa: E40228from src.train.datamodule import GuardrailDataModule # noqa: E402
2929
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")
Importance #19: test/test_harness_smoke.py @@ -65,9 +65,9 @@
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 key68 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)
7171
7272
73def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None:73def test_null_sections_fall_back_to_defaults(tmp_path: Path) -> None:
Importance #20: test/test_harness_smoke.py @@ -81,9 +81,9 @@
8181
82def test_config_rejects_out_of_range_values(tmp_path: Path) -> None:82def 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)
8787
8888
89def test_synthetic_dataset_is_deterministic() -> None:89def test_synthetic_dataset_is_deterministic() -> None:
Importance #21: test/test_harness_smoke.py @@ -195,9 +195,9 @@
195195
196@pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}])196@pytest.mark.parametrize("flags", [{"max_epochs": -2}, {"batch_size": 0}])
197def test_apply_cli_overrides_validates_like_the_yaml_path(flags: dict) -> None:197def 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))
201201
202202
203def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None:203def test_apply_cli_overrides_coerces_like_the_yaml_path() -> None: