Back to report index

mlsegmentation 8ddca59: AI3D-379 Review fixes: freeze mapping fields, drop provenance aliases, reject null visualization

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

Commit #73 ยท 12 snippets

 src/train/config.py          | 10 ++-------
 src/train/config_schema.py   | 19 +++++++++++++++++
 src/train/config_sections.py | 49 +++++++++++++++++++++++++++++++++++++++-----
 src/train/config_values.py   | 47 ++++++++++++++++++++++++++++++++++++++++++
 tests/test_config.py         | 34 ++++++++++++++++++++++++++++++
 5 files changed, 146 insertions(+), 13 deletions(-)
Importance #1: src/train/config.py @@ -27,9 +27,8 @@
27from iolabs.common import config_loader27from iolabs.common import config_loader
2828
29from src.train import (29from src.train import (
30 config_rules,30 config_rules,
31 config_sections,
32 config_study,31 config_study,
33 config_values,32 config_values,
34)33)
35from src.train.config_rules import is_canonical_metric, resolve_ontology_path34from src.train.config_rules import is_canonical_metric, resolve_ontology_path
Importance #2: src/train/config.py @@ -263,18 +262,13 @@
263262
264def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:263def _build_config(raw: Any, config_path: Path, sha256: str) -> HarnessConfig:
265 """Validate one raw document into a configuration carrying its digest."""264 """Validate one raw document into a configuration carrying its digest."""
266 root = config_values.mapping(raw, str(config_path))265 root = config_values.mapping(raw, str(config_path))
267 payload = {
268 **root,
269 config_sections.SOURCE_PATH_ALIAS: config_path,
270 config_sections.SHA256_ALIAS: sha256,
271 }
272 config = config_loader.validate_config(266 config = config_loader.validate_config(
273 HarnessConfig, payload, context=_CONTEXT, error_cls=ConfigError267 HarnessConfig, dict(root), context=_CONTEXT, error_cls=ConfigError
274 )268 )
269 config.attach_provenance(config_path, sha256, copy.deepcopy(dict(root)))
275 config_rules.validate_config(config, root)270 config_rules.validate_config(config, root)
276 config._raw_document = copy.deepcopy(dict(root))
277 return config271 return config
278272
279273
280def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:274def _raw_document(config: HarnessConfig) -> Mapping[str, Any]:
Importance #3: src/train/config_schema.py @@ -36,8 +36,27 @@
36 if field is None or field.annotation is None:36 if field is None or field.annotation is None:
37 return value37 return value
38 return config_values.typed_value(field.annotation, value, name)38 return config_values.typed_value(field.annotation, value, name)
3939
40 @pydantic.model_validator(mode="after")
41 def _freeze_mapping_fields(self) -> StrictConfigModel:
42 """Replace validated mapping fields with read-only views."""
43 for name in type(self).model_fields:
44 value = getattr(self, name)
45 frozen = config_values.freeze_value(value)
46 if frozen is not value:
47 object.__setattr__(self, name, frozen)
48 return self
49
50 @pydantic.field_serializer("*", mode="wrap")
51 def _serialize_frozen_mappings(
52 self,
53 value: Any,
54 handler: pydantic.SerializerFunctionWrapHandler,
55 ) -> Any:
56 """Serialize read-only mapping views as plain dicts."""
57 return handler(config_values.thaw_value(value))
58
4059
41class SptPartitionOracleParams(StrictConfigModel):60class SptPartitionOracleParams(StrictConfigModel):
42 """Required SPT partition-purity report and per-class thresholds."""61 """Required SPT partition-purity report and per-class thresholds."""
4362
Importance #4: src/train/config_sections.py @@ -18,10 +18,8 @@
18from src.train import config_schema, config_values18from src.train import config_schema, config_values
1919
20logger = logging.getLogger(__name__)20logger = logging.getLogger(__name__)
2121
22SOURCE_PATH_ALIAS = "__source_path__"
23SHA256_ALIAS = "__sha256__"
24_MODEL_ARG_KEYS = frozenset(22_MODEL_ARG_KEYS = frozenset(
25 {23 {
26 "aggregation", "annotation_mode", "balanced_crops",24 "aggregation", "annotation_mode", "balanced_crops",
27 "confidence_only_forbidden", "enable_flash", "geometry_context",25 "confidence_only_forbidden", "enable_flash", "geometry_context",
Importance #5: src/train/config_sections.py @@ -402,13 +400,54 @@
402 train: TrainConfig400 train: TrainConfig
403 evaluation: EvaluationConfig401 evaluation: EvaluationConfig
404 runtime: RuntimeConfig402 runtime: RuntimeConfig
405 provenance: ProvenanceConfig403 provenance: ProvenanceConfig
406 source_path: Path = pydantic.Field(validation_alias=SOURCE_PATH_ALIAS)
407 sha256: str = pydantic.Field(validation_alias=SHA256_ALIAS)
408 visualization: VisualizationConfig | None = None404 visualization: VisualizationConfig | None = None
409405
406 _source_path: Path = pydantic.PrivateAttr(default=Path())
407 _sha256: str = pydantic.PrivateAttr(default="")
410 _raw_document: Mapping[str, Any] | None = pydantic.PrivateAttr(default=None)408 _raw_document: Mapping[str, Any] | None = pydantic.PrivateAttr(default=None)
411409
410 @pydantic.model_validator(mode="before")
411 @classmethod
412 def _reject_null_sections(cls, data: Any) -> Any:
413 """Reject an explicitly null optional section, as the old parser did."""
414 if isinstance(data, Mapping) and data.get("visualization", False) is None:
415 raise ValueError("visualization must be a mapping")
416 return data
417
418 @property
419 def source_path(self) -> Path:
420 """Path of the YAML document this configuration was parsed from."""
421 return self._source_path
422
423 @property
424 def sha256(self) -> str:
425 """SHA-256 digest of the resolved document this configuration holds."""
426 return self._sha256
427
428 def attach_provenance(
429 self,
430 source_path: Path,
431 sha256: str,
432 raw_document: Mapping[str, Any] | None = None,
433 ) -> None:
434 """Record where this configuration came from and what it hashes to.
435
436 Provenance is kept out of the validated field set so its keys can never
437 be smuggled in through the YAML document.
438
439 Args:
440 source_path: Path the document was read from.
441 sha256: Digest of the resolved document.
442 raw_document: Raw mapping the model was validated from, if known.
443 """
444 self._source_path = source_path
445 self._sha256 = sha256
446 self._raw_document = raw_document
447
412 def as_dict(self) -> dict[str, Any]:448 def as_dict(self) -> dict[str, Any]:
413 """Return the resolved model tree as JSON-safe primitives."""449 """Return the resolved model tree as JSON-safe primitives."""
414 return self.model_dump(mode="json")450 payload = self.model_dump(mode="json")
451 payload["source_path"] = self._source_path.as_posix()
452 payload["sha256"] = self._sha256
453 return payload
Importance #6: src/train/config_values.py @@ -27,8 +27,55 @@
27class ConfigError(config_loader.ConfigError):27class ConfigError(config_loader.ConfigError):
28 """Raised when an experiment configuration violates its strict schema."""28 """Raised when an experiment configuration violates its strict schema."""
2929
3030
31def freeze_value(value: Any) -> Any:
32 """Return *value* with every nested mapping wrapped read-only.
33
34 Pydantic rebuilds mapping fields as plain dicts, so a frozen model would
35 otherwise hand out mutable ``args``/``overrides`` tables. Sequences are
36 rebuilt with the same container type so declared list fields stay lists.
37
38 Args:
39 value: Any validated field value.
40
41 Returns:
42 The value with dicts replaced by :class:`~types.MappingProxyType`.
43 """
44 if isinstance(value, MappingProxyType):
45 return value
46 if isinstance(value, Mapping):
47 return MappingProxyType(
48 {key: freeze_value(item) for key, item in value.items()}
49 )
50 if isinstance(value, tuple):
51 return tuple(freeze_value(item) for item in value)
52 if isinstance(value, list):
53 return [freeze_value(item) for item in value]
54 return value
55
56
57def thaw_value(value: Any) -> Any:
58 """Return *value* with every read-only mapping view turned back into a dict.
59
60 Pydantic's serializer cannot handle ``mappingproxy``, so frozen fields are
61 thawed on the way out of :meth:`~pydantic.BaseModel.model_dump`.
62
63 Args:
64 value: Any validated field value.
65
66 Returns:
67 The value with :class:`~types.MappingProxyType` replaced by dicts.
68 """
69 if isinstance(value, Mapping):
70 return {key: thaw_value(item) for key, item in value.items()}
71 if isinstance(value, tuple):
72 return tuple(thaw_value(item) for item in value)
73 if isinstance(value, list):
74 return [thaw_value(item) for item in value]
75 return value
76
77
31def typed_value(annotation: Any, value: Any, where: str) -> Any:78def typed_value(annotation: Any, value: Any, where: str) -> Any:
32 """Parse one config leaf strictly as its declared annotation.79 """Parse one config leaf strictly as its declared annotation.
3380
34 Nested models, ``Annotated`` aliases and unresolved annotations are81 Nested models, ``Annotated`` aliases and unresolved annotations are
Importance #7: tests/test_config.py @@ -607,4 +607,38 @@
607 config = load_config(config_path)607 config = load_config(config_path)
608608
609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")
610 assert config.task.num_classes == 9610 assert config.task.num_classes == 9
611
612
613def test_provenance_keys_cannot_be_smuggled_through_the_document(
614 tmp_path: Path,
615) -> None:
616 """source_path/sha256 are recorded out of band, never taken from the YAML."""
617 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
618 raw["__sha256__"] = "deadbeef"
619 path = tmp_path / "smuggled.yaml"
620 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
621 with pytest.raises(ConfigError, match="Unknown.*__sha256__"):
622 load_config(path)
623
624
625def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:
626 """An explicitly null visualization block is a typo, not an omission."""
627 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
628 raw["visualization"] = None
629 path = tmp_path / "viz_null.yaml"
630 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
631 with pytest.raises(ConfigError, match="visualization must be a mapping"):
632 load_config(path)
633
634
635def test_mapping_sections_are_read_only() -> None:
636 """Frozen configs hand out read-only mappings, not mutable dicts."""
637 config = load_config(CONFIGS[0])
638 with pytest.raises(TypeError):
639 config.model.args["num_classes"] = 99 # type: ignore[index]
640 with pytest.raises(TypeError):
641 config.loss.args["gamma"] = 0.0 # type: ignore[index]
642 with pytest.raises(TypeError):
643 config.evaluation.precision_floors["curb"] = 0.0 # type: ignore[index]
644 assert isinstance(config.as_dict()["model"]["args"], dict)
Importance #8: src/train/config_schema.py @@ -36,8 +36,27 @@
36 if field is None or field.annotation is None:36 if field is None or field.annotation is None:
37 return value37 return value
38 return config_values.typed_value(field.annotation, value, name)38 return config_values.typed_value(field.annotation, value, name)
3939
40 @pydantic.model_validator(mode="after")
41 def _freeze_mapping_fields(self) -> StrictConfigModel:
42 """Replace validated mapping fields with read-only views."""
43 for name in type(self).model_fields:
44 value = getattr(self, name)
45 frozen = config_values.freeze_value(value)
46 if frozen is not value:
47 object.__setattr__(self, name, frozen)
48 return self
49
50 @pydantic.field_serializer("*", mode="wrap")
51 def _serialize_frozen_mappings(
52 self,
53 value: Any,
54 handler: pydantic.SerializerFunctionWrapHandler,
55 ) -> Any:
56 """Serialize read-only mapping views as plain dicts."""
57 return handler(config_values.thaw_value(value))
58
4059
41class SptPartitionOracleParams(StrictConfigModel):60class SptPartitionOracleParams(StrictConfigModel):
42 """Required SPT partition-purity report and per-class thresholds."""61 """Required SPT partition-purity report and per-class thresholds."""
4362
Importance #9: src/train/config_sections.py @@ -18,10 +18,8 @@
18from src.train import config_schema, config_values18from src.train import config_schema, config_values
1919
20logger = logging.getLogger(__name__)20logger = logging.getLogger(__name__)
2121
22SOURCE_PATH_ALIAS = "__source_path__"
23SHA256_ALIAS = "__sha256__"
24_MODEL_ARG_KEYS = frozenset(22_MODEL_ARG_KEYS = frozenset(
25 {23 {
26 "aggregation", "annotation_mode", "balanced_crops",24 "aggregation", "annotation_mode", "balanced_crops",
27 "confidence_only_forbidden", "enable_flash", "geometry_context",25 "confidence_only_forbidden", "enable_flash", "geometry_context",
Importance #10: src/train/config_sections.py @@ -402,13 +400,54 @@
402 train: TrainConfig400 train: TrainConfig
403 evaluation: EvaluationConfig401 evaluation: EvaluationConfig
404 runtime: RuntimeConfig402 runtime: RuntimeConfig
405 provenance: ProvenanceConfig403 provenance: ProvenanceConfig
406 source_path: Path = pydantic.Field(validation_alias=SOURCE_PATH_ALIAS)
407 sha256: str = pydantic.Field(validation_alias=SHA256_ALIAS)
408 visualization: VisualizationConfig | None = None404 visualization: VisualizationConfig | None = None
409405
406 _source_path: Path = pydantic.PrivateAttr(default=Path())
407 _sha256: str = pydantic.PrivateAttr(default="")
410 _raw_document: Mapping[str, Any] | None = pydantic.PrivateAttr(default=None)408 _raw_document: Mapping[str, Any] | None = pydantic.PrivateAttr(default=None)
411409
410 @pydantic.model_validator(mode="before")
411 @classmethod
412 def _reject_null_sections(cls, data: Any) -> Any:
413 """Reject an explicitly null optional section, as the old parser did."""
414 if isinstance(data, Mapping) and data.get("visualization", False) is None:
415 raise ValueError("visualization must be a mapping")
416 return data
417
418 @property
419 def source_path(self) -> Path:
420 """Path of the YAML document this configuration was parsed from."""
421 return self._source_path
422
423 @property
424 def sha256(self) -> str:
425 """SHA-256 digest of the resolved document this configuration holds."""
426 return self._sha256
427
428 def attach_provenance(
429 self,
430 source_path: Path,
431 sha256: str,
432 raw_document: Mapping[str, Any] | None = None,
433 ) -> None:
434 """Record where this configuration came from and what it hashes to.
435
436 Provenance is kept out of the validated field set so its keys can never
437 be smuggled in through the YAML document.
438
439 Args:
440 source_path: Path the document was read from.
441 sha256: Digest of the resolved document.
442 raw_document: Raw mapping the model was validated from, if known.
443 """
444 self._source_path = source_path
445 self._sha256 = sha256
446 self._raw_document = raw_document
447
412 def as_dict(self) -> dict[str, Any]:448 def as_dict(self) -> dict[str, Any]:
413 """Return the resolved model tree as JSON-safe primitives."""449 """Return the resolved model tree as JSON-safe primitives."""
414 return self.model_dump(mode="json")450 payload = self.model_dump(mode="json")
451 payload["source_path"] = self._source_path.as_posix()
452 payload["sha256"] = self._sha256
453 return payload
Importance #11: src/train/config_values.py @@ -27,8 +27,55 @@
27class ConfigError(config_loader.ConfigError):27class ConfigError(config_loader.ConfigError):
28 """Raised when an experiment configuration violates its strict schema."""28 """Raised when an experiment configuration violates its strict schema."""
2929
3030
31def freeze_value(value: Any) -> Any:
32 """Return *value* with every nested mapping wrapped read-only.
33
34 Pydantic rebuilds mapping fields as plain dicts, so a frozen model would
35 otherwise hand out mutable ``args``/``overrides`` tables. Sequences are
36 rebuilt with the same container type so declared list fields stay lists.
37
38 Args:
39 value: Any validated field value.
40
41 Returns:
42 The value with dicts replaced by :class:`~types.MappingProxyType`.
43 """
44 if isinstance(value, MappingProxyType):
45 return value
46 if isinstance(value, Mapping):
47 return MappingProxyType(
48 {key: freeze_value(item) for key, item in value.items()}
49 )
50 if isinstance(value, tuple):
51 return tuple(freeze_value(item) for item in value)
52 if isinstance(value, list):
53 return [freeze_value(item) for item in value]
54 return value
55
56
57def thaw_value(value: Any) -> Any:
58 """Return *value* with every read-only mapping view turned back into a dict.
59
60 Pydantic's serializer cannot handle ``mappingproxy``, so frozen fields are
61 thawed on the way out of :meth:`~pydantic.BaseModel.model_dump`.
62
63 Args:
64 value: Any validated field value.
65
66 Returns:
67 The value with :class:`~types.MappingProxyType` replaced by dicts.
68 """
69 if isinstance(value, Mapping):
70 return {key: thaw_value(item) for key, item in value.items()}
71 if isinstance(value, tuple):
72 return tuple(thaw_value(item) for item in value)
73 if isinstance(value, list):
74 return [thaw_value(item) for item in value]
75 return value
76
77
31def typed_value(annotation: Any, value: Any, where: str) -> Any:78def typed_value(annotation: Any, value: Any, where: str) -> Any:
32 """Parse one config leaf strictly as its declared annotation.79 """Parse one config leaf strictly as its declared annotation.
3380
34 Nested models, ``Annotated`` aliases and unresolved annotations are81 Nested models, ``Annotated`` aliases and unresolved annotations are
Importance #12: tests/test_config.py @@ -607,4 +607,38 @@
607 config = load_config(config_path)607 config = load_config(config_path)
608608
609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")609 assert config.task.ontology == Path("configs/contracts/ontology_v1.yaml")
610 assert config.task.num_classes == 9610 assert config.task.num_classes == 9
611
612
613def test_provenance_keys_cannot_be_smuggled_through_the_document(
614 tmp_path: Path,
615) -> None:
616 """source_path/sha256 are recorded out of band, never taken from the YAML."""
617 raw = yaml.safe_load(CONFIGS[0].read_text(encoding="utf-8"))
618 raw["__sha256__"] = "deadbeef"
619 path = tmp_path / "smuggled.yaml"
620 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
621 with pytest.raises(ConfigError, match="Unknown.*__sha256__"):
622 load_config(path)
623
624
625def test_null_visualization_block_is_rejected(tmp_path: Path) -> None:
626 """An explicitly null visualization block is a typo, not an omission."""
627 raw = yaml.safe_load(CONFIGS[8].read_text(encoding="utf-8"))
628 raw["visualization"] = None
629 path = tmp_path / "viz_null.yaml"
630 path.write_text(yaml.safe_dump(raw), encoding="utf-8")
631 with pytest.raises(ConfigError, match="visualization must be a mapping"):
632 load_config(path)
633
634
635def test_mapping_sections_are_read_only() -> None:
636 """Frozen configs hand out read-only mappings, not mutable dicts."""
637 config = load_config(CONFIGS[0])
638 with pytest.raises(TypeError):
639 config.model.args["num_classes"] = 99 # type: ignore[index]
640 with pytest.raises(TypeError):
641 config.loss.args["gamma"] = 0.0 # type: ignore[index]
642 with pytest.raises(TypeError):
643 config.evaluation.precision_floors["curb"] = 0.0 # type: ignore[index]
644 assert isinstance(config.as_dict()["model"]["args"], dict)