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