Back to report index

guardrails 0758988: AI3D-379 Review fixes: validated with_overrides() replaces model_copy(update=) config derivations

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

Commit #58 ยท 13 snippets

 guardrails/config.py  | 36 +++++++++++++++++++++++++++++-------
 guardrails/outputs.py |  4 ++--
 tests/test_config.py  | 22 +++++++++++++++++++++-
 3 files changed, 52 insertions(+), 10 deletions(-)
Importance #1: guardrails/config.py @@ -80,9 +80,9 @@
80 return value80 return value
8181
8282
83class DetectorConfigError(config_loader.ConfigError):83class DetectorConfigError(config_loader.ConfigError):
84 """Raised when the guardrails config contains unsupported keys."""84 """Raised for an unsupported key or an invalid value in the guardrails config."""
8585
8686
87def load_default_config_dict() -> dict[str, Any]:87def load_default_config_dict() -> dict[str, Any]:
88 """Return the package-owned default config as a plain dict.88 """Return the package-owned default config as a plain dict.
Importance #2: guardrails/config.py @@ -149,14 +149,37 @@
149 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))149 logger.info("Config overrides applied: %s", ", ".join(sorted(overrides)))
150 return config150 return config
151151
152152
153def with_overrides(config: DetectorConfig, updates: dict[str, Any]) -> DetectorConfig:
154 """Return a re-validated copy of *config* with *updates* applied.
155
156 Unlike ``model_copy(update=...)``, which writes the raw values straight
157 into the copy, this rebuilds the model, so an unknown key, a value of the
158 wrong type and a failing cross-value check are all rejected exactly as they
159 are on load. Every in-package config derivation goes through here.
160
161 Args:
162 config: The config to derive from; never mutated (frozen model).
163 updates: Field name to new value; values go through the same coercion
164 as raw JSON / ``--set`` input.
165
166 Returns:
167 A validated copy carrying *updates*.
168
169 Raises:
170 DetectorConfigError: *updates* names an unknown field or holds a value
171 that is not valid for its declared field type.
172 """
173 return config_from_dict({**config.model_dump(), **updates})
174
175
153def wall_view_config(config: DetectorConfig) -> DetectorConfig:176def wall_view_config(config: DetectorConfig) -> DetectorConfig:
154 """Return a wall-view :class:`DetectorConfig` for the shared fitter.177 """Return a wall-view :class:`DetectorConfig` for the shared fitter.
155178
156 Maps every ``wall_*`` clustering/merge/fit override onto the matching179 Maps every ``wall_*`` clustering/merge/fit override onto the matching
157 guardrail-named field via ``model_copy``. No other field changes, and the180 guardrail-named field via :func:`with_overrides`. No other field changes,
158 source ``config`` is never mutated (frozen model). This lets181 and the source ``config`` is never mutated (frozen model). This lets
159 ``detect_instances()``/``_fit_instance()`` run unmodified for walls: only182 ``detect_instances()``/``_fit_instance()`` run unmodified for walls: only
160 the config view differs, not the fitter code.183 the config view differs, not the fitter code.
161184
162 Args:185 Args:
Importance #3: guardrails/config.py @@ -164,12 +187,11 @@
164187
165 Returns:188 Returns:
166 A copy whose geometry fields carry the ``wall_*`` values.189 A copy whose geometry fields carry the ``wall_*`` values.
167 """190 """
168 return config.model_copy(191 return with_overrides(
169 update={192 config,
170 target: getattr(config, source) for target, source in _WALL_VIEW_MAP.items()193 {target: getattr(config, source) for target, source in _WALL_VIEW_MAP.items()},
171 }
172 )194 )
173195
174196
175def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:197def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]:
Importance #4: guardrails/outputs.py @@ -15,9 +15,9 @@
15from iolabs.common.segment_points_io import iter_points_chunks15from iolabs.common.segment_points_io import iter_points_chunks
16from iolabs_geometry_geometry.grid import decimation_indices16from iolabs_geometry_geometry.grid import decimation_indices
1717
18from .candidates import _station_window_mask18from .candidates import _station_window_mask
19from .config import DetectorConfig19from .config import DetectorConfig, with_overrides
20from .corridor import RoadSpine, transform_points20from .corridor import RoadSpine, transform_points
21from .ground import GroundModel21from .ground import GroundModel
22from .lane_xml import LateralZoneModel, classify_station_offsets22from .lane_xml import LateralZoneModel, classify_station_offsets
23from .posts import SupportClaim, adopt_post_points23from .posts import SupportClaim, adopt_post_points
Importance #5: guardrails/outputs.py @@ -82,9 +82,9 @@
82 widen_low_band = collect_height_station and (82 widen_low_band = collect_height_station and (
83 config.post_low_band_min_m < config.min_height_m83 config.post_low_band_min_m < config.min_height_m
84 )84 )
85 replay_config = (85 replay_config = (
86 config.model_copy(update={"min_height_m": config.post_low_band_min_m})86 with_overrides(config, {"min_height_m": config.post_low_band_min_m})
87 if widen_low_band87 if widen_low_band
88 else config88 else config
89 )89 )
90 min_height_m = replay_config.min_height_m90 min_height_m = replay_config.min_height_m
Importance #6: tests/test_config.py @@ -10,8 +10,9 @@
10 load_config,10 load_config,
11 load_default_config_dict,11 load_default_config_dict,
12 parse_set_overrides,12 parse_set_overrides,
13 wall_view_config,13 wall_view_config,
14 with_overrides,
14)15)
1516
16# Fields wall_view_config() maps from a wall_* source field onto the matching17# Fields wall_view_config() maps from a wall_* source field onto the matching
17# guardrail-named field on the returned config (design section 2).18# guardrail-named field on the returned config (design section 2).
Importance #7: tests/test_config.py @@ -144,9 +145,9 @@
144 expected = nondefault_wall_values[wall_field]145 expected = nondefault_wall_values[wall_field]
145 assert getattr(result, target_field) == expected, target_field146 assert getattr(result, target_field) == expected, target_field
146 assert getattr(result, target_field) != getattr(source, target_field), target_field147 assert getattr(result, target_field) != getattr(source, target_field), target_field
147148
148 # Source config is untouched (frozen model; model_copy never mutates).149 # Source config is untouched (frozen model; with_overrides never mutates).
149 for wall_field, value in nondefault_wall_values.items():150 for wall_field, value in nondefault_wall_values.items():
150 assert getattr(source, wall_field) == value151 assert getattr(source, wall_field) == value
151152
152 # Every unrelated (unmapped) field is identical between source and result.153 # Every unrelated (unmapped) field is identical between source and result.
Importance #8: tests/test_config.py @@ -155,4 +156,23 @@
155 if field_name in mapped_targets:156 if field_name in mapped_targets:
156 continue157 continue
157 assert getattr(result, field_name) == getattr(source, field_name), field_name158 assert getattr(result, field_name) == getattr(source, field_name), field_name
158159
160
161
162def test_with_overrides_revalidates_updates() -> None:
163 """with_overrides() rejects what load_config() rejects, unlike model_copy()."""
164 config = DetectorConfig()
165 with pytest.raises(DetectorConfigError):
166 with_overrides(config, {"not_a_key": 1})
167 with pytest.raises(DetectorConfigError):
168 with_overrides(config, {"merge_face_max_faces": 3.7})
169 with pytest.raises(DetectorConfigError):
170 with_overrides(config, {"decimation_enabled": "flase"})
171 with pytest.raises(DetectorConfigError):
172 with_overrides(config, {"residue_lever_band_m": [0.1, 0.2, 0.3]})
173
174 updated = with_overrides(config, {"min_height_m": config.min_height_m + 0.25})
175 assert updated.min_height_m == config.min_height_m + 0.25
176 assert updated.model_dump(exclude={"min_height_m"}) == config.model_dump(
177 exclude={"min_height_m"}
178 )
Importance #9: guardrails/outputs.py @@ -15,9 +15,9 @@
15from iolabs.common.segment_points_io import iter_points_chunks15from iolabs.common.segment_points_io import iter_points_chunks
16from iolabs_geometry_geometry.grid import decimation_indices16from iolabs_geometry_geometry.grid import decimation_indices
1717
18from .candidates import _station_window_mask18from .candidates import _station_window_mask
19from .config import DetectorConfig19from .config import DetectorConfig, with_overrides
20from .corridor import RoadSpine, transform_points20from .corridor import RoadSpine, transform_points
21from .ground import GroundModel21from .ground import GroundModel
22from .lane_xml import LateralZoneModel, classify_station_offsets22from .lane_xml import LateralZoneModel, classify_station_offsets
23from .posts import SupportClaim, adopt_post_points23from .posts import SupportClaim, adopt_post_points
Importance #10: guardrails/outputs.py @@ -82,9 +82,9 @@
82 widen_low_band = collect_height_station and (82 widen_low_band = collect_height_station and (
83 config.post_low_band_min_m < config.min_height_m83 config.post_low_band_min_m < config.min_height_m
84 )84 )
85 replay_config = (85 replay_config = (
86 config.model_copy(update={"min_height_m": config.post_low_band_min_m})86 with_overrides(config, {"min_height_m": config.post_low_band_min_m})
87 if widen_low_band87 if widen_low_band
88 else config88 else config
89 )89 )
90 min_height_m = replay_config.min_height_m90 min_height_m = replay_config.min_height_m
Importance #11: tests/test_config.py @@ -10,8 +10,9 @@
10 load_config,10 load_config,
11 load_default_config_dict,11 load_default_config_dict,
12 parse_set_overrides,12 parse_set_overrides,
13 wall_view_config,13 wall_view_config,
14 with_overrides,
14)15)
1516
16# Fields wall_view_config() maps from a wall_* source field onto the matching17# Fields wall_view_config() maps from a wall_* source field onto the matching
17# guardrail-named field on the returned config (design section 2).18# guardrail-named field on the returned config (design section 2).
Importance #12: tests/test_config.py @@ -144,9 +145,9 @@
144 expected = nondefault_wall_values[wall_field]145 expected = nondefault_wall_values[wall_field]
145 assert getattr(result, target_field) == expected, target_field146 assert getattr(result, target_field) == expected, target_field
146 assert getattr(result, target_field) != getattr(source, target_field), target_field147 assert getattr(result, target_field) != getattr(source, target_field), target_field
147148
148 # Source config is untouched (frozen model; model_copy never mutates).149 # Source config is untouched (frozen model; with_overrides never mutates).
149 for wall_field, value in nondefault_wall_values.items():150 for wall_field, value in nondefault_wall_values.items():
150 assert getattr(source, wall_field) == value151 assert getattr(source, wall_field) == value
151152
152 # Every unrelated (unmapped) field is identical between source and result.153 # Every unrelated (unmapped) field is identical between source and result.
Importance #13: tests/test_config.py @@ -155,4 +156,23 @@
155 if field_name in mapped_targets:156 if field_name in mapped_targets:
156 continue157 continue
157 assert getattr(result, field_name) == getattr(source, field_name), field_name158 assert getattr(result, field_name) == getattr(source, field_name), field_name
158159
160
161
162def test_with_overrides_revalidates_updates() -> None:
163 """with_overrides() rejects what load_config() rejects, unlike model_copy()."""
164 config = DetectorConfig()
165 with pytest.raises(DetectorConfigError):
166 with_overrides(config, {"not_a_key": 1})
167 with pytest.raises(DetectorConfigError):
168 with_overrides(config, {"merge_face_max_faces": 3.7})
169 with pytest.raises(DetectorConfigError):
170 with_overrides(config, {"decimation_enabled": "flase"})
171 with pytest.raises(DetectorConfigError):
172 with_overrides(config, {"residue_lever_band_m": [0.1, 0.2, 0.3]})
173
174 updated = with_overrides(config, {"min_height_m": config.min_height_m + 0.25})
175 assert updated.min_height_m == config.min_height_m + 0.25
176 assert updated.model_dump(exclude={"min_height_m"}) == config.model_dump(
177 exclude={"min_height_m"}
178 )