233 lines
7.0 KiB
Python
233 lines
7.0 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from cmvr_edge_ai.capabilities import (
|
||
|
|
CapabilityDeploymentStatus,
|
||
|
|
CapabilityRegistry,
|
||
|
|
CapabilityRegistryError,
|
||
|
|
CapabilitySpec,
|
||
|
|
DuplicateCapabilityError,
|
||
|
|
ServingMode,
|
||
|
|
UnknownCapabilityError,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _factory(params: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
return dict(params)
|
||
|
|
|
||
|
|
|
||
|
|
def _spec(
|
||
|
|
model_id: str = "mobile-phone@1",
|
||
|
|
*,
|
||
|
|
category: str = "detect.mobile_phone",
|
||
|
|
serving_modes: tuple[ServingMode, ...] = (ServingMode.PASSIVE_INVOKE,),
|
||
|
|
) -> CapabilitySpec:
|
||
|
|
return CapabilitySpec(
|
||
|
|
category=category,
|
||
|
|
family="detect",
|
||
|
|
model_id=model_id,
|
||
|
|
serving_modes=serving_modes,
|
||
|
|
input_kinds=("image",),
|
||
|
|
output_kinds=("detections",),
|
||
|
|
artifact_roles=("annotated",),
|
||
|
|
parameters_schema={
|
||
|
|
"type": "object",
|
||
|
|
"properties": {"confidence": {"type": "number"}},
|
||
|
|
},
|
||
|
|
factory=_factory,
|
||
|
|
name="Mobile phone detector",
|
||
|
|
backend="onnxruntime",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_capability_spec_normalizes_and_defensively_copies_metadata() -> None:
|
||
|
|
schema: dict[str, Any] = {
|
||
|
|
"type": "object",
|
||
|
|
"required": ["confidence"],
|
||
|
|
}
|
||
|
|
spec = CapabilitySpec(
|
||
|
|
category="detect.analog_gauge",
|
||
|
|
family="detect",
|
||
|
|
model_id="analog-gauge-reader@1",
|
||
|
|
serving_modes=("active_push", "passive_invoke"),
|
||
|
|
input_kinds=("image",),
|
||
|
|
output_kinds=("scalar", "detections"),
|
||
|
|
artifact_roles=("annotated", "debug"),
|
||
|
|
parameters_schema=schema,
|
||
|
|
factory=_factory,
|
||
|
|
name=" Analog gauge reader ",
|
||
|
|
backend=" isolated-worker ",
|
||
|
|
)
|
||
|
|
schema["required"].append("unexpected")
|
||
|
|
|
||
|
|
assert spec.serving_modes == (
|
||
|
|
ServingMode.ACTIVE_PUSH,
|
||
|
|
ServingMode.PASSIVE_INVOKE,
|
||
|
|
)
|
||
|
|
assert spec.name == "Analog gauge reader"
|
||
|
|
assert spec.backend == "isolated-worker"
|
||
|
|
assert spec.parameters_schema["required"] == ["confidence"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize(
|
||
|
|
("overrides", "exception", "message"),
|
||
|
|
[
|
||
|
|
({"model_id": "mobile-phone"}, ValueError, "versioned"),
|
||
|
|
({"category": "talk.asr"}, ValueError, "must belong to family"),
|
||
|
|
({"category": "Detect.Phone"}, ValueError, "lowercase dotted"),
|
||
|
|
({"serving_modes": ()}, ValueError, "must not be empty"),
|
||
|
|
(
|
||
|
|
{"serving_modes": ("passive_invoke", "passive_invoke")},
|
||
|
|
ValueError,
|
||
|
|
"duplicates",
|
||
|
|
),
|
||
|
|
({"input_kinds": "image"}, TypeError, "ordered collection"),
|
||
|
|
({"output_kinds": ()}, ValueError, "must not be empty"),
|
||
|
|
({"factory": None}, TypeError, "factory must be callable"),
|
||
|
|
(
|
||
|
|
{"parameters_schema": {"default": object()}},
|
||
|
|
ValueError,
|
||
|
|
"only JSON values",
|
||
|
|
),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
def test_capability_spec_rejects_invalid_metadata(
|
||
|
|
overrides: dict[str, Any], exception: type[Exception], message: str
|
||
|
|
) -> None:
|
||
|
|
values: dict[str, Any] = {
|
||
|
|
"category": "detect.mobile_phone",
|
||
|
|
"family": "detect",
|
||
|
|
"model_id": "mobile-phone@1",
|
||
|
|
"serving_modes": ("passive_invoke",),
|
||
|
|
"input_kinds": ("image",),
|
||
|
|
"output_kinds": ("detections",),
|
||
|
|
"factory": _factory,
|
||
|
|
}
|
||
|
|
values.update(overrides)
|
||
|
|
with pytest.raises(exception, match=message):
|
||
|
|
CapabilitySpec(**values)
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_registers_resolves_filters_and_creates() -> None:
|
||
|
|
registry = CapabilityRegistry()
|
||
|
|
phone = registry.register(_spec("phone@1"))
|
||
|
|
gauge = registry.register(
|
||
|
|
_spec(
|
||
|
|
"gauge@2",
|
||
|
|
category="detect.analog_gauge",
|
||
|
|
serving_modes=(ServingMode.ACTIVE_PUSH, ServingMode.PASSIVE_INVOKE),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
assert registry.resolve("phone@1") is phone
|
||
|
|
assert registry.specs() == (gauge, phone)
|
||
|
|
assert registry.specs_for_category(
|
||
|
|
"detect.analog_gauge", serving_mode="active_push"
|
||
|
|
) == (gauge,)
|
||
|
|
assert registry.create("phone@1", {"confidence": 0.6}) == {"confidence": 0.6}
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_rejects_duplicates_and_reports_available_capabilities() -> None:
|
||
|
|
registry = CapabilityRegistry()
|
||
|
|
registry.register(_spec("phone@1"))
|
||
|
|
|
||
|
|
with pytest.raises(DuplicateCapabilityError, match="phone@1"):
|
||
|
|
registry.register(_spec("phone@1"))
|
||
|
|
with pytest.raises(
|
||
|
|
UnknownCapabilityError, match=r"available capabilities: phone@1"
|
||
|
|
):
|
||
|
|
registry.resolve("missing@1")
|
||
|
|
|
||
|
|
|
||
|
|
def test_catalog_groups_modes_and_keeps_registration_and_deployment_separate() -> None:
|
||
|
|
registry = CapabilityRegistry()
|
||
|
|
dual = registry.register(
|
||
|
|
_spec(
|
||
|
|
"gauge@1",
|
||
|
|
category="detect.analog_gauge",
|
||
|
|
serving_modes=(ServingMode.ACTIVE_PUSH, ServingMode.PASSIVE_INVOKE),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
passive = registry.register(_spec("phone@1"))
|
||
|
|
|
||
|
|
catalog = registry.build_catalog(
|
||
|
|
{
|
||
|
|
dual.model_id: CapabilityDeploymentStatus.READY,
|
||
|
|
passive.model_id: CapabilityDeploymentStatus.DISABLED,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
assert [item.model_id for item in catalog.active_push] == ["gauge@1"]
|
||
|
|
assert [item.model_id for item in catalog.passive_invoke] == [
|
||
|
|
"gauge@1",
|
||
|
|
"phone@1",
|
||
|
|
]
|
||
|
|
assert catalog.active_push[0].registration_status.value == "registered"
|
||
|
|
assert (
|
||
|
|
catalog.active_push[0].deployment_status
|
||
|
|
is CapabilityDeploymentStatus.READY
|
||
|
|
)
|
||
|
|
assert (
|
||
|
|
catalog.passive_invoke[1].deployment_status
|
||
|
|
is CapabilityDeploymentStatus.DISABLED
|
||
|
|
)
|
||
|
|
assert catalog.model_dump(mode="json")["schema_version"] == (
|
||
|
|
"cmvr.model-catalog/v1"
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(UnknownCapabilityError, match="unknown capability"):
|
||
|
|
registry.build_catalog({"missing@1": CapabilityDeploymentStatus.READY})
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class _EntryPoint:
|
||
|
|
name: str
|
||
|
|
value: Any
|
||
|
|
|
||
|
|
def load(self) -> Any:
|
||
|
|
return self.value
|
||
|
|
|
||
|
|
|
||
|
|
class _EntryPoints(list[_EntryPoint]):
|
||
|
|
def select(self, *, group: str) -> _EntryPoints:
|
||
|
|
assert group == CapabilityRegistry.ENTRY_POINT_GROUP
|
||
|
|
return self
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_discovers_specs_and_callbacks(
|
||
|
|
monkeypatch,
|
||
|
|
) -> None: # type: ignore[no-untyped-def]
|
||
|
|
def callback(registry: CapabilityRegistry) -> None:
|
||
|
|
registry.register(_spec("callback@1"))
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"cmvr_edge_ai.capabilities.registry.metadata.entry_points",
|
||
|
|
lambda: _EntryPoints(
|
||
|
|
[
|
||
|
|
_EntryPoint("spec", _spec("spec@1")),
|
||
|
|
_EntryPoint("callback", callback),
|
||
|
|
]
|
||
|
|
),
|
||
|
|
)
|
||
|
|
registry = CapabilityRegistry()
|
||
|
|
|
||
|
|
registry.load_entry_points()
|
||
|
|
|
||
|
|
assert [spec.model_id for spec in registry.specs()] == ["callback@1", "spec@1"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_registry_rejects_invalid_entry_point(
|
||
|
|
monkeypatch,
|
||
|
|
) -> None: # type: ignore[no-untyped-def]
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"cmvr_edge_ai.capabilities.registry.metadata.entry_points",
|
||
|
|
lambda: _EntryPoints([_EntryPoint("invalid", object())]),
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(CapabilityRegistryError, match="invalid"):
|
||
|
|
CapabilityRegistry().load_entry_points()
|