From 5778c87065aa13093bfc7c83e0b1728a845a33a6 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Thu, 12 Mar 2026 18:36:41 +0000 Subject: [PATCH 01/21] got it working --- edge_config_example.py | 41 ++++++++++ src/groundlight/edge/__init__.py | 21 +++++ src/groundlight/edge/config.py | 134 +++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 edge_config_example.py create mode 100644 src/groundlight/edge/__init__.py create mode 100644 src/groundlight/edge/config.py diff --git a/edge_config_example.py b/edge_config_example.py new file mode 100644 index 00000000..98ca7f9c --- /dev/null +++ b/edge_config_example.py @@ -0,0 +1,41 @@ +"""Example of constructing an edge endpoint configuration programmatically.""" + +from groundlight import Groundlight +from groundlight.edge import DEFAULT, EDGE_WITH_ESCALATION, NO_CLOUD, EdgeInferenceConfig, RootEdgeConfig + +gl = Groundlight() +detector1 = gl.get_detector("det_2z41nK0CyoFdWF6tEoB7DN5qwAx") +detector2 = gl.get_detector("det_2z41rs0Fo12LAk0oOZg0r4wR9Fn") +detector3 = gl.get_detector("det_2tYVTZrz8VLZhe94tjuPRl5rDeG") +detector4 = gl.get_detector("det_2sDfBz5xp6ZysB82kK7LfNYYSXx") +detector5 = gl.get_detector("det_2sDfGUP8cBt9Wrq0YFVLjVZhoI5") + +config = RootEdgeConfig() + +config.add_detector(detector1, NO_CLOUD) +config.add_detector(detector2, EDGE_WITH_ESCALATION) +config.add_detector(detector3, DEFAULT) + +# Custom configs work alongside presets +my_custom_config = EdgeInferenceConfig( + name="my_custom_config", + always_return_edge_prediction=True, + min_time_between_escalations=0.5, +) +detector_id = detector4.id +config.add_detector(detector_id, my_custom_config) + +# Cannot reuse names on EdgeInferenceConfig +config_with_name_collision = EdgeInferenceConfig(name='default') +try: + config.add_detector(detector5, config_with_name_collision) +except ValueError as e: + print(e) + +# Frozen -- mutation raises an error +try: + NO_CLOUD.enabled = False +except Exception as e: + print(e) + +print(config.model_dump_json(indent=2)) diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py new file mode 100644 index 00000000..e1ea4c33 --- /dev/null +++ b/src/groundlight/edge/__init__.py @@ -0,0 +1,21 @@ +from .config import ( + DEFAULT, + DISABLED, + EDGE_WITH_ESCALATION, + NO_CLOUD, + DetectorConfig, + EdgeInferenceConfig, + GlobalConfig, + RootEdgeConfig, +) + +__all__ = [ + "DEFAULT", + "DISABLED", + "EDGE_WITH_ESCALATION", + "NO_CLOUD", + "DetectorConfig", + "EdgeInferenceConfig", + "GlobalConfig", + "RootEdgeConfig", +] diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py new file mode 100644 index 00000000..6dbc6afa --- /dev/null +++ b/src/groundlight/edge/config.py @@ -0,0 +1,134 @@ +from typing import Union + +from model import Detector +from pydantic import BaseModel, ConfigDict, Field, model_validator +from typing_extensions import Self + + +class GlobalConfig(BaseModel): + refresh_rate: float = Field( + default=60.0, + description="The interval (in seconds) at which the inference server checks for a new model binary update.", + ) + confident_audit_rate: float = Field( + default=1e-5, # A detector running at 1 FPS = ~100,000 IQ/day, so 1e-5 is ~1 confident IQ/day audited + description="The probability that any given confident prediction will be sent to the cloud for auditing.", + ) + + +class EdgeInferenceConfig(BaseModel): + """ + Configuration for edge inference on a specific detector. + """ + + model_config = ConfigDict(frozen=True) + + name: str = Field(..., exclude=True, description="A unique name for this inference config preset.") + enabled: bool = Field( # TODO investigate and update the functionality of this option + default=True, description="Whether the edge endpoint should accept image queries for this detector." + ) + api_token: str | None = Field( + default=None, description="API token used to fetch the inference model for this detector." + ) + always_return_edge_prediction: bool = Field( + default=False, + description=( + "Indicates if the edge-endpoint should always provide edge ML predictions, regardless of confidence. " + "When this setting is true, whether or not the edge-endpoint should escalate low-confidence predictions " + "to the cloud is determined by `disable_cloud_escalation`." + ), + ) + disable_cloud_escalation: bool = Field( + default=False, + description=( + "Never escalate ImageQueries from the edge-endpoint to the cloud. " + "Requires `always_return_edge_prediction=True`." + ), + ) + min_time_between_escalations: float = Field( + default=2.0, + description=( + "The minimum time (in seconds) to wait between cloud escalations for a given detector. " + "Cannot be less than 0.0. " + "Only applies when `always_return_edge_prediction=True` and `disable_cloud_escalation=False`." + ), + ) + + @model_validator(mode="after") + def validate_configuration(self) -> Self: + if self.disable_cloud_escalation and not self.always_return_edge_prediction: + raise ValueError( + "The `disable_cloud_escalation` flag is only valid when `always_return_edge_prediction` is set to True." + ) + if self.min_time_between_escalations < 0.0: + raise ValueError("`min_time_between_escalations` cannot be less than 0.0.") + return self + + +class DetectorConfig(BaseModel): + """ + Configuration for a specific detector. + """ + + detector_id: str = Field(..., description="Detector ID") + edge_inference_config: str = Field(..., description="Config for edge inference.") + + +class RootEdgeConfig(BaseModel): + """ + Root configuration for edge inference. + """ + + global_config: GlobalConfig = Field(default_factory=GlobalConfig) + edge_inference_configs: dict[str, EdgeInferenceConfig] = Field(default_factory=dict) + detectors: list[DetectorConfig] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_inference_configs(self): + for detector_config in self.detectors: + if detector_config.edge_inference_config not in self.edge_inference_configs: + raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") + return self + + def add_detector( + self, detector: Union[str, Detector], edge_inference_config: Union[str, EdgeInferenceConfig] + ) -> None: + detector_id = detector.id if isinstance(detector, Detector) else detector + if any(d.detector_id == detector_id for d in self.detectors): + raise ValueError(f"A detector with ID '{detector_id}' already exists.") + if isinstance(edge_inference_config, EdgeInferenceConfig): + config = edge_inference_config + existing = self.edge_inference_configs.get(config.name) + if existing is None: + self.edge_inference_configs[config.name] = config + elif existing is not config: + raise ValueError( + f"A different inference config named '{config.name}' is already registered." + ) + config_name = config.name + else: + config_name = edge_inference_config + if config_name not in self.edge_inference_configs: + raise ValueError( + f"Edge inference config '{config_name}' not defined. " + f"Available configs: {list(self.edge_inference_configs.keys())}" + ) + self.detectors.append(DetectorConfig( + detector_id=detector_id, + edge_inference_config=config_name, + )) + + +# Preset inference configs matching the standard edge-endpoint defaults. +DEFAULT = EdgeInferenceConfig(name="default") +EDGE_WITH_ESCALATION = EdgeInferenceConfig( + name="edge_with_escalation", + always_return_edge_prediction=True, + min_time_between_escalations=2.0, +) +NO_CLOUD = EdgeInferenceConfig( + name="no_cloud", + always_return_edge_prediction=True, + disable_cloud_escalation=True, +) +DISABLED = EdgeInferenceConfig(name="disabled", enabled=False) From 240f12445142a846e9d9a087676c5f843ad23309 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Thu, 12 Mar 2026 18:37:48 +0000 Subject: [PATCH 02/21] Automatically reformatting code --- src/groundlight/edge/config.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 6dbc6afa..19c6c82a 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -102,9 +102,7 @@ def add_detector( if existing is None: self.edge_inference_configs[config.name] = config elif existing is not config: - raise ValueError( - f"A different inference config named '{config.name}' is already registered." - ) + raise ValueError(f"A different inference config named '{config.name}' is already registered.") config_name = config.name else: config_name = edge_inference_config @@ -113,10 +111,12 @@ def add_detector( f"Edge inference config '{config_name}' not defined. " f"Available configs: {list(self.edge_inference_configs.keys())}" ) - self.detectors.append(DetectorConfig( - detector_id=detector_id, - edge_inference_config=config_name, - )) + self.detectors.append( + DetectorConfig( + detector_id=detector_id, + edge_inference_config=config_name, + ) + ) # Preset inference configs matching the standard edge-endpoint defaults. From ab42eceaffa557ed5879ab9ea3f2c745943bf6da Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 16 Mar 2026 16:05:55 -0700 Subject: [PATCH 03/21] adding DetectorsConfig model --- src/groundlight/edge/__init__.py | 12 +++-- src/groundlight/edge/config.py | 72 ++++++++++++++++++++++------ test/unit/test_edge_config.py | 81 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 19 deletions(-) create mode 100644 test/unit/test_edge_config.py diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index e1ea4c33..8e0918d7 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -1,21 +1,25 @@ from .config import ( DEFAULT, DISABLED, - EDGE_WITH_ESCALATION, + EDGE_ANSWERS_WITH_ESCALATION, NO_CLOUD, + DetectorsConfig, DetectorConfig, + EdgeEndpointConfig, EdgeInferenceConfig, GlobalConfig, - RootEdgeConfig, + InferenceConfig, ) __all__ = [ "DEFAULT", "DISABLED", - "EDGE_WITH_ESCALATION", + "EDGE_ANSWERS_WITH_ESCALATION", "NO_CLOUD", + "DetectorsConfig", "DetectorConfig", + "EdgeEndpointConfig", "EdgeInferenceConfig", "GlobalConfig", - "RootEdgeConfig", + "InferenceConfig", ] diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 19c6c82a..614b4328 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Optional, Union from model import Detector from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -16,7 +16,7 @@ class GlobalConfig(BaseModel): ) -class EdgeInferenceConfig(BaseModel): +class InferenceConfig(BaseModel): """ Configuration for edge inference on a specific detector. """ @@ -27,7 +27,7 @@ class EdgeInferenceConfig(BaseModel): enabled: bool = Field( # TODO investigate and update the functionality of this option default=True, description="Whether the edge endpoint should accept image queries for this detector." ) - api_token: str | None = Field( + api_token: Union[str, None] = Field( default=None, description="API token used to fetch the inference model for this detector." ) always_return_edge_prediction: bool = Field( @@ -74,13 +74,12 @@ class DetectorConfig(BaseModel): edge_inference_config: str = Field(..., description="Config for edge inference.") -class RootEdgeConfig(BaseModel): +class DetectorsConfig(BaseModel): """ - Root configuration for edge inference. + Detector and inference-config mappings for edge inference. """ - global_config: GlobalConfig = Field(default_factory=GlobalConfig) - edge_inference_configs: dict[str, EdgeInferenceConfig] = Field(default_factory=dict) + edge_inference_configs: dict[str, InferenceConfig] = Field(default_factory=dict) detectors: list[DetectorConfig] = Field(default_factory=list) @model_validator(mode="after") @@ -91,17 +90,17 @@ def validate_inference_configs(self): return self def add_detector( - self, detector: Union[str, Detector], edge_inference_config: Union[str, EdgeInferenceConfig] + self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig] ) -> None: detector_id = detector.id if isinstance(detector, Detector) else detector if any(d.detector_id == detector_id for d in self.detectors): raise ValueError(f"A detector with ID '{detector_id}' already exists.") - if isinstance(edge_inference_config, EdgeInferenceConfig): + if isinstance(edge_inference_config, InferenceConfig): config = edge_inference_config existing = self.edge_inference_configs.get(config.name) if existing is None: self.edge_inference_configs[config.name] = config - elif existing is not config: + elif existing != config: raise ValueError(f"A different inference config named '{config.name}' is already registered.") config_name = config.name else: @@ -119,16 +118,59 @@ def add_detector( ) +class EdgeEndpointConfig(BaseModel): + """ + Top-level edge endpoint configuration. + """ + + global_config: GlobalConfig = Field(default_factory=GlobalConfig) + edge_inference_configs: dict[str, InferenceConfig] = Field(default_factory=dict) + detectors: list[DetectorConfig] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_inference_configs(self): + DetectorsConfig( + edge_inference_configs=self.edge_inference_configs, + detectors=self.detectors, + ) + return self + + def add_detector( + self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig] + ) -> None: + detectors_config = DetectorsConfig( + edge_inference_configs=self.edge_inference_configs, + detectors=self.detectors, + ) + detectors_config.add_detector(detector, edge_inference_config) + self.edge_inference_configs = detectors_config.edge_inference_configs + self.detectors = detectors_config.detectors + + @classmethod + def from_detectors_config( + cls, detectors_config: "DetectorsConfig", global_config: Optional[GlobalConfig] = None + ) -> "EdgeEndpointConfig": + copied_config = detectors_config.model_copy(deep=True) + return cls( + global_config=global_config or GlobalConfig(), + edge_inference_configs=copied_config.edge_inference_configs, + detectors=copied_config.detectors, + ) + + +EdgeInferenceConfig = InferenceConfig + + # Preset inference configs matching the standard edge-endpoint defaults. -DEFAULT = EdgeInferenceConfig(name="default") -EDGE_WITH_ESCALATION = EdgeInferenceConfig( - name="edge_with_escalation", +DEFAULT = InferenceConfig(name="default") +EDGE_ANSWERS_WITH_ESCALATION = InferenceConfig( + name="edge_answers_with_escalation", always_return_edge_prediction=True, min_time_between_escalations=2.0, ) -NO_CLOUD = EdgeInferenceConfig( +NO_CLOUD = InferenceConfig( name="no_cloud", always_return_edge_prediction=True, disable_cloud_escalation=True, ) -DISABLED = EdgeInferenceConfig(name="disabled", enabled=False) +DISABLED = InferenceConfig(name="disabled", enabled=False) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py new file mode 100644 index 00000000..ba251205 --- /dev/null +++ b/test/unit/test_edge_config.py @@ -0,0 +1,81 @@ +import pytest + +from groundlight.edge import ( + DEFAULT, + EDGE_ANSWERS_WITH_ESCALATION, + DetectorsConfig, + EdgeEndpointConfig, + InferenceConfig, + NO_CLOUD, +) + + +def test_edge_endpoint_config_is_not_subclass_of_detectors_config(): + assert not issubclass(EdgeEndpointConfig, DetectorsConfig) + + +def test_add_detector_allows_equivalent_named_inference_config(): + detectors_config = DetectorsConfig() + detectors_config.add_detector( + "det_1", + InferenceConfig( + name="custom_config", + always_return_edge_prediction=True, + min_time_between_escalations=0.5, + ), + ) + detectors_config.add_detector( + "det_2", + InferenceConfig( + name="custom_config", + always_return_edge_prediction=True, + min_time_between_escalations=0.5, + ), + ) + + assert len(detectors_config.detectors) == 2 + assert list(detectors_config.edge_inference_configs.keys()) == ["custom_config"] + + +def test_add_detector_rejects_different_named_inference_config(): + detectors_config = DetectorsConfig() + detectors_config.add_detector("det_1", InferenceConfig(name="custom_config")) + + with pytest.raises(ValueError, match="different inference config named 'custom_config'"): + detectors_config.add_detector( + "det_2", + InferenceConfig(name="custom_config", always_return_edge_prediction=True), + ) + + +def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): + config = EdgeEndpointConfig() + config.add_detector("det_1", NO_CLOUD) + config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) + config.add_detector("det_3", DEFAULT) + + assert [detector.detector_id for detector in config.detectors] == ["det_1", "det_2", "det_3"] + assert set(config.edge_inference_configs.keys()) == {"no_cloud", "edge_answers_with_escalation", "default"} + + +def test_from_detectors_config_copies_detector_data(): + detectors_config = DetectorsConfig() + detectors_config.add_detector("det_1", DEFAULT) + + config = EdgeEndpointConfig.from_detectors_config(detectors_config) + detectors_config.add_detector("det_2", DEFAULT) + + assert len(config.detectors) == 1 + assert len(detectors_config.detectors) == 2 + + +def test_inference_config_validation_errors(): + with pytest.raises(ValueError, match="disable_cloud_escalation"): + InferenceConfig(name="bad", disable_cloud_escalation=True) + + with pytest.raises(ValueError, match="cannot be less than 0.0"): + InferenceConfig( + name="bad_escalation_interval", + always_return_edge_prediction=True, + min_time_between_escalations=-1.0, + ) From 83fe03717805eb20b8e7b0a7e296180656cf73a7 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 16 Mar 2026 23:06:35 +0000 Subject: [PATCH 04/21] Automatically reformatting code --- src/groundlight/edge/config.py | 8 ++------ test/unit/test_edge_config.py | 3 +-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 614b4328..ce8c6282 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -89,9 +89,7 @@ def validate_inference_configs(self): raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") return self - def add_detector( - self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig] - ) -> None: + def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: detector_id = detector.id if isinstance(detector, Detector) else detector if any(d.detector_id == detector_id for d in self.detectors): raise ValueError(f"A detector with ID '{detector_id}' already exists.") @@ -135,9 +133,7 @@ def validate_inference_configs(self): ) return self - def add_detector( - self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig] - ) -> None: + def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: detectors_config = DetectorsConfig( edge_inference_configs=self.edge_inference_configs, detectors=self.detectors, diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index ba251205..d9a15218 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,12 +1,11 @@ import pytest - from groundlight.edge import ( DEFAULT, EDGE_ANSWERS_WITH_ESCALATION, + NO_CLOUD, DetectorsConfig, EdgeEndpointConfig, InferenceConfig, - NO_CLOUD, ) From 3d2895d41a0779916534dc0ae87c01972daa9898 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 16 Mar 2026 16:17:43 -0700 Subject: [PATCH 05/21] removing unnecessary script --- edge_config_example.py | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 edge_config_example.py diff --git a/edge_config_example.py b/edge_config_example.py deleted file mode 100644 index 98ca7f9c..00000000 --- a/edge_config_example.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Example of constructing an edge endpoint configuration programmatically.""" - -from groundlight import Groundlight -from groundlight.edge import DEFAULT, EDGE_WITH_ESCALATION, NO_CLOUD, EdgeInferenceConfig, RootEdgeConfig - -gl = Groundlight() -detector1 = gl.get_detector("det_2z41nK0CyoFdWF6tEoB7DN5qwAx") -detector2 = gl.get_detector("det_2z41rs0Fo12LAk0oOZg0r4wR9Fn") -detector3 = gl.get_detector("det_2tYVTZrz8VLZhe94tjuPRl5rDeG") -detector4 = gl.get_detector("det_2sDfBz5xp6ZysB82kK7LfNYYSXx") -detector5 = gl.get_detector("det_2sDfGUP8cBt9Wrq0YFVLjVZhoI5") - -config = RootEdgeConfig() - -config.add_detector(detector1, NO_CLOUD) -config.add_detector(detector2, EDGE_WITH_ESCALATION) -config.add_detector(detector3, DEFAULT) - -# Custom configs work alongside presets -my_custom_config = EdgeInferenceConfig( - name="my_custom_config", - always_return_edge_prediction=True, - min_time_between_escalations=0.5, -) -detector_id = detector4.id -config.add_detector(detector_id, my_custom_config) - -# Cannot reuse names on EdgeInferenceConfig -config_with_name_collision = EdgeInferenceConfig(name='default') -try: - config.add_detector(detector5, config_with_name_collision) -except ValueError as e: - print(e) - -# Frozen -- mutation raises an error -try: - NO_CLOUD.enabled = False -except Exception as e: - print(e) - -print(config.model_dump_json(indent=2)) From 7c2b3216ee961de4a33a19e6f2bfa3f858efae01 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 16 Mar 2026 16:24:20 -0700 Subject: [PATCH 06/21] fixing a linter error --- test/unit/test_edge_config.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index d9a15218..ea6e3f37 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,4 +1,5 @@ import pytest + from groundlight.edge import ( DEFAULT, EDGE_ANSWERS_WITH_ESCALATION, @@ -8,6 +9,9 @@ InferenceConfig, ) +ONE_DETECTOR = 1 +TWO_DETECTORS = 2 + def test_edge_endpoint_config_is_not_subclass_of_detectors_config(): assert not issubclass(EdgeEndpointConfig, DetectorsConfig) @@ -32,7 +36,7 @@ def test_add_detector_allows_equivalent_named_inference_config(): ), ) - assert len(detectors_config.detectors) == 2 + assert len(detectors_config.detectors) == TWO_DETECTORS assert list(detectors_config.edge_inference_configs.keys()) == ["custom_config"] @@ -64,8 +68,8 @@ def test_from_detectors_config_copies_detector_data(): config = EdgeEndpointConfig.from_detectors_config(detectors_config) detectors_config.add_detector("det_2", DEFAULT) - assert len(config.detectors) == 1 - assert len(detectors_config.detectors) == 2 + assert len(config.detectors) == ONE_DETECTOR + assert len(detectors_config.detectors) == TWO_DETECTORS def test_inference_config_validation_errors(): From ddb26a16eb753e3097796d63976544fe18abdedc Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 16 Mar 2026 23:25:03 +0000 Subject: [PATCH 07/21] Automatically reformatting code --- test/unit/test_edge_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index ea6e3f37..0ab2e8f2 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,5 +1,4 @@ import pytest - from groundlight.edge import ( DEFAULT, EDGE_ANSWERS_WITH_ESCALATION, From 2f8a474a3fcd267f8ffe78bcbe39132f6892f479 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 16 Mar 2026 16:42:31 -0700 Subject: [PATCH 08/21] responding to AI PR feedback --- src/groundlight/edge/config.py | 18 ++++++++++++++++++ test/unit/test_edge_config.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index ce8c6282..1737d67c 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -84,6 +84,24 @@ class DetectorsConfig(BaseModel): @model_validator(mode="after") def validate_inference_configs(self): + for name, config in self.edge_inference_configs.items(): + if name != config.name: + raise ValueError( + f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'." + ) + + seen_detector_ids = set() + duplicate_detector_ids = set() + for detector_config in self.detectors: + detector_id = detector_config.detector_id + if detector_id in seen_detector_ids: + duplicate_detector_ids.add(detector_id) + else: + seen_detector_ids.add(detector_id) + if duplicate_detector_ids: + duplicates = ", ".join(sorted(duplicate_detector_ids)) + raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.") + for detector_config in self.detectors: if detector_config.edge_inference_config not in self.edge_inference_configs: raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index ea6e3f37..fafd5560 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -51,6 +51,35 @@ def test_add_detector_rejects_different_named_inference_config(): ) +def test_constructor_rejects_duplicate_detector_ids(): + with pytest.raises(ValueError, match="Duplicate detector IDs"): + DetectorsConfig( + edge_inference_configs={"default": DEFAULT}, + detectors=[ + {"detector_id": "det_1", "edge_inference_config": "default"}, + {"detector_id": "det_1", "edge_inference_config": "default"}, + ], + ) + + +def test_constructor_rejects_mismatched_inference_config_key_and_name(): + with pytest.raises(ValueError, match="must match InferenceConfig.name"): + DetectorsConfig( + edge_inference_configs={"default": InferenceConfig(name="not_default")}, + detectors=[], + ) + + +def test_constructor_accepts_matching_inference_config_key_and_name(): + config = DetectorsConfig( + edge_inference_configs={"default": InferenceConfig(name="default")}, + detectors=[{"detector_id": "det_1", "edge_inference_config": "default"}], + ) + + assert list(config.edge_inference_configs.keys()) == ["default"] + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): config = EdgeEndpointConfig() config.add_detector("det_1", NO_CLOUD) From 8fcebb97debe8ef5a47aced8dba78473d6991e2b Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 16 Mar 2026 23:43:30 +0000 Subject: [PATCH 09/21] Automatically reformatting code --- src/groundlight/edge/config.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 1737d67c..e53e3816 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -86,9 +86,7 @@ class DetectorsConfig(BaseModel): def validate_inference_configs(self): for name, config in self.edge_inference_configs.items(): if name != config.name: - raise ValueError( - f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'." - ) + raise ValueError(f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'.") seen_detector_ids = set() duplicate_detector_ids = set() From 45feffaf001b6b6c5a18fc81b52c6511f9ab1b79 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Mon, 16 Mar 2026 16:56:23 -0700 Subject: [PATCH 10/21] responding to more AI PR feedback --- src/groundlight/edge/__init__.py | 4 +- src/groundlight/edge/config.py | 124 ++++++++++++++++--------------- test/unit/test_edge_config.py | 73 ++++++++++++++++++ 3 files changed, 137 insertions(+), 64 deletions(-) diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index 8e0918d7..a3479721 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -3,10 +3,9 @@ DISABLED, EDGE_ANSWERS_WITH_ESCALATION, NO_CLOUD, - DetectorsConfig, DetectorConfig, + DetectorsConfig, EdgeEndpointConfig, - EdgeInferenceConfig, GlobalConfig, InferenceConfig, ) @@ -19,7 +18,6 @@ "DetectorsConfig", "DetectorConfig", "EdgeEndpointConfig", - "EdgeInferenceConfig", "GlobalConfig", "InferenceConfig", ] diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 1737d67c..003e54af 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -24,10 +24,10 @@ class InferenceConfig(BaseModel): model_config = ConfigDict(frozen=True) name: str = Field(..., exclude=True, description="A unique name for this inference config preset.") - enabled: bool = Field( # TODO investigate and update the functionality of this option + enabled: bool = Field( default=True, description="Whether the edge endpoint should accept image queries for this detector." ) - api_token: Union[str, None] = Field( + api_token: Optional[str] = Field( default=None, description="API token used to fetch the inference model for this detector." ) always_return_edge_prediction: bool = Field( @@ -74,6 +74,63 @@ class DetectorConfig(BaseModel): edge_inference_config: str = Field(..., description="Config for edge inference.") +def _validate_detector_config_state( + edge_inference_configs: dict[str, InferenceConfig], detectors: list[DetectorConfig] +) -> None: + for name, config in edge_inference_configs.items(): + if name != config.name: + raise ValueError(f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'.") + + seen_detector_ids = set() + duplicate_detector_ids = set() + for detector_config in detectors: + detector_id = detector_config.detector_id + if detector_id in seen_detector_ids: + duplicate_detector_ids.add(detector_id) + else: + seen_detector_ids.add(detector_id) + if duplicate_detector_ids: + duplicates = ", ".join(sorted(duplicate_detector_ids)) + raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.") + + for detector_config in detectors: + if detector_config.edge_inference_config not in edge_inference_configs: + raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") + + +def _add_detector_to_state( + edge_inference_configs: dict[str, InferenceConfig], + detectors: list[DetectorConfig], + detector: Union[str, Detector], + edge_inference_config: Union[str, InferenceConfig], +) -> DetectorConfig: + detector_id = detector.id if isinstance(detector, Detector) else detector + if any(existing.detector_id == detector_id for existing in detectors): + raise ValueError(f"A detector with ID '{detector_id}' already exists.") + if isinstance(edge_inference_config, InferenceConfig): + config = edge_inference_config + existing = edge_inference_configs.get(config.name) + if existing is None: + edge_inference_configs[config.name] = config + elif existing != config: + raise ValueError(f"A different inference config named '{config.name}' is already registered.") + config_name = config.name + else: + config_name = edge_inference_config + if config_name not in edge_inference_configs: + raise ValueError( + f"Edge inference config '{config_name}' not defined. " + f"Available configs: {list(edge_inference_configs.keys())}" + ) + + detector_config = DetectorConfig( + detector_id=detector_id, + edge_inference_config=config_name, + ) + detectors.append(detector_config) + return detector_config + + class DetectorsConfig(BaseModel): """ Detector and inference-config mappings for edge inference. @@ -84,54 +141,11 @@ class DetectorsConfig(BaseModel): @model_validator(mode="after") def validate_inference_configs(self): - for name, config in self.edge_inference_configs.items(): - if name != config.name: - raise ValueError( - f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'." - ) - - seen_detector_ids = set() - duplicate_detector_ids = set() - for detector_config in self.detectors: - detector_id = detector_config.detector_id - if detector_id in seen_detector_ids: - duplicate_detector_ids.add(detector_id) - else: - seen_detector_ids.add(detector_id) - if duplicate_detector_ids: - duplicates = ", ".join(sorted(duplicate_detector_ids)) - raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.") - - for detector_config in self.detectors: - if detector_config.edge_inference_config not in self.edge_inference_configs: - raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") + _validate_detector_config_state(self.edge_inference_configs, self.detectors) return self def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: - detector_id = detector.id if isinstance(detector, Detector) else detector - if any(d.detector_id == detector_id for d in self.detectors): - raise ValueError(f"A detector with ID '{detector_id}' already exists.") - if isinstance(edge_inference_config, InferenceConfig): - config = edge_inference_config - existing = self.edge_inference_configs.get(config.name) - if existing is None: - self.edge_inference_configs[config.name] = config - elif existing != config: - raise ValueError(f"A different inference config named '{config.name}' is already registered.") - config_name = config.name - else: - config_name = edge_inference_config - if config_name not in self.edge_inference_configs: - raise ValueError( - f"Edge inference config '{config_name}' not defined. " - f"Available configs: {list(self.edge_inference_configs.keys())}" - ) - self.detectors.append( - DetectorConfig( - detector_id=detector_id, - edge_inference_config=config_name, - ) - ) + _add_detector_to_state(self.edge_inference_configs, self.detectors, detector, edge_inference_config) class EdgeEndpointConfig(BaseModel): @@ -145,20 +159,11 @@ class EdgeEndpointConfig(BaseModel): @model_validator(mode="after") def validate_inference_configs(self): - DetectorsConfig( - edge_inference_configs=self.edge_inference_configs, - detectors=self.detectors, - ) + _validate_detector_config_state(self.edge_inference_configs, self.detectors) return self def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: - detectors_config = DetectorsConfig( - edge_inference_configs=self.edge_inference_configs, - detectors=self.detectors, - ) - detectors_config.add_detector(detector, edge_inference_config) - self.edge_inference_configs = detectors_config.edge_inference_configs - self.detectors = detectors_config.detectors + _add_detector_to_state(self.edge_inference_configs, self.detectors, detector, edge_inference_config) @classmethod def from_detectors_config( @@ -172,9 +177,6 @@ def from_detectors_config( ) -EdgeInferenceConfig = InferenceConfig - - # Preset inference configs matching the standard edge-endpoint defaults. DEFAULT = InferenceConfig(name="default") EDGE_ANSWERS_WITH_ESCALATION = InferenceConfig( diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index f41800b4..9204baf3 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,15 +1,38 @@ +from datetime import datetime + import pytest +from model import Detector, DetectorTypeEnum + from groundlight.edge import ( DEFAULT, + DISABLED, EDGE_ANSWERS_WITH_ESCALATION, NO_CLOUD, DetectorsConfig, EdgeEndpointConfig, + GlobalConfig, InferenceConfig, ) ONE_DETECTOR = 1 TWO_DETECTORS = 2 +THREE_DETECTORS = 3 +CUSTOM_REFRESH_RATE = 10.0 +CUSTOM_AUDIT_RATE = 0.0 + + +def _make_detector(detector_id: str) -> Detector: + return Detector( + id=detector_id, + type=DetectorTypeEnum.detector, + created_at=datetime.utcnow(), + name="test detector", + query="Is there a dog?", + group_name="default", + metadata=None, + mode="BINARY", + mode_configuration=None, + ) def test_edge_endpoint_config_is_not_subclass_of_detectors_config(): @@ -89,6 +112,29 @@ def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): assert set(config.edge_inference_configs.keys()) == {"no_cloud", "edge_answers_with_escalation", "default"} +def test_add_detector_accepts_detector_object(): + config = EdgeEndpointConfig() + config.add_detector(_make_detector("det_1"), DEFAULT) + + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + +def test_add_detector_accepts_string_inference_config_name(): + config = EdgeEndpointConfig() + config.edge_inference_configs["default"] = DEFAULT + config.add_detector("det_1", "default") + + assert [detector.edge_inference_config for detector in config.detectors] == ["default"] + + +def test_disabled_preset_can_be_used(): + config = EdgeEndpointConfig() + config.add_detector("det_1", DISABLED) + + assert [detector.edge_inference_config for detector in config.detectors] == ["disabled"] + assert config.edge_inference_configs["disabled"] == DISABLED + + def test_from_detectors_config_copies_detector_data(): detectors_config = DetectorsConfig() detectors_config.add_detector("det_1", DEFAULT) @@ -100,6 +146,33 @@ def test_from_detectors_config_copies_detector_data(): assert len(detectors_config.detectors) == TWO_DETECTORS +def test_from_detectors_config_uses_custom_global_config(): + detectors_config = DetectorsConfig() + detectors_config.add_detector("det_1", DEFAULT) + custom_global_config = GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) + + config = EdgeEndpointConfig.from_detectors_config(detectors_config, global_config=custom_global_config) + + assert config.global_config == custom_global_config + assert len(config.detectors) == ONE_DETECTOR + + +def test_model_dump_shape_for_edge_endpoint_config(): + config = EdgeEndpointConfig( + global_config=GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) + ) + config.add_detector("det_1", DEFAULT) + config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) + config.add_detector("det_3", NO_CLOUD) + + payload = config.model_dump() + + assert payload["global_config"]["refresh_rate"] == CUSTOM_REFRESH_RATE + assert payload["global_config"]["confident_audit_rate"] == CUSTOM_AUDIT_RATE + assert len(payload["detectors"]) == THREE_DETECTORS + assert set(payload["edge_inference_configs"].keys()) == {"default", "edge_answers_with_escalation", "no_cloud"} + + def test_inference_config_validation_errors(): with pytest.raises(ValueError, match="disable_cloud_escalation"): InferenceConfig(name="bad", disable_cloud_escalation=True) From dbe714f71df201c99ab29b8632d76b4c77598e2d Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Mon, 16 Mar 2026 23:58:55 +0000 Subject: [PATCH 11/21] Automatically reformatting code --- test/unit/test_edge_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 9204baf3..a592f787 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,8 +1,6 @@ from datetime import datetime import pytest -from model import Detector, DetectorTypeEnum - from groundlight.edge import ( DEFAULT, DISABLED, @@ -13,6 +11,7 @@ GlobalConfig, InferenceConfig, ) +from model import Detector, DetectorTypeEnum ONE_DETECTOR = 1 TWO_DETECTORS = 2 From 0fbd05e36be9b9ce651696a1de46f917e1cca680 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 17 Mar 2026 10:19:05 -0700 Subject: [PATCH 12/21] code cleanup --- src/groundlight/edge/config.py | 157 ++++++++++++++++----------------- test/unit/test_edge_config.py | 29 ++---- 2 files changed, 80 insertions(+), 106 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 003e54af..a6787289 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -1,7 +1,7 @@ from typing import Optional, Union from model import Detector -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator from typing_extensions import Self @@ -74,63 +74,6 @@ class DetectorConfig(BaseModel): edge_inference_config: str = Field(..., description="Config for edge inference.") -def _validate_detector_config_state( - edge_inference_configs: dict[str, InferenceConfig], detectors: list[DetectorConfig] -) -> None: - for name, config in edge_inference_configs.items(): - if name != config.name: - raise ValueError(f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'.") - - seen_detector_ids = set() - duplicate_detector_ids = set() - for detector_config in detectors: - detector_id = detector_config.detector_id - if detector_id in seen_detector_ids: - duplicate_detector_ids.add(detector_id) - else: - seen_detector_ids.add(detector_id) - if duplicate_detector_ids: - duplicates = ", ".join(sorted(duplicate_detector_ids)) - raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.") - - for detector_config in detectors: - if detector_config.edge_inference_config not in edge_inference_configs: - raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") - - -def _add_detector_to_state( - edge_inference_configs: dict[str, InferenceConfig], - detectors: list[DetectorConfig], - detector: Union[str, Detector], - edge_inference_config: Union[str, InferenceConfig], -) -> DetectorConfig: - detector_id = detector.id if isinstance(detector, Detector) else detector - if any(existing.detector_id == detector_id for existing in detectors): - raise ValueError(f"A detector with ID '{detector_id}' already exists.") - if isinstance(edge_inference_config, InferenceConfig): - config = edge_inference_config - existing = edge_inference_configs.get(config.name) - if existing is None: - edge_inference_configs[config.name] = config - elif existing != config: - raise ValueError(f"A different inference config named '{config.name}' is already registered.") - config_name = config.name - else: - config_name = edge_inference_config - if config_name not in edge_inference_configs: - raise ValueError( - f"Edge inference config '{config_name}' not defined. " - f"Available configs: {list(edge_inference_configs.keys())}" - ) - - detector_config = DetectorConfig( - detector_id=detector_id, - edge_inference_config=config_name, - ) - detectors.append(detector_config) - return detector_config - - class DetectorsConfig(BaseModel): """ Detector and inference-config mappings for edge inference. @@ -141,11 +84,59 @@ class DetectorsConfig(BaseModel): @model_validator(mode="after") def validate_inference_configs(self): - _validate_detector_config_state(self.edge_inference_configs, self.detectors) + """ + Validates detector config state. + Raises ValueError if dict keys mismatch InferenceConfig.name, detector IDs are duplicated, + or any detector references an undefined inference config. + """ + for name, config in self.edge_inference_configs.items(): + if name != config.name: + raise ValueError(f"Edge inference config key '{name}' must match InferenceConfig.name '{config.name}'.") + + seen_detector_ids = set() + duplicate_detector_ids = set() + for detector_config in self.detectors: + detector_id = detector_config.detector_id + if detector_id in seen_detector_ids: + duplicate_detector_ids.add(detector_id) + else: + seen_detector_ids.add(detector_id) + if duplicate_detector_ids: + duplicates = ", ".join(sorted(duplicate_detector_ids)) + raise ValueError(f"Duplicate detector IDs are not allowed: {duplicates}.") + + for detector_config in self.detectors: + if detector_config.edge_inference_config not in self.edge_inference_configs: + raise ValueError(f"Edge inference config '{detector_config.edge_inference_config}' not defined.") return self - def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: - _add_detector_to_state(self.edge_inference_configs, self.detectors, detector, edge_inference_config) + def add_detector(self, detector: Union[str, Detector], edge_inference_config: InferenceConfig) -> None: + """Add a detector with the given inference config. Accepts detector ID or Detector object.""" + detector_id = detector.id if isinstance(detector, Detector) else detector + if any(existing.detector_id == detector_id for existing in self.detectors): + raise ValueError(f"A detector with ID '{detector_id}' already exists.") + + existing = self.edge_inference_configs.get(edge_inference_config.name) + if existing is None: + self.edge_inference_configs[edge_inference_config.name] = edge_inference_config + elif existing != edge_inference_config: + raise ValueError( + f"A different inference config named '{edge_inference_config.name}' is already registered." + ) + + self.detectors.append( + DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name) + ) + + + def to_payload(self) -> dict[str, object]: + """Return flattened detector payload used by edge-endpoint config HTTP APIs.""" + return { + "edge_inference_configs": { + name: config.model_dump() for name, config in self.edge_inference_configs.items() + }, + "detectors": [detector.model_dump() for detector in self.detectors], + } class EdgeEndpointConfig(BaseModel): @@ -154,27 +145,29 @@ class EdgeEndpointConfig(BaseModel): """ global_config: GlobalConfig = Field(default_factory=GlobalConfig) - edge_inference_configs: dict[str, InferenceConfig] = Field(default_factory=dict) - detectors: list[DetectorConfig] = Field(default_factory=list) - - @model_validator(mode="after") - def validate_inference_configs(self): - _validate_detector_config_state(self.edge_inference_configs, self.detectors) - return self - - def add_detector(self, detector: Union[str, Detector], edge_inference_config: Union[str, InferenceConfig]) -> None: - _add_detector_to_state(self.edge_inference_configs, self.detectors, detector, edge_inference_config) - - @classmethod - def from_detectors_config( - cls, detectors_config: "DetectorsConfig", global_config: Optional[GlobalConfig] = None - ) -> "EdgeEndpointConfig": - copied_config = detectors_config.model_copy(deep=True) - return cls( - global_config=global_config or GlobalConfig(), - edge_inference_configs=copied_config.edge_inference_configs, - detectors=copied_config.detectors, - ) + detectors_config: DetectorsConfig = Field(default_factory=DetectorsConfig) + + @property + def edge_inference_configs(self) -> dict[str, InferenceConfig]: + """Convenience accessor for detector inference config map.""" + return self.detectors_config.edge_inference_configs + + @property + def detectors(self) -> list[DetectorConfig]: + """Convenience accessor for detector assignments.""" + return self.detectors_config.detectors + + @model_serializer(mode="plain") + def serialize(self): + """Serialize to the flattened shape expected by edge-endpoint configs.""" + return { + "global_config": self.global_config.model_dump(), + **self.detectors_config.to_payload(), + } + + def add_detector(self, detector: Union[str, Detector], edge_inference_config: InferenceConfig) -> None: + """Add a detector with the given inference config. Accepts detector ID or Detector object.""" + self.detectors_config.add_detector(detector, edge_inference_config) # Preset inference configs matching the standard edge-endpoint defaults. diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 9204baf3..b88d8623 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -14,7 +14,6 @@ InferenceConfig, ) -ONE_DETECTOR = 1 TWO_DETECTORS = 2 THREE_DETECTORS = 3 CUSTOM_REFRESH_RATE = 10.0 @@ -119,13 +118,6 @@ def test_add_detector_accepts_detector_object(): assert [detector.detector_id for detector in config.detectors] == ["det_1"] -def test_add_detector_accepts_string_inference_config_name(): - config = EdgeEndpointConfig() - config.edge_inference_configs["default"] = DEFAULT - config.add_detector("det_1", "default") - - assert [detector.edge_inference_config for detector in config.detectors] == ["default"] - def test_disabled_preset_can_be_used(): config = EdgeEndpointConfig() @@ -135,26 +127,15 @@ def test_disabled_preset_can_be_used(): assert config.edge_inference_configs["disabled"] == DISABLED -def test_from_detectors_config_copies_detector_data(): - detectors_config = DetectorsConfig() - detectors_config.add_detector("det_1", DEFAULT) - - config = EdgeEndpointConfig.from_detectors_config(detectors_config) - detectors_config.add_detector("det_2", DEFAULT) - - assert len(config.detectors) == ONE_DETECTOR - assert len(detectors_config.detectors) == TWO_DETECTORS - - -def test_from_detectors_config_uses_custom_global_config(): +def test_detectors_config_to_payload_shape(): detectors_config = DetectorsConfig() detectors_config.add_detector("det_1", DEFAULT) - custom_global_config = GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) + detectors_config.add_detector("det_2", NO_CLOUD) - config = EdgeEndpointConfig.from_detectors_config(detectors_config, global_config=custom_global_config) + payload = detectors_config.to_payload() - assert config.global_config == custom_global_config - assert len(config.detectors) == ONE_DETECTOR + assert len(payload["detectors"]) == TWO_DETECTORS + assert set(payload["edge_inference_configs"].keys()) == {"default", "no_cloud"} def test_model_dump_shape_for_edge_endpoint_config(): From 21c5cd8a252489783f451ad60910f7fc34223a0d Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Tue, 17 Mar 2026 17:20:04 +0000 Subject: [PATCH 13/21] Automatically reformatting code --- src/groundlight/edge/config.py | 5 +---- test/unit/test_edge_config.py | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index a6787289..18ab3bf7 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -124,10 +124,7 @@ def add_detector(self, detector: Union[str, Detector], edge_inference_config: In f"A different inference config named '{edge_inference_config.name}' is already registered." ) - self.detectors.append( - DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name) - ) - + self.detectors.append(DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name)) def to_payload(self) -> dict[str, object]: """Return flattened detector payload used by edge-endpoint config HTTP APIs.""" diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 8b6c7605..8777572b 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -117,7 +117,6 @@ def test_add_detector_accepts_detector_object(): assert [detector.detector_id for detector in config.detectors] == ["det_1"] - def test_disabled_preset_can_be_used(): config = EdgeEndpointConfig() config.add_detector("det_1", DISABLED) From 963b35b20176c241719270274d0ccfa1e617cb1e Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 17 Mar 2026 10:55:07 -0700 Subject: [PATCH 14/21] more code cleanup --- src/groundlight/edge/config.py | 5 +++-- test/unit/test_edge_config.py | 33 +++++++++++++++++++++++---------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index a6787289..6040b886 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import Any, Optional, Union from model import Detector from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator @@ -21,6 +21,7 @@ class InferenceConfig(BaseModel): Configuration for edge inference on a specific detector. """ + # Keep shared presets immutable (DEFAULT/NO_CLOUD/etc.) so one mutation cannot globally change behavior. model_config = ConfigDict(frozen=True) name: str = Field(..., exclude=True, description="A unique name for this inference config preset.") @@ -129,7 +130,7 @@ def add_detector(self, detector: Union[str, Detector], edge_inference_config: In ) - def to_payload(self) -> dict[str, object]: + def to_payload(self) -> dict[str, Any]: """Return flattened detector payload used by edge-endpoint config HTTP APIs.""" return { "edge_inference_configs": { diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 8b6c7605..46a1a035 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,6 +1,8 @@ -from datetime import datetime +from datetime import datetime, timezone import pytest +from model import Detector, DetectorTypeEnum + from groundlight.edge import ( DEFAULT, DISABLED, @@ -11,10 +13,7 @@ GlobalConfig, InferenceConfig, ) -from model import Detector, DetectorTypeEnum -TWO_DETECTORS = 2 -THREE_DETECTORS = 3 CUSTOM_REFRESH_RATE = 10.0 CUSTOM_AUDIT_RATE = 0.0 @@ -23,7 +22,7 @@ def _make_detector(detector_id: str) -> Detector: return Detector( id=detector_id, type=DetectorTypeEnum.detector, - created_at=datetime.utcnow(), + created_at=datetime.now(timezone.utc), name="test detector", query="Is there a dog?", group_name="default", @@ -56,7 +55,7 @@ def test_add_detector_allows_equivalent_named_inference_config(): ), ) - assert len(detectors_config.detectors) == TWO_DETECTORS + assert len(detectors_config.detectors) == 2 # noqa: PLR2004 assert list(detectors_config.edge_inference_configs.keys()) == ["custom_config"] @@ -71,6 +70,14 @@ def test_add_detector_rejects_different_named_inference_config(): ) +def test_add_detector_rejects_duplicate_detector_id(): + detectors_config = DetectorsConfig() + detectors_config.add_detector("det_1", DEFAULT) + + with pytest.raises(ValueError, match="already exists"): + detectors_config.add_detector("det_1", DEFAULT) + + def test_constructor_rejects_duplicate_detector_ids(): with pytest.raises(ValueError, match="Duplicate detector IDs"): DetectorsConfig( @@ -100,6 +107,14 @@ def test_constructor_accepts_matching_inference_config_key_and_name(): assert [detector.detector_id for detector in config.detectors] == ["det_1"] +def test_constructor_rejects_undefined_inference_config_reference(): + with pytest.raises(ValueError, match="not defined"): + DetectorsConfig( + edge_inference_configs={}, + detectors=[{"detector_id": "det_1", "edge_inference_config": "does_not_exist"}], + ) + + def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): config = EdgeEndpointConfig() config.add_detector("det_1", NO_CLOUD) @@ -116,8 +131,6 @@ def test_add_detector_accepts_detector_object(): assert [detector.detector_id for detector in config.detectors] == ["det_1"] - - def test_disabled_preset_can_be_used(): config = EdgeEndpointConfig() config.add_detector("det_1", DISABLED) @@ -133,7 +146,7 @@ def test_detectors_config_to_payload_shape(): payload = detectors_config.to_payload() - assert len(payload["detectors"]) == TWO_DETECTORS + assert len(payload["detectors"]) == 2 # noqa: PLR2004 assert set(payload["edge_inference_configs"].keys()) == {"default", "no_cloud"} @@ -149,7 +162,7 @@ def test_model_dump_shape_for_edge_endpoint_config(): assert payload["global_config"]["refresh_rate"] == CUSTOM_REFRESH_RATE assert payload["global_config"]["confident_audit_rate"] == CUSTOM_AUDIT_RATE - assert len(payload["detectors"]) == THREE_DETECTORS + assert len(payload["detectors"]) == 3 # noqa: PLR2004 assert set(payload["edge_inference_configs"].keys()) == {"default", "edge_answers_with_escalation", "no_cloud"} From 9c0670117befa525014109dbd08c8472a64b852b Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Tue, 17 Mar 2026 17:56:59 +0000 Subject: [PATCH 15/21] Automatically reformatting code --- test/unit/test_edge_config.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 2864ffb5..ffcac800 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,8 +1,6 @@ from datetime import datetime, timezone import pytest -from model import Detector, DetectorTypeEnum - from groundlight.edge import ( DEFAULT, DISABLED, @@ -13,6 +11,7 @@ GlobalConfig, InferenceConfig, ) +from model import Detector, DetectorTypeEnum CUSTOM_REFRESH_RATE = 10.0 CUSTOM_AUDIT_RATE = 0.0 @@ -130,6 +129,8 @@ def test_add_detector_accepts_detector_object(): config.add_detector(_make_detector("det_1"), DEFAULT) assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + def test_disabled_preset_can_be_used(): config = EdgeEndpointConfig() config.add_detector("det_1", DISABLED) From db1d86b0beb805d8c7eb3f8f3dee585cfb6c6744 Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 17 Mar 2026 15:42:58 -0700 Subject: [PATCH 16/21] responding to PR feedback --- pyproject.toml | 2 +- test/unit/test_edge_config.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5bd9f5b..7f459583 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = [ {include = "**/*.py", from = "src"}, ] readme = "README.md" -version = "0.24.0" +version = "0.25.0" [tool.poetry.dependencies] # For certifi, use ">=" instead of "^" since it upgrades its "major version" every year, not really following semver diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 2864ffb5..aeb691fb 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -32,11 +32,8 @@ def _make_detector(detector_id: str) -> Detector: ) -def test_edge_endpoint_config_is_not_subclass_of_detectors_config(): - assert not issubclass(EdgeEndpointConfig, DetectorsConfig) - - def test_add_detector_allows_equivalent_named_inference_config(): + """Allows reusing the same named inference config with equivalent values.""" detectors_config = DetectorsConfig() detectors_config.add_detector( "det_1", @@ -60,6 +57,7 @@ def test_add_detector_allows_equivalent_named_inference_config(): def test_add_detector_rejects_different_named_inference_config(): + """Rejects conflicting inference config values under the same name.""" detectors_config = DetectorsConfig() detectors_config.add_detector("det_1", InferenceConfig(name="custom_config")) @@ -71,6 +69,7 @@ def test_add_detector_rejects_different_named_inference_config(): def test_add_detector_rejects_duplicate_detector_id(): + """Rejects adding the same detector ID more than once.""" detectors_config = DetectorsConfig() detectors_config.add_detector("det_1", DEFAULT) @@ -79,6 +78,7 @@ def test_add_detector_rejects_duplicate_detector_id(): def test_constructor_rejects_duplicate_detector_ids(): + """Rejects duplicated detector IDs in constructor input.""" with pytest.raises(ValueError, match="Duplicate detector IDs"): DetectorsConfig( edge_inference_configs={"default": DEFAULT}, @@ -90,6 +90,7 @@ def test_constructor_rejects_duplicate_detector_ids(): def test_constructor_rejects_mismatched_inference_config_key_and_name(): + """Rejects inference config dict keys that do not match config names.""" with pytest.raises(ValueError, match="must match InferenceConfig.name"): DetectorsConfig( edge_inference_configs={"default": InferenceConfig(name="not_default")}, @@ -98,6 +99,7 @@ def test_constructor_rejects_mismatched_inference_config_key_and_name(): def test_constructor_accepts_matching_inference_config_key_and_name(): + """Accepts constructor input when key/name pairs are consistent.""" config = DetectorsConfig( edge_inference_configs={"default": InferenceConfig(name="default")}, detectors=[{"detector_id": "det_1", "edge_inference_config": "default"}], @@ -108,6 +110,7 @@ def test_constructor_accepts_matching_inference_config_key_and_name(): def test_constructor_rejects_undefined_inference_config_reference(): + """Rejects detector entries that reference missing inference configs.""" with pytest.raises(ValueError, match="not defined"): DetectorsConfig( edge_inference_configs={}, @@ -116,6 +119,7 @@ def test_constructor_rejects_undefined_inference_config_reference(): def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): + """Adds detectors via EdgeEndpointConfig and preserves inferred config mapping.""" config = EdgeEndpointConfig() config.add_detector("det_1", NO_CLOUD) config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) @@ -126,11 +130,15 @@ def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): def test_add_detector_accepts_detector_object(): + """Accepts Detector objects in add_detector.""" config = EdgeEndpointConfig() config.add_detector(_make_detector("det_1"), DEFAULT) assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + def test_disabled_preset_can_be_used(): + """Allows assigning the DISABLED inference preset to a detector.""" config = EdgeEndpointConfig() config.add_detector("det_1", DISABLED) @@ -139,6 +147,7 @@ def test_disabled_preset_can_be_used(): def test_detectors_config_to_payload_shape(): + """Serializes detector-scoped payload with expected top-level keys.""" detectors_config = DetectorsConfig() detectors_config.add_detector("det_1", DEFAULT) detectors_config.add_detector("det_2", NO_CLOUD) @@ -150,6 +159,7 @@ def test_detectors_config_to_payload_shape(): def test_model_dump_shape_for_edge_endpoint_config(): + """Serializes full edge endpoint config in flattened wire shape.""" config = EdgeEndpointConfig( global_config=GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) ) @@ -166,6 +176,7 @@ def test_model_dump_shape_for_edge_endpoint_config(): def test_inference_config_validation_errors(): + """Raises on invalid inference config flag combinations and values.""" with pytest.raises(ValueError, match="disable_cloud_escalation"): InferenceConfig(name="bad", disable_cloud_escalation=True) From 79121f8403698ecf71b6213331acce1a5bbc4fcf Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Tue, 17 Mar 2026 15:57:49 -0700 Subject: [PATCH 17/21] addressing linter error --- test/unit/test_edge_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 0ae936e8..aeb691fb 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,6 +1,8 @@ from datetime import datetime, timezone import pytest +from model import Detector, DetectorTypeEnum + from groundlight.edge import ( DEFAULT, DISABLED, @@ -11,7 +13,6 @@ GlobalConfig, InferenceConfig, ) -from model import Detector, DetectorTypeEnum CUSTOM_REFRESH_RATE = 10.0 CUSTOM_AUDIT_RATE = 0.0 From 418eecf5a9276defdd5a1cb024fe7c133bbbfe97 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Tue, 17 Mar 2026 22:58:36 +0000 Subject: [PATCH 18/21] Automatically reformatting code --- test/unit/test_edge_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index aeb691fb..0ae936e8 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,8 +1,6 @@ from datetime import datetime, timezone import pytest -from model import Detector, DetectorTypeEnum - from groundlight.edge import ( DEFAULT, DISABLED, @@ -13,6 +11,7 @@ GlobalConfig, InferenceConfig, ) +from model import Detector, DetectorTypeEnum CUSTOM_REFRESH_RATE = 10.0 CUSTOM_AUDIT_RATE = 0.0 From b0b53db3b721d2313262cbb2f8ebb3f311e05b5f Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 18 Mar 2026 03:38:34 +0000 Subject: [PATCH 19/21] code cleanup --- pyproject.toml | 1 + src/groundlight/edge/__init__.py | 2 + src/groundlight/edge/config.py | 98 ++++++++++++++++++++-------- test/unit/test_edge_config.py | 106 +++++++++++++++++++++++++++++-- 4 files changed, 177 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7f459583..42c10153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ python-dateutil = "^2.9.0" requests = "^2.28.2" typer = "^0.15.4" urllib3 = "^2.6.1" +pyyaml = "^6.0.3" [tool.poetry.group.dev.dependencies] datamodel-code-generator = "^0.35.0" diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index a3479721..42bfe99f 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -1,4 +1,5 @@ from .config import ( + ConfigBase, DEFAULT, DISABLED, EDGE_ANSWERS_WITH_ESCALATION, @@ -15,6 +16,7 @@ "DISABLED", "EDGE_ANSWERS_WITH_ESCALATION", "NO_CLOUD", + "ConfigBase", "DetectorsConfig", "DetectorConfig", "EdgeEndpointConfig", diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index 677a56a2..ca3c9493 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -1,11 +1,16 @@ from typing import Any, Optional, Union from model import Detector -from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing_extensions import Self +import yaml class GlobalConfig(BaseModel): + """Global runtime settings for edge-endpoint behavior.""" + + model_config = ConfigDict(extra="forbid") + refresh_rate: float = Field( default=60.0, description="The interval (in seconds) at which the inference server checks for a new model binary update.", @@ -22,7 +27,7 @@ class InferenceConfig(BaseModel): """ # Keep shared presets immutable (DEFAULT/NO_CLOUD/etc.) so one mutation cannot globally change behavior. - model_config = ConfigDict(frozen=True) + model_config = ConfigDict(extra="forbid", frozen=True) name: str = Field(..., exclude=True, description="A unique name for this inference config preset.") enabled: bool = Field( @@ -71,18 +76,41 @@ class DetectorConfig(BaseModel): Configuration for a specific detector. """ + model_config = ConfigDict(extra="forbid") + detector_id: str = Field(..., description="Detector ID") edge_inference_config: str = Field(..., description="Config for edge inference.") -class DetectorsConfig(BaseModel): - """ - Detector and inference-config mappings for edge inference. - """ +class ConfigBase(BaseModel): + """Shared detector/inference configuration behavior for edge config models.""" + + model_config = ConfigDict(extra="forbid") edge_inference_configs: dict[str, InferenceConfig] = Field(default_factory=dict) detectors: list[DetectorConfig] = Field(default_factory=list) + @field_validator("edge_inference_configs", mode="before") + @classmethod + def hydrate_inference_config_names( + cls, value: dict[str, InferenceConfig | dict[str, Any]] | None + ) -> dict[str, InferenceConfig | dict[str, Any]]: + """Hydrate InferenceConfig.name from payload mapping keys.""" + if value is None: + return {} + if not isinstance(value, dict): + return value + + hydrated_configs: dict[str, InferenceConfig | dict[str, Any]] = {} + for name, config in value.items(): + if isinstance(config, InferenceConfig): + hydrated_configs[name] = config + continue + if not isinstance(config, dict): + raise TypeError("Each edge inference config must be an object.") + hydrated_configs[name] = {"name": name, **config} + return hydrated_configs + @model_validator(mode="after") def validate_inference_configs(self): """ @@ -128,7 +156,7 @@ def add_detector(self, detector: Union[str, Detector], edge_inference_config: In self.detectors.append(DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name)) def to_payload(self) -> dict[str, Any]: - """Return flattened detector payload used by edge-endpoint config HTTP APIs.""" + """Return detector payload used by edge-endpoint config HTTP APIs.""" return { "edge_inference_configs": { name: config.model_dump() for name, config in self.edge_inference_configs.items() @@ -137,36 +165,54 @@ def to_payload(self) -> dict[str, Any]: } -class EdgeEndpointConfig(BaseModel): +class DetectorsConfig(ConfigBase): + """ + Detector and inference-config mappings for edge inference. + """ + + +class EdgeEndpointConfig(ConfigBase): """ Top-level edge endpoint configuration. """ global_config: GlobalConfig = Field(default_factory=GlobalConfig) - detectors_config: DetectorsConfig = Field(default_factory=DetectorsConfig) - - @property - def edge_inference_configs(self) -> dict[str, InferenceConfig]: - """Convenience accessor for detector inference config map.""" - return self.detectors_config.edge_inference_configs - @property - def detectors(self) -> list[DetectorConfig]: - """Convenience accessor for detector assignments.""" - return self.detectors_config.detectors + @classmethod + def from_yaml( + cls, + filename: Optional[str] = None, + yaml_str: Optional[str] = None, + ) -> "EdgeEndpointConfig": + """Create an EdgeEndpointConfig from a YAML filename or YAML string.""" + if filename is None and yaml_str is None: + raise ValueError("Either filename or yaml_str must be provided.") + if filename is not None and yaml_str is not None: + raise ValueError("Only one of filename or yaml_str can be provided.") + if filename is not None: + if not filename.strip(): + raise ValueError("filename must be a non-empty path when provided.") + with open(filename, "r") as f: + yaml_str = f.read() + + yaml_text = yaml_str or "" + parsed = yaml.safe_load(yaml_text) or {} + return cls.model_validate(parsed) - @model_serializer(mode="plain") - def serialize(self): - """Serialize to the flattened shape expected by edge-endpoint configs.""" + def to_payload(self) -> dict[str, Any]: + """Return the full edge-endpoint payload shape.""" return { "global_config": self.global_config.model_dump(), - **self.detectors_config.to_payload(), + "edge_inference_configs": { + name: config.model_dump() for name, config in self.edge_inference_configs.items() + }, + "detectors": [detector.model_dump() for detector in self.detectors], } - def add_detector(self, detector: Union[str, Detector], edge_inference_config: InferenceConfig) -> None: - """Add a detector with the given inference config. Accepts detector ID or Detector object.""" - self.detectors_config.add_detector(detector, edge_inference_config) - + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "EdgeEndpointConfig": + """Construct an EdgeEndpointConfig from a payload dictionary.""" + return cls.model_validate(payload) # Preset inference configs matching the standard edge-endpoint defaults. DEFAULT = InferenceConfig(name="default") diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index 0ae936e8..f7b29ed6 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,5 +1,4 @@ from datetime import datetime, timezone - import pytest from groundlight.edge import ( DEFAULT, @@ -108,6 +107,25 @@ def test_constructor_accepts_matching_inference_config_key_and_name(): assert [detector.detector_id for detector in config.detectors] == ["det_1"] +def test_constructor_hydrates_inference_config_name_from_dict_key(): + """Hydrates inference config names from payload dict keys.""" + config = DetectorsConfig( + edge_inference_configs={"default": {"enabled": True}}, + detectors=[{"detector_id": "det_1", "edge_inference_config": "default"}], + ) + + assert config.edge_inference_configs["default"].name == "default" + + +def test_constructor_rejects_detector_map_input(): + """Rejects detector maps and requires detector list payloads.""" + with pytest.raises(ValueError): + DetectorsConfig( + edge_inference_configs={"default": {"enabled": True}}, + detectors={"det_1": {"detector_id": "det_1", "edge_inference_config": "default"}}, + ) + + def test_constructor_rejects_undefined_inference_config_reference(): """Rejects detector entries that reference missing inference configs.""" with pytest.raises(ValueError, match="not defined"): @@ -117,7 +135,7 @@ def test_constructor_rejects_undefined_inference_config_reference(): ) -def test_edge_endpoint_config_add_detector_delegates_to_detectors_logic(): +def test_edge_endpoint_config_add_detector_uses_shared_config_logic(): """Adds detectors via EdgeEndpointConfig and preserves inferred config mapping.""" config = EdgeEndpointConfig() config.add_detector("det_1", NO_CLOUD) @@ -157,8 +175,76 @@ def test_detectors_config_to_payload_shape(): assert set(payload["edge_inference_configs"].keys()) == {"default", "no_cloud"} +def test_edge_endpoint_config_accepts_top_level_payload_shape(): + """Accepts the top-level edge endpoint payload shape used by APIs.""" + config = EdgeEndpointConfig.model_validate( + { + "global_config": {"refresh_rate": CUSTOM_REFRESH_RATE}, + "edge_inference_configs": {"default": {"enabled": True}}, + "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + } + ) + + assert config.global_config.refresh_rate == CUSTOM_REFRESH_RATE + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + +def test_edge_endpoint_config_from_yaml_accepts_yaml_text(): + """Parses edge-endpoint YAML text using EdgeEndpointConfig.from_yaml.""" + config = EdgeEndpointConfig.from_yaml( + yaml_str=""" + global_config: + refresh_rate: 15.0 + edge_inference_configs: + default: + enabled: true + detectors: + - detector_id: det_1 + edge_inference_config: default + """ + ) + + assert config.global_config.refresh_rate == 15.0 + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + +def test_edge_endpoint_config_from_yaml_accepts_filename(tmp_path): + """Parses edge-endpoint YAML from a file path.""" + config_file = tmp_path / "edge-config.yaml" + config_file.write_text( + "global_config: {}\n" + "edge_inference_configs:\n" + " default:\n" + " enabled: true\n" + "detectors:\n" + " - detector_id: det_1\n" + " edge_inference_config: default\n" + ) + config = EdgeEndpointConfig.from_yaml(filename=str(config_file)) + + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + +def test_edge_endpoint_config_from_yaml_requires_exactly_one_input(): + """Rejects missing input and mixed filename/yaml_str input.""" + with pytest.raises(ValueError, match="Either filename or yaml_str must be provided"): + EdgeEndpointConfig.from_yaml() + + with pytest.raises(ValueError, match="Only one of filename or yaml_str can be provided"): + EdgeEndpointConfig.from_yaml(filename="a.yaml", yaml_str="global_config: {}") + + with pytest.raises(ValueError, match="filename must be a non-empty path"): + EdgeEndpointConfig.from_yaml(filename=" ") + + +def test_edge_endpoint_config_rejects_extra_top_level_fields(): + """Rejects unknown top-level fields to avoid silent config drift.""" + with pytest.raises(ValueError, match="Extra inputs are not permitted"): + EdgeEndpointConfig.model_validate({"global_config": {}, "unknown_field": True}) + + def test_model_dump_shape_for_edge_endpoint_config(): - """Serializes full edge endpoint config in flattened wire shape.""" + """Serializes full edge endpoint config in wire payload shape.""" config = EdgeEndpointConfig( global_config=GlobalConfig(refresh_rate=CUSTOM_REFRESH_RATE, confident_audit_rate=CUSTOM_AUDIT_RATE) ) @@ -166,7 +252,7 @@ def test_model_dump_shape_for_edge_endpoint_config(): config.add_detector("det_2", EDGE_ANSWERS_WITH_ESCALATION) config.add_detector("det_3", NO_CLOUD) - payload = config.model_dump() + payload = config.to_payload() assert payload["global_config"]["refresh_rate"] == CUSTOM_REFRESH_RATE assert payload["global_config"]["confident_audit_rate"] == CUSTOM_AUDIT_RATE @@ -174,6 +260,18 @@ def test_model_dump_shape_for_edge_endpoint_config(): assert set(payload["edge_inference_configs"].keys()) == {"default", "edge_answers_with_escalation", "no_cloud"} +def test_edge_endpoint_config_from_payload_round_trip(): + """Round-trips edge endpoint config through payload helpers.""" + config = EdgeEndpointConfig() + config.add_detector("det_1", DEFAULT) + config.add_detector("det_2", NO_CLOUD) + + payload = config.to_payload() + reconstructed = EdgeEndpointConfig.from_payload(payload) + + assert reconstructed == config + + def test_inference_config_validation_errors(): """Raises on invalid inference config flag combinations and values.""" with pytest.raises(ValueError, match="disable_cloud_escalation"): From bf0c2842858518a9aac36e4fcb33f5c004ae85a5 Mon Sep 17 00:00:00 2001 From: Auto-format Bot Date: Wed, 18 Mar 2026 03:39:15 +0000 Subject: [PATCH 20/21] Automatically reformatting code --- src/groundlight/edge/config.py | 3 ++- test/unit/test_edge_config.py | 19 ++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index ca3c9493..f26da467 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -1,9 +1,9 @@ from typing import Any, Optional, Union +import yaml from model import Detector from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing_extensions import Self -import yaml class GlobalConfig(BaseModel): @@ -214,6 +214,7 @@ def from_payload(cls, payload: dict[str, Any]) -> "EdgeEndpointConfig": """Construct an EdgeEndpointConfig from a payload dictionary.""" return cls.model_validate(payload) + # Preset inference configs matching the standard edge-endpoint defaults. DEFAULT = InferenceConfig(name="default") EDGE_ANSWERS_WITH_ESCALATION = InferenceConfig( diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index f7b29ed6..c9a9b1c6 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone + import pytest from groundlight.edge import ( DEFAULT, @@ -177,13 +178,11 @@ def test_detectors_config_to_payload_shape(): def test_edge_endpoint_config_accepts_top_level_payload_shape(): """Accepts the top-level edge endpoint payload shape used by APIs.""" - config = EdgeEndpointConfig.model_validate( - { - "global_config": {"refresh_rate": CUSTOM_REFRESH_RATE}, - "edge_inference_configs": {"default": {"enabled": True}}, - "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], - } - ) + config = EdgeEndpointConfig.model_validate({ + "global_config": {"refresh_rate": CUSTOM_REFRESH_RATE}, + "edge_inference_configs": {"default": {"enabled": True}}, + "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + }) assert config.global_config.refresh_rate == CUSTOM_REFRESH_RATE assert [detector.detector_id for detector in config.detectors] == ["det_1"] @@ -191,8 +190,7 @@ def test_edge_endpoint_config_accepts_top_level_payload_shape(): def test_edge_endpoint_config_from_yaml_accepts_yaml_text(): """Parses edge-endpoint YAML text using EdgeEndpointConfig.from_yaml.""" - config = EdgeEndpointConfig.from_yaml( - yaml_str=""" + config = EdgeEndpointConfig.from_yaml(yaml_str=""" global_config: refresh_rate: 15.0 edge_inference_configs: @@ -201,8 +199,7 @@ def test_edge_endpoint_config_from_yaml_accepts_yaml_text(): detectors: - detector_id: det_1 edge_inference_config: default - """ - ) + """) assert config.global_config.refresh_rate == 15.0 assert [detector.detector_id for detector in config.detectors] == ["det_1"] From d0608a1badb6a62f14bdbbe1fb17437079778dad Mon Sep 17 00:00:00 2001 From: Tim Huff Date: Wed, 18 Mar 2026 04:03:15 +0000 Subject: [PATCH 21/21] responding to PR feedback --- src/groundlight/edge/__init__.py | 2 -- src/groundlight/edge/config.py | 19 ++----------------- test/unit/test_edge_config.py | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/groundlight/edge/__init__.py b/src/groundlight/edge/__init__.py index 42bfe99f..a3479721 100644 --- a/src/groundlight/edge/__init__.py +++ b/src/groundlight/edge/__init__.py @@ -1,5 +1,4 @@ from .config import ( - ConfigBase, DEFAULT, DISABLED, EDGE_ANSWERS_WITH_ESCALATION, @@ -16,7 +15,6 @@ "DISABLED", "EDGE_ANSWERS_WITH_ESCALATION", "NO_CLOUD", - "ConfigBase", "DetectorsConfig", "DetectorConfig", "EdgeEndpointConfig", diff --git a/src/groundlight/edge/config.py b/src/groundlight/edge/config.py index f26da467..bf6339ac 100644 --- a/src/groundlight/edge/config.py +++ b/src/groundlight/edge/config.py @@ -156,13 +156,8 @@ def add_detector(self, detector: Union[str, Detector], edge_inference_config: In self.detectors.append(DetectorConfig(detector_id=detector_id, edge_inference_config=edge_inference_config.name)) def to_payload(self) -> dict[str, Any]: - """Return detector payload used by edge-endpoint config HTTP APIs.""" - return { - "edge_inference_configs": { - name: config.model_dump() for name, config in self.edge_inference_configs.items() - }, - "detectors": [detector.model_dump() for detector in self.detectors], - } + """Return this config as a payload dictionary.""" + return self.model_dump() class DetectorsConfig(ConfigBase): @@ -199,16 +194,6 @@ def from_yaml( parsed = yaml.safe_load(yaml_text) or {} return cls.model_validate(parsed) - def to_payload(self) -> dict[str, Any]: - """Return the full edge-endpoint payload shape.""" - return { - "global_config": self.global_config.model_dump(), - "edge_inference_configs": { - name: config.model_dump() for name, config in self.edge_inference_configs.items() - }, - "detectors": [detector.model_dump() for detector in self.detectors], - } - @classmethod def from_payload(cls, payload: dict[str, Any]) -> "EdgeEndpointConfig": """Construct an EdgeEndpointConfig from a payload dictionary.""" diff --git a/test/unit/test_edge_config.py b/test/unit/test_edge_config.py index c9a9b1c6..3f10156c 100644 --- a/test/unit/test_edge_config.py +++ b/test/unit/test_edge_config.py @@ -269,6 +269,21 @@ def test_edge_endpoint_config_from_payload_round_trip(): assert reconstructed == config +def test_edge_endpoint_config_from_payload_accepts_literal_payload(): + """Constructs EdgeEndpointConfig from a literal payload dictionary.""" + payload = { + "global_config": {"refresh_rate": 15.0}, + "edge_inference_configs": {"default": {"enabled": True}}, + "detectors": [{"detector_id": "det_1", "edge_inference_config": "default"}], + } + + config = EdgeEndpointConfig.from_payload(payload) + + assert config.global_config.refresh_rate == 15.0 + assert config.edge_inference_configs["default"].name == "default" + assert [detector.detector_id for detector in config.detectors] == ["det_1"] + + def test_inference_config_validation_errors(): """Raises on invalid inference config flag combinations and values.""" with pytest.raises(ValueError, match="disable_cloud_escalation"):