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::FrameworkVersion;
12use serde::{Deserialize, Serialize};
13
14use crate::{
15 ASSETS_DIR, AssetIndex, BinaryReference, BundleError, BundlePath, DocumentError,
16 RuntimeParticipant, SelectionError,
17};
18
19#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ParticipantClock {
26 Real,
28 Simulation,
30 Clockless,
32}
33
34impl fmt::Display for ParticipantClock {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 formatter.write_str(match self {
37 Self::Real => "real",
38 Self::Simulation => "simulation",
39 Self::Clockless => "clockless",
40 })
41 }
42}
43
44#[derive(Clone, Debug, Serialize)]
46#[serde(tag = "schema", deny_unknown_fields)]
47pub enum RuntimeDocument {
48 #[serde(rename = "phoxal/runtime-bundle/v0")]
51 V0(Runtime),
52}
53
54impl<'de> Deserialize<'de> for RuntimeDocument {
55 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
56 #[derive(Deserialize)]
57 #[serde(tag = "schema", deny_unknown_fields)]
58 enum Wire {
59 #[serde(rename = "phoxal/runtime-bundle/v0")]
60 V0(Runtime),
61 }
62
63 match Wire::deserialize(deserializer)? {
64 Wire::V0(runtime) => Ok(Self::new(runtime)),
65 }
66 }
67}
68
69impl RuntimeDocument {
70 #[must_use]
72 pub const fn new(runtime: Runtime) -> Self {
73 Self::V0(runtime)
74 }
75
76 #[must_use]
78 pub const fn runtime(&self) -> &Runtime {
79 match self {
80 Self::V0(runtime) => runtime,
81 }
82 }
83
84 #[must_use]
86 pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
87 self.runtime().robot.id()
88 }
89
90 #[must_use]
92 pub fn robot(&self) -> &Robot {
93 &self.runtime().robot
94 }
95
96 #[must_use]
98 pub fn framework(&self) -> FrameworkVersion {
99 self.runtime().framework()
100 }
101
102 #[must_use]
104 pub fn participants(&self) -> &[RuntimeParticipant] {
105 &self.runtime().participants
106 }
107
108 #[must_use]
110 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
111 &self.runtime().artifacts
112 }
113
114 pub fn participant(&self, id: &ParticipantId) -> Result<&RuntimeParticipant, SelectionError> {
116 self.participants()
117 .iter()
118 .find(|participant| participant.id == *id)
119 .ok_or_else(|| SelectionError::Unknown {
120 requested: id.clone(),
121 })
122 }
123}
124
125#[derive(Clone, Debug, Serialize)]
127#[serde(deny_unknown_fields)]
128pub struct Runtime {
129 pub(crate) robot: Robot,
132 pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
135 pub(crate) participants: Vec<RuntimeParticipant>,
138 pub(crate) assets: AssetIndex,
140 pub(crate) router: Option<RuntimeRouterConfig>,
142}
143
144impl<'de> Deserialize<'de> for Runtime {
145 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
146 #[derive(Deserialize)]
147 #[serde(deny_unknown_fields)]
148 struct Wire {
149 robot: Robot,
150 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
151 participants: Vec<RuntimeParticipant>,
152 assets: AssetIndex,
153 router: Option<RuntimeRouterConfig>,
154 }
155
156 let wire = Wire::deserialize(deserializer)?;
157 Self::new(
158 wire.robot,
159 wire.artifacts,
160 wire.participants,
161 wire.assets,
162 wire.router,
163 )
164 .map_err(serde::de::Error::custom)
165 }
166}
167
168impl Runtime {
169 pub fn new(
172 robot: Robot,
173 artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
174 participants: Vec<RuntimeParticipant>,
175 assets: AssetIndex,
176 router: Option<RuntimeRouterConfig>,
177 ) -> Result<Self, DocumentError> {
178 let runtime = Self {
179 robot,
180 artifacts,
181 participants,
182 assets,
183 router,
184 };
185 runtime.validate()?;
186 Ok(runtime)
187 }
188
189 #[must_use]
191 pub const fn robot(&self) -> &Robot {
192 &self.robot
193 }
194
195 #[must_use]
197 pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
198 &self.artifacts
199 }
200
201 #[must_use]
203 pub fn participants(&self) -> &[RuntimeParticipant] {
204 &self.participants
205 }
206
207 #[must_use]
209 pub const fn assets(&self) -> &AssetIndex {
210 &self.assets
211 }
212
213 #[must_use]
215 pub const fn router(&self) -> Option<&RuntimeRouterConfig> {
216 self.router.as_ref()
217 }
218
219 #[must_use]
224 #[expect(
225 clippy::expect_used,
226 reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
227 )]
228 pub fn framework(&self) -> FrameworkVersion {
229 self.participants
230 .first()
231 .and_then(|participant| self.artifacts.get(&participant.artifact))
232 .map(|artifact| artifact.contract().framework)
233 .expect("validated runtime has a selected participant artifact")
234 }
235
236 fn validate(&self) -> Result<(), DocumentError> {
237 if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
238 return Err(DocumentError::TooManyParticipants {
239 count: self.participants.len(),
240 });
241 }
242 let mut ids = BTreeSet::new();
243 let mut artifact_paths = BTreeSet::new();
244 let mut validators = BTreeMap::new();
245 for (id, artifact) in &self.artifacts {
246 artifact.validate(id)?;
247 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
248 {
249 if id.as_str() != "brain" {
250 return Err(DocumentError::BrainArtifactId { actual: id.clone() });
251 }
252 if artifact.path().as_str() != "bin/brain" {
253 return Err(DocumentError::BrainArtifactPath {
254 actual: artifact.path().clone(),
255 });
256 }
257 }
258 if !artifact_paths.insert(artifact.path.clone()) {
259 return Err(DocumentError::DuplicateBinary {
260 path: artifact.path.clone(),
261 });
262 }
263 let validator =
264 jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
265 DocumentError::InvalidConfigSchema {
266 artifact: id.clone(),
267 error: error.to_string(),
268 }
269 })?;
270 validate_requirement(artifact.contract(), id, &self.robot)?;
271 validators.insert(id, validator);
272 }
273 let mut referenced_artifacts = BTreeSet::new();
274 let mut brain = None;
275 let mut simulator_count = 0_u8;
276 let mut framework = None;
277 for participant in &self.participants {
278 let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
279 DocumentError::UnknownArtifact {
280 participant: participant.id.clone(),
281 artifact: participant.artifact.clone(),
282 }
283 })?;
284 let validator = validators.get(&participant.artifact).ok_or_else(|| {
285 DocumentError::UnknownArtifact {
286 participant: participant.id.clone(),
287 artifact: participant.artifact.clone(),
288 }
289 })?;
290 participant.validate(&self.robot, artifact, validator)?;
291 let artifact_framework = artifact.contract().framework;
294 if let Some(expected) = framework
295 && expected != artifact_framework
296 {
297 return Err(DocumentError::MixedFramework {
298 artifact: participant.artifact.clone(),
299 expected,
300 actual: artifact_framework,
301 });
302 }
303 framework = Some(artifact_framework);
304 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
305 {
306 if participant.id.as_str() != "brain" {
307 return Err(DocumentError::BrainIdMismatch {
308 actual: participant.id.clone(),
309 });
310 }
311 if brain.replace(participant.id.clone()).is_some() {
312 return Err(DocumentError::DuplicateBrain);
313 }
314 }
315 if artifact.contract().kind
316 == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
317 {
318 simulator_count = simulator_count.saturating_add(1);
319 }
320 referenced_artifacts.insert(participant.artifact.clone());
321 if !ids.insert(participant.id.clone()) {
322 return Err(DocumentError::DuplicateParticipant {
323 id: participant.id.clone(),
324 });
325 }
326 }
327 if brain.is_none() {
328 return Err(DocumentError::MissingBrain);
329 }
330 if self.robot.clock() == phoxal_model::Clock::Simulated {
331 match simulator_count {
332 0 => return Err(DocumentError::MissingSimulator),
333 1 => {}
334 _ => return Err(DocumentError::DuplicateSimulator),
335 }
336 }
337 if let Some(artifact) = self
338 .artifacts
339 .keys()
340 .find(|id| !referenced_artifacts.contains(*id))
341 {
342 return Err(DocumentError::UnusedArtifact {
343 artifact: artifact.clone(),
344 });
345 }
346 self.assets.validate()?;
347 if let Some(router) = &self.router {
348 router.validate(&self.assets)?;
349 }
350 Ok(())
351 }
352}
353
354pub(crate) fn validate_requirement(
357 contract: &ParticipantContract,
358 artifact: &ParticipantArtifactId,
359 robot: &Robot,
360) -> Result<(), DocumentError> {
361 let Some(requirement) = contract.requirement else {
362 return Ok(());
363 };
364 match requirement {
365 ParticipantRequirement::DifferentialDriveVelocity => {
366 let phoxal_model::robot::KinematicConfig::Differential {
367 left_actuators,
368 right_actuators,
369 ..
370 } = robot.motion().kinematic()
371 else {
372 return Err(DocumentError::RequirementKinematicsMismatch {
373 artifact: artifact.clone(),
374 requirement,
375 actual: robot.motion().kinematic().kind(),
376 });
377 };
378 validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
379 validate_drive_side(artifact, "right_actuators", right_actuators, robot)
380 }
381 }
382}
383
384fn validate_drive_side(
385 artifact: &ParticipantArtifactId,
386 side: &'static str,
387 actuators: &[CapabilityRef],
388 robot: &Robot,
389) -> Result<(), DocumentError> {
390 if actuators.is_empty() {
391 return Err(DocumentError::RequirementActuatorListEmpty {
392 artifact: artifact.clone(),
393 side,
394 });
395 }
396 for reference in actuators {
397 let (motor, _) = robot.require_motor(reference).map_err(|error| {
398 DocumentError::RequirementActuatorInvalid {
399 artifact: artifact.clone(),
400 actuator: reference.clone(),
401 error: error.to_string(),
402 }
403 })?;
404 if motor.command != MotorCommand::Velocity {
405 return Err(DocumentError::RequirementMotorModeMismatch {
406 artifact: artifact.clone(),
407 actuator: reference.clone(),
408 expected: MotorCommand::Velocity,
409 actual: motor.command,
410 });
411 }
412 }
413 Ok(())
414}
415
416#[derive(Clone, Debug, Deserialize, Serialize)]
418#[serde(deny_unknown_fields)]
419pub struct RuntimeRouterConfig {
420 path: BundlePath,
422}
423
424impl RuntimeRouterConfig {
425 #[must_use]
427 pub const fn new(path: BundlePath) -> Self {
428 Self { path }
429 }
430
431 #[must_use]
433 pub const fn path(&self) -> &BundlePath {
434 &self.path
435 }
436
437 fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
438 if !self.path.starts_with_directory(ASSETS_DIR) {
439 return Err(DocumentError::RouterOutsideAssets {
440 path: self.path.clone(),
441 });
442 }
443 if !assets.entries.iter().any(|entry| entry.path == self.path) {
444 return Err(DocumentError::RouterMissingAsset {
445 path: self.path.clone(),
446 });
447 }
448 Ok(())
449 }
450}
451
452pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
454 serde_json::from_slice(bytes).map_err(BundleError::from)
455}