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(
24    phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
25)]
26#[serde(rename_all = "snake_case")]
27pub enum ParticipantClock {
28    /// Follow the host's boot-anchored real clock.
29    Real,
30    /// Follow the simulation world clock supplied by the runtime.
31    Simulation,
32    /// Do not schedule robot-time steps.
33    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/// A schema-tagged persisted runtime document.
47#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
48#[serde(tag = "schema", deny_unknown_fields)]
49pub enum RuntimeDocument {
50    /// The first runtime bundle schema. Older/future schemas are refused
51    /// rather than guessed at by a runtime process.
52    #[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    /// Wrap one already-validated runtime document.
73    #[must_use]
74    pub const fn new(runtime: Runtime) -> Self {
75        Self::V0(runtime)
76    }
77
78    /// The runtime payload.
79    #[must_use]
80    pub const fn runtime(&self) -> &Runtime {
81        match self {
82            Self::V0(runtime) => runtime,
83        }
84    }
85
86    /// The canonical robot identity persisted by this document.
87    #[must_use]
88    pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
89        self.runtime().robot.id()
90    }
91
92    /// The canonical compiled robot.
93    #[must_use]
94    pub fn robot(&self) -> &Robot {
95        &self.runtime().robot
96    }
97
98    /// The one framework train validated for this execution.
99    #[must_use]
100    pub fn framework(&self) -> FrameworkVersion {
101        self.runtime().framework()
102    }
103
104    /// The final participant set, in persisted order.
105    #[must_use]
106    pub fn participants(&self) -> &[RuntimeParticipant] {
107        &self.runtime().participants
108    }
109
110    /// The reusable executable artifacts selected by participant instances.
111    #[must_use]
112    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
113        &self.runtime().artifacts
114    }
115
116    /// Find the exact persisted participant selected by a process boundary.
117    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/// The persisted final runtime graph and all framework-owned runtime facts.
128#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
129#[serde(deny_unknown_fields)]
130pub struct Runtime {
131    /// The complete canonical model. Its `id` is the sole persisted RobotId;
132    /// there is no namespace or duplicate top-level identity field.
133    pub(crate) robot: Robot,
134    /// The reusable staged executables and their embedded compatibility
135    /// contracts. Multiple participant instances may point to one entry.
136    pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
137    /// The exact process instances the executor must launch, in final
138    /// persisted form.
139    pub(crate) participants: Vec<RuntimeParticipant>,
140    /// The participant-readable asset index and integrity facts.
141    pub(crate) assets: AssetIndex,
142    /// Optional supervisor router configuration, kept as an indexed asset.
143    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    /// Construct the complete in-memory runtime document, validating its
172    /// cross-field invariants exactly once.
173    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    /// The canonical compiled robot.
192    #[must_use]
193    pub const fn robot(&self) -> &Robot {
194        &self.robot
195    }
196
197    /// The reusable executable artifacts retained by this runtime.
198    #[must_use]
199    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
200        &self.artifacts
201    }
202
203    /// The final participant set, in persisted order.
204    #[must_use]
205    pub fn participants(&self) -> &[RuntimeParticipant] {
206        &self.participants
207    }
208
209    /// The participant-readable asset index.
210    #[must_use]
211    pub const fn assets(&self) -> &AssetIndex {
212        &self.assets
213    }
214
215    /// Optional router configuration selected by build tooling.
216    #[must_use]
217    pub const fn router(&self) -> Option<&RuntimeRouterConfig> {
218        self.router.as_ref()
219    }
220
221    /// The one framework train every launched participant was built from.
222    ///
223    /// Runtime construction proves this invariant and every valid runtime has
224    /// a brain participant, so the lookup cannot fail after validation.
225    #[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            // One execution runs one framework train: compatibility is exact
294            // version equality, so a bundle mixing trains has no valid launch.
295            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
356/// Validate one artifact's static topology requirement against the canonical
357/// robot once, independently of how many runtime instances select it.
358pub(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/// A normalized reference to optional router configuration.
419#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
420#[serde(deny_unknown_fields)]
421pub struct RuntimeRouterConfig {
422    /// The config is an indexed asset, never an arbitrary filesystem path.
423    path: BundlePath,
424}
425
426impl RuntimeRouterConfig {
427    /// Construct router configuration pointing at one bundle asset.
428    #[must_use]
429    pub const fn new(path: BundlePath) -> Self {
430        Self { path }
431    }
432
433    /// The indexed asset path containing the router configuration.
434    #[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
454/// Decode the one schema-tagged document retained in an installed bundle.
455pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
456    serde_json::from_slice(bytes).map_err(BundleError::from)
457}