1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5
6use phoxal_model::Robot;
7use phoxal_model::component::capability::MotorCommand;
8use phoxal_model::identity::CapabilityRef;
9use phoxal_runtime_contract::identity::{ParticipantArtifactId, ParticipantId};
10use phoxal_runtime_contract::metadata::{ParticipantContract, ParticipantRequirement};
11use phoxal_runtime_contract::version::{CompatibilityLine, FrameworkVersion};
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 AssetIndex, BinaryReference, BundleError, DocumentError, RuntimeParticipant, SelectionError,
16};
17
18#[derive(
23 phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
24)]
25#[serde(rename_all = "snake_case")]
26pub enum ParticipantClock {
27 Real,
29 Simulation,
31 Clockless,
33}
34
35impl fmt::Display for ParticipantClock {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter.write_str(match self {
38 Self::Real => "real",
39 Self::Simulation => "simulation",
40 Self::Clockless => "clockless",
41 })
42 }
43}
44
45#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
47#[serde(tag = "schema", deny_unknown_fields)]
48pub enum RuntimeDocument {
49 #[serde(rename = "phoxal/runtime-bundle/v0")]
52 V0(Runtime),
53}
54
55impl<'de> Deserialize<'de> for RuntimeDocument {
56 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
57 #[derive(Deserialize)]
58 #[serde(tag = "schema", deny_unknown_fields)]
59 enum Wire {
60 #[serde(rename = "phoxal/runtime-bundle/v0")]
61 V0(Runtime),
62 }
63
64 match Wire::deserialize(deserializer)? {
65 Wire::V0(runtime) => Ok(Self::new(runtime)),
66 }
67 }
68}
69
70impl RuntimeDocument {
71 #[must_use]
73 pub const fn new(runtime: Runtime) -> Self {
74 Self::V0(runtime)
75 }
76
77 #[must_use]
79 pub const fn runtime(&self) -> &Runtime {
80 match self {
81 Self::V0(runtime) => runtime,
82 }
83 }
84
85 #[must_use]
87 pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
88 self.runtime().robot.id()
89 }
90
91 #[must_use]
93 pub fn robot(&self) -> &Robot {
94 &self.runtime().robot
95 }
96
97 #[must_use]
99 pub fn framework_line(&self) -> CompatibilityLine {
100 self.runtime().framework_line()
101 }
102
103 #[must_use]
105 pub fn participants(&self) -> &[RuntimeParticipant] {
106 &self.runtime().participants
107 }
108
109 #[must_use]
111 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
112 &self.runtime().artifacts
113 }
114
115 pub fn participant(&self, id: &ParticipantId) -> Result<&RuntimeParticipant, SelectionError> {
117 self.participants()
118 .iter()
119 .find(|participant| participant.id == *id)
120 .ok_or_else(|| SelectionError::Unknown {
121 requested: id.clone(),
122 })
123 }
124}
125
126#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct Runtime {
130 pub(crate) robot: Robot,
133 pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
136 pub(crate) participants: Vec<RuntimeParticipant>,
139 pub(crate) assets: AssetIndex,
141}
142
143impl<'de> Deserialize<'de> for Runtime {
144 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
145 #[derive(Deserialize)]
146 #[serde(deny_unknown_fields)]
147 struct Wire {
148 robot: Robot,
149 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
150 participants: Vec<RuntimeParticipant>,
151 assets: AssetIndex,
152 }
153
154 let wire = Wire::deserialize(deserializer)?;
155 Self::new(wire.robot, wire.artifacts, wire.participants, wire.assets)
156 .map_err(serde::de::Error::custom)
157 }
158}
159
160impl Runtime {
161 pub fn new(
164 robot: Robot,
165 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
166 participants: Vec<RuntimeParticipant>,
167 assets: AssetIndex,
168 ) -> Result<Self, DocumentError> {
169 let runtime = Self {
170 robot,
171 artifacts,
172 participants,
173 assets,
174 };
175 runtime.validate()?;
176 Ok(runtime)
177 }
178
179 #[must_use]
181 pub const fn robot(&self) -> &Robot {
182 &self.robot
183 }
184
185 #[must_use]
187 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
188 &self.artifacts
189 }
190
191 #[must_use]
193 pub fn participants(&self) -> &[RuntimeParticipant] {
194 &self.participants
195 }
196
197 #[must_use]
199 pub const fn assets(&self) -> &AssetIndex {
200 &self.assets
201 }
202
203 #[must_use]
215 #[expect(
216 clippy::expect_used,
217 reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
218 )]
219 pub fn framework_line(&self) -> CompatibilityLine {
220 self.participants
221 .first()
222 .and_then(|participant| self.artifacts.get(&participant.artifact))
223 .map(|artifact| artifact.contract().framework.compatibility_line())
224 .expect("validated runtime has a selected participant artifact")
225 }
226
227 fn validate(&self) -> Result<(), DocumentError> {
228 if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
229 return Err(DocumentError::TooManyParticipants {
230 count: self.participants.len(),
231 });
232 }
233 let mut ids = BTreeSet::new();
234 let mut artifact_paths = BTreeSet::new();
235 let mut validators = BTreeMap::new();
236 for (id, artifact) in &self.artifacts {
237 artifact.validate(id)?;
238 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
239 {
240 if id.as_str() != "brain" {
241 return Err(DocumentError::BrainArtifactId { actual: id.clone() });
242 }
243 if artifact.path().as_str() != "bin/brain" {
244 return Err(DocumentError::BrainArtifactPath {
245 actual: artifact.path().clone(),
246 });
247 }
248 }
249 if !artifact_paths.insert(artifact.path.clone()) {
250 return Err(DocumentError::DuplicateBinary {
251 path: artifact.path.clone(),
252 });
253 }
254 let validator =
255 jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
256 DocumentError::InvalidConfigSchema {
257 artifact: id.clone(),
258 error: error.to_string(),
259 }
260 })?;
261 validate_requirement(artifact.contract(), id, &self.robot)?;
262 validators.insert(id, validator);
263 }
264 let mut referenced_artifacts = BTreeSet::new();
265 let mut brain = None;
266 let mut simulator_count = 0_u8;
267 let mut framework: Option<FrameworkVersion> = None;
268 for participant in &self.participants {
269 let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
270 DocumentError::UnknownArtifact {
271 participant: participant.id.clone(),
272 artifact: participant.artifact.clone(),
273 }
274 })?;
275 let validator = validators.get(&participant.artifact).ok_or_else(|| {
276 DocumentError::UnknownArtifact {
277 participant: participant.id.clone(),
278 artifact: participant.artifact.clone(),
279 }
280 })?;
281 participant.validate(&self.robot, artifact, validator)?;
282 let artifact_framework = artifact.contract().framework;
288 let expected = *framework.get_or_insert(artifact_framework);
289 if !expected.is_compatible_with(artifact_framework) {
290 return Err(DocumentError::MixedFrameworkLine {
291 artifact: participant.artifact.clone(),
292 expected,
293 actual: artifact_framework,
294 });
295 }
296 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
297 {
298 if participant.id.as_str() != "brain" {
299 return Err(DocumentError::BrainIdMismatch {
300 actual: participant.id.clone(),
301 });
302 }
303 if brain.replace(participant.id.clone()).is_some() {
304 return Err(DocumentError::DuplicateBrain);
305 }
306 }
307 if artifact.contract().kind
308 == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
309 {
310 simulator_count = simulator_count.saturating_add(1);
311 }
312 referenced_artifacts.insert(participant.artifact.clone());
313 if !ids.insert(participant.id.clone()) {
314 return Err(DocumentError::DuplicateParticipant {
315 id: participant.id.clone(),
316 });
317 }
318 }
319 if brain.is_none() {
320 return Err(DocumentError::MissingBrain);
321 }
322 if self.robot.clock() == phoxal_model::Clock::Simulated {
323 match simulator_count {
324 0 => return Err(DocumentError::MissingSimulator),
325 1 => {}
326 _ => return Err(DocumentError::DuplicateSimulator),
327 }
328 }
329 if let Some(artifact) = self
330 .artifacts
331 .keys()
332 .find(|id| !referenced_artifacts.contains(*id))
333 {
334 return Err(DocumentError::UnusedArtifact {
335 artifact: artifact.clone(),
336 });
337 }
338 self.assets.validate()?;
339 Ok(())
340 }
341}
342
343pub(crate) fn validate_requirement(
346 contract: &ParticipantContract,
347 artifact: &ParticipantArtifactId,
348 robot: &Robot,
349) -> Result<(), DocumentError> {
350 let Some(requirement) = contract.requirement else {
351 return Ok(());
352 };
353 match requirement {
354 ParticipantRequirement::DifferentialDriveVelocity => {
355 let phoxal_model::robot::KinematicConfig::Differential {
356 left_actuators,
357 right_actuators,
358 ..
359 } = robot.motion().kinematic()
360 else {
361 return Err(DocumentError::RequirementKinematicsMismatch {
362 artifact: artifact.clone(),
363 requirement,
364 actual: robot.motion().kinematic().kind(),
365 });
366 };
367 validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
368 validate_drive_side(artifact, "right_actuators", right_actuators, robot)
369 }
370 }
371}
372
373fn validate_drive_side(
374 artifact: &ParticipantArtifactId,
375 side: &'static str,
376 actuators: &[CapabilityRef],
377 robot: &Robot,
378) -> Result<(), DocumentError> {
379 if actuators.is_empty() {
380 return Err(DocumentError::RequirementActuatorListEmpty {
381 artifact: artifact.clone(),
382 side,
383 });
384 }
385 for reference in actuators {
386 let (motor, _) = robot.require_motor(reference).map_err(|error| {
387 DocumentError::RequirementActuatorInvalid {
388 artifact: artifact.clone(),
389 actuator: reference.clone(),
390 error: error.to_string(),
391 }
392 })?;
393 if motor.command != MotorCommand::Velocity {
394 return Err(DocumentError::RequirementMotorModeMismatch {
395 artifact: artifact.clone(),
396 actuator: reference.clone(),
397 expected: MotorCommand::Velocity,
398 actual: motor.command,
399 });
400 }
401 }
402 Ok(())
403}
404
405pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
407 serde_json::from_slice(bytes).map_err(BundleError::from)
408}