Back to report index

mlharness c87f779: AI3D-379 Review fixes: SectionModel null-means-default, int precision, bool guard on precision/devices

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

Commit #76 ยท 20 snippets

 README.md                         | 14 +++++++--
 src/iolabs_ml_harness/__init__.py |  2 ++
 src/iolabs_ml_harness/config.py   | 66 +++++++++++++++++++++++++++++++++------
 tests/test_config_registry.py     | 37 +++++++++++++++++++++-
 4 files changed, 107 insertions(+), 12 deletions(-)
Importance #1: src/iolabs_ml_harness/config.py @@ -10,22 +10,62 @@
10``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D10``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D
11consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict11consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict
12`ConfigModel`.12`ConfigModel`.
13"""13"""
14import logging
15from collections.abc import Mapping14from collections.abc import Mapping
16from pathlib import Path15from pathlib import Path
17from typing import Any, Literal, TypeVar16from typing import Any, Literal, TypeVar, get_args
1817
19import pydantic18import pydantic
20import yaml19import yaml
21from iolabs.common import config_loader20from iolabs.common import config_loader
2221from pydantic import fields as pydantic_fields
23logger = logging.getLogger(__name__)
2422
25T = TypeVar("T", bound=config_loader.ConfigModel)23T = TypeVar("T", bound=config_loader.ConfigModel)
2624
2725
26class SectionModel(config_loader.ConfigModel):
27 """`ConfigModel` in which a YAML ``null`` means "use this field's default".
28
29 A bare ``model:`` / ``args:`` line parses to ``None``; the hand-written
30 ``data or {}`` coalescing that predates the pydantic layer treated that as
31 "section omitted". Fields that accept ``None`` (e.g. ``encoder_weights``)
32 keep it as a real value, and required fields still fail.
33 """
34
35 @pydantic.model_validator(mode="before")
36 @classmethod
37 def _null_means_default(cls, data: Any) -> Any:
38 """Drop ``None`` entries whose field has a default and forbids ``None``."""
39 if not isinstance(data, Mapping):
40 return data
41 dropped = {
42 name for name, value in data.items()
43 if value is None and _null_means_default_for(cls.model_fields.get(name))}
44 if not dropped:
45 return data
46 return {name: value for name, value in data.items() if name not in dropped}
47
48
49def _null_means_default_for(field: pydantic_fields.FieldInfo | None) -> bool:
50 """True when *field* has a default and its annotation does not accept None."""
51 if field is None or field.is_required():
52 return False
53 return type(None) not in get_args(field.annotation)
54
55
56def reject_bool(value: Any, name: str) -> Any:
57 """Raise when *value* is a ``bool`` reaching an ``int | str`` field.
58
59 YAML turns ``yes``/``on`` into ``True``, which pydantic would happily narrow
60 to ``1`` on an ``int | str`` union -- a typo would become a silent
61 single-device / precision-1 run.
62 """
63 if isinstance(value, bool):
64 raise ValueError(f"{name} must be an int or a str, got bool {value!r}")
65 return value
66
67
28class ConfigError(config_loader.ConfigError):68class ConfigError(config_loader.ConfigError):
29 """Raised when a harness config section holds unknown keys or bad values."""69 """Raised when a harness config section holds unknown keys or bad values."""
3070
3171
Importance #2: src/iolabs_ml_harness/config.py @@ -76,15 +116,15 @@
76 f"got {type(loaded).__name__}")116 f"got {type(loaded).__name__}")
77 return dict(loaded)117 return dict(loaded)
78118
79119
80class FactoryConfig(config_loader.ConfigModel):120class FactoryConfig(SectionModel):
81 """Modality-neutral registry selection: a name plus factory keyword args."""121 """Modality-neutral registry selection: a name plus factory keyword args."""
82 name: str122 name: str
83 args: dict[str, Any] = {}123 args: dict[str, Any] = {}
84124
85125
86class ModelConfig(config_loader.ConfigModel):126class ModelConfig(SectionModel):
87 """Legacy image-model schema (segmentation-models-pytorch shaped)."""127 """Legacy image-model schema (segmentation-models-pytorch shaped)."""
88 name: str = "unet"128 name: str = "unet"
89 encoder_name: str = "resnet18"129 encoder_name: str = "resnet18"
90 encoder_weights: str | None = "imagenet" # None = train from scratch130 encoder_weights: str | None = "imagenet" # None = train from scratch
Importance #3: src/iolabs_ml_harness/config.py @@ -92,20 +132,22 @@
92 num_classes: int = pydantic.Field(default=3, gt=0)132 num_classes: int = pydantic.Field(default=3, gt=0)
93 extra: dict[str, Any] = {} # passed to the factory133 extra: dict[str, Any] = {} # passed to the factory
94134
95135
96class LossConfig(config_loader.ConfigModel):136class LossConfig(SectionModel):
97 """Loss selection by registry name plus factory keyword arguments."""137 """Loss selection by registry name plus factory keyword arguments."""
98 name: str = "dice_focal"138 name: str = "dice_focal"
99 args: dict[str, Any] = {}139 args: dict[str, Any] = {}
100140
101141
102class TrainerConfig(config_loader.ConfigModel):142class TrainerConfig(SectionModel):
103 """Harness-visible Lightning trainer, logger, and callback knobs."""143 """Harness-visible Lightning trainer, logger, and callback knobs."""
104 max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap144 max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap
105 lr: float = pydantic.Field(default=3.0e-4, gt=0)145 lr: float = pydantic.Field(default=3.0e-4, gt=0)
106 weight_decay: float = pydantic.Field(default=1.0e-4, ge=0)146 weight_decay: float = pydantic.Field(default=1.0e-4, ge=0)
107 precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU147 # "auto" -> 16-mixed on CUDA, 32-true on CPU; else a Lightning precision
148 # (16, "16-mixed", "bf16-mixed", 32, "32-true", ...)
149 precision: int | str = "auto"
108 accumulate_grad_batches: int = pydantic.Field(default=1, ge=1)150 accumulate_grad_batches: int = pydantic.Field(default=1, ge=1)
109 accelerator: str = "auto"151 accelerator: str = "auto"
110 devices: int | str = 1152 devices: int | str = 1
111 viz_every_n_epochs: int = pydantic.Field(default=2, ge=0)153 viz_every_n_epochs: int = pydantic.Field(default=2, ge=0)
Importance #4: src/iolabs_ml_harness/config.py @@ -117,4 +159,10 @@
117 # epochs without improvement before stopping; 0 disables159 # epochs without improvement before stopping; 0 disables
118 early_stop_patience: int = pydantic.Field(default=4, ge=0)160 early_stop_patience: int = pydantic.Field(default=4, ge=0)
119 log_dir: str = "runs"161 log_dir: str = "runs"
120 log_every_n_steps: int = pydantic.Field(default=10, ge=1)162 log_every_n_steps: int = pydantic.Field(default=10, ge=1)
163
164 @pydantic.field_validator("precision", "devices", mode="before")
165 @classmethod
166 def _reject_bool(cls, value: Any, info: pydantic.ValidationInfo) -> Any:
167 """Keep YAML ``yes``/``on`` from silently narrowing to ``1``."""
168 return reject_bool(value, info.field_name or "value")
Importance #5: src/iolabs_ml_harness/__init__.py @@ -11,8 +11,9 @@
11 ConfigError,11 ConfigError,
12 FactoryConfig,12 FactoryConfig,
13 LossConfig,13 LossConfig,
14 ModelConfig,14 ModelConfig,
15 SectionModel,
15 TrainerConfig,16 TrainerConfig,
16 build_section,17 build_section,
17 load_yaml_mapping,18 load_yaml_mapping,
18)19)
Importance #6: src/iolabs_ml_harness/__init__.py @@ -72,8 +73,9 @@
72 "FactoryRegistry",73 "FactoryRegistry",
73 "LossConfig",74 "LossConfig",
74 "MaskOverlayWriter",75 "MaskOverlayWriter",
75 "ModelConfig",76 "ModelConfig",
77 "SectionModel",
76 "SegmentationModule",78 "SegmentationModule",
77 "SegmentationStats",79 "SegmentationStats",
78 "TrainerConfig",80 "TrainerConfig",
79 "WeightedSum",81 "WeightedSum",
Importance #7: tests/test_config_registry.py @@ -11,8 +11,9 @@
11 ConfigError,11 ConfigError,
12 FactoryConfig,12 FactoryConfig,
13 LossConfig,13 LossConfig,
14 ModelConfig,14 ModelConfig,
15 SectionModel,
15 TrainerConfig,16 TrainerConfig,
16 build_section,17 build_section,
17 load_yaml_mapping,18 load_yaml_mapping,
18)19)
Importance #8: tests/test_config_registry.py @@ -48,9 +49,9 @@
48 with pytest.raises(ValueError):49 with pytest.raises(ValueError):
49 build_section(_Section, {"alpha": "not-an-int"}, "train")50 build_section(_Section, {"alpha": "not-an-int"}, "train")
5051
5152
52def test_legacy_dataclass_defaults_are_unchanged() -> None:53def test_shared_section_defaults_are_unchanged() -> None:
53 model = ModelConfig()54 model = ModelConfig()
54 assert (model.name, model.encoder_name, model.encoder_weights) == (55 assert (model.name, model.encoder_name, model.encoder_weights) == (
55 "unet", "resnet18", "imagenet")56 "unet", "resnet18", "imagenet")
56 assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {})57 assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {})
Importance #9: tests/test_config_registry.py @@ -67,8 +68,42 @@
67 assert train.weight_decay == pytest.approx(1.0e-4)68 assert train.weight_decay == pytest.approx(1.0e-4)
68 assert train.early_stop_mode == "min"69 assert train.early_stop_mode == "min"
6970
7071
72def test_null_section_value_falls_back_to_the_default() -> None:
73 """A bare ``args:``/``extra:`` line parses to None and must mean "default"."""
74 assert build_section(LossConfig, {"name": "dice", "args": None}, "loss").args == {}
75 assert build_section(ModelConfig, {"extra": None}, "model").extra == {}
76 # a field that really accepts None keeps it
77 assert build_section(
78 ModelConfig, {"encoder_weights": None}, "model").encoder_weights is None
79 # required fields still fail on None
80 with pytest.raises(ConfigError, match="name"):
81 build_section(FactoryConfig, {"name": None}, "loss")
82
83
84def test_null_means_default_only_applies_to_section_models() -> None:
85 class _Plain(config_loader.ConfigModel):
86 alpha: int = 1
87
88 assert issubclass(LossConfig, SectionModel)
89 with pytest.raises(ConfigError):
90 build_section(_Plain, {"alpha": None}, "plain")
91
92
93def test_precision_accepts_lightning_ints_and_strings() -> None:
94 assert build_section(TrainerConfig, {"precision": 16}, "train").precision == 16
95 assert build_section(
96 TrainerConfig, {"precision": "bf16-mixed"}, "train").precision == "bf16-mixed"
97
98
99@pytest.mark.parametrize("field", ["precision", "devices"])
100def test_yaml_yes_is_rejected_instead_of_becoming_one(field: str) -> None:
101 """PyYAML turns ``devices: yes`` into True; int|str would narrow it to 1."""
102 with pytest.raises(ConfigError, match="got bool"):
103 build_section(TrainerConfig, {field: True}, "train")
104
105
71def test_factory_config_is_name_plus_args() -> None:106def test_factory_config_is_name_plus_args() -> None:
72 cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky",107 cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky",
73 "args": {"alpha": 0.8}}, "loss")108 "args": {"alpha": 0.8}}, "loss")
74 assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8}109 assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8}
Importance #10: README.md @@ -7,9 +7,9 @@
7Core is modality neutral and depends only on `numpy`, `pyyaml`, `pydantic`, `iolabs-common` (the shared config layer), `torch`, `lightning`, `tensorboard`, and `torchmetrics`. OpenCV and `segmentation-models-pytorch` moved into the `image` extra in 0.2.0, so a point-cloud consumer never installs an image stack.7Core is modality neutral and depends only on `numpy`, `pyyaml`, `pydantic`, `iolabs-common` (the shared config layer), `torch`, `lightning`, `tensorboard`, and `torchmetrics`. OpenCV and `segmentation-models-pytorch` moved into the `image` extra in 0.2.0, so a point-cloud consumer never installs an image stack.
88
9| Module | Extra | Purpose |9| Module | Extra | Purpose |
10| --- | --- | --- |10| --- | --- | --- |
11| `config` | core | Strict `build_section`, `load_yaml_mapping`, `ConfigError`, `FactoryConfig`, and the legacy model/loss/trainer `ConfigModel` sections. |11| `config` | core | Strict `build_section`, `load_yaml_mapping`, `ConfigError`, `SectionModel`, `FactoryConfig`, and the legacy model/loss/trainer sections. |
12| `registry` | core | Typed `FactoryRegistry` behind the model and loss registries. |12| `registry` | core | Typed `FactoryRegistry` behind the model and loss registries. |
13| `models` | core | Model registry, `build_registered_model`, and the legacy lazy smp fallback in `build_model`. |13| `models` | core | Model registry, `build_registered_model`, and the legacy lazy smp fallback in `build_model`. |
14| `losses` | core | Task-neutral registry, `WeightedSum`, and rank-agnostic `cross_entropy` / `focal_cross_entropy` / `masked_focal_tversky`. |14| `losses` | core | Task-neutral registry, `WeightedSum`, and rank-agnostic `cross_entropy` / `focal_cross_entropy` / `masked_focal_tversky`. |
15| `metrics` | core | Rank-agnostic, ignore-aware confusion matrix and `SegmentationStats`. |15| `metrics` | core | Rank-agnostic, ignore-aware confusion matrix and `SegmentationStats`. |
Importance #11: README.md @@ -32,9 +32,19 @@
32`iolabs.common.config_loader.ConfigModel` (unknown keys rejected, values coerced32`iolabs.common.config_loader.ConfigModel` (unknown keys rejected, values coerced
33by the shared fleet matrix, instances frozen). **Adding a config key = adding one33by the shared fleet matrix, instances frozen). **Adding a config key = adding one
34field with its default to the model in `config.py`** โ€” there is no separate34field with its default to the model in `config.py`** โ€” there is no separate
35allow-list, coercion helper, or dataclass to keep in sync. `build_section`35allow-list, coercion helper, or dataclass to keep in sync. `build_section`
36raises `ConfigError`, which derives from `ValueError`.36raises `ConfigError`, which derives from `ValueError`. Constructing a model
37directly (`TrainerConfig(max_epochs=-2)`) raises `pydantic.ValidationError`;
38only the `build_section` / `validate_config` entry points wrap it in
39`ConfigError`, so mutate configs through them rather than `model_copy(update=)`,
40which skips validation entirely.
41
42Sections derive from `SectionModel`, so a bare `loss:` / `args:` line (YAML
43`null`) means "use the defaults" โ€” as the pre-pydantic `data or {}` coalescing
44did โ€” while a field that genuinely accepts `None` (`model.encoder_weights`)
45keeps it. `train.precision` takes Lightning ints (`16`) as well as strings;
46`precision`/`devices` reject YAML `yes`/`on` instead of narrowing them to `1`.
3747
38## Compatibility48## Compatibility
3949
400.2.1 keeps every public name; `build_section` now raises `ConfigError`500.2.1 keeps every public name; `build_section` now raises `ConfigError`
Importance #12: src/iolabs_ml_harness/__init__.py @@ -11,8 +11,9 @@
11 ConfigError,11 ConfigError,
12 FactoryConfig,12 FactoryConfig,
13 LossConfig,13 LossConfig,
14 ModelConfig,14 ModelConfig,
15 SectionModel,
15 TrainerConfig,16 TrainerConfig,
16 build_section,17 build_section,
17 load_yaml_mapping,18 load_yaml_mapping,
18)19)
Importance #13: src/iolabs_ml_harness/__init__.py @@ -72,8 +73,9 @@
72 "FactoryRegistry",73 "FactoryRegistry",
73 "LossConfig",74 "LossConfig",
74 "MaskOverlayWriter",75 "MaskOverlayWriter",
75 "ModelConfig",76 "ModelConfig",
77 "SectionModel",
76 "SegmentationModule",78 "SegmentationModule",
77 "SegmentationStats",79 "SegmentationStats",
78 "TrainerConfig",80 "TrainerConfig",
79 "WeightedSum",81 "WeightedSum",
Importance #14: src/iolabs_ml_harness/config.py @@ -10,22 +10,62 @@
10``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D10``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D
11consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict11consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict
12`ConfigModel`.12`ConfigModel`.
13"""13"""
14import logging
15from collections.abc import Mapping14from collections.abc import Mapping
16from pathlib import Path15from pathlib import Path
17from typing import Any, Literal, TypeVar16from typing import Any, Literal, TypeVar, get_args
1817
19import pydantic18import pydantic
20import yaml19import yaml
21from iolabs.common import config_loader20from iolabs.common import config_loader
2221from pydantic import fields as pydantic_fields
23logger = logging.getLogger(__name__)
2422
25T = TypeVar("T", bound=config_loader.ConfigModel)23T = TypeVar("T", bound=config_loader.ConfigModel)
2624
2725
26class SectionModel(config_loader.ConfigModel):
27 """`ConfigModel` in which a YAML ``null`` means "use this field's default".
28
29 A bare ``model:`` / ``args:`` line parses to ``None``; the hand-written
30 ``data or {}`` coalescing that predates the pydantic layer treated that as
31 "section omitted". Fields that accept ``None`` (e.g. ``encoder_weights``)
32 keep it as a real value, and required fields still fail.
33 """
34
35 @pydantic.model_validator(mode="before")
36 @classmethod
37 def _null_means_default(cls, data: Any) -> Any:
38 """Drop ``None`` entries whose field has a default and forbids ``None``."""
39 if not isinstance(data, Mapping):
40 return data
41 dropped = {
42 name for name, value in data.items()
43 if value is None and _null_means_default_for(cls.model_fields.get(name))}
44 if not dropped:
45 return data
46 return {name: value for name, value in data.items() if name not in dropped}
47
48
49def _null_means_default_for(field: pydantic_fields.FieldInfo | None) -> bool:
50 """True when *field* has a default and its annotation does not accept None."""
51 if field is None or field.is_required():
52 return False
53 return type(None) not in get_args(field.annotation)
54
55
56def reject_bool(value: Any, name: str) -> Any:
57 """Raise when *value* is a ``bool`` reaching an ``int | str`` field.
58
59 YAML turns ``yes``/``on`` into ``True``, which pydantic would happily narrow
60 to ``1`` on an ``int | str`` union -- a typo would become a silent
61 single-device / precision-1 run.
62 """
63 if isinstance(value, bool):
64 raise ValueError(f"{name} must be an int or a str, got bool {value!r}")
65 return value
66
67
28class ConfigError(config_loader.ConfigError):68class ConfigError(config_loader.ConfigError):
29 """Raised when a harness config section holds unknown keys or bad values."""69 """Raised when a harness config section holds unknown keys or bad values."""
3070
3171
Importance #15: src/iolabs_ml_harness/config.py @@ -76,15 +116,15 @@
76 f"got {type(loaded).__name__}")116 f"got {type(loaded).__name__}")
77 return dict(loaded)117 return dict(loaded)
78118
79119
80class FactoryConfig(config_loader.ConfigModel):120class FactoryConfig(SectionModel):
81 """Modality-neutral registry selection: a name plus factory keyword args."""121 """Modality-neutral registry selection: a name plus factory keyword args."""
82 name: str122 name: str
83 args: dict[str, Any] = {}123 args: dict[str, Any] = {}
84124
85125
86class ModelConfig(config_loader.ConfigModel):126class ModelConfig(SectionModel):
87 """Legacy image-model schema (segmentation-models-pytorch shaped)."""127 """Legacy image-model schema (segmentation-models-pytorch shaped)."""
88 name: str = "unet"128 name: str = "unet"
89 encoder_name: str = "resnet18"129 encoder_name: str = "resnet18"
90 encoder_weights: str | None = "imagenet" # None = train from scratch130 encoder_weights: str | None = "imagenet" # None = train from scratch
Importance #16: src/iolabs_ml_harness/config.py @@ -92,20 +132,22 @@
92 num_classes: int = pydantic.Field(default=3, gt=0)132 num_classes: int = pydantic.Field(default=3, gt=0)
93 extra: dict[str, Any] = {} # passed to the factory133 extra: dict[str, Any] = {} # passed to the factory
94134
95135
96class LossConfig(config_loader.ConfigModel):136class LossConfig(SectionModel):
97 """Loss selection by registry name plus factory keyword arguments."""137 """Loss selection by registry name plus factory keyword arguments."""
98 name: str = "dice_focal"138 name: str = "dice_focal"
99 args: dict[str, Any] = {}139 args: dict[str, Any] = {}
100140
101141
102class TrainerConfig(config_loader.ConfigModel):142class TrainerConfig(SectionModel):
103 """Harness-visible Lightning trainer, logger, and callback knobs."""143 """Harness-visible Lightning trainer, logger, and callback knobs."""
104 max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap144 max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap
105 lr: float = pydantic.Field(default=3.0e-4, gt=0)145 lr: float = pydantic.Field(default=3.0e-4, gt=0)
106 weight_decay: float = pydantic.Field(default=1.0e-4, ge=0)146 weight_decay: float = pydantic.Field(default=1.0e-4, ge=0)
107 precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU147 # "auto" -> 16-mixed on CUDA, 32-true on CPU; else a Lightning precision
148 # (16, "16-mixed", "bf16-mixed", 32, "32-true", ...)
149 precision: int | str = "auto"
108 accumulate_grad_batches: int = pydantic.Field(default=1, ge=1)150 accumulate_grad_batches: int = pydantic.Field(default=1, ge=1)
109 accelerator: str = "auto"151 accelerator: str = "auto"
110 devices: int | str = 1152 devices: int | str = 1
111 viz_every_n_epochs: int = pydantic.Field(default=2, ge=0)153 viz_every_n_epochs: int = pydantic.Field(default=2, ge=0)
Importance #17: src/iolabs_ml_harness/config.py @@ -117,4 +159,10 @@
117 # epochs without improvement before stopping; 0 disables159 # epochs without improvement before stopping; 0 disables
118 early_stop_patience: int = pydantic.Field(default=4, ge=0)160 early_stop_patience: int = pydantic.Field(default=4, ge=0)
119 log_dir: str = "runs"161 log_dir: str = "runs"
120 log_every_n_steps: int = pydantic.Field(default=10, ge=1)162 log_every_n_steps: int = pydantic.Field(default=10, ge=1)
163
164 @pydantic.field_validator("precision", "devices", mode="before")
165 @classmethod
166 def _reject_bool(cls, value: Any, info: pydantic.ValidationInfo) -> Any:
167 """Keep YAML ``yes``/``on`` from silently narrowing to ``1``."""
168 return reject_bool(value, info.field_name or "value")
Importance #18: tests/test_config_registry.py @@ -11,8 +11,9 @@
11 ConfigError,11 ConfigError,
12 FactoryConfig,12 FactoryConfig,
13 LossConfig,13 LossConfig,
14 ModelConfig,14 ModelConfig,
15 SectionModel,
15 TrainerConfig,16 TrainerConfig,
16 build_section,17 build_section,
17 load_yaml_mapping,18 load_yaml_mapping,
18)19)
Importance #19: tests/test_config_registry.py @@ -48,9 +49,9 @@
48 with pytest.raises(ValueError):49 with pytest.raises(ValueError):
49 build_section(_Section, {"alpha": "not-an-int"}, "train")50 build_section(_Section, {"alpha": "not-an-int"}, "train")
5051
5152
52def test_legacy_dataclass_defaults_are_unchanged() -> None:53def test_shared_section_defaults_are_unchanged() -> None:
53 model = ModelConfig()54 model = ModelConfig()
54 assert (model.name, model.encoder_name, model.encoder_weights) == (55 assert (model.name, model.encoder_name, model.encoder_weights) == (
55 "unet", "resnet18", "imagenet")56 "unet", "resnet18", "imagenet")
56 assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {})57 assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {})
Importance #20: tests/test_config_registry.py @@ -67,8 +68,42 @@
67 assert train.weight_decay == pytest.approx(1.0e-4)68 assert train.weight_decay == pytest.approx(1.0e-4)
68 assert train.early_stop_mode == "min"69 assert train.early_stop_mode == "min"
6970
7071
72def test_null_section_value_falls_back_to_the_default() -> None:
73 """A bare ``args:``/``extra:`` line parses to None and must mean "default"."""
74 assert build_section(LossConfig, {"name": "dice", "args": None}, "loss").args == {}
75 assert build_section(ModelConfig, {"extra": None}, "model").extra == {}
76 # a field that really accepts None keeps it
77 assert build_section(
78 ModelConfig, {"encoder_weights": None}, "model").encoder_weights is None
79 # required fields still fail on None
80 with pytest.raises(ConfigError, match="name"):
81 build_section(FactoryConfig, {"name": None}, "loss")
82
83
84def test_null_means_default_only_applies_to_section_models() -> None:
85 class _Plain(config_loader.ConfigModel):
86 alpha: int = 1
87
88 assert issubclass(LossConfig, SectionModel)
89 with pytest.raises(ConfigError):
90 build_section(_Plain, {"alpha": None}, "plain")
91
92
93def test_precision_accepts_lightning_ints_and_strings() -> None:
94 assert build_section(TrainerConfig, {"precision": 16}, "train").precision == 16
95 assert build_section(
96 TrainerConfig, {"precision": "bf16-mixed"}, "train").precision == "bf16-mixed"
97
98
99@pytest.mark.parametrize("field", ["precision", "devices"])
100def test_yaml_yes_is_rejected_instead_of_becoming_one(field: str) -> None:
101 """PyYAML turns ``devices: yes`` into True; int|str would narrow it to 1."""
102 with pytest.raises(ConfigError, match="got bool"):
103 build_section(TrainerConfig, {field: True}, "train")
104
105
71def test_factory_config_is_name_plus_args() -> None:106def test_factory_config_is_name_plus_args() -> None:
72 cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky",107 cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky",
73 "args": {"alpha": 0.8}}, "loss")108 "args": {"alpha": 0.8}}, "loss")
74 assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8}109 assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8}