cmvr_edge_ai/tests/unit/test_invocation_pipeline_compile.py

514 lines
16 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from typing import Any
import pytest
from cmvr_edge_ai.compiler import PipelineCompileError, compile_pipeline
from cmvr_edge_ai.config import load_config_data
from cmvr_edge_ai.core import Envelope, Operator, Sink, Source
from cmvr_edge_ai.plugins import (
InvocationCardinality,
PluginKind,
PluginRegistry,
PluginSpec,
)
class EmptySource(Source):
async def messages(self): # type: ignore[no-untyped-def]
if False:
yield Envelope("unused")
class IdentityOperator(Operator):
async def process(
self, envelope: Envelope[Any], input_port: str = "input"
) -> Envelope[Any]:
del input_port
return envelope
class NullSink(Sink):
async def consume(
self, envelope: Envelope[Any], input_port: str = "input"
) -> None:
del envelope, input_port
def _source_factory(node_id: str, params: Mapping[str, Any]) -> EmptySource:
del node_id, params
return EmptySource()
def _operator_factory(
node_id: str, params: Mapping[str, Any]
) -> IdentityOperator:
del node_id, params
return IdentityOperator()
def _sink_factory(node_id: str, params: Mapping[str, Any]) -> NullSink:
del node_id, params
return NullSink()
def _registry() -> PluginRegistry:
registry = PluginRegistry()
registry.register(
PluginSpec(
"server.request_source@1",
PluginKind.SOURCE,
_source_factory,
outputs={"requests": "InferenceRequest/v1"},
)
)
registry.register(
PluginSpec(
"test.image_decoder@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"requests": "InferenceRequest/v1"},
outputs={"frames": "ImageFrame/v1"},
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"test.frame_identity@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"frames": "ImageFrame/v1"},
outputs={"frames": "ImageFrame/v1"},
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"test.unsafe_identity@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"requests": "InferenceRequest/v1"},
outputs={"frames": "ImageFrame/v1"},
)
)
registry.register(
PluginSpec(
"detection.model@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"frames": "ImageFrame/v1"},
outputs={"detections": "DetectionResult/v1"},
route_model_param="model",
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"test.generic_model@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"requests": "InferenceRequest/v1"},
outputs={"responses": "InferenceResponse/v1"},
route_model_param="model_id",
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"test.detector_bypass@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"frames": "ImageFrame/v1"},
outputs={"detections": "DetectionResult/v1"},
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"server.detection_response@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"detections": "DetectionResult/v1"},
outputs={"responses": "InferenceResponse/v1"},
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"test.untyped_response@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"detections": "DetectionResult/v1"},
outputs={"responses": "*"},
invocation_cardinality=InvocationCardinality.EXACTLY_ONE,
)
)
registry.register(
PluginSpec(
"server.response_sink@1",
PluginKind.SINK,
_sink_factory,
inputs={"responses": "InferenceResponse/v1"},
)
)
registry.register(
PluginSpec(
"test.active_source@1",
PluginKind.SOURCE,
_source_factory,
outputs={"events": "TextEvent/v1"},
)
)
registry.register(
PluginSpec(
"test.active_sink@1",
PluginKind.SINK,
_sink_factory,
inputs={"events": "TextEvent/v1"},
)
)
return registry
def _valid_nodes() -> dict[str, dict[str, Any]]:
return {
"request": {
"uses": "server.request_source@1",
"with": {"category": "detect.mobile_phone"},
},
"decode": {"uses": "test.image_decoder@1"},
"detector": {
"uses": "detection.model@1",
"with": {"model": "mobile-phone@1"},
},
"response": {
"uses": "server.detection_response@1",
"with": {"category": "detect.mobile_phone"},
},
"sink": {"uses": "server.response_sink@1"},
}
def _valid_edges() -> list[dict[str, Any]]:
return [
{"from": "request.requests", "to": "decode.requests"},
{"from": "decode.frames", "to": "detector.frames"},
{"from": "detector.detections", "to": "response.detections"},
{"from": "response.responses", "to": "sink.responses"},
]
def _config(
*,
nodes: dict[str, dict[str, Any]] | None = None,
edges: list[dict[str, str]] | None = None,
): # type: ignore[no-untyped-def]
return load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"remote": {
"nodes": deepcopy(nodes if nodes is not None else _valid_nodes()),
"edges": deepcopy(edges if edges is not None else _valid_edges()),
}
},
"server": {
"enabled": True,
"routes": {
"detect.mobile_phone": {
"pipeline": "remote",
"model_id": "mobile-phone@1",
}
},
},
}
)
def test_compile_accepts_strict_detection_invocation_pipeline() -> None:
compiled = compile_pipeline(_config(), "remote", _registry())
assert compiled.pipeline_id == "remote"
assert set(compiled.plugin_specs) == set(_valid_nodes())
@pytest.mark.parametrize(
("boundary", "message"),
[
("request", "exactly one server.request_source@1"),
("sink", "exactly one server.response_sink@1"),
],
)
def test_compile_requires_one_request_and_response_boundary(
boundary: str, message: str
) -> None:
nodes = _valid_nodes()
del nodes[boundary]
edges = [
edge
for edge in _valid_edges()
if not edge["from"].startswith(boundary + ".")
and not edge["to"].startswith(boundary + ".")
]
with pytest.raises(PipelineCompileError, match=message):
compile_pipeline(_config(nodes=nodes, edges=edges), "remote", _registry())
@pytest.mark.parametrize(
("original", "duplicate", "message"),
[
("request", "request_copy", "exactly one server.request_source@1"),
("sink", "sink_copy", "exactly one server.response_sink@1"),
],
)
def test_compile_rejects_duplicate_invocation_boundaries(
original: str, duplicate: str, message: str
) -> None:
nodes = _valid_nodes()
nodes[duplicate] = deepcopy(nodes[original])
with pytest.raises(PipelineCompileError, match=message):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
@pytest.mark.parametrize("node_id", ["request", "response"])
def test_compile_requires_source_and_response_categories_to_match_route(
node_id: str,
) -> None:
nodes = _valid_nodes()
nodes[node_id]["with"]["category"] = "detect.other" # type: ignore[index]
with pytest.raises(PipelineCompileError, match="does not match route category"):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
def test_compile_requires_exactly_one_registered_detection_node() -> None:
missing = _valid_nodes()
missing["detector"]["uses"] = "test.detector_bypass@1"
with pytest.raises(PipelineCompileError, match="exactly one detection.model@1"):
compile_pipeline(_config(nodes=missing), "remote", _registry())
duplicate = _valid_nodes()
duplicate["detector_copy"] = deepcopy(duplicate["detector"])
with pytest.raises(PipelineCompileError, match="exactly one detection.model@1"):
compile_pipeline(_config(nodes=duplicate), "remote", _registry())
def test_compile_requires_detector_model_to_match_route() -> None:
nodes = _valid_nodes()
nodes["detector"]["with"]["model"] = "different@1" # type: ignore[index]
with pytest.raises(PipelineCompileError, match="does not match route model_id"):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
def test_compile_rejects_rate_limited_passive_detector() -> None:
nodes = _valid_nodes()
nodes["detector"]["with"]["max_fps"] = 1.0 # type: ignore[index]
with pytest.raises(PipelineCompileError, match="must not set max_fps"):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
def test_compile_requires_exactly_one_output_contract_for_each_operator() -> None:
nodes = _valid_nodes()
nodes["decode"]["uses"] = "test.unsafe_identity@1"
with pytest.raises(PipelineCompileError, match="invocation_cardinality"):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
@pytest.mark.parametrize(
("profile", "overflow"),
[
("telemetry", "drop_oldest"),
("request", "reject"),
],
)
def test_compile_requires_lossless_blocking_invocation_edges(
profile: str,
overflow: str,
) -> None:
edges: list[dict[str, Any]] = deepcopy(_valid_edges())
edges[0]["qos"] = {"profile": profile, "overflow": overflow}
with pytest.raises(PipelineCompileError, match="must use qos"):
compile_pipeline(_config(edges=edges), "remote", _registry())
def test_compile_validates_route_model_for_non_detection_adapter() -> None:
def config(model_id: str): # type: ignore[no-untyped-def]
return load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"gauge": {
"nodes": {
"request": {
"uses": "server.request_source@1",
"with": {"category": "gauge.analog"},
},
"reader": {
"uses": "test.generic_model@1",
"with": {
"category": "gauge.analog",
"model_id": model_id,
},
},
"sink": {"uses": "server.response_sink@1"},
},
"edges": [
{
"from": "request.requests",
"to": "reader.requests",
},
{
"from": "reader.responses",
"to": "sink.responses",
},
],
}
},
"server": {
"enabled": True,
"routes": {
"gauge.analog": {
"pipeline": "gauge",
"model_id": "ethz-gauge@1",
}
},
},
}
)
assert compile_pipeline(config("ethz-gauge@1"), "gauge", _registry())
with pytest.raises(PipelineCompileError, match="route model_id"):
compile_pipeline(config("another-gauge@1"), "gauge", _registry())
def test_compile_rejects_fan_out_and_merge_without_join_semantics() -> None:
nodes = _valid_nodes()
nodes["left"] = {"uses": "test.frame_identity@1"}
nodes["right"] = {"uses": "test.frame_identity@1"}
edges = [
{"from": "request.requests", "to": "decode.requests"},
{"from": "decode.frames", "to": "left.frames"},
{"from": "decode.frames", "to": "right.frames"},
{"from": "left.frames", "to": "detector.frames"},
{"from": "right.frames", "to": "detector.frames"},
{"from": "detector.detections", "to": "response.detections"},
{"from": "response.responses", "to": "sink.responses"},
]
with pytest.raises(PipelineCompileError, match="single linear path"):
compile_pipeline(_config(nodes=nodes, edges=edges), "remote", _registry())
def test_compile_requires_a_directed_request_to_response_path() -> None:
edges = _valid_edges()[:-1]
with pytest.raises(PipelineCompileError, match="no directed path"):
compile_pipeline(_config(edges=edges), "remote", _registry())
@pytest.mark.parametrize("connect_branch", [False, True])
def test_compile_rejects_orphan_and_dead_end_branch_nodes(
connect_branch: bool,
) -> None:
nodes = _valid_nodes()
nodes["unused"] = {"uses": "test.frame_identity@1"}
edges = _valid_edges()
if connect_branch:
edges.append({"from": "decode.frames", "to": "unused.frames"})
with pytest.raises(PipelineCompileError, match="outside every valid"):
compile_pipeline(
_config(nodes=nodes, edges=edges),
"remote",
_registry(),
)
def test_compile_rejects_path_that_bypasses_required_detector() -> None:
nodes = _valid_nodes()
nodes["bypass"] = {"uses": "test.detector_bypass@1"}
edges = _valid_edges()
edges.extend(
[
{"from": "decode.frames", "to": "bypass.frames"},
{"from": "bypass.detections", "to": "response.detections"},
]
)
with pytest.raises(PipelineCompileError, match="bypasses required detection"):
compile_pipeline(
_config(nodes=nodes, edges=edges),
"remote",
_registry(),
)
def test_compile_requires_one_typed_response_producer() -> None:
nodes = _valid_nodes()
nodes["response"]["uses"] = "test.untyped_response@1"
with pytest.raises(PipelineCompileError, match="emits InferenceResponse/v1"):
compile_pipeline(_config(nodes=nodes), "remote", _registry())
def test_active_push_pipeline_is_not_subject_to_invocation_validation() -> None:
config = load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"active": {
"nodes": {
"source": {"uses": "test.active_source@1"},
"sink": {"uses": "test.active_sink@1"},
},
"edges": [
{"from": "source.events", "to": "sink.events"},
],
}
},
}
)
compiled = compile_pipeline(config, "active", _registry())
assert compiled.pipeline_id == "active"
def test_disabled_server_routes_do_not_change_active_pipeline_validation() -> None:
config = load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"active": {
"nodes": {
"source": {"uses": "test.active_source@1"},
"sink": {"uses": "test.active_sink@1"},
},
"edges": [
{"from": "source.events", "to": "sink.events"},
],
}
},
"server": {
"enabled": False,
"routes": {
"detect.mobile_phone": {
"pipeline": "active",
"model_id": "mobile-phone@1",
}
},
},
}
)
assert compile_pipeline(config, "active", _registry()).pipeline_id == "active"