Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:39:31+02:00
Commit #63 · 5 snippets
README.md | 30 +++--- guardrails/config.py | 262 ++++++++++++++++++++++++++++++--------------------- tests/test_config.py | 79 ++++++++++------ 3 files changed, 215 insertions(+), 156 deletions(-)
| 1 | """Detector configuration. | 1 | """Detector configuration for the guardrails point-cloud detector. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the iolabs point-cloud packages | 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | `guardrails.default.json` key for key: unknown keys are rejected and raw JSON / |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | `--set` values are coerced to the declared field types by the shared layer. |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | |
| 7 | applied through repeatable ``--set KEY=VALUE`` flags, never repo-local JSON. | 7 | Adding a config key means adding the field to the model and the same key to |
| 8 | ``config.py`` is the loader/schema: the pydantic model field set is the schema | 8 | `guardrails.default.json` — nothing else. Unknown keys are rejected. |
| 9 | and every field default is kept identical to ``guardrails.default.json`` | 9 | |
| 10 | (guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | 10 | `load_default_config_dict` returns a plain `dict`; `config_from_dict`, |
| 11 | 11 | `load_config` and `with_overrides` return the frozen `DetectorConfig`. | |
| 12 | To add a config key: add a field on :class:`DetectorConfig` and the matching | 12 | Runtime overrides come from repeatable `--set KEY=VALUE`, never repo-local JSON. |
| 13 | key/value on ``guardrails.default.json``. Nothing else. | ||
| 14 | """ | 13 | """ |
| 15 | 14 | ||
| 16 | from __future__ import annotations | 15 | from __future__ import annotations |
| 17 | 16 | ||
| 18 | import json | ||
| 19 | import logging | 17 | import logging |
| 20 | from pathlib import Path | ||
| 21 | from typing import Any | 18 | from typing import Any |
| 22 | 19 | ||
| 20 | import pydantic | ||
| 23 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 24 | 22 | ||
| 25 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 26 | 24 | ||
| 27 | _PACKAGE_NAME = "guardrails" | 25 | _PACKAGE_NAME = "guardrails" |
| 28 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | 26 | _DEFAULT_FILENAME = "guardrails.default.json" |
| 27 | _CONTEXT = "guardrails config" | ||
| 29 | 28 | ||
| 30 | 29 | ||
| 31 | class DetectorConfig(config_loader.ConfigModel): | 30 | class DetectorConfig(config_loader.ConfigModel): |
| 32 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" | 31 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" |
| 33 | 32 | ||
| 34 | # Ground model | 33 | # Ground model |
| 35 | ground_cell_m: float = 0.75 | 34 | ground_cell_m: float = pydantic.Field(0.75, gt=0) |
| 36 | ground_percentile: float = 8.0 | 35 | ground_percentile: float = pydantic.Field(8.0, ge=0, le=100) |
| 37 | 36 | ||
| 38 | # Corridor crop (station / offset frame) | 37 | # Corridor crop (station / offset frame) |
| 39 | corridor_offset_min_m: float = 1.5 | 38 | corridor_offset_min_m: float = pydantic.Field(1.5, ge=0) |
| 40 | corridor_offset_max_m: float = 10.0 | 39 | corridor_offset_max_m: float = pydantic.Field(10.0, gt=0) |
| 41 | corridor_include_median_zone: bool = True | 40 | corridor_include_median_zone: bool = True |
| 42 | median_corridor_offset_min_m: float = 0.8 | 41 | median_corridor_offset_min_m: float = pydantic.Field(0.8, ge=0) |
| 43 | median_corridor_offset_max_m: float = 3.8 | 42 | median_corridor_offset_max_m: float = pydantic.Field(3.8, gt=0) |
| 44 | corridor_max_height_m: float = 2.0 | 43 | corridor_max_height_m: float = pydantic.Field(2.0, gt=0) |
| 45 | station_window_m: float = 5.0 | 44 | station_window_m: float = pydantic.Field(5.0, gt=0) |
| 46 | median_side_max_offset_m: float = 3.5 | 45 | median_side_max_offset_m: float = pydantic.Field(3.5, ge=0) |
| 47 | 46 | ||
| 48 | # Occupancy grid for candidate cells | 47 | # Occupancy grid for candidate cells |
| 49 | occupancy_cell_m: float = 0.10 | 48 | occupancy_cell_m: float = pydantic.Field(0.10, gt=0) |
| 50 | 49 | ||
| 51 | # Height band for initial point candidates (also drives candidates overlay) | 50 | # Height band for initial point candidates (also drives candidates overlay) |
| 52 | min_height_m: float = 0.20 | 51 | min_height_m: float = pydantic.Field(0.20, ge=0) |
| 53 | max_height_m: float = 1.30 | 52 | max_height_m: float = pydantic.Field(1.30, gt=0) |
| 54 | 53 | ||
| 55 | # Per-cell rail-band fraction and mean-height gates | 54 | # Per-cell rail-band fraction and mean-height gates |
| 56 | rail_band_min_m: float = 0.35 | 55 | rail_band_min_m: float = pydantic.Field(0.35, ge=0) |
| 57 | rail_band_max_m: float = 0.85 | 56 | rail_band_max_m: float = pydantic.Field(0.85, gt=0) |
| 58 | min_cell_points: int = 3 | 57 | min_cell_points: int = pydantic.Field(3, ge=1) |
| 59 | min_rail_points: int = 2 | 58 | min_rail_points: int = pydantic.Field(2, ge=1) |
| 60 | min_rail_fraction: float = 0.40 | 59 | min_rail_fraction: float = pydantic.Field(0.40, ge=0, le=1) |
| 61 | min_mean_height_m: float = 0.42 | 60 | min_mean_height_m: float = pydantic.Field(0.42, ge=0) |
| 62 | max_mean_height_m: float = 0.78 | 61 | max_mean_height_m: float = pydantic.Field(0.78, gt=0) |
| 63 | 62 | ||
| 64 | # Vegetation rejection: compact height-above-ground spread within a cell | 63 | # Vegetation rejection: compact height-above-ground spread within a cell |
| 65 | max_cell_height_spread_m: float = 0.50 | 64 | max_cell_height_spread_m: float = pydantic.Field(0.50, ge=0) |
| 66 | 65 | ||
| 67 | # Tall-object fraction per cell (trees, poles) | 66 | # Tall-object fraction per cell (trees, poles) |
| 68 | tall_min_m: float = 1.30 | 67 | tall_min_m: float = pydantic.Field(1.30, ge=0) |
| 69 | tall_max_m: float = 4.50 | 68 | tall_max_m: float = pydantic.Field(4.50, gt=0) |
| 70 | max_tall_fraction: float = 0.12 | 69 | max_tall_fraction: float = pydantic.Field(0.12, ge=0, le=1) |
| 71 | 70 | ||
| 72 | # Local covariance / eigenvector candidate filter (cell-level) | 71 | # Local covariance / eigenvector candidate filter (cell-level) |
| 73 | eigen_neighborhood_radius_m: float = 0.40 | 72 | eigen_neighborhood_radius_m: float = pydantic.Field(0.40, gt=0) |
| 74 | eigen_min_neighbors: int = 5 | 73 | eigen_min_neighbors: int = pydantic.Field(5, ge=1) |
| 75 | min_linearity: float = 0.30 | 74 | min_linearity: float = pydantic.Field(0.30, ge=0, le=1) |
| 76 | min_verticality: float = 0.15 | 75 | min_verticality: float = pydantic.Field(0.15, ge=0, le=1) |
| 77 | use_eigen_cell_filter: bool = False | 76 | use_eigen_cell_filter: bool = False |
| 78 | 77 | ||
| 79 | # DBSCAN clustering on selected occupancy cells | 78 | # DBSCAN clustering on selected occupancy cells |
| 80 | cluster_eps_m: float = 0.20 | 79 | cluster_eps_m: float = pydantic.Field(0.20, gt=0) |
| 81 | cluster_min_samples: int = 3 | 80 | cluster_min_samples: int = pydantic.Field(3, ge=1) |
| 82 | 81 | ||
| 83 | # Post-cluster merge of collinear fragments | 82 | # Post-cluster merge of collinear fragments |
| 84 | merge_gap_m: float = 4.5 | 83 | merge_gap_m: float = pydantic.Field(4.5, ge=0) |
| 85 | merge_angle_deg: float = 15.0 | 84 | merge_angle_deg: float = pydantic.Field(15.0, ge=0, le=180) |
| 86 | merge_lateral_max_m: float = 0.50 | 85 | merge_lateral_max_m: float = pydantic.Field(0.50, ge=0) |
| 87 | 86 | ||
| 88 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | 87 | # Occlusion bridging: join collinear fragments across a parked-vehicle / |
| 89 | # occlusion shadow when heading and offset stay continuous (defect 4). The | 88 | # occlusion shadow when heading and offset stay continuous (defect 4). The |
| 90 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | 89 | # bridged station interval is recorded in ``gap_spans`` (never interpolated |
| 91 | # silently). | 90 | # silently). |
| 92 | # Default is conservative (8 m) so bridging never fuses two distinct | 91 | # Default is conservative (8 m) so bridging never fuses two distinct |
| 93 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | 92 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for |
| 94 | # datasets with longer occlusion shadows. | 93 | # datasets with longer occlusion shadows. |
| 95 | occlusion_bridge_max_m: float = 8.0 | 94 | occlusion_bridge_max_m: float = pydantic.Field(8.0, ge=0) |
| 96 | occlusion_bridge_max_angle_deg: float = 4.0 | 95 | occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0, le=180) |
| 97 | occlusion_bridge_max_lateral_m: float = 0.40 | 96 | occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0) |
| 98 | 97 | ||
| 99 | # Parallel-face deduplication (two faces of one physical rail). | 98 | # Parallel-face deduplication (two faces of one physical rail). |
| 100 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | 99 | # ``dedupe_*`` are retained for backward compatibility; the active policy is |
| 101 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | 100 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). |
| 102 | dedupe_face_max_sep_m: float = 1.0 | 101 | dedupe_face_max_sep_m: float = pydantic.Field(1.0, ge=0) |
| 103 | dedupe_max_angle_deg: float = 12.0 | 102 | dedupe_max_angle_deg: float = pydantic.Field(12.0, ge=0, le=180) |
| 104 | merge_face_max_spacing_m: float = 1.3 | 103 | merge_face_max_spacing_m: float = pydantic.Field(1.3, ge=0) |
| 105 | merge_face_max_heading_deg: float = 5.0 | 104 | merge_face_max_heading_deg: float = pydantic.Field(5.0, ge=0, le=180) |
| 106 | merge_face_min_station_overlap: float = 0.5 | 105 | merge_face_min_station_overlap: float = pydantic.Field(0.5, ge=0, le=1) |
| 107 | merge_face_max_faces: int = 2 | 106 | merge_face_max_faces: int = pydantic.Field(2, ge=1) |
| 108 | 107 | ||
| 109 | # Instance acceptance (applied after merge) | 108 | # Instance acceptance (applied after merge) |
| 110 | min_length_m: float = 12.0 | 109 | min_length_m: float = pydantic.Field(12.0, ge=0) |
| 111 | max_local_width_m: float = 0.75 | 110 | max_local_width_m: float = pydantic.Field(0.75, gt=0) |
| 112 | min_longitudinal_coverage: float = 0.35 | 111 | min_longitudinal_coverage: float = pydantic.Field(0.35, ge=0, le=1) |
| 113 | 112 | ||
| 114 | # Ordered-walk polyline construction | 113 | # Ordered-walk polyline construction |
| 115 | polyline_bin_m: float = 1.0 | 114 | polyline_bin_m: float = pydantic.Field(1.0, gt=0) |
| 116 | polyline_smooth_window: int = 5 | 115 | polyline_smooth_window: int = pydantic.Field(5, ge=1) |
| 117 | walk_max_step_m: float = 0.30 | 116 | walk_max_step_m: float = pydantic.Field(0.30, gt=0) |
| 118 | 117 | ||
| 119 | # Gap recording along station | 118 | # Gap recording along station |
| 120 | gap_min_span_m: float = 2.0 | 119 | gap_min_span_m: float = pydantic.Field(2.0, ge=0) |
| 121 | 120 | ||
| 122 | # Vehicle / occlusion-shadow rejection on cluster height distribution | 121 | # Vehicle / occlusion-shadow rejection on cluster height distribution |
| 123 | max_cluster_height_spread_m: float = 0.80 | 122 | max_cluster_height_spread_m: float = pydantic.Field(0.80, ge=0) |
| 124 | max_cluster_p95_height_m: float = 1.15 | 123 | max_cluster_p95_height_m: float = pydantic.Field(1.15, ge=0) |
| 125 | 124 | ||
| 126 | # Straightness check along sliding window (short clusters only) | 125 | # Straightness check along sliding window (short clusters only) |
| 127 | straightness_window_m: float = 10.0 | 126 | straightness_window_m: float = pydantic.Field(10.0, gt=0) |
| 128 | max_straightness_deviation_m: float = 0.50 | 127 | max_straightness_deviation_m: float = pydantic.Field(0.50, ge=0) |
| 129 | straightness_max_length_m: float = 25.0 | 128 | straightness_max_length_m: float = pydantic.Field(25.0, ge=0) |
| 130 | 129 | ||
| 131 | # Heuristic type classification thresholds | 130 | # Heuristic type classification thresholds |
| 132 | w_beam_min_height_m: float = 0.40 | 131 | w_beam_min_height_m: float = pydantic.Field(0.40, ge=0) |
| 133 | w_beam_max_height_m: float = 0.90 | 132 | w_beam_max_height_m: float = pydantic.Field(0.90, gt=0) |
| 134 | w_beam_max_height_spread_m: float = 0.55 | 133 | w_beam_max_height_spread_m: float = pydantic.Field(0.55, ge=0) |
| 135 | concrete_min_height_m: float = 0.80 | 134 | concrete_min_height_m: float = pydantic.Field(0.80, ge=0) |
| 136 | concrete_max_height_spread_m: float = 0.45 | 135 | concrete_max_height_spread_m: float = pydantic.Field(0.45, ge=0) |
| 137 | cable_suspect_max_spread_m: float = 0.25 | 136 | cable_suspect_max_spread_m: float = pydantic.Field(0.25, ge=0) |
| 138 | 137 | ||
| 139 | # Per-run confidence heuristic (0-1); see README "Run confidence". | 138 | # Per-run confidence heuristic (0-1); see README "Run confidence". |
| 140 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | 139 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height |
| 141 | confidence_density_norm_pts_per_m: float = 500.0 | 140 | confidence_density_norm_pts_per_m: float = pydantic.Field(500.0, gt=0) |
| 142 | confidence_full_extent_m: float = 40.0 | 141 | confidence_full_extent_m: float = pydantic.Field(40.0, gt=0) |
| 143 | confidence_max_height_std_m: float = 0.2 | 142 | confidence_max_height_std_m: float = pydantic.Field(0.2, gt=0) |
| 144 | 143 | ||
| 145 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | 144 | # Memory hardening (deployment target is a 32 GB RAM Azure node). |
| 146 | memory_budget_gb: float = 10.0 | 145 | memory_budget_gb: float = pydantic.Field(10.0, gt=0) |
| 147 | station_process_window_m: float = 5.0 | 146 | station_process_window_m: float = pydantic.Field(5.0, gt=0) |
| 148 | decimation_enabled: bool = False | 147 | decimation_enabled: bool = False |
| 149 | decimation_voxel_m: float = 0.05 | 148 | decimation_voxel_m: float = pydantic.Field(0.05, gt=0) |
| 150 | decimation_density_cap: int = 400000 | 149 | decimation_density_cap: int = pydantic.Field(400000, ge=1) |
| 151 | # Records larger than this stream through the corridor crop in chunks of | 150 | # Records larger than this stream through the corridor crop in chunks of |
| 152 | # this many points instead of being materialized whole (byte-identical | 151 | # this many points instead of being materialized whole (byte-identical |
| 153 | # results for records at or below the threshold, which use the old path). | 152 | # results for records at or below the threshold, which use the old path). |
| 154 | record_chunk_points: int = 4000000 | 153 | record_chunk_points: int = pydantic.Field(4000000, ge=1) |
| 155 | # Exclusion clustering guard: DBSCAN memory scales with the number of | 154 | # Exclusion clustering guard: DBSCAN memory scales with the number of |
| 156 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | 155 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds |
| 157 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | 156 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger |
| 158 | # only; sparse segments are untouched). segment_134's dense record | 157 | # only; sparse segments are untouched). segment_134's dense record |
| 159 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | 158 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. |
| 160 | exclusion_pair_estimate_max: float = 1000000000.0 | 159 | exclusion_pair_estimate_max: float = pydantic.Field(1000000000.0, gt=0) |
| 161 | exclusion_decimation_cell_m: float = 0.10 | 160 | exclusion_decimation_cell_m: float = pydantic.Field(0.10, gt=0) |
| 162 | # After the density trigger decimates, the residual DBSCAN runs under the | 161 | # After the density trigger decimates, the residual DBSCAN runs under the |
| 163 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | 162 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS |
| 164 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | 163 | # monitor, hard kill above the limit) as a second line of defense. Mirrors |
| 165 | # the subcluster_dbscan_memory_guard wiring in | 164 | # the subcluster_dbscan_memory_guard wiring in |
| 166 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | 165 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. |
| 167 | exclusion_use_shared_watchdog: bool = True | 166 | exclusion_use_shared_watchdog: bool = True |
| 168 | exclusion_dbscan_mem_limit_gb: float = 6.0 | 167 | exclusion_dbscan_mem_limit_gb: float = pydantic.Field(6.0, gt=0) |
| 169 | exclusion_dbscan_timeout_s: float = 120.0 | 168 | exclusion_dbscan_timeout_s: float = pydantic.Field(120.0, gt=0) |
| 170 | 169 | ||
| 171 | 170 | ||
| 172 | class DetectorConfigError(config_loader.ConfigError): | 171 | class DetectorConfigError(config_loader.ConfigError): |
| 173 | """Raised when the guardrails config holds unknown keys or invalid values.""" | 172 | """Raised when guardrails config contains unsupported keys or values.""" |
| 174 | |||
| 175 | |||
| 176 | def _default_config_path() -> Path: | ||
| 177 | if __package__ in {None, ""}: | ||
| 178 | return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME) | ||
| 179 | return config_loader.default_config_path(__package__, _DEFAULT_CONFIG_NAME) | ||
| 180 | 173 | ||
| 181 | 174 | ||
| 182 | def load_default_config_dict() -> dict[str, Any]: | 175 | def load_default_config_dict() -> dict[str, Any]: |
| 183 | """Return the package-owned default config as a plain dict.""" | 176 | """Return the package-owned default config as a plain dict. |
| 184 | if __package__ in {None, ""}: | 177 | |
| 185 | with _default_config_path().open("r", encoding="utf-8") as handle: | 178 | Returns: |
| 186 | return json.load(handle) | 179 | The decoded ``guardrails.default.json`` object. |
| 187 | return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME) | 180 | """ |
| 181 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) | ||
| 188 | 182 | ||
| 189 | 183 | ||
| 190 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 184 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: |
| 191 | """Build a validated :class:`DetectorConfig` from a raw mapping.""" | 185 | """Build a validated :class:`DetectorConfig` from a raw mapping. |
| 186 | |||
| 187 | Args: | ||
| 188 | raw: Merged config mapping (packaged defaults plus overrides). | ||
| 189 | |||
| 190 | Returns: | ||
| 191 | The validated config. | ||
| 192 | |||
| 193 | Raises: | ||
| 194 | DetectorConfigError: ``raw`` holds an unknown key or a value outside | ||
| 195 | its declared type/range. | ||
| 196 | """ | ||
| 192 | return config_loader.validate_config( | 197 | return config_loader.validate_config( |
| 193 | DetectorConfig, | 198 | DetectorConfig, |
| 194 | raw, | 199 | raw, |
| 195 | context="guardrails config", | 200 | context=_CONTEXT, |
| 196 | error_cls=DetectorConfigError, | 201 | error_cls=DetectorConfigError, |
| 197 | ) | 202 | ) |
| 198 | 203 | ||
| 199 | 204 | ||
| 200 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 205 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: |
| 201 | """Load the default config and apply flat ``PATH=VALUE`` overrides. | 206 | """Load the packaged default config and apply flat ``KEY=VALUE`` overrides. |
| 202 | 207 | ||
| 203 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 208 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 209 | |||
| 210 | Args: | ||
| 211 | overrides: Flat mapping of config key to value, or ``None``. | ||
| 212 | |||
| 213 | Returns: | ||
| 214 | The validated config. | ||
| 215 | |||
| 216 | Raises: | ||
| 217 | DetectorConfigError: An override names an unknown key or holds a value | ||
| 218 | outside its declared type/range. | ||
| 204 | """ | 219 | """ |
| 205 | config_path = None | ||
| 206 | if __package__ in {None, ""}: | ||
| 207 | config_path = Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME) | ||
| 208 | config = config_loader.load_config( | 220 | config = config_loader.load_config( |
| 209 | DetectorConfig, | 221 | DetectorConfig, |
| 210 | package=__package__ or _PACKAGE_NAME, | 222 | package=_PACKAGE_NAME, |
| 211 | filename=_DEFAULT_CONFIG_NAME, | 223 | filename=_DEFAULT_FILENAME, |
| 212 | overrides=overrides, | 224 | overrides=overrides, |
| 213 | config_path=config_path, | 225 | context=_CONTEXT, |
| 214 | context="guardrails config", | ||
| 215 | error_cls=DetectorConfigError, | 226 | error_cls=DetectorConfigError, |
| 216 | ) | 227 | ) |
| 217 | if overrides: | 228 | if overrides: |
| 218 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 229 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 219 | return config | 230 | return config |
| 220 | 231 | ||
| 221 | 232 | ||
| 233 | def with_overrides(config: DetectorConfig, updates: dict[str, Any]) -> DetectorConfig: | ||
| 234 | """Return a re-validated copy of *config* with *updates* applied. | ||
| 235 | |||
| 236 | Unlike ``model_copy(update=...)``, which writes raw values straight into the | ||
| 237 | copy, this rebuilds the model, so unknown keys, wrong types and out-of-range | ||
| 238 | values are rejected exactly as they are on load. | ||
| 239 | |||
| 240 | Args: | ||
| 241 | config: The config to derive from; never mutated (frozen model). | ||
| 242 | updates: Field name to new value. | ||
| 243 | |||
| 244 | Returns: | ||
| 245 | A validated copy carrying *updates*. | ||
| 246 | |||
| 247 | Raises: | ||
| 248 | DetectorConfigError: *updates* names an unknown field or holds a value | ||
| 249 | outside its declared type/range. | ||
| 250 | """ | ||
| 251 | return config_from_dict({**config.model_dump(), **updates}) | ||
| 252 | |||
| 253 | |||
| 222 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 254 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 223 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value.""" | 255 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. |
| 256 | |||
| 257 | Args: | ||
| 258 | raw_overrides: Raw ``KEY=VALUE`` strings from the CLI, or ``None``. | ||
| 259 | |||
| 260 | Returns: | ||
| 261 | A flat mapping of config key to decoded value; later duplicates win. | ||
| 262 | |||
| 263 | Raises: | ||
| 264 | DetectorConfigError: An override is missing its ``=``. | ||
| 265 | """ | ||
| 224 | return config_loader.parse_set_overrides( | 266 | return config_loader.parse_set_overrides( |
| 225 | raw_overrides, | 267 | raw_overrides, |
| 226 | error_cls=DetectorConfigError, | 268 | error_cls=DetectorConfigError, |
| 227 | ) | 269 | ) |
| 1 | """Config schema/loader tests: JSON parity, rejection, overrides, coercion.""" | ||
| 2 | |||
| 1 | import pydantic | 3 | import pydantic |
| 2 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 3 | 6 | ||
| 4 | from guardrails import config as config_module | 7 | from guardrails import config as config_module |
| 5 | 8 | ||
| 6 | 9 | ||
| 7 | def test_default_json_matches_model_defaults() -> None: | 10 | def test_model_defaults_match_packaged_json() -> None: |
| 8 | """guardrails.default.json is the schema source of truth; keep it in sync.""" | 11 | """guardrails.default.json is the schema mirror; compare the whole dict.""" |
| 9 | defaults = config_module.DetectorConfig().model_dump() | 12 | assert config_module.load_default_config_dict() == ( |
| 10 | json_config = config_module.load_default_config_dict() | 13 | config_module.DetectorConfig().model_dump() |
| 11 | assert set(json_config) == set(defaults) | 14 | ) |
| 12 | for key, value in defaults.items(): | ||
| 13 | assert json_config[key] == value, key | ||
| 14 | 15 | ||
| 15 | 16 | ||
| 16 | def test_load_config_without_overrides_equals_defaults() -> None: | 17 | def test_load_config_returns_packaged_defaults() -> None: |
| 17 | assert config_module.load_config() == config_module.DetectorConfig() | 18 | assert config_module.load_config() == config_module.DetectorConfig() |
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | def test_parse_set_overrides_json_decodes_values() -> None: | 21 | def test_error_class_is_config_error() -> None: |
| 21 | parsed = config_module.parse_set_overrides( | 22 | assert issubclass(config_module.DetectorConfigError, config_loader.ConfigError) |
| 22 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] | 23 | assert issubclass(config_module.DetectorConfigError, ValueError) |
| 23 | ) | ||
| 24 | assert parsed == { | ||
| 25 | "merge_face_max_spacing_m": 1.5, | ||
| 26 | "decimation_enabled": True, | ||
| 27 | "memory_budget_gb": 8, | ||
| 28 | } | ||
| 29 | 24 | ||
| 30 | 25 | ||
| 31 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 26 | def test_unknown_top_level_key_is_rejected() -> None: |
| 27 | with pytest.raises(config_module.DetectorConfigError, match="not_a_key"): | ||
| 28 | config_module.config_from_dict( | ||
| 29 | {**config_module.load_default_config_dict(), "not_a_key": 1} | ||
| 30 | ) | ||
| 31 | |||
| 32 | |||
| 33 | def test_out_of_range_value_is_rejected() -> None: | ||
| 34 | with pytest.raises(config_module.DetectorConfigError, match="min_rail_fraction"): | ||
| 35 | config_module.load_config({"min_rail_fraction": 1.5}) | ||
| 36 | |||
| 37 | |||
| 38 | def test_overrides_apply_onto_defaults() -> None: | ||
| 32 | config = config_module.load_config( | 39 | config = config_module.load_config( |
| 33 | {"decimation_enabled": "true", "merge_face_max_faces": 3} | 40 | {"decimation_enabled": "true", "merge_face_max_faces": 3} |
| 34 | ) | 41 | ) |
| 35 | assert config.decimation_enabled is True | 42 | assert config.decimation_enabled is True |
| 36 | assert config.merge_face_max_faces == 3 | 43 | assert config.merge_face_max_faces == 3 |
| 44 | assert config.merge_gap_m == config_module.DetectorConfig().merge_gap_m | ||
| 37 | 45 | ||
| 38 | 46 | ||
| 39 | def test_unknown_key_rejected() -> None: | 47 | def test_set_override_coercion_and_rejection() -> None: |
| 48 | parsed = config_module.parse_set_overrides( | ||
| 49 | [ | ||
| 50 | "merge_face_max_spacing_m=1.5", | ||
| 51 | "decimation_enabled=true", | ||
| 52 | "memory_budget_gb=8", | ||
| 53 | ] | ||
| 54 | ) | ||
| 55 | assert parsed == { | ||
| 56 | "merge_face_max_spacing_m": 1.5, | ||
| 57 | "decimation_enabled": True, | ||
| 58 | "memory_budget_gb": 8, | ||
| 59 | } | ||
| 40 | with pytest.raises(config_module.DetectorConfigError): | 60 | with pytest.raises(config_module.DetectorConfigError): |
| 41 | config_module.config_from_dict( | 61 | config_module.parse_set_overrides(["missing_equals_sign"]) |
| 42 | {**config_module.load_default_config_dict(), "not_a_key": 1} | ||
| 43 | ) | ||
| 44 | |||
| 45 | |||
| 46 | def test_invalid_value_rejected() -> None: | ||
| 47 | with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"): | 62 | with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"): |
| 48 | config_module.load_config({"merge_face_max_faces": "abc"}) | 63 | config_module.load_config({"merge_face_max_faces": "abc"}) |
| 49 | 64 | ||
| 50 | 65 | ||
| 66 | def test_with_overrides_revalidates_updates() -> None: | ||
| 67 | """with_overrides() rejects what load_config() rejects, unlike model_copy().""" | ||
| 68 | config = config_module.load_config() | ||
| 69 | assert config_module.with_overrides(config, {"merge_gap_m": 3.0}).merge_gap_m == 3.0 | ||
| 70 | assert config.merge_gap_m != 3.0 | ||
| 71 | with pytest.raises(config_module.DetectorConfigError): | ||
| 72 | config_module.with_overrides(config, {"not_a_key": 1}) | ||
| 73 | |||
| 74 | |||
| 51 | def test_config_is_frozen() -> None: | 75 | def test_config_is_frozen() -> None: |
| 52 | config = config_module.DetectorConfig() | 76 | config = config_module.DetectorConfig() |
| 53 | with pytest.raises(pydantic.ValidationError): | 77 | with pytest.raises(pydantic.ValidationError): |
| 54 | config.merge_gap_m = 1.0 | 78 | config.merge_gap_m = 1.0 |
| 55 | |||
| 56 | |||
| 57 | def test_invalid_override_string_rejected() -> None: | ||
| 58 | with pytest.raises(config_module.DetectorConfigError): | ||
| 59 | config_module.parse_set_overrides(["missing_equals_sign"]) |
| 18 | Each segment directory contains `guardrails.json`, RGB and intensity overlays, | 18 | Each segment directory contains `guardrails.json`, RGB and intensity overlays, |
| 19 | and a red candidate-mask diagnostic. `out/run_summary.json` records per-segment | 19 | and a red candidate-mask diagnostic. `out/run_summary.json` records per-segment |
| 20 | timings, alignment checks and peak RSS. | 20 | timings, alignment checks and peak RSS. |
| 21 | 21 | ||
| 22 | ## Configuration (iolabs convention) | 22 | ## Configuration |
| 23 | 23 | ||
| 24 | Following the other iolabs point-cloud packages | 24 | Defaults live in `guardrails/guardrails.default.json`. The schema is |
| 25 | (`iolabs_point_cloud_segmentation_trajectory` etc.), the package owns an | 25 | `DetectorConfig` in `guardrails/config.py` (a `config_loader.ConfigModel`); |
| 26 | algorithm config `guardrails/guardrails.default.json`. `guardrails/config.py` is | 26 | unknown keys are rejected and field ranges are declared with `Field(ge=..., ...)`. |
| 27 | the loader/schema: the frozen pydantic `DetectorConfig` model (derived from | 27 | **To add a config key: add the field (with its type, default and any `Field` |
| 28 | `iolabs.common.config_loader.ConfigModel`) is the typed params object and its | 28 | range) to the model and the same key with the same default to the JSON — nothing |
| 29 | field set is the schema. Every model default is kept identical to the JSON | 29 | else.** `load_default_config_dict()` returns a plain `dict`; `config_from_dict()`, |
| 30 | (asserted by `tests/test_config.py`). To add a config key, add a field on | 30 | `load_config()` and `with_overrides()` return the frozen `DetectorConfig`. |
| 31 | `DetectorConfig` and the matching default in the JSON; nothing else. | 31 | Runtime overrides come from repeatable `--set KEY=VALUE` (values are |
| 32 | 32 | JSON-decoded), never repo-local JSON: | |
| 33 | Runtime overrides use the repeatable `--set KEY=VALUE` CLI flag (values are | ||
| 34 | JSON-decoded), never repo-local JSON files: | ||
| 35 | 33 | ||
| 36 | ```bash | 34 | ```bash |
| 37 | --set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=15 | 35 | --set decimation_enabled=true --set memory_budget_gb=8 --set occlusion_bridge_max_m=15 |
| 38 | ``` | 36 | ``` |
| 39 | 37 | ||
| 40 | Logging mirrors those packages: `logging.getLogger(__name__)` with INFO progress | 38 | Logging mirrors the other iolabs packages: `logging.getLogger(__name__)` with |
| 41 | per stage (ground DEM, per-record corridor candidates, occupancy clustering, XML | 39 | INFO progress per stage (ground DEM, per-record corridor candidates, occupancy |
| 42 | export, per-segment completion with peak RSS). | 40 | clustering, XML export, per-segment completion with peak RSS). |
| 43 | 41 | ||
| 44 | ## Face / barrier merge policy | 42 | ## Face / barrier merge policy |
| 45 | 43 | ||
| 46 | A single physical rail (e.g. a W-beam) presents up to two near-parallel faces | 44 | A single physical rail (e.g. a W-beam) presents up to two near-parallel faces |
| 1 | """Detector configuration. | 1 | """Detector configuration for the guardrails point-cloud detector. |
| 2 | 2 | ||
| 3 | Mirrors the config convention used by the iolabs point-cloud packages | 3 | The schema is `DetectorConfig` (a `config_loader.ConfigModel`), mirroring |
| 4 | (``iolabs_point_cloud_segmentation_trajectory`` etc.): the package owns a | 4 | `guardrails.default.json` key for key: unknown keys are rejected and raw JSON / |
| 5 | ``guardrails.default.json`` algorithm config, and a typed params object | 5 | `--set` values are coerced to the declared field types by the shared layer. |
| 6 | (:class:`DetectorConfig`) is loaded from it at CLI start. Runtime overrides are | 6 | |
| 7 | applied through repeatable ``--set KEY=VALUE`` flags, never repo-local JSON. | 7 | Adding a config key means adding the field to the model and the same key to |
| 8 | ``config.py`` is the loader/schema: the pydantic model field set is the schema | 8 | `guardrails.default.json` — nothing else. Unknown keys are rejected. |
| 9 | and every field default is kept identical to ``guardrails.default.json`` | 9 | |
| 10 | (guarded by a unit test), so ``DetectorConfig()`` and ``load_config()`` agree. | 10 | `load_default_config_dict` returns a plain `dict`; `config_from_dict`, |
| 11 | 11 | `load_config` and `with_overrides` return the frozen `DetectorConfig`. | |
| 12 | To add a config key: add a field on :class:`DetectorConfig` and the matching | 12 | Runtime overrides come from repeatable `--set KEY=VALUE`, never repo-local JSON. |
| 13 | key/value on ``guardrails.default.json``. Nothing else. | ||
| 14 | """ | 13 | """ |
| 15 | 14 | ||
| 16 | from __future__ import annotations | 15 | from __future__ import annotations |
| 17 | 16 | ||
| 18 | import json | ||
| 19 | import logging | 17 | import logging |
| 20 | from pathlib import Path | ||
| 21 | from typing import Any | 18 | from typing import Any |
| 22 | 19 | ||
| 20 | import pydantic | ||
| 23 | from iolabs.common import config_loader | 21 | from iolabs.common import config_loader |
| 24 | 22 | ||
| 25 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 26 | 24 | ||
| 27 | _PACKAGE_NAME = "guardrails" | 25 | _PACKAGE_NAME = "guardrails" |
| 28 | _DEFAULT_CONFIG_NAME = "guardrails.default.json" | 26 | _DEFAULT_FILENAME = "guardrails.default.json" |
| 27 | _CONTEXT = "guardrails config" | ||
| 29 | 28 | ||
| 30 | 29 | ||
| 31 | class DetectorConfig(config_loader.ConfigModel): | 30 | class DetectorConfig(config_loader.ConfigModel): |
| 32 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" | 31 | """Spatial and geometric thresholds, in metres unless stated otherwise.""" |
| 33 | 32 | ||
| 34 | # Ground model | 33 | # Ground model |
| 35 | ground_cell_m: float = 0.75 | 34 | ground_cell_m: float = pydantic.Field(0.75, gt=0) |
| 36 | ground_percentile: float = 8.0 | 35 | ground_percentile: float = pydantic.Field(8.0, ge=0, le=100) |
| 37 | 36 | ||
| 38 | # Corridor crop (station / offset frame) | 37 | # Corridor crop (station / offset frame) |
| 39 | corridor_offset_min_m: float = 1.5 | 38 | corridor_offset_min_m: float = pydantic.Field(1.5, ge=0) |
| 40 | corridor_offset_max_m: float = 10.0 | 39 | corridor_offset_max_m: float = pydantic.Field(10.0, gt=0) |
| 41 | corridor_include_median_zone: bool = True | 40 | corridor_include_median_zone: bool = True |
| 42 | median_corridor_offset_min_m: float = 0.8 | 41 | median_corridor_offset_min_m: float = pydantic.Field(0.8, ge=0) |
| 43 | median_corridor_offset_max_m: float = 3.8 | 42 | median_corridor_offset_max_m: float = pydantic.Field(3.8, gt=0) |
| 44 | corridor_max_height_m: float = 2.0 | 43 | corridor_max_height_m: float = pydantic.Field(2.0, gt=0) |
| 45 | station_window_m: float = 5.0 | 44 | station_window_m: float = pydantic.Field(5.0, gt=0) |
| 46 | median_side_max_offset_m: float = 3.5 | 45 | median_side_max_offset_m: float = pydantic.Field(3.5, ge=0) |
| 47 | 46 | ||
| 48 | # Occupancy grid for candidate cells | 47 | # Occupancy grid for candidate cells |
| 49 | occupancy_cell_m: float = 0.10 | 48 | occupancy_cell_m: float = pydantic.Field(0.10, gt=0) |
| 50 | 49 | ||
| 51 | # Height band for initial point candidates (also drives candidates overlay) | 50 | # Height band for initial point candidates (also drives candidates overlay) |
| 52 | min_height_m: float = 0.20 | 51 | min_height_m: float = pydantic.Field(0.20, ge=0) |
| 53 | max_height_m: float = 1.30 | 52 | max_height_m: float = pydantic.Field(1.30, gt=0) |
| 54 | 53 | ||
| 55 | # Per-cell rail-band fraction and mean-height gates | 54 | # Per-cell rail-band fraction and mean-height gates |
| 56 | rail_band_min_m: float = 0.35 | 55 | rail_band_min_m: float = pydantic.Field(0.35, ge=0) |
| 57 | rail_band_max_m: float = 0.85 | 56 | rail_band_max_m: float = pydantic.Field(0.85, gt=0) |
| 58 | min_cell_points: int = 3 | 57 | min_cell_points: int = pydantic.Field(3, ge=1) |
| 59 | min_rail_points: int = 2 | 58 | min_rail_points: int = pydantic.Field(2, ge=1) |
| 60 | min_rail_fraction: float = 0.40 | 59 | min_rail_fraction: float = pydantic.Field(0.40, ge=0, le=1) |
| 61 | min_mean_height_m: float = 0.42 | 60 | min_mean_height_m: float = pydantic.Field(0.42, ge=0) |
| 62 | max_mean_height_m: float = 0.78 | 61 | max_mean_height_m: float = pydantic.Field(0.78, gt=0) |
| 63 | 62 | ||
| 64 | # Vegetation rejection: compact height-above-ground spread within a cell | 63 | # Vegetation rejection: compact height-above-ground spread within a cell |
| 65 | max_cell_height_spread_m: float = 0.50 | 64 | max_cell_height_spread_m: float = pydantic.Field(0.50, ge=0) |
| 66 | 65 | ||
| 67 | # Tall-object fraction per cell (trees, poles) | 66 | # Tall-object fraction per cell (trees, poles) |
| 68 | tall_min_m: float = 1.30 | 67 | tall_min_m: float = pydantic.Field(1.30, ge=0) |
| 69 | tall_max_m: float = 4.50 | 68 | tall_max_m: float = pydantic.Field(4.50, gt=0) |
| 70 | max_tall_fraction: float = 0.12 | 69 | max_tall_fraction: float = pydantic.Field(0.12, ge=0, le=1) |
| 71 | 70 | ||
| 72 | # Local covariance / eigenvector candidate filter (cell-level) | 71 | # Local covariance / eigenvector candidate filter (cell-level) |
| 73 | eigen_neighborhood_radius_m: float = 0.40 | 72 | eigen_neighborhood_radius_m: float = pydantic.Field(0.40, gt=0) |
| 74 | eigen_min_neighbors: int = 5 | 73 | eigen_min_neighbors: int = pydantic.Field(5, ge=1) |
| 75 | min_linearity: float = 0.30 | 74 | min_linearity: float = pydantic.Field(0.30, ge=0, le=1) |
| 76 | min_verticality: float = 0.15 | 75 | min_verticality: float = pydantic.Field(0.15, ge=0, le=1) |
| 77 | use_eigen_cell_filter: bool = False | 76 | use_eigen_cell_filter: bool = False |
| 78 | 77 | ||
| 79 | # DBSCAN clustering on selected occupancy cells | 78 | # DBSCAN clustering on selected occupancy cells |
| 80 | cluster_eps_m: float = 0.20 | 79 | cluster_eps_m: float = pydantic.Field(0.20, gt=0) |
| 81 | cluster_min_samples: int = 3 | 80 | cluster_min_samples: int = pydantic.Field(3, ge=1) |
| 82 | 81 | ||
| 83 | # Post-cluster merge of collinear fragments | 82 | # Post-cluster merge of collinear fragments |
| 84 | merge_gap_m: float = 4.5 | 83 | merge_gap_m: float = pydantic.Field(4.5, ge=0) |
| 85 | merge_angle_deg: float = 15.0 | 84 | merge_angle_deg: float = pydantic.Field(15.0, ge=0, le=180) |
| 86 | merge_lateral_max_m: float = 0.50 | 85 | merge_lateral_max_m: float = pydantic.Field(0.50, ge=0) |
| 87 | 86 | ||
| 88 | # Occlusion bridging: join collinear fragments across a parked-vehicle / | 87 | # Occlusion bridging: join collinear fragments across a parked-vehicle / |
| 89 | # occlusion shadow when heading and offset stay continuous (defect 4). The | 88 | # occlusion shadow when heading and offset stay continuous (defect 4). The |
| 90 | # bridged station interval is recorded in ``gap_spans`` (never interpolated | 89 | # bridged station interval is recorded in ``gap_spans`` (never interpolated |
| 91 | # silently). | 90 | # silently). |
| 92 | # Default is conservative (8 m) so bridging never fuses two distinct | 91 | # Default is conservative (8 m) so bridging never fuses two distinct |
| 93 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for | 92 | # barriers into one instance; raise via --set occlusion_bridge_max_m=15 for |
| 94 | # datasets with longer occlusion shadows. | 93 | # datasets with longer occlusion shadows. |
| 95 | occlusion_bridge_max_m: float = 8.0 | 94 | occlusion_bridge_max_m: float = pydantic.Field(8.0, ge=0) |
| 96 | occlusion_bridge_max_angle_deg: float = 4.0 | 95 | occlusion_bridge_max_angle_deg: float = pydantic.Field(4.0, ge=0, le=180) |
| 97 | occlusion_bridge_max_lateral_m: float = 0.40 | 96 | occlusion_bridge_max_lateral_m: float = pydantic.Field(0.40, ge=0) |
| 98 | 97 | ||
| 99 | # Parallel-face deduplication (two faces of one physical rail). | 98 | # Parallel-face deduplication (two faces of one physical rail). |
| 100 | # ``dedupe_*`` are retained for backward compatibility; the active policy is | 99 | # ``dedupe_*`` are retained for backward compatibility; the active policy is |
| 101 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). | 100 | # driven by ``merge_face_*`` (see README "Face / barrier merge policy"). |
| 102 | dedupe_face_max_sep_m: float = 1.0 | 101 | dedupe_face_max_sep_m: float = pydantic.Field(1.0, ge=0) |
| 103 | dedupe_max_angle_deg: float = 12.0 | 102 | dedupe_max_angle_deg: float = pydantic.Field(12.0, ge=0, le=180) |
| 104 | merge_face_max_spacing_m: float = 1.3 | 103 | merge_face_max_spacing_m: float = pydantic.Field(1.3, ge=0) |
| 105 | merge_face_max_heading_deg: float = 5.0 | 104 | merge_face_max_heading_deg: float = pydantic.Field(5.0, ge=0, le=180) |
| 106 | merge_face_min_station_overlap: float = 0.5 | 105 | merge_face_min_station_overlap: float = pydantic.Field(0.5, ge=0, le=1) |
| 107 | merge_face_max_faces: int = 2 | 106 | merge_face_max_faces: int = pydantic.Field(2, ge=1) |
| 108 | 107 | ||
| 109 | # Instance acceptance (applied after merge) | 108 | # Instance acceptance (applied after merge) |
| 110 | min_length_m: float = 12.0 | 109 | min_length_m: float = pydantic.Field(12.0, ge=0) |
| 111 | max_local_width_m: float = 0.75 | 110 | max_local_width_m: float = pydantic.Field(0.75, gt=0) |
| 112 | min_longitudinal_coverage: float = 0.35 | 111 | min_longitudinal_coverage: float = pydantic.Field(0.35, ge=0, le=1) |
| 113 | 112 | ||
| 114 | # Ordered-walk polyline construction | 113 | # Ordered-walk polyline construction |
| 115 | polyline_bin_m: float = 1.0 | 114 | polyline_bin_m: float = pydantic.Field(1.0, gt=0) |
| 116 | polyline_smooth_window: int = 5 | 115 | polyline_smooth_window: int = pydantic.Field(5, ge=1) |
| 117 | walk_max_step_m: float = 0.30 | 116 | walk_max_step_m: float = pydantic.Field(0.30, gt=0) |
| 118 | 117 | ||
| 119 | # Gap recording along station | 118 | # Gap recording along station |
| 120 | gap_min_span_m: float = 2.0 | 119 | gap_min_span_m: float = pydantic.Field(2.0, ge=0) |
| 121 | 120 | ||
| 122 | # Vehicle / occlusion-shadow rejection on cluster height distribution | 121 | # Vehicle / occlusion-shadow rejection on cluster height distribution |
| 123 | max_cluster_height_spread_m: float = 0.80 | 122 | max_cluster_height_spread_m: float = pydantic.Field(0.80, ge=0) |
| 124 | max_cluster_p95_height_m: float = 1.15 | 123 | max_cluster_p95_height_m: float = pydantic.Field(1.15, ge=0) |
| 125 | 124 | ||
| 126 | # Straightness check along sliding window (short clusters only) | 125 | # Straightness check along sliding window (short clusters only) |
| 127 | straightness_window_m: float = 10.0 | 126 | straightness_window_m: float = pydantic.Field(10.0, gt=0) |
| 128 | max_straightness_deviation_m: float = 0.50 | 127 | max_straightness_deviation_m: float = pydantic.Field(0.50, ge=0) |
| 129 | straightness_max_length_m: float = 25.0 | 128 | straightness_max_length_m: float = pydantic.Field(25.0, ge=0) |
| 130 | 129 | ||
| 131 | # Heuristic type classification thresholds | 130 | # Heuristic type classification thresholds |
| 132 | w_beam_min_height_m: float = 0.40 | 131 | w_beam_min_height_m: float = pydantic.Field(0.40, ge=0) |
| 133 | w_beam_max_height_m: float = 0.90 | 132 | w_beam_max_height_m: float = pydantic.Field(0.90, gt=0) |
| 134 | w_beam_max_height_spread_m: float = 0.55 | 133 | w_beam_max_height_spread_m: float = pydantic.Field(0.55, ge=0) |
| 135 | concrete_min_height_m: float = 0.80 | 134 | concrete_min_height_m: float = pydantic.Field(0.80, ge=0) |
| 136 | concrete_max_height_spread_m: float = 0.45 | 135 | concrete_max_height_spread_m: float = pydantic.Field(0.45, ge=0) |
| 137 | cable_suspect_max_spread_m: float = 0.25 | 136 | cable_suspect_max_spread_m: float = pydantic.Field(0.25, ge=0) |
| 138 | 137 | ||
| 139 | # Per-run confidence heuristic (0-1); see README "Run confidence". | 138 | # Per-run confidence heuristic (0-1); see README "Run confidence". |
| 140 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height | 139 | # confidence = 0.35*support + 0.25*continuity + 0.25*extent + 0.15*height |
| 141 | confidence_density_norm_pts_per_m: float = 500.0 | 140 | confidence_density_norm_pts_per_m: float = pydantic.Field(500.0, gt=0) |
| 142 | confidence_full_extent_m: float = 40.0 | 141 | confidence_full_extent_m: float = pydantic.Field(40.0, gt=0) |
| 143 | confidence_max_height_std_m: float = 0.2 | 142 | confidence_max_height_std_m: float = pydantic.Field(0.2, gt=0) |
| 144 | 143 | ||
| 145 | # Memory hardening (deployment target is a 32 GB RAM Azure node). | 144 | # Memory hardening (deployment target is a 32 GB RAM Azure node). |
| 146 | memory_budget_gb: float = 10.0 | 145 | memory_budget_gb: float = pydantic.Field(10.0, gt=0) |
| 147 | station_process_window_m: float = 5.0 | 146 | station_process_window_m: float = pydantic.Field(5.0, gt=0) |
| 148 | decimation_enabled: bool = False | 147 | decimation_enabled: bool = False |
| 149 | decimation_voxel_m: float = 0.05 | 148 | decimation_voxel_m: float = pydantic.Field(0.05, gt=0) |
| 150 | decimation_density_cap: int = 400000 | 149 | decimation_density_cap: int = pydantic.Field(400000, ge=1) |
| 151 | # Records larger than this stream through the corridor crop in chunks of | 150 | # Records larger than this stream through the corridor crop in chunks of |
| 152 | # this many points instead of being materialized whole (byte-identical | 151 | # this many points instead of being materialized whole (byte-identical |
| 153 | # results for records at or below the threshold, which use the old path). | 152 | # results for records at or below the threshold, which use the old path). |
| 154 | record_chunk_points: int = 4000000 | 153 | record_chunk_points: int = pydantic.Field(4000000, ge=1) |
| 155 | # Exclusion clustering guard: DBSCAN memory scales with the number of | 154 | # Exclusion clustering guard: DBSCAN memory scales with the number of |
| 156 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds | 155 | # eps-neighbour pairs. When a cheap grid estimate of that count exceeds |
| 157 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger | 156 | # this cap the exclusion candidates are voxel-decimated first (auto-trigger |
| 158 | # only; sparse segments are untouched). segment_134's dense record | 157 | # only; sparse segments are untouched). segment_134's dense record |
| 159 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. | 158 | # estimated 4.0e9 pairs (25 GB RSS); curated segments peak at 6.3e8. |
| 160 | exclusion_pair_estimate_max: float = 1000000000.0 | 159 | exclusion_pair_estimate_max: float = pydantic.Field(1000000000.0, gt=0) |
| 161 | exclusion_decimation_cell_m: float = 0.10 | 160 | exclusion_decimation_cell_m: float = pydantic.Field(0.10, gt=0) |
| 162 | # After the density trigger decimates, the residual DBSCAN runs under the | 161 | # After the density trigger decimates, the residual DBSCAN runs under the |
| 163 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS | 162 | # shared iolabs.common.memory_guard watchdog (subprocess + psutil RSS |
| 164 | # monitor, hard kill above the limit) as a second line of defense. Mirrors | 163 | # monitor, hard kill above the limit) as a second line of defense. Mirrors |
| 165 | # the subcluster_dbscan_memory_guard wiring in | 164 | # the subcluster_dbscan_memory_guard wiring in |
| 166 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. | 165 | # iolabs_point_cloud_modelling_lines / iolabs_geometry_geometry.fit_spline. |
| 167 | exclusion_use_shared_watchdog: bool = True | 166 | exclusion_use_shared_watchdog: bool = True |
| 168 | exclusion_dbscan_mem_limit_gb: float = 6.0 | 167 | exclusion_dbscan_mem_limit_gb: float = pydantic.Field(6.0, gt=0) |
| 169 | exclusion_dbscan_timeout_s: float = 120.0 | 168 | exclusion_dbscan_timeout_s: float = pydantic.Field(120.0, gt=0) |
| 170 | 169 | ||
| 171 | 170 | ||
| 172 | class DetectorConfigError(config_loader.ConfigError): | 171 | class DetectorConfigError(config_loader.ConfigError): |
| 173 | """Raised when the guardrails config holds unknown keys or invalid values.""" | 172 | """Raised when guardrails config contains unsupported keys or values.""" |
| 174 | |||
| 175 | |||
| 176 | def _default_config_path() -> Path: | ||
| 177 | if __package__ in {None, ""}: | ||
| 178 | return Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME) | ||
| 179 | return config_loader.default_config_path(__package__, _DEFAULT_CONFIG_NAME) | ||
| 180 | 173 | ||
| 181 | 174 | ||
| 182 | def load_default_config_dict() -> dict[str, Any]: | 175 | def load_default_config_dict() -> dict[str, Any]: |
| 183 | """Return the package-owned default config as a plain dict.""" | 176 | """Return the package-owned default config as a plain dict. |
| 184 | if __package__ in {None, ""}: | 177 | |
| 185 | with _default_config_path().open("r", encoding="utf-8") as handle: | 178 | Returns: |
| 186 | return json.load(handle) | 179 | The decoded ``guardrails.default.json`` object. |
| 187 | return config_loader.load_packaged_json(__package__, _DEFAULT_CONFIG_NAME) | 180 | """ |
| 181 | return config_loader.load_packaged_json(_PACKAGE_NAME, _DEFAULT_FILENAME) | ||
| 188 | 182 | ||
| 189 | 183 | ||
| 190 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: | 184 | def config_from_dict(raw: dict[str, Any]) -> DetectorConfig: |
| 191 | """Build a validated :class:`DetectorConfig` from a raw mapping.""" | 185 | """Build a validated :class:`DetectorConfig` from a raw mapping. |
| 186 | |||
| 187 | Args: | ||
| 188 | raw: Merged config mapping (packaged defaults plus overrides). | ||
| 189 | |||
| 190 | Returns: | ||
| 191 | The validated config. | ||
| 192 | |||
| 193 | Raises: | ||
| 194 | DetectorConfigError: ``raw`` holds an unknown key or a value outside | ||
| 195 | its declared type/range. | ||
| 196 | """ | ||
| 192 | return config_loader.validate_config( | 197 | return config_loader.validate_config( |
| 193 | DetectorConfig, | 198 | DetectorConfig, |
| 194 | raw, | 199 | raw, |
| 195 | context="guardrails config", | 200 | context=_CONTEXT, |
| 196 | error_cls=DetectorConfigError, | 201 | error_cls=DetectorConfigError, |
| 197 | ) | 202 | ) |
| 198 | 203 | ||
| 199 | 204 | ||
| 200 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: | 205 | def load_config(overrides: dict[str, Any] | None = None) -> DetectorConfig: |
| 201 | """Load the default config and apply flat ``PATH=VALUE`` overrides. | 206 | """Load the packaged default config and apply flat ``KEY=VALUE`` overrides. |
| 202 | 207 | ||
| 203 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). | 208 | Overrides come from the CLI ``--set`` flag (already parsed into a dict). |
| 209 | |||
| 210 | Args: | ||
| 211 | overrides: Flat mapping of config key to value, or ``None``. | ||
| 212 | |||
| 213 | Returns: | ||
| 214 | The validated config. | ||
| 215 | |||
| 216 | Raises: | ||
| 217 | DetectorConfigError: An override names an unknown key or holds a value | ||
| 218 | outside its declared type/range. | ||
| 204 | """ | 219 | """ |
| 205 | config_path = None | ||
| 206 | if __package__ in {None, ""}: | ||
| 207 | config_path = Path(__file__).resolve().with_name(_DEFAULT_CONFIG_NAME) | ||
| 208 | config = config_loader.load_config( | 220 | config = config_loader.load_config( |
| 209 | DetectorConfig, | 221 | DetectorConfig, |
| 210 | package=__package__ or _PACKAGE_NAME, | 222 | package=_PACKAGE_NAME, |
| 211 | filename=_DEFAULT_CONFIG_NAME, | 223 | filename=_DEFAULT_FILENAME, |
| 212 | overrides=overrides, | 224 | overrides=overrides, |
| 213 | config_path=config_path, | 225 | context=_CONTEXT, |
| 214 | context="guardrails config", | ||
| 215 | error_cls=DetectorConfigError, | 226 | error_cls=DetectorConfigError, |
| 216 | ) | 227 | ) |
| 217 | if overrides: | 228 | if overrides: |
| 218 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) | 229 | logger.info("Config overrides applied: %s", ", ".join(sorted(overrides))) |
| 219 | return config | 230 | return config |
| 220 | 231 | ||
| 221 | 232 | ||
| 233 | def with_overrides(config: DetectorConfig, updates: dict[str, Any]) -> DetectorConfig: | ||
| 234 | """Return a re-validated copy of *config* with *updates* applied. | ||
| 235 | |||
| 236 | Unlike ``model_copy(update=...)``, which writes raw values straight into the | ||
| 237 | copy, this rebuilds the model, so unknown keys, wrong types and out-of-range | ||
| 238 | values are rejected exactly as they are on load. | ||
| 239 | |||
| 240 | Args: | ||
| 241 | config: The config to derive from; never mutated (frozen model). | ||
| 242 | updates: Field name to new value. | ||
| 243 | |||
| 244 | Returns: | ||
| 245 | A validated copy carrying *updates*. | ||
| 246 | |||
| 247 | Raises: | ||
| 248 | DetectorConfigError: *updates* names an unknown field or holds a value | ||
| 249 | outside its declared type/range. | ||
| 250 | """ | ||
| 251 | return config_from_dict({**config.model_dump(), **updates}) | ||
| 252 | |||
| 253 | |||
| 222 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: | 254 | def parse_set_overrides(raw_overrides: list[str] | None) -> dict[str, Any]: |
| 223 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value.""" | 255 | """Parse repeated ``--set KEY=VALUE`` strings, JSON-decoding each value. |
| 256 | |||
| 257 | Args: | ||
| 258 | raw_overrides: Raw ``KEY=VALUE`` strings from the CLI, or ``None``. | ||
| 259 | |||
| 260 | Returns: | ||
| 261 | A flat mapping of config key to decoded value; later duplicates win. | ||
| 262 | |||
| 263 | Raises: | ||
| 264 | DetectorConfigError: An override is missing its ``=``. | ||
| 265 | """ | ||
| 224 | return config_loader.parse_set_overrides( | 266 | return config_loader.parse_set_overrides( |
| 225 | raw_overrides, | 267 | raw_overrides, |
| 226 | error_cls=DetectorConfigError, | 268 | error_cls=DetectorConfigError, |
| 227 | ) | 269 | ) |
| 1 | """Config schema/loader tests: JSON parity, rejection, overrides, coercion.""" | ||
| 2 | |||
| 1 | import pydantic | 3 | import pydantic |
| 2 | import pytest | 4 | import pytest |
| 5 | from iolabs.common import config_loader | ||
| 3 | 6 | ||
| 4 | from guardrails import config as config_module | 7 | from guardrails import config as config_module |
| 5 | 8 | ||
| 6 | 9 | ||
| 7 | def test_default_json_matches_model_defaults() -> None: | 10 | def test_model_defaults_match_packaged_json() -> None: |
| 8 | """guardrails.default.json is the schema source of truth; keep it in sync.""" | 11 | """guardrails.default.json is the schema mirror; compare the whole dict.""" |
| 9 | defaults = config_module.DetectorConfig().model_dump() | 12 | assert config_module.load_default_config_dict() == ( |
| 10 | json_config = config_module.load_default_config_dict() | 13 | config_module.DetectorConfig().model_dump() |
| 11 | assert set(json_config) == set(defaults) | 14 | ) |
| 12 | for key, value in defaults.items(): | ||
| 13 | assert json_config[key] == value, key | ||
| 14 | 15 | ||
| 15 | 16 | ||
| 16 | def test_load_config_without_overrides_equals_defaults() -> None: | 17 | def test_load_config_returns_packaged_defaults() -> None: |
| 17 | assert config_module.load_config() == config_module.DetectorConfig() | 18 | assert config_module.load_config() == config_module.DetectorConfig() |
| 18 | 19 | ||
| 19 | 20 | ||
| 20 | def test_parse_set_overrides_json_decodes_values() -> None: | 21 | def test_error_class_is_config_error() -> None: |
| 21 | parsed = config_module.parse_set_overrides( | 22 | assert issubclass(config_module.DetectorConfigError, config_loader.ConfigError) |
| 22 | ["merge_face_max_spacing_m=1.5", "decimation_enabled=true", "memory_budget_gb=8"] | 23 | assert issubclass(config_module.DetectorConfigError, ValueError) |
| 23 | ) | ||
| 24 | assert parsed == { | ||
| 25 | "merge_face_max_spacing_m": 1.5, | ||
| 26 | "decimation_enabled": True, | ||
| 27 | "memory_budget_gb": 8, | ||
| 28 | } | ||
| 29 | 24 | ||
| 30 | 25 | ||
| 31 | def test_load_config_applies_overrides_with_type_coercion() -> None: | 26 | def test_unknown_top_level_key_is_rejected() -> None: |
| 27 | with pytest.raises(config_module.DetectorConfigError, match="not_a_key"): | ||
| 28 | config_module.config_from_dict( | ||
| 29 | {**config_module.load_default_config_dict(), "not_a_key": 1} | ||
| 30 | ) | ||
| 31 | |||
| 32 | |||
| 33 | def test_out_of_range_value_is_rejected() -> None: | ||
| 34 | with pytest.raises(config_module.DetectorConfigError, match="min_rail_fraction"): | ||
| 35 | config_module.load_config({"min_rail_fraction": 1.5}) | ||
| 36 | |||
| 37 | |||
| 38 | def test_overrides_apply_onto_defaults() -> None: | ||
| 32 | config = config_module.load_config( | 39 | config = config_module.load_config( |
| 33 | {"decimation_enabled": "true", "merge_face_max_faces": 3} | 40 | {"decimation_enabled": "true", "merge_face_max_faces": 3} |
| 34 | ) | 41 | ) |
| 35 | assert config.decimation_enabled is True | 42 | assert config.decimation_enabled is True |
| 36 | assert config.merge_face_max_faces == 3 | 43 | assert config.merge_face_max_faces == 3 |
| 44 | assert config.merge_gap_m == config_module.DetectorConfig().merge_gap_m | ||
| 37 | 45 | ||
| 38 | 46 | ||
| 39 | def test_unknown_key_rejected() -> None: | 47 | def test_set_override_coercion_and_rejection() -> None: |
| 48 | parsed = config_module.parse_set_overrides( | ||
| 49 | [ | ||
| 50 | "merge_face_max_spacing_m=1.5", | ||
| 51 | "decimation_enabled=true", | ||
| 52 | "memory_budget_gb=8", | ||
| 53 | ] | ||
| 54 | ) | ||
| 55 | assert parsed == { | ||
| 56 | "merge_face_max_spacing_m": 1.5, | ||
| 57 | "decimation_enabled": True, | ||
| 58 | "memory_budget_gb": 8, | ||
| 59 | } | ||
| 40 | with pytest.raises(config_module.DetectorConfigError): | 60 | with pytest.raises(config_module.DetectorConfigError): |
| 41 | config_module.config_from_dict( | 61 | config_module.parse_set_overrides(["missing_equals_sign"]) |
| 42 | {**config_module.load_default_config_dict(), "not_a_key": 1} | ||
| 43 | ) | ||
| 44 | |||
| 45 | |||
| 46 | def test_invalid_value_rejected() -> None: | ||
| 47 | with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"): | 62 | with pytest.raises(config_module.DetectorConfigError, match="merge_face_max_faces"): |
| 48 | config_module.load_config({"merge_face_max_faces": "abc"}) | 63 | config_module.load_config({"merge_face_max_faces": "abc"}) |
| 49 | 64 | ||
| 50 | 65 | ||
| 66 | def test_with_overrides_revalidates_updates() -> None: | ||
| 67 | """with_overrides() rejects what load_config() rejects, unlike model_copy().""" | ||
| 68 | config = config_module.load_config() | ||
| 69 | assert config_module.with_overrides(config, {"merge_gap_m": 3.0}).merge_gap_m == 3.0 | ||
| 70 | assert config.merge_gap_m != 3.0 | ||
| 71 | with pytest.raises(config_module.DetectorConfigError): | ||
| 72 | config_module.with_overrides(config, {"not_a_key": 1}) | ||
| 73 | |||
| 74 | |||
| 51 | def test_config_is_frozen() -> None: | 75 | def test_config_is_frozen() -> None: |
| 52 | config = config_module.DetectorConfig() | 76 | config = config_module.DetectorConfig() |
| 53 | with pytest.raises(pydantic.ValidationError): | 77 | with pytest.raises(pydantic.ValidationError): |
| 54 | config.merge_gap_m = 1.0 | 78 | config.merge_gap_m = 1.0 |
| 55 | |||
| 56 | |||
| 57 | def test_invalid_override_string_rejected() -> None: | ||
| 58 | with pytest.raises(config_module.DetectorConfigError): | ||
| 59 | config_module.parse_set_overrides(["missing_equals_sign"]) |
<Name><Section>Config), module constants, keyword-only entry points, canonical test names, README config section. No behaviour change intended.