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(-)
| 80 | return value | 80 | return value |
| 81 | 81 | ||
| 82 | 82 | ||
| 83 | class DetectorConfigError(config_loader.ConfigError): | 83 | class 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.""" |
| 85 | 85 | ||
| 86 | 86 | ||
| 87 | def load_default_config_dict() -> dict[str, Any]: | 87 | def 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. |
| 149 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 149 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 150 | return config | 150 | return config |
| 151 | 151 | ||
| 152 | 152 | ||
| 153 | def 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 | |||
| 153 | def wall_view_config(config: DetectorConfig) -> DetectorConfig: | 176 | def 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. |
| 155 | 178 | ||
| 156 | Maps every ``wall_*`` clustering/merge/fit override onto the matching | 179 | Maps every ``wall_*`` clustering/merge/fit override onto the matching |
| 157 | guardrail-named field via ``model_copy``. No other field changes, and the | 180 | guardrail-named field via :func:`with_overrides`. No other field changes, |
| 158 | source ``config`` is never mutated (frozen model). This lets | 181 | and the source ``config`` is never mutated (frozen model). This lets |
| 159 | ``detect_instances()``/``_fit_instance()`` run unmodified for walls: only | 182 | ``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. |
| 161 | 184 | ||
| 162 | Args: | 185 | Args: |
| 164 | 187 | ||
| 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 | ) |
| 173 | 195 | ||
| 174 | 196 | ||
| 175 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 197 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 15 | from iolabs.common.segment_points_io import iter_points_chunks | 15 | from iolabs.common.segment_points_io import iter_points_chunks |
| 16 | from iolabs_geometry_geometry.grid import decimation_indices | 16 | from iolabs_geometry_geometry.grid import decimation_indices |
| 17 | 17 | ||
| 18 | from .candidates import _station_window_mask | 18 | from .candidates import _station_window_mask |
| 19 | from .config import DetectorConfig | 19 | from .config import DetectorConfig, with_overrides |
| 20 | from .corridor import RoadSpine, transform_points | 20 | from .corridor import RoadSpine, transform_points |
| 21 | from .ground import GroundModel | 21 | from .ground import GroundModel |
| 22 | from .lane_xml import LateralZoneModel, classify_station_offsets | 22 | from .lane_xml import LateralZoneModel, classify_station_offsets |
| 23 | from .posts import SupportClaim, adopt_post_points | 23 | from .posts import SupportClaim, adopt_post_points |
| 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_m | 83 | 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_band | 87 | if widen_low_band |
| 88 | else config | 88 | else config |
| 89 | ) | 89 | ) |
| 90 | min_height_m = replay_config.min_height_m | 90 | min_height_m = replay_config.min_height_m |
| 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 | ) |
| 15 | 16 | ||
| 16 | # Fields wall_view_config() maps from a wall_* source field onto the matching | 17 | # 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). |
| 144 | expected = nondefault_wall_values[wall_field] | 145 | expected = nondefault_wall_values[wall_field] |
| 145 | assert getattr(result, target_field) == expected, target_field | 146 | assert getattr(result, target_field) == expected, target_field |
| 146 | assert getattr(result, target_field) != getattr(source, target_field), target_field | 147 | assert getattr(result, target_field) != getattr(source, target_field), target_field |
| 147 | 148 | ||
| 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) == value | 151 | assert getattr(source, wall_field) == value |
| 151 | 152 | ||
| 152 | # Every unrelated (unmapped) field is identical between source and result. | 153 | # Every unrelated (unmapped) field is identical between source and result. |
| 155 | if field_name in mapped_targets: | 156 | if field_name in mapped_targets: |
| 156 | continue | 157 | continue |
| 157 | assert getattr(result, field_name) == getattr(source, field_name), field_name | 158 | assert getattr(result, field_name) == getattr(source, field_name), field_name |
| 158 | 159 | ||
| 160 | |||
| 161 | |||
| 162 | def 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 | ) |
| 15 | from iolabs.common.segment_points_io import iter_points_chunks | 15 | from iolabs.common.segment_points_io import iter_points_chunks |
| 16 | from iolabs_geometry_geometry.grid import decimation_indices | 16 | from iolabs_geometry_geometry.grid import decimation_indices |
| 17 | 17 | ||
| 18 | from .candidates import _station_window_mask | 18 | from .candidates import _station_window_mask |
| 19 | from .config import DetectorConfig | 19 | from .config import DetectorConfig, with_overrides |
| 20 | from .corridor import RoadSpine, transform_points | 20 | from .corridor import RoadSpine, transform_points |
| 21 | from .ground import GroundModel | 21 | from .ground import GroundModel |
| 22 | from .lane_xml import LateralZoneModel, classify_station_offsets | 22 | from .lane_xml import LateralZoneModel, classify_station_offsets |
| 23 | from .posts import SupportClaim, adopt_post_points | 23 | from .posts import SupportClaim, adopt_post_points |
| 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_m | 83 | 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_band | 87 | if widen_low_band |
| 88 | else config | 88 | else config |
| 89 | ) | 89 | ) |
| 90 | min_height_m = replay_config.min_height_m | 90 | min_height_m = replay_config.min_height_m |
| 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 | ) |
| 15 | 16 | ||
| 16 | # Fields wall_view_config() maps from a wall_* source field onto the matching | 17 | # 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). |
| 144 | expected = nondefault_wall_values[wall_field] | 145 | expected = nondefault_wall_values[wall_field] |
| 145 | assert getattr(result, target_field) == expected, target_field | 146 | assert getattr(result, target_field) == expected, target_field |
| 146 | assert getattr(result, target_field) != getattr(source, target_field), target_field | 147 | assert getattr(result, target_field) != getattr(source, target_field), target_field |
| 147 | 148 | ||
| 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) == value | 151 | assert getattr(source, wall_field) == value |
| 151 | 152 | ||
| 152 | # Every unrelated (unmapped) field is identical between source and result. | 153 | # Every unrelated (unmapped) field is identical between source and result. |
| 155 | if field_name in mapped_targets: | 156 | if field_name in mapped_targets: |
| 156 | continue | 157 | continue |
| 157 | assert getattr(result, field_name) == getattr(source, field_name), field_name | 158 | assert getattr(result, field_name) == getattr(source, field_name), field_name |
| 158 | 159 | ||
| 160 | |||
| 161 | |||
| 162 | def 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 | ) |
with_overrides()instead ofmodel_copy(update=), which bypasses validation.