cmvr_edge_ai/tests/unit/test_compiler.py
2026-07-20 16:59:37 +08:00

373 lines
10 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
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 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(
"test.text_source@1",
PluginKind.SOURCE,
_source_factory,
outputs={"events": "TextEvent/v1"},
)
)
registry.register(
PluginSpec(
"test.text_operator@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"input": "TextEvent/v1"},
outputs={"events": "TextEvent/v1"},
)
)
registry.register(
PluginSpec(
"test.audio_sink@1",
PluginKind.SINK,
_sink_factory,
inputs={"input": "AudioChunk/v1"},
)
)
registry.register(
PluginSpec(
"test.any_sink@1",
PluginKind.SINK,
_sink_factory,
inputs={"input": "*"},
)
)
registry.register(
PluginSpec(
"test.command_source@1",
PluginKind.SOURCE,
_source_factory,
outputs={"commands": "RobotCommand/v1"},
)
)
registry.register(
PluginSpec(
"test.approved_source@1",
PluginKind.SOURCE,
_source_factory,
outputs={"commands": "ApprovedRobotCommand/v1"},
)
)
registry.register(
PluginSpec(
"test.safety_gate@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"input": "RobotCommand/v1"},
outputs={"commands": "ApprovedRobotCommand/v1"},
tags=frozenset({"safety_gate"}),
)
)
registry.register(
PluginSpec(
"test.approved_operator@1",
PluginKind.OPERATOR,
_operator_factory,
inputs={"input": "ApprovedRobotCommand/v1"},
outputs={"commands": "ApprovedRobotCommand/v1"},
)
)
registry.register(
PluginSpec(
"test.actuator@1",
PluginKind.SINK,
_sink_factory,
inputs={"commands": "ApprovedRobotCommand/v1"},
tags=frozenset({"actuator"}),
)
)
return registry
def _config(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]]):
return load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"test": {
"nodes": nodes,
"edges": edges,
}
},
}
)
@pytest.mark.parametrize(
("edge", "message"),
[
(
{"from": "source.missing", "to": "sink.input"},
"unknown output port",
),
(
{"from": "source.events", "to": "sink.missing"},
"unknown input port",
),
],
)
def test_compile_rejects_unknown_plugin_ports(
edge: dict[str, str], message: str
) -> None:
config = _config(
{
"source": {"uses": "test.text_source@1"},
"sink": {"uses": "test.any_sink@1"},
},
[edge],
)
with pytest.raises(PipelineCompileError, match=message):
compile_pipeline(config, "test", _registry())
def test_compile_rejects_incompatible_schemas() -> None:
config = _config(
{
"source": {"uses": "test.text_source@1"},
"sink": {"uses": "test.audio_sink@1"},
},
[{"from": "source.events", "to": "sink.input"}],
)
with pytest.raises(PipelineCompileError, match="schema mismatch"):
compile_pipeline(config, "test", _registry())
def test_compile_accepts_wildcard_schema() -> None:
config = _config(
{
"source": {"uses": "test.text_source@1"},
"sink": {"uses": "test.any_sink@1"},
},
[{"from": "source.events", "to": "sink.input"}],
)
compiled = compile_pipeline(config, "test", _registry())
assert compiled.pipeline_id == "test"
assert compiled.runtime.edges[0].source_port == "events"
def test_video_contiguous_qos_requires_lossless_overflow() -> None:
nodes = {
"source": {"uses": "test.text_source@1"},
"operator": {"uses": "test.text_operator@1"},
}
accepted = _config(
nodes,
[
{
"from": "source.events",
"to": "operator.input",
"qos": {
"profile": "video_contiguous",
"capacity": 8,
"overflow": "block",
},
}
],
)
compiled = compile_pipeline(accepted, "test", _registry())
assert compiled.runtime.edges[0].capacity == 8
rejected = _config(
nodes,
[
{
"from": "source.events",
"to": "operator.input",
"qos": {
"profile": "video_contiguous",
"capacity": 2,
"overflow": "drop_oldest",
},
}
],
)
with pytest.raises(PipelineCompileError, match="does not allow"):
compile_pipeline(rejected, "test", _registry())
def test_compile_rejects_direct_path_into_actuator() -> None:
config = _config(
{
"source": {"uses": "test.approved_source@1"},
"actuator": {"uses": "test.actuator@1"},
},
[{"from": "source.commands", "to": "actuator.commands"}],
)
with pytest.raises(PipelineCompileError, match="must pass through a safety gate"):
compile_pipeline(config, "test", _registry())
def test_compile_accepts_actuator_only_after_safety_gate() -> None:
config = _config(
{
"source": {"uses": "test.command_source@1"},
"gate": {"uses": "test.safety_gate@1"},
"actuator": {"uses": "test.actuator@1"},
},
[
{"from": "source.commands", "to": "gate.input"},
{"from": "gate.commands", "to": "actuator.commands"},
],
)
compiled = compile_pipeline(config, "test", _registry())
assert set(compiled.plugin_specs) == {"source", "gate", "actuator"}
def test_compile_rejects_mixed_safe_and_unsafe_actuator_paths() -> None:
config = _config(
{
"source": {"uses": "test.command_source@1"},
"bypass": {"uses": "test.approved_source@1"},
"gate": {"uses": "test.safety_gate@1"},
"actuator": {"uses": "test.actuator@1"},
},
[
{"from": "source.commands", "to": "gate.input"},
{"from": "gate.commands", "to": "actuator.commands"},
{"from": "bypass.commands", "to": "actuator.commands"},
],
)
with pytest.raises(PipelineCompileError, match="unsafe actuator"):
compile_pipeline(config, "test", _registry())
def test_compile_rejects_operator_between_gate_and_actuator() -> None:
config = _config(
{
"source": {"uses": "test.command_source@1"},
"gate": {"uses": "test.safety_gate@1"},
"transform": {"uses": "test.approved_operator@1"},
"actuator": {"uses": "test.actuator@1"},
},
[
{"from": "source.commands", "to": "gate.input"},
{"from": "gate.commands", "to": "transform.input"},
{"from": "transform.commands", "to": "actuator.commands"},
],
)
with pytest.raises(PipelineCompileError, match="safety gate directly"):
compile_pipeline(config, "test", _registry())
@pytest.mark.parametrize(
("node_options", "message"),
[
({"execution": {"mode": "thread"}}, "execution settings not implemented"),
({"resources": {"memory_mb": 256}}, "resource limits"),
],
)
def test_compile_rejects_unenforced_node_settings(
node_options: dict[str, Any], message: str
) -> None:
config = _config(
{
"source": {
"uses": "test.text_source@1",
**node_options,
}
},
[],
)
with pytest.raises(PipelineCompileError, match=message):
compile_pipeline(config, "test", _registry())
@pytest.mark.parametrize(
("field", "value"),
[
("max_processes", 2),
("process_start_method", "forkserver"),
("health_bind", "127.0.0.1:8080"),
("reserved_memory_mb", 256),
],
)
def test_compile_rejects_reserved_runtime_fields(field: str, value: Any) -> None:
config = load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"runtime": {field: value},
"pipelines": {
"test": {"nodes": {"source": {"uses": "test.text_source@1"}}}
},
}
)
with pytest.raises(PipelineCompileError, match="reserved but not enforced"):
compile_pipeline(config, "test", _registry())
def test_compile_rejects_pipeline_priority_without_scheduler() -> None:
config = load_config_data(
{
"api_version": "cmvr.edge.ai/v1",
"pipelines": {
"test": {
"priority": "high",
"nodes": {"source": {"uses": "test.text_source@1"}},
}
},
}
)
with pytest.raises(PipelineCompileError, match="no priority scheduler"):
compile_pipeline(config, "test", _registry())