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(
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(&self) -> FrameworkVersion {
101 self.runtime().framework()
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]
226 #[expect(
227 clippy::expect_used,
228 reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
229 )]
230 pub fn framework(&self) -> FrameworkVersion {
231 self.participants
232 .first()
233 .and_then(|participant| self.artifacts.get(&participant.artifact))
234 .map(|artifact| artifact.contract().framework)
235 .expect("validated runtime has a selected participant artifact")
236 }
237
238 fn validate(&self) -> Result<(), DocumentError> {
239 if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
240 return Err(DocumentError::TooManyParticipants {
241 count: self.participants.len(),
242 });
243 }
244 let mut ids = BTreeSet::new();
245 let mut artifact_paths = BTreeSet::new();
246 let mut validators = BTreeMap::new();
247 for (id, artifact) in &self.artifacts {
248 artifact.validate(id)?;
249 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
250 {
251 if id.as_str() != "brain" {
252 return Err(DocumentError::BrainArtifactId { actual: id.clone() });
253 }
254 if artifact.path().as_str() != "bin/brain" {
255 return Err(DocumentError::BrainArtifactPath {
256 actual: artifact.path().clone(),
257 });
258 }
259 }
260 if !artifact_paths.insert(artifact.path.clone()) {
261 return Err(DocumentError::DuplicateBinary {
262 path: artifact.path.clone(),
263 });
264 }
265 let validator =
266 jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
267 DocumentError::InvalidConfigSchema {
268 artifact: id.clone(),
269 error: error.to_string(),
270 }
271 })?;
272 validate_requirement(artifact.contract(), id, &self.robot)?;
273 validators.insert(id, validator);
274 }
275 let mut referenced_artifacts = BTreeSet::new();
276 let mut brain = None;
277 let mut simulator_count = 0_u8;
278 let mut framework = None;
279 for participant in &self.participants {
280 let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
281 DocumentError::UnknownArtifact {
282 participant: participant.id.clone(),
283 artifact: participant.artifact.clone(),
284 }
285 })?;
286 let validator = validators.get(&participant.artifact).ok_or_else(|| {
287 DocumentError::UnknownArtifact {
288 participant: participant.id.clone(),
289 artifact: participant.artifact.clone(),
290 }
291 })?;
292 participant.validate(&self.robot, artifact, validator)?;
293 let artifact_framework = artifact.contract().framework;
296 if let Some(expected) = framework
297 && expected != artifact_framework
298 {
299 return Err(DocumentError::MixedFramework {
300 artifact: participant.artifact.clone(),
301 expected,
302 actual: artifact_framework,
303 });
304 }
305 framework = Some(artifact_framework);
306 if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
307 {
308 if participant.id.as_str() != "brain" {
309 return Err(DocumentError::BrainIdMismatch {
310 actual: participant.id.clone(),
311 });
312 }
313 if brain.replace(participant.id.clone()).is_some() {
314 return Err(DocumentError::DuplicateBrain);
315 }
316 }
317 if artifact.contract().kind
318 == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
319 {
320 simulator_count = simulator_count.saturating_add(1);
321 }
322 referenced_artifacts.insert(participant.artifact.clone());
323 if !ids.insert(participant.id.clone()) {
324 return Err(DocumentError::DuplicateParticipant {
325 id: participant.id.clone(),
326 });
327 }
328 }
329 if brain.is_none() {
330 return Err(DocumentError::MissingBrain);
331 }
332 if self.robot.clock() == phoxal_model::Clock::Simulated {
333 match simulator_count {
334 0 => return Err(DocumentError::MissingSimulator),
335 1 => {}
336 _ => return Err(DocumentError::DuplicateSimulator),
337 }
338 }
339 if let Some(artifact) = self
340 .artifacts
341 .keys()
342 .find(|id| !referenced_artifacts.contains(*id))
343 {
344 return Err(DocumentError::UnusedArtifact {
345 artifact: artifact.clone(),
346 });
347 }
348 self.assets.validate()?;
349 if let Some(router) = &self.router {
350 router.validate(&self.assets)?;
351 }
352 Ok(())
353 }
354}
355
356pub(crate) fn validate_requirement(
359 contract: &ParticipantContract,
360 artifact: &ParticipantArtifactId,
361 robot: &Robot,
362) -> Result<(), DocumentError> {
363 let Some(requirement) = contract.requirement else {
364 return Ok(());
365 };
366 match requirement {
367 ParticipantRequirement::DifferentialDriveVelocity => {
368 let phoxal_model::robot::KinematicConfig::Differential {
369 left_actuators,
370 right_actuators,
371 ..
372 } = robot.motion().kinematic()
373 else {
374 return Err(DocumentError::RequirementKinematicsMismatch {
375 artifact: artifact.clone(),
376 requirement,
377 actual: robot.motion().kinematic().kind(),
378 });
379 };
380 validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
381 validate_drive_side(artifact, "right_actuators", right_actuators, robot)
382 }
383 }
384}
385
386fn validate_drive_side(
387 artifact: &ParticipantArtifactId,
388 side: &'static str,
389 actuators: &[CapabilityRef],
390 robot: &Robot,
391) -> Result<(), DocumentError> {
392 if actuators.is_empty() {
393 return Err(DocumentError::RequirementActuatorListEmpty {
394 artifact: artifact.clone(),
395 side,
396 });
397 }
398 for reference in actuators {
399 let (motor, _) = robot.require_motor(reference).map_err(|error| {
400 DocumentError::RequirementActuatorInvalid {
401 artifact: artifact.clone(),
402 actuator: reference.clone(),
403 error: error.to_string(),
404 }
405 })?;
406 if motor.command != MotorCommand::Velocity {
407 return Err(DocumentError::RequirementMotorModeMismatch {
408 artifact: artifact.clone(),
409 actuator: reference.clone(),
410 expected: MotorCommand::Velocity,
411 actual: motor.command,
412 });
413 }
414 }
415 Ok(())
416}
417
418#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct RuntimeRouterConfig {
422 path: BundlePath,
424}
425
426impl RuntimeRouterConfig {
427 #[must_use]
429 pub const fn new(path: BundlePath) -> Self {
430 Self { path }
431 }
432
433 #[must_use]
435 pub const fn path(&self) -> &BundlePath {
436 &self.path
437 }
438
439 fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
440 if !self.path.starts_with_directory(ASSETS_DIR) {
441 return Err(DocumentError::RouterOutsideAssets {
442 path: self.path.clone(),
443 });
444 }
445 if !assets.entries.iter().any(|entry| entry.path == self.path) {
446 return Err(DocumentError::RouterMissingAsset {
447 path: self.path.clone(),
448 });
449 }
450 Ok(())
451 }
452}
453
454pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
456 serde_json::from_slice(bytes).map_err(BundleError::from)
457}