use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParticipantApis {
pub participant_id: String,
pub artifact_id: String,
pub participant_kind: ParticipantKind,
pub participant_class: ParticipantClass,
pub api_version: String,
pub bus_abi: Option<String>,
pub config_schema: Option<serde_json::Value>,
pub scope: ParticipantScope,
pub contracts: Vec<Contract>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ParticipantKind {
Service,
Driver,
Tool,
Simulator,
Other(String),
}
impl ParticipantKind {
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"service" => Self::Service,
"driver" => Self::Driver,
"tool" => Self::Tool,
"simulator" => Self::Simulator,
other => Self::Other(other.to_string()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Service => "service",
Self::Driver => "driver",
Self::Tool => "tool",
Self::Simulator => "simulator",
Self::Other(kind) => kind,
}
}
#[must_use]
pub const fn is_simulator(&self) -> bool {
matches!(self, Self::Simulator)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ParticipantClass {
#[default]
Checked,
Privileged,
}
impl ParticipantClass {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"checked" => Self::Checked,
"privileged" => Self::Privileged,
_ => return None,
})
}
#[must_use]
pub const fn is_checked(self) -> bool {
matches!(self, Self::Checked)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ParticipantScope {
#[default]
Graph,
ComponentInstance(String),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Contract {
pub family: String,
pub topic: String,
pub direction: Direction,
pub schema_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Direction {
Publish,
Subscribe,
QueryRequest,
QueryResponse,
ServerRequest,
ServerResponse,
}
impl Direction {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"publish" => Self::Publish,
"subscribe" => Self::Subscribe,
"query_request" => Self::QueryRequest,
"query_response" => Self::QueryResponse,
"server_request" => Self::ServerRequest,
"server_response" => Self::ServerResponse,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct CheckInput<'a> {
pub participants: &'a [ParticipantApis],
pub robot_graph: &'a RobotGraph,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Problem {
ContractSchemaMismatch {
family: String,
topic: String,
schema_ids: Vec<(String, Vec<String>)>,
},
MultipleServerResponders {
family: String,
topic: String,
responders: Vec<String>,
},
InvalidConfig {
runtime_id: String,
errors: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Report {
pub problems: Vec<Problem>,
}
impl Report {
#[must_use]
pub fn is_ok(&self) -> bool {
self.problems.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RobotGraph {
pub component_capabilities: Vec<ComponentCapability>,
pub motion_capabilities: BTreeSet<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ComponentCapability {
pub instance: String,
pub capability: String,
pub kind: String,
}
#[must_use]
pub fn check_graph(participants: &[ParticipantApis]) -> Report {
let robot_graph = RobotGraph::default();
check_plan(CheckInput {
participants,
robot_graph: &robot_graph,
})
}
#[must_use]
pub fn check_graph_with_topology(
participants: &[ParticipantApis],
robot_graph: &RobotGraph,
) -> Report {
check_plan(CheckInput {
participants,
robot_graph,
})
}
#[must_use]
pub fn check_plan(input: CheckInput<'_>) -> Report {
let mut problems = Vec::new();
let MaterializedGraph { participants } =
materialize_participants(input.participants, input.robot_graph);
problems.extend(schema_mismatches(&participants));
let mut by_direction: BTreeMap<(String, String, Direction), BTreeSet<String>> = BTreeMap::new();
for p in &participants {
for c in &p.contracts {
if is_template_topic(&c.topic) {
continue;
}
by_direction
.entry((c.family.clone(), c.topic.clone(), c.direction))
.or_default()
.insert(p.participant_id.clone());
}
}
for ((family, topic, direction), producers) in &by_direction {
if *direction == Direction::ServerResponse && producers.len() > 1 {
problems.push(Problem::MultipleServerResponders {
family: family.clone(),
topic: topic.clone(),
responders: producers.iter().cloned().collect(),
});
}
}
Report { problems }
}
fn schema_mismatches(participants: &[ParticipantApis]) -> Vec<Problem> {
let mut by_contract: BTreeMap<(String, String), BTreeMap<String, BTreeSet<String>>> =
BTreeMap::new();
for p in participants {
for c in &p.contracts {
if is_template_topic(&c.topic) {
continue;
}
by_contract
.entry((c.family.clone(), c.topic.clone()))
.or_default()
.entry(c.schema_id.clone())
.or_default()
.insert(p.participant_id.clone());
}
}
by_contract
.into_iter()
.filter(|(_, schema_ids)| schema_ids.len() > 1)
.map(
|((family, topic), schema_ids)| Problem::ContractSchemaMismatch {
family,
topic,
schema_ids: schema_ids
.into_iter()
.map(|(schema_id, participants)| {
(schema_id, participants.into_iter().collect())
})
.collect(),
},
)
.collect()
}
fn is_template_topic(topic: &str) -> bool {
topic.contains("{instance}") || topic.contains("{capability}")
}
struct MaterializedGraph {
participants: Vec<ParticipantApis>,
}
fn materialize_participants(
participants: &[ParticipantApis],
robot_graph: &RobotGraph,
) -> MaterializedGraph {
let materialized = participants
.iter()
.map(|participant| {
let contracts = participant
.contracts
.iter()
.flat_map(|contract| materialize_contract(participant, contract, robot_graph))
.collect();
ParticipantApis {
participant_id: participant.participant_id.clone(),
artifact_id: participant.artifact_id.clone(),
participant_kind: participant.participant_kind.clone(),
participant_class: participant.participant_class,
api_version: participant.api_version.clone(),
bus_abi: participant.bus_abi.clone(),
config_schema: participant.config_schema.clone(),
scope: participant.scope.clone(),
contracts,
}
})
.collect();
MaterializedGraph {
participants: materialized,
}
}
fn materialize_contract(
participant: &ParticipantApis,
contract: &Contract,
robot_graph: &RobotGraph,
) -> Vec<Contract> {
if !is_template_topic(&contract.topic) {
return vec![contract.clone()];
}
let kind = component_topic_kind(&contract.topic);
let mut candidates = robot_graph
.component_capabilities
.iter()
.filter(|capability| kind.is_none_or(|kind| capability.kind == kind))
.filter(|capability| match &participant.scope {
ParticipantScope::Graph => {
!is_motion_topic_kind(kind)
|| robot_graph.motion_capabilities.is_empty()
|| robot_graph
.motion_capabilities
.contains(&(capability.instance.clone(), capability.capability.clone()))
}
ParticipantScope::ComponentInstance(instance) => capability.instance == *instance,
})
.collect::<Vec<_>>();
candidates.sort();
let mut materialized = candidates
.into_iter()
.map(|capability| Contract {
family: contract.family.clone(),
topic: contract
.topic
.replace("{instance}", &capability.instance)
.replace("{capability}", &capability.capability),
direction: contract.direction,
schema_id: contract.schema_id.clone(),
})
.collect::<Vec<_>>();
materialized.sort_by(|a, b| a.topic.cmp(&b.topic).then(a.direction.cmp(&b.direction)));
materialized.dedup();
materialized
}
fn component_topic_kind(topic: &str) -> Option<&str> {
let mut segments = topic.split('/');
match (segments.next(), segments.next(), segments.next()) {
(Some("component"), Some("{instance}" | "*"), Some(kind)) if !kind.starts_with('{') => {
Some(kind)
}
_ => None,
}
}
fn is_motion_topic_kind(kind: Option<&str>) -> bool {
matches!(kind, Some("motor" | "encoder"))
}
#[cfg(test)]
mod tests {
use super::*;
fn contract(family: &str, topic: &str, direction: Direction) -> Contract {
contract_with_schema(family, topic, direction, "deadbeef")
}
fn contract_with_schema(
family: &str,
topic: &str,
direction: Direction,
schema_id: &str,
) -> Contract {
Contract {
family: family.to_string(),
topic: topic.to_string(),
direction,
schema_id: schema_id.to_string(),
}
}
fn participant(id: &str, api: &str, contracts: Vec<Contract>) -> ParticipantApis {
ParticipantApis {
participant_id: id.to_string(),
artifact_id: id.to_string(),
participant_kind: ParticipantKind::Service,
participant_class: ParticipantClass::Checked,
api_version: api.to_string(),
bus_abi: None,
config_schema: None,
scope: ParticipantScope::Graph,
contracts,
}
}
fn scoped_component_participant(
id: &str,
instance: &str,
contracts: Vec<Contract>,
) -> ParticipantApis {
ParticipantApis {
participant_id: instance.to_string(),
artifact_id: id.to_string(),
participant_kind: ParticipantKind::Driver,
participant_class: ParticipantClass::Checked,
api_version: "y2026_1".to_string(),
bus_abi: None,
config_schema: None,
scope: ParticipantScope::ComponentInstance(instance.to_string()),
contracts,
}
}
fn privileged_participant(id: &str, api: &str, contracts: Vec<Contract>) -> ParticipantApis {
ParticipantApis {
participant_id: id.to_string(),
artifact_id: id.to_string(),
participant_kind: ParticipantKind::Tool,
participant_class: ParticipantClass::Privileged,
api_version: api.to_string(),
bus_abi: None,
config_schema: None,
scope: ParticipantScope::Graph,
contracts,
}
}
fn robot_graph() -> RobotGraph {
RobotGraph {
component_capabilities: vec![
ComponentCapability {
instance: "left_drive".to_string(),
capability: "motor".to_string(),
kind: "motor".to_string(),
},
ComponentCapability {
instance: "right_drive".to_string(),
capability: "motor".to_string(),
kind: "motor".to_string(),
},
ComponentCapability {
instance: "left_drive".to_string(),
capability: "encoder".to_string(),
kind: "encoder".to_string(),
},
ComponentCapability {
instance: "right_drive".to_string(),
capability: "encoder".to_string(),
kind: "encoder".to_string(),
},
],
motion_capabilities: BTreeSet::from([
("left_drive".to_string(), "motor".to_string()),
("right_drive".to_string(), "motor".to_string()),
("left_drive".to_string(), "encoder".to_string()),
("right_drive".to_string(), "encoder".to_string()),
]),
}
}
#[test]
fn direction_parse_round_trips_and_rejects_unknown() {
assert_eq!(Direction::parse("publish"), Some(Direction::Publish));
assert_eq!(Direction::parse("subscribe"), Some(Direction::Subscribe));
assert_eq!(
Direction::parse("server_response"),
Some(Direction::ServerResponse)
);
assert_eq!(
Direction::parse("query_request"),
Some(Direction::QueryRequest)
);
assert_eq!(Direction::parse("nonsense"), None);
}
#[test]
fn participant_kind_parse_preserves_unknown_kinds() {
assert_eq!(ParticipantKind::parse("service"), ParticipantKind::Service);
assert_eq!(ParticipantKind::parse("driver"), ParticipantKind::Driver);
assert_eq!(
ParticipantKind::parse("simulator"),
ParticipantKind::Simulator
);
assert_eq!(
ParticipantKind::parse("custom-kind"),
ParticipantKind::Other("custom-kind".to_string())
);
}
#[test]
fn participant_class_parse_round_trips_and_rejects_unknown() {
assert_eq!(
ParticipantClass::parse("checked"),
Some(ParticipantClass::Checked)
);
assert_eq!(
ParticipantClass::parse("privileged"),
Some(ParticipantClass::Privileged)
);
assert_eq!(ParticipantClass::parse("service"), None);
}
#[test]
fn healthy_pubsub_graph_has_no_problems() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract(
"drive::Target",
"drive/target",
Direction::Publish,
)],
),
participant(
"drive",
"y2026_1",
vec![contract(
"drive::Target",
"drive/target",
Direction::Subscribe,
)],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn healthy_query_graph_has_no_problems() {
let graph = vec![
participant(
"asset",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::ServerRequest),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
],
),
participant(
"client",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::QueryRequest),
contract("asset::GetResponse", "asset/get", Direction::QueryResponse),
],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn duplicate_checked_query_servers_report_multiple_server_responders() {
let graph = vec![
participant(
"asset-beta",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::ServerRequest),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
],
),
participant(
"asset-alpha",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::ServerRequest),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
],
),
participant(
"client",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::QueryRequest),
contract("asset::GetResponse", "asset/get", Direction::QueryResponse),
],
),
];
let report = check_graph(&graph);
assert_eq!(
report.problems,
vec![Problem::MultipleServerResponders {
family: "asset::GetResponse".to_string(),
topic: "asset/get".to_string(),
responders: vec!["asset-alpha".to_string(), "asset-beta".to_string()],
}]
);
}
#[test]
fn single_checked_query_server_has_no_multiple_server_responders() {
let graph = vec![
participant(
"asset",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::ServerRequest),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
],
),
participant(
"client",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::QueryRequest),
contract("asset::GetResponse", "asset/get", Direction::QueryResponse),
],
),
];
let report = check_graph(&graph);
assert!(report.is_ok());
assert!(
report
.problems
.iter()
.all(|problem| !matches!(problem, Problem::MultipleServerResponders { .. }))
);
}
#[test]
fn contract_schema_mismatch_is_reported_per_contract() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Publish,
"aaaa",
)],
),
participant(
"drive",
"y2026_2",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Subscribe,
"bbbb",
)],
),
];
let report = check_graph(&graph);
assert_eq!(
report.problems,
vec![Problem::ContractSchemaMismatch {
family: "drive::Target".to_string(),
topic: "drive/target".to_string(),
schema_ids: vec![
("aaaa".to_string(), vec!["mission".to_string()]),
("bbbb".to_string(), vec!["drive".to_string()]),
],
}]
);
}
#[test]
fn matching_contract_schema_ids_pass_even_with_mixed_api_versions() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Publish,
"cafe",
)],
),
participant(
"drive",
"y2026_2",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Subscribe,
"cafe",
)],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn tool_kind_participant_contracts_still_participate_in_schema_agreement() {
let graph = vec![
participant(
"mission",
"y2026_1",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Publish,
"aaaa",
)],
),
participant(
"drive",
"y2026_1",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Subscribe,
"aaaa",
)],
),
privileged_participant(
"inspector",
"y2026_1",
vec![contract_with_schema(
"drive::Target",
"drive/target",
Direction::Subscribe,
"bbbb",
)],
),
];
let report = check_graph(&graph);
assert_eq!(
report.problems,
vec![Problem::ContractSchemaMismatch {
family: "drive::Target".to_string(),
topic: "drive/target".to_string(),
schema_ids: vec![
(
"aaaa".to_string(),
vec!["drive".to_string(), "mission".to_string()]
),
("bbbb".to_string(), vec!["inspector".to_string()]),
],
}]
);
}
#[test]
fn component_templates_expand_to_concrete_manifest_topics() {
let participants = vec![
participant(
"motion",
"y2026_1",
vec![contract(
"component::MotorCommand",
"component/{instance}/motor/{capability}/command",
Direction::Publish,
)],
),
scoped_component_participant(
"left-driver",
"left_drive",
vec![contract(
"component::MotorCommand",
"component/{instance}/motor/{capability}/command",
Direction::Subscribe,
)],
),
];
let report = check_graph_with_topology(&participants, &robot_graph());
assert!(report.problems.is_empty());
}
#[test]
fn component_template_expanding_to_zero_instances_is_legal() {
let participants = vec![
participant(
"motion",
"y2026_1",
vec![contract(
"component::MotorCommand",
"component/{instance}/motor/{capability}/command",
Direction::Publish,
)],
),
participant(
"phantom-driver",
"y2026_1",
vec![contract(
"component::MotorCommand",
"component/{instance}/motor/{capability}/command",
Direction::Subscribe,
)],
),
];
let report = check_graph_with_topology(&participants, &RobotGraph::default());
assert_eq!(report, Report::default());
}
#[test]
fn component_driver_template_missing_capability_is_legal() {
let graph = RobotGraph {
component_capabilities: vec![ComponentCapability {
instance: "left_drive".to_string(),
capability: "motor".to_string(),
kind: "motor".to_string(),
}],
motion_capabilities: BTreeSet::new(),
};
let participants = vec![scoped_component_participant(
"ddsm115",
"left_drive",
vec![contract(
"component::EncoderSample",
"component/{instance}/encoder/{capability}/sample",
Direction::Publish,
)],
)];
let report = check_graph_with_topology(&participants, &graph);
assert_eq!(report, Report::default());
}
#[test]
fn a_publisher_anywhere_satisfies_all_subscribers() {
let graph = vec![
participant(
"odometry",
"y2026_1",
vec![contract(
"odometry::State",
"odometry/state",
Direction::Publish,
)],
),
participant(
"localize",
"y2026_1",
vec![contract(
"odometry::State",
"odometry/state",
Direction::Subscribe,
)],
),
participant(
"map",
"y2026_1",
vec![contract(
"odometry::State",
"odometry/state",
Direction::Subscribe,
)],
),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn dangling_consumers_dangling_servers_and_unmatched_templates_are_all_legal() {
let participants = vec![
participant(
"mission",
"y2026_1",
vec![contract(
"mission::Command",
"mission/command",
Direction::Subscribe,
)],
),
participant(
"asset",
"y2026_1",
vec![
contract("asset::GetRequest", "asset/get", Direction::ServerRequest),
contract("asset::GetResponse", "asset/get", Direction::ServerResponse),
],
),
participant(
"safety",
"y2026_1",
vec![contract(
"component::EmergencyStopState",
"component/{instance}/emergency_stop/{capability}/state",
Direction::Subscribe,
)],
),
];
let robot_graph = RobotGraph {
component_capabilities: vec![ComponentCapability {
instance: "left_drive".to_string(),
capability: "motor".to_string(),
kind: "motor".to_string(),
}],
motion_capabilities: BTreeSet::new(),
};
let report = check_graph_with_topology(&participants, &robot_graph);
assert_eq!(report, Report::default());
}
#[test]
fn empty_graph_is_ok() {
assert!(check_graph(&[]).is_ok());
}
}