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        let mut ids = BTreeSet::new();
238        let mut artifact_paths = BTreeSet::new();
239        let mut validators = BTreeMap::new();
240        for (id, artifact) in &self.artifacts {
241            artifact.validate(id)?;
242            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
243            {
244                if id.as_str() != "brain" {
245                    return Err(DocumentError::BrainArtifactId { actual: id.clone() });
246                }
247                if artifact.path().as_str() != "bin/brain" {
248                    return Err(DocumentError::BrainArtifactPath {
249                        actual: artifact.path().clone(),
250                    });
251                }
252            }
253            if !artifact_paths.insert(artifact.path.clone()) {
254                return Err(DocumentError::DuplicateBinary {
255                    path: artifact.path.clone(),
256                });
257            }
258            let validator =
259                jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
260                    DocumentError::InvalidConfigSchema {
261                        artifact: id.clone(),
262                        error: error.to_string(),
263                    }
264                })?;
265            validate_requirement(artifact.contract(), id, &self.robot)?;
266            validators.insert(id, validator);
267        }
268        let mut referenced_artifacts = BTreeSet::new();
269        let mut brain = None;
270        let mut simulator_count = 0_u8;
271        let mut robot_api = None;
272        for participant in &self.participants {
273            let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
274                DocumentError::UnknownArtifact {
275                    participant: participant.id.clone(),
276                    artifact: participant.artifact.clone(),
277                }
278            })?;
279            let validator = validators.get(&participant.artifact).ok_or_else(|| {
280                DocumentError::UnknownArtifact {
281                    participant: participant.id.clone(),
282                    artifact: participant.artifact.clone(),
283                }
284            })?;
285            participant.validate(&self.robot, artifact, validator)?;
286            let api = artifact.contract().api;
287            if let Some(expected) = robot_api
288                && expected != api
289            {
290                return Err(DocumentError::MixedRobotApi {
291                    artifact: participant.artifact.clone(),
292                    expected,
293                    actual: api,
294                });
295            }
296            robot_api = Some(api);
297            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
298            {
299                if participant.id.as_str() != "brain" {
300                    return Err(DocumentError::BrainIdMismatch {
301                        actual: participant.id.clone(),
302                    });
303                }
304                if brain.replace(participant.id.clone()).is_some() {
305                    return Err(DocumentError::DuplicateBrain);
306                }
307            }
308            if artifact.contract().kind
309                == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
310            {
311                simulator_count = simulator_count.saturating_add(1);
312            }
313            referenced_artifacts.insert(participant.artifact.clone());
314            if !ids.insert(participant.id.clone()) {
315                return Err(DocumentError::DuplicateParticipant {
316                    id: participant.id.clone(),
317                });
318            }
319        }
320        if brain.is_none() {
321            return Err(DocumentError::MissingBrain);
322        }
323        if self.robot.clock() == phoxal_model::Clock::Simulated {
324            match simulator_count {
325                0 => return Err(DocumentError::MissingSimulator),
326                1 => {}
327                _ => return Err(DocumentError::DuplicateSimulator),
328            }
329        }
330        if let Some(artifact) = self
331            .artifacts
332            .keys()
333            .find(|id| !referenced_artifacts.contains(*id))
334        {
335            return Err(DocumentError::UnusedArtifact {
336                artifact: artifact.clone(),
337            });
338        }
339        self.assets.validate()?;
340        if let Some(router) = &self.router {
341            router.validate(&self.assets)?;
342        }
343        Ok(())
344    }
345}
346
347/// Validate one artifact's static topology requirement against the canonical
348/// robot once, independently of how many runtime instances select it.
349pub(crate) fn validate_requirement(
350    contract: &ParticipantContract,
351    artifact: &ParticipantArtifactId,
352    robot: &Robot,
353) -> Result<(), DocumentError> {
354    let Some(requirement) = contract.requirement else {
355        return Ok(());
356    };
357    match requirement {
358        ParticipantRequirement::DifferentialDriveVelocity => {
359            let phoxal_model::robot::KinematicConfig::Differential {
360                left_actuators,
361                right_actuators,
362                ..
363            } = robot.motion().kinematic()
364            else {
365                return Err(DocumentError::RequirementKinematicsMismatch {
366                    artifact: artifact.clone(),
367                    requirement,
368                    actual: robot.motion().kinematic().kind(),
369                });
370            };
371            validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
372            validate_drive_side(artifact, "right_actuators", right_actuators, robot)
373        }
374    }
375}
376
377fn validate_drive_side(
378    artifact: &ParticipantArtifactId,
379    side: &'static str,
380    actuators: &[CapabilityRef],
381    robot: &Robot,
382) -> Result<(), DocumentError> {
383    if actuators.is_empty() {
384        return Err(DocumentError::RequirementActuatorListEmpty {
385            artifact: artifact.clone(),
386            side,
387        });
388    }
389    for reference in actuators {
390        let (motor, _) = robot.require_motor(reference).map_err(|error| {
391            DocumentError::RequirementActuatorInvalid {
392                artifact: artifact.clone(),
393                actuator: reference.clone(),
394                error: error.to_string(),
395            }
396        })?;
397        if motor.command != MotorCommand::Velocity {
398            return Err(DocumentError::RequirementMotorModeMismatch {
399                artifact: artifact.clone(),
400                actuator: reference.clone(),
401                expected: MotorCommand::Velocity,
402                actual: motor.command,
403            });
404        }
405    }
406    Ok(())
407}
408
409/// A normalized reference to optional router configuration.
410#[derive(Clone, Debug, Deserialize, Serialize)]
411#[serde(deny_unknown_fields)]
412pub struct RuntimeRouterConfig {
413    /// The config is an indexed asset, never an arbitrary filesystem path.
414    path: BundlePath,
415}
416
417impl RuntimeRouterConfig {
418    /// Construct router configuration pointing at one bundle asset.
419    #[must_use]
420    pub const fn new(path: BundlePath) -> Self {
421        Self { path }
422    }
423
424    /// The indexed asset path containing the router configuration.
425    #[must_use]
426    pub const fn path(&self) -> &BundlePath {
427        &self.path
428    }
429
430    fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
431        if !self.path.starts_with_directory(ASSETS_DIR) {
432            return Err(DocumentError::RouterOutsideAssets {
433                path: self.path.clone(),
434            });
435        }
436        if !assets.entries.iter().any(|entry| entry.path == self.path) {
437            return Err(DocumentError::RouterMissingAsset {
438                path: self.path.clone(),
439            });
440        }
441        Ok(())
442    }
443}
444
445/// Decode the one schema-tagged document retained in an installed bundle.
446pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
447    serde_json::from_slice(bytes).map_err(BundleError::from)
448}