use std::collections::{BTreeMap, BTreeSet};
use crate::participant::metadata::ParticipantMetaContract;
#[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 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,
}
#[derive(Debug, Clone, Copy)]
pub struct CheckInput<'a> {
pub participants: &'a [ParticipantApis],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Problem {
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()
}
}
#[must_use]
pub fn check_graph(participants: &[ParticipantApis]) -> Report {
check_plan(CheckInput { participants })
}
#[must_use]
pub fn check_plan(input: CheckInput<'_>) -> Report {
let _ = input.participants;
Report::default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParticipantContractSurface {
pub participant_id: String,
pub contracts: Vec<ParticipantMetaContract>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoherenceMismatch {
PubSubDisjoint {
participant_id: String,
contract: String,
subscribed: BTreeSet<String>,
published: BTreeSet<String>,
},
UnservedAsk {
participant_id: String,
contract: String,
version: String,
served: BTreeSet<String>,
},
MultipleTimelineAuthorities {
participant_ids: BTreeSet<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CoherenceReport {
pub mismatches: Vec<CoherenceMismatch>,
}
impl CoherenceReport {
#[must_use]
pub fn is_ok(&self) -> bool {
self.mismatches.is_empty()
}
}
pub const TIMELINE_AUTHORITY_CONTRACT: &str = "simulation::Clock";
#[must_use]
pub fn check_coherence(participants: &[ParticipantContractSurface]) -> CoherenceReport {
let mut published: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut served: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for p in participants {
for c in &p.contracts {
match c.role.as_str() {
"publish" => {
published
.entry(c.contract.as_str())
.or_default()
.insert(c.version.as_str());
}
"serve" => {
served
.entry(c.contract.as_str())
.or_default()
.insert(c.version.as_str());
}
_ => {}
}
}
}
let mut mismatches = Vec::new();
let authorities: BTreeSet<String> = participants
.iter()
.filter(|p| {
p.contracts
.iter()
.any(|c| c.role == "publish" && c.contract == TIMELINE_AUTHORITY_CONTRACT)
})
.map(|p| p.participant_id.clone())
.collect();
if authorities.len() > 1 {
mismatches.push(CoherenceMismatch::MultipleTimelineAuthorities {
participant_ids: authorities,
});
}
for p in participants {
let mut sub_by_contract: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for c in &p.contracts {
if c.role == "subscribe" && !c.external {
sub_by_contract
.entry(c.contract.as_str())
.or_default()
.insert(c.version.as_str());
}
}
for (contract, subscribed) in sub_by_contract {
let Some(published) = published.get(contract) else {
continue; };
if subscribed.is_disjoint(published) {
mismatches.push(CoherenceMismatch::PubSubDisjoint {
participant_id: p.participant_id.clone(),
contract: contract.to_string(),
subscribed: subscribed.iter().map(|s| (*s).to_string()).collect(),
published: published.iter().map(|s| (*s).to_string()).collect(),
});
}
}
for c in &p.contracts {
if c.role != "ask" || c.external {
continue;
}
let contract_served = served.get(c.contract.as_str());
let has_server = contract_served.is_some_and(|gens| gens.contains(c.version.as_str()));
if !has_server {
mismatches.push(CoherenceMismatch::UnservedAsk {
participant_id: p.participant_id.clone(),
contract: c.contract.clone(),
version: c.version.clone(),
served: contract_served
.map(|gens| gens.iter().map(|s| (*s).to_string()).collect())
.unwrap_or_default(),
});
}
}
}
CoherenceReport { mismatches }
}
#[cfg(test)]
mod tests {
use super::*;
fn contract(family: &str) -> Contract {
Contract {
family: family.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(),
config_schema: None,
scope: ParticipantScope::Graph,
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(),
config_schema: None,
scope: ParticipantScope::Graph,
contracts,
}
}
#[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", "v1", vec![contract("api::drive::Target")]),
participant("drive", "v1", vec![contract("api::drive::Target")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn healthy_query_graph_has_no_problems() {
let graph = vec![
participant("asset", "v1", vec![contract("api::asset::GetRequest")]),
participant("client", "v1", vec![contract("api::asset::GetRequest")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn mixed_versions_on_the_same_contract_family_are_simply_different_contracts() {
let graph = vec![
participant("mission", "v1", vec![contract("api::drive::Target")]),
participant("drive", "v2", vec![contract("api::drive::Target")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn tool_kind_participant_contracts_do_not_gate_the_graph() {
let graph = vec![
participant("mission", "v1", vec![contract("api::drive::Target")]),
participant("drive", "v1", vec![contract("api::drive::Target")]),
privileged_participant("inspector", "v1", vec![contract("api::drive::Target")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn a_publisher_anywhere_satisfies_all_subscribers() {
let graph = vec![
participant("odometry", "v1", vec![contract("api::odometry::State")]),
participant("localize", "v1", vec![contract("api::odometry::State")]),
participant("map", "v1", vec![contract("api::odometry::State")]),
];
assert!(check_graph(&graph).is_ok());
}
#[test]
fn dangling_consumers_and_dangling_servers_are_legal() {
let participants = vec![
participant(
"navigation",
"v1",
vec![contract("api::navigation::Request")],
),
participant("asset", "v1", vec![contract("api::asset::GetRequest")]),
];
let report = check_graph(&participants);
assert_eq!(report, Report::default());
}
#[test]
fn empty_graph_is_ok() {
assert!(check_graph(&[]).is_ok());
}
fn meta_contract(
role: &str,
version: &str,
contract: &str,
external: bool,
) -> ParticipantMetaContract {
ParticipantMetaContract {
role: role.to_string(),
version: version.to_string(),
contract: contract.to_string(),
external,
}
}
fn surface(id: &str, contracts: Vec<ParticipantMetaContract>) -> ParticipantContractSurface {
ParticipantContractSurface {
participant_id: id.to_string(),
contracts,
}
}
fn gens(vals: &[&str]) -> BTreeSet<String> {
vals.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn coherence_empty_participant_set_is_ok() {
assert!(check_coherence(&[]).is_ok());
}
#[test]
fn coherence_rejects_a_second_timeline_authority_but_allows_none() {
let clock = || {
vec![meta_contract(
"publish",
"v0.1",
TIMELINE_AUTHORITY_CONTRACT,
false,
)]
};
let subscriber = surface(
"drive",
vec![meta_contract(
"subscribe",
"v0.1",
TIMELINE_AUTHORITY_CONTRACT,
false,
)],
);
assert!(
check_coherence(std::slice::from_ref(&subscriber)).is_ok(),
"a real robot has no world authority at all"
);
assert!(
check_coherence(&[surface("webots", clock()), subscriber.clone()]).is_ok(),
"exactly one authority is the simulation case"
);
let report = check_coherence(&[
surface("webots", clock()),
surface("second-controller", clock()),
subscriber,
]);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::MultipleTimelineAuthorities {
participant_ids: gens(&["second-controller", "webots"]),
}]
);
}
#[test]
fn coherence_pub_sub_disjoint_versions_block() {
let participants = vec![
surface(
"drive",
vec![meta_contract("publish", "v1", "drive::Target", false)],
),
surface(
"mission",
vec![meta_contract("subscribe", "v2", "drive::Target", false)],
),
];
let report = check_coherence(&participants);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::PubSubDisjoint {
participant_id: "mission".to_string(),
contract: "drive::Target".to_string(),
subscribed: gens(&["v2"]),
published: gens(&["v1"]),
}]
);
}
#[test]
fn coherence_dual_subscribe_spans_migration_is_valid() {
let participants = vec![
surface(
"drive",
vec![meta_contract("publish", "v1", "drive::Target", false)],
),
surface(
"mission",
vec![
meta_contract("subscribe", "v1", "drive::Target", false),
meta_contract("subscribe", "v2", "drive::Target", false),
],
),
];
assert!(check_coherence(&participants).is_ok());
}
#[test]
fn coherence_dropping_old_sub_after_dual_subscribe_blocks() {
let participants = vec![
surface(
"drive",
vec![meta_contract("publish", "v1", "drive::Target", false)],
),
surface(
"mission",
vec![meta_contract("subscribe", "v2", "drive::Target", false)],
),
];
let report = check_coherence(&participants);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::PubSubDisjoint {
participant_id: "mission".to_string(),
contract: "drive::Target".to_string(),
subscribed: gens(&["v2"]),
published: gens(&["v1"]),
}]
);
}
#[test]
fn coherence_pooled_overlap_does_not_excuse_a_stranded_subscriber() {
let participants = vec![
surface(
"drive",
vec![meta_contract("publish", "v1", "drive::Target", false)],
),
surface(
"service_a",
vec![meta_contract("subscribe", "v1", "drive::Target", false)],
),
surface(
"service_b",
vec![meta_contract("subscribe", "v2", "drive::Target", false)],
),
];
let report = check_coherence(&participants);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::PubSubDisjoint {
participant_id: "service_b".to_string(),
contract: "drive::Target".to_string(),
subscribed: gens(&["v2"]),
published: gens(&["v1"]),
}]
);
}
#[test]
fn coherence_ask_matched_by_a_superset_server_is_valid() {
let participants = vec![
surface(
"asset",
vec![
meta_contract("serve", "v1", "asset::Get", false),
meta_contract("serve", "v2", "asset::Get", false),
],
),
surface(
"client",
vec![meta_contract("ask", "v2", "asset::Get", false)],
),
];
assert!(check_coherence(&participants).is_ok());
}
#[test]
fn coherence_ask_with_no_matching_server_version_blocks() {
let participants = vec![
surface(
"asset",
vec![meta_contract("serve", "v1", "asset::Get", false)],
),
surface(
"client",
vec![meta_contract("ask", "v2", "asset::Get", false)],
),
];
let report = check_coherence(&participants);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::UnservedAsk {
participant_id: "client".to_string(),
contract: "asset::Get".to_string(),
version: "v2".to_string(),
served: gens(&["v1"]),
}]
);
}
#[test]
fn coherence_partial_ask_coverage_blocks_only_the_unserved_version() {
let participants = vec![
surface(
"asset",
vec![meta_contract("serve", "v1", "asset::Get", false)],
),
surface(
"client",
vec![
meta_contract("ask", "v1", "asset::Get", false),
meta_contract("ask", "v2", "asset::Get", false),
],
),
];
let report = check_coherence(&participants);
assert_eq!(
report.mismatches,
vec![CoherenceMismatch::UnservedAsk {
participant_id: "client".to_string(),
contract: "asset::Get".to_string(),
version: "v2".to_string(),
served: gens(&["v1"]),
}]
);
}
#[test]
fn coherence_external_marker_excuses_a_subscribe_mismatch() {
let participants = vec![
surface(
"drive",
vec![meta_contract("publish", "v1", "drive::Target", false)],
),
surface(
"teleop",
vec![meta_contract("subscribe", "v2", "drive::Target", true)],
),
];
assert!(check_coherence(&participants).is_ok());
}
#[test]
fn coherence_external_marker_excuses_an_ask_mismatch() {
let participants = vec![
surface(
"asset",
vec![meta_contract("serve", "v1", "asset::Get", false)],
),
surface(
"client",
vec![meta_contract("ask", "v2", "asset::Get", true)],
),
];
assert!(check_coherence(&participants).is_ok());
}
#[test]
fn coherence_open_world_ok_when_nothing_publishes_in_set() {
let participants = vec![surface(
"mission",
vec![meta_contract(
"subscribe",
"v1",
"navigation::Request",
false,
)],
)];
assert!(check_coherence(&participants).is_ok());
}
#[test]
fn coherence_server_with_no_asker_is_harmless() {
let participants = vec![surface(
"asset",
vec![meta_contract("serve", "v1", "asset::Get", false)],
)];
assert!(check_coherence(&participants).is_ok());
}
}