Skip to main content

phoxal_bundle/
document.rs

1//! Runtime document, asset index, and invariant validation.
2
3use 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::RobotApiVersion;
12use serde::{Deserialize, Serialize};
13
14use crate::{
15    ASSETS_DIR, AssetIndex, BinaryReference, BundleError, BundlePath, DocumentError,
16    RuntimeParticipant, SelectionError,
17};
18
19/// The scheduler policy persisted for one runtime participant instance.
20///
21/// This belongs to the compiled runtime bundle because it is a runtime
22/// selection fact, not a process-contract/launch parser type.
23#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum ParticipantClock {
26    /// Follow the host's boot-anchored real clock.
27    Real,
28    /// Follow the simulation world clock supplied by the runtime.
29    Simulation,
30    /// Do not schedule robot-time steps.
31    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/// A schema-tagged persisted runtime document.
45#[derive(Clone, Debug, Serialize)]
46#[serde(tag = "schema", deny_unknown_fields)]
47pub enum RuntimeDocument {
48    /// The first runtime bundle schema. Older/future schemas are refused
49    /// rather than guessed at by a runtime process.
50    #[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    /// Wrap one already-validated runtime document.
71    #[must_use]
72    pub const fn new(runtime: Runtime) -> Self {
73        Self::V0(runtime)
74    }
75
76    /// The runtime payload.
77    #[must_use]
78    pub const fn runtime(&self) -> &Runtime {
79        match self {
80            Self::V0(runtime) => runtime,
81        }
82    }
83
84    /// The canonical robot identity persisted by this document.
85    #[must_use]
86    pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
87        self.runtime().robot.id()
88    }
89
90    /// The canonical compiled robot.
91    #[must_use]
92    pub fn robot(&self) -> &Robot {
93        &self.runtime().robot
94    }
95
96    /// The one Robot API revision validated for this execution.
97    #[must_use]
98    pub fn robot_api(&self) -> RobotApiVersion {
99        self.runtime().robot_api()
100    }
101
102    /// The final participant set, in persisted order.
103    #[must_use]
104    pub fn participants(&self) -> &[RuntimeParticipant] {
105        &self.runtime().participants
106    }
107
108    /// The reusable executable artifacts selected by participant instances.
109    #[must_use]
110    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
111        &self.runtime().artifacts
112    }
113
114    /// Find the exact persisted participant selected by a process boundary.
115    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/// The persisted final runtime graph and all framework-owned runtime facts.
126#[derive(Clone, Debug, Serialize)]
127#[serde(deny_unknown_fields)]
128pub struct Runtime {
129    /// The complete canonical model. Its `id` is the sole persisted RobotId;
130    /// there is no namespace or duplicate top-level identity field.
131    pub(crate) robot: Robot,
132    /// The reusable staged executables and their embedded compatibility
133    /// contracts. Multiple participant instances may point to one entry.
134    pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
135    /// The exact process instances the executor must launch, in final
136    /// persisted form.
137    pub(crate) participants: Vec<RuntimeParticipant>,
138    /// The participant-readable asset index and integrity facts.
139    pub(crate) assets: AssetIndex,
140    /// Optional supervisor router configuration, kept as an indexed asset.
141    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    /// Construct the complete in-memory runtime document, validating its
170    /// cross-field invariants exactly once.
171    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    /// The canonical compiled robot.
190    #[must_use]
191    pub const fn robot(&self) -> &Robot {
192        &self.robot
193    }
194
195    /// The reusable executable artifacts retained by this runtime.
196    #[must_use]
197    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
198        &self.artifacts
199    }
200
201    /// The final participant set, in persisted order.
202    #[must_use]
203    pub fn participants(&self) -> &[RuntimeParticipant] {
204        &self.participants
205    }
206
207    /// The participant-readable asset index.
208    #[must_use]
209    pub const fn assets(&self) -> &AssetIndex {
210        &self.assets
211    }
212
213    /// Optional router configuration selected by build tooling.
214    #[must_use]
215    pub const fn router(&self) -> Option<&RuntimeRouterConfig> {
216        self.router.as_ref()
217    }
218
219    /// The one Robot API revision selected by every launched participant.
220    ///
221    /// Runtime construction proves this invariant and every valid runtime has
222    /// a brain participant, so the lookup cannot fail after validation.
223    #[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 robot_api(&self) -> RobotApiVersion {
229        self.participants
230            .first()
231            .and_then(|participant| self.artifacts.get(&participant.artifact))
232            .map(|artifact| artifact.contract().api)
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 robot_api = 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 api = artifact.contract().api;
292            if let Some(expected) = robot_api
293                && expected != api
294            {
295                return Err(DocumentError::MixedRobotApi {
296                    artifact: participant.artifact.clone(),
297                    expected,
298                    actual: api,
299                });
300            }
301            robot_api = Some(api);
302            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
303            {
304                if participant.id.as_str() != "brain" {
305                    return Err(DocumentError::BrainIdMismatch {
306                        actual: participant.id.clone(),
307                    });
308                }
309                if brain.replace(participant.id.clone()).is_some() {
310                    return Err(DocumentError::DuplicateBrain);
311                }
312            }
313            if artifact.contract().kind
314                == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
315            {
316                simulator_count = simulator_count.saturating_add(1);
317            }
318            referenced_artifacts.insert(participant.artifact.clone());
319            if !ids.insert(participant.id.clone()) {
320                return Err(DocumentError::DuplicateParticipant {
321                    id: participant.id.clone(),
322                });
323            }
324        }
325        if brain.is_none() {
326            return Err(DocumentError::MissingBrain);
327        }
328        if self.robot.clock() == phoxal_model::Clock::Simulated {
329            match simulator_count {
330                0 => return Err(DocumentError::MissingSimulator),
331                1 => {}
332                _ => return Err(DocumentError::DuplicateSimulator),
333            }
334        }
335        if let Some(artifact) = self
336            .artifacts
337            .keys()
338            .find(|id| !referenced_artifacts.contains(*id))
339        {
340            return Err(DocumentError::UnusedArtifact {
341                artifact: artifact.clone(),
342            });
343        }
344        self.assets.validate()?;
345        if let Some(router) = &self.router {
346            router.validate(&self.assets)?;
347        }
348        Ok(())
349    }
350}
351
352/// Validate one artifact's static topology requirement against the canonical
353/// robot once, independently of how many runtime instances select it.
354pub(crate) fn validate_requirement(
355    contract: &ParticipantContract,
356    artifact: &ParticipantArtifactId,
357    robot: &Robot,
358) -> Result<(), DocumentError> {
359    let Some(requirement) = contract.requirement else {
360        return Ok(());
361    };
362    match requirement {
363        ParticipantRequirement::DifferentialDriveVelocity => {
364            let phoxal_model::robot::KinematicConfig::Differential {
365                left_actuators,
366                right_actuators,
367                ..
368            } = robot.motion().kinematic()
369            else {
370                return Err(DocumentError::RequirementKinematicsMismatch {
371                    artifact: artifact.clone(),
372                    requirement,
373                    actual: robot.motion().kinematic().kind(),
374                });
375            };
376            validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
377            validate_drive_side(artifact, "right_actuators", right_actuators, robot)
378        }
379    }
380}
381
382fn validate_drive_side(
383    artifact: &ParticipantArtifactId,
384    side: &'static str,
385    actuators: &[CapabilityRef],
386    robot: &Robot,
387) -> Result<(), DocumentError> {
388    if actuators.is_empty() {
389        return Err(DocumentError::RequirementActuatorListEmpty {
390            artifact: artifact.clone(),
391            side,
392        });
393    }
394    for reference in actuators {
395        let (motor, _) = robot.require_motor(reference).map_err(|error| {
396            DocumentError::RequirementActuatorInvalid {
397                artifact: artifact.clone(),
398                actuator: reference.clone(),
399                error: error.to_string(),
400            }
401        })?;
402        if motor.command != MotorCommand::Velocity {
403            return Err(DocumentError::RequirementMotorModeMismatch {
404                artifact: artifact.clone(),
405                actuator: reference.clone(),
406                expected: MotorCommand::Velocity,
407                actual: motor.command,
408            });
409        }
410    }
411    Ok(())
412}
413
414/// A normalized reference to optional router configuration.
415#[derive(Clone, Debug, Deserialize, Serialize)]
416#[serde(deny_unknown_fields)]
417pub struct RuntimeRouterConfig {
418    /// The config is an indexed asset, never an arbitrary filesystem path.
419    path: BundlePath,
420}
421
422impl RuntimeRouterConfig {
423    /// Construct router configuration pointing at one bundle asset.
424    #[must_use]
425    pub const fn new(path: BundlePath) -> Self {
426        Self { path }
427    }
428
429    /// The indexed asset path containing the router configuration.
430    #[must_use]
431    pub const fn path(&self) -> &BundlePath {
432        &self.path
433    }
434
435    fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
436        if !self.path.starts_with_directory(ASSETS_DIR) {
437            return Err(DocumentError::RouterOutsideAssets {
438                path: self.path.clone(),
439            });
440        }
441        if !assets.entries.iter().any(|entry| entry.path == self.path) {
442            return Err(DocumentError::RouterMissingAsset {
443                path: self.path.clone(),
444            });
445        }
446        Ok(())
447    }
448}
449
450/// Decode the one schema-tagged document retained in an installed bundle.
451pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
452    serde_json::from_slice(bytes).map_err(BundleError::from)
453}