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::FrameworkVersion;
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 framework train validated for this execution.
97    #[must_use]
98    pub fn framework(&self) -> FrameworkVersion {
99        self.runtime().framework()
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 framework train every launched participant was built from.
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 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            // One execution runs one framework train: compatibility is exact
292            // version equality, so a bundle mixing trains has no valid launch.
293            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
354/// Validate one artifact's static topology requirement against the canonical
355/// robot once, independently of how many runtime instances select it.
356pub(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/// A normalized reference to optional router configuration.
417#[derive(Clone, Debug, Deserialize, Serialize)]
418#[serde(deny_unknown_fields)]
419pub struct RuntimeRouterConfig {
420    /// The config is an indexed asset, never an arbitrary filesystem path.
421    path: BundlePath,
422}
423
424impl RuntimeRouterConfig {
425    /// Construct router configuration pointing at one bundle asset.
426    #[must_use]
427    pub const fn new(path: BundlePath) -> Self {
428        Self { path }
429    }
430
431    /// The indexed asset path containing the router configuration.
432    #[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
452/// Decode the one schema-tagged document retained in an installed bundle.
453pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
454    serde_json::from_slice(bytes).map_err(BundleError::from)
455}