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::{CompatibilityLine, 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 compatibility line validated for this execution.
99    #[must_use]
100    pub fn framework_line(&self) -> CompatibilityLine {
101        self.runtime().framework_line()
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 compatibility line every launched participant was built on.
222    ///
223    /// This is the line, not a version, because that is all the document can
224    /// honestly promise: validation proves the selected artifacts share a
225    /// line, and they may have been built from different trains on it. The
226    /// exact train behind each artifact stays readable through
227    /// [`Self::artifacts`], which is where a provenance report or a diagnostic
228    /// reads it from.
229    ///
230    /// Runtime construction proves this invariant and every valid runtime has
231    /// a brain participant, so the lookup cannot fail after validation.
232    #[must_use]
233    #[expect(
234        clippy::expect_used,
235        reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
236    )]
237    pub fn framework_line(&self) -> CompatibilityLine {
238        self.participants
239            .first()
240            .and_then(|participant| self.artifacts.get(&participant.artifact))
241            .map(|artifact| artifact.contract().framework.compatibility_line())
242            .expect("validated runtime has a selected participant artifact")
243    }
244
245    fn validate(&self) -> Result<(), DocumentError> {
246        if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
247            return Err(DocumentError::TooManyParticipants {
248                count: self.participants.len(),
249            });
250        }
251        let mut ids = BTreeSet::new();
252        let mut artifact_paths = BTreeSet::new();
253        let mut validators = BTreeMap::new();
254        for (id, artifact) in &self.artifacts {
255            artifact.validate(id)?;
256            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
257            {
258                if id.as_str() != "brain" {
259                    return Err(DocumentError::BrainArtifactId { actual: id.clone() });
260                }
261                if artifact.path().as_str() != "bin/brain" {
262                    return Err(DocumentError::BrainArtifactPath {
263                        actual: artifact.path().clone(),
264                    });
265                }
266            }
267            if !artifact_paths.insert(artifact.path.clone()) {
268                return Err(DocumentError::DuplicateBinary {
269                    path: artifact.path.clone(),
270                });
271            }
272            let validator =
273                jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
274                    DocumentError::InvalidConfigSchema {
275                        artifact: id.clone(),
276                        error: error.to_string(),
277                    }
278                })?;
279            validate_requirement(artifact.contract(), id, &self.robot)?;
280            validators.insert(id, validator);
281        }
282        let mut referenced_artifacts = BTreeSet::new();
283        let mut brain = None;
284        let mut simulator_count = 0_u8;
285        let mut framework: Option<FrameworkVersion> = None;
286        for participant in &self.participants {
287            let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
288                DocumentError::UnknownArtifact {
289                    participant: participant.id.clone(),
290                    artifact: participant.artifact.clone(),
291                }
292            })?;
293            let validator = validators.get(&participant.artifact).ok_or_else(|| {
294                DocumentError::UnknownArtifact {
295                    participant: participant.id.clone(),
296                    artifact: participant.artifact.clone(),
297                }
298            })?;
299            participant.validate(&self.robot, artifact, validator)?;
300            // One execution runs one compatibility line. Artifacts may have
301            // been built from different trains on that line, because trains on
302            // one line speak the same contracts; a bundle spanning two lines
303            // has no valid launch. The first selected artifact's train is kept
304            // as the reported one so the diagnostic names a stable side.
305            let artifact_framework = artifact.contract().framework;
306            let expected = *framework.get_or_insert(artifact_framework);
307            if !expected.is_compatible_with(artifact_framework) {
308                return Err(DocumentError::MixedFrameworkLine {
309                    artifact: participant.artifact.clone(),
310                    expected,
311                    actual: artifact_framework,
312                });
313            }
314            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
315            {
316                if participant.id.as_str() != "brain" {
317                    return Err(DocumentError::BrainIdMismatch {
318                        actual: participant.id.clone(),
319                    });
320                }
321                if brain.replace(participant.id.clone()).is_some() {
322                    return Err(DocumentError::DuplicateBrain);
323                }
324            }
325            if artifact.contract().kind
326                == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
327            {
328                simulator_count = simulator_count.saturating_add(1);
329            }
330            referenced_artifacts.insert(participant.artifact.clone());
331            if !ids.insert(participant.id.clone()) {
332                return Err(DocumentError::DuplicateParticipant {
333                    id: participant.id.clone(),
334                });
335            }
336        }
337        if brain.is_none() {
338            return Err(DocumentError::MissingBrain);
339        }
340        if self.robot.clock() == phoxal_model::Clock::Simulated {
341            match simulator_count {
342                0 => return Err(DocumentError::MissingSimulator),
343                1 => {}
344                _ => return Err(DocumentError::DuplicateSimulator),
345            }
346        }
347        if let Some(artifact) = self
348            .artifacts
349            .keys()
350            .find(|id| !referenced_artifacts.contains(*id))
351        {
352            return Err(DocumentError::UnusedArtifact {
353                artifact: artifact.clone(),
354            });
355        }
356        self.assets.validate()?;
357        if let Some(router) = &self.router {
358            router.validate(&self.assets)?;
359        }
360        Ok(())
361    }
362}
363
364/// Validate one artifact's static topology requirement against the canonical
365/// robot once, independently of how many runtime instances select it.
366pub(crate) fn validate_requirement(
367    contract: &ParticipantContract,
368    artifact: &ParticipantArtifactId,
369    robot: &Robot,
370) -> Result<(), DocumentError> {
371    let Some(requirement) = contract.requirement else {
372        return Ok(());
373    };
374    match requirement {
375        ParticipantRequirement::DifferentialDriveVelocity => {
376            let phoxal_model::robot::KinematicConfig::Differential {
377                left_actuators,
378                right_actuators,
379                ..
380            } = robot.motion().kinematic()
381            else {
382                return Err(DocumentError::RequirementKinematicsMismatch {
383                    artifact: artifact.clone(),
384                    requirement,
385                    actual: robot.motion().kinematic().kind(),
386                });
387            };
388            validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
389            validate_drive_side(artifact, "right_actuators", right_actuators, robot)
390        }
391    }
392}
393
394fn validate_drive_side(
395    artifact: &ParticipantArtifactId,
396    side: &'static str,
397    actuators: &[CapabilityRef],
398    robot: &Robot,
399) -> Result<(), DocumentError> {
400    if actuators.is_empty() {
401        return Err(DocumentError::RequirementActuatorListEmpty {
402            artifact: artifact.clone(),
403            side,
404        });
405    }
406    for reference in actuators {
407        let (motor, _) = robot.require_motor(reference).map_err(|error| {
408            DocumentError::RequirementActuatorInvalid {
409                artifact: artifact.clone(),
410                actuator: reference.clone(),
411                error: error.to_string(),
412            }
413        })?;
414        if motor.command != MotorCommand::Velocity {
415            return Err(DocumentError::RequirementMotorModeMismatch {
416                artifact: artifact.clone(),
417                actuator: reference.clone(),
418                expected: MotorCommand::Velocity,
419                actual: motor.command,
420            });
421        }
422    }
423    Ok(())
424}
425
426/// A normalized reference to optional router configuration.
427#[derive(phoxal_macros::DescribeWire, Clone, Debug, Deserialize, Serialize)]
428#[serde(deny_unknown_fields)]
429pub struct RuntimeRouterConfig {
430    /// The config is an indexed asset, never an arbitrary filesystem path.
431    path: BundlePath,
432}
433
434impl RuntimeRouterConfig {
435    /// Construct router configuration pointing at one bundle asset.
436    #[must_use]
437    pub const fn new(path: BundlePath) -> Self {
438        Self { path }
439    }
440
441    /// The indexed asset path containing the router configuration.
442    #[must_use]
443    pub const fn path(&self) -> &BundlePath {
444        &self.path
445    }
446
447    fn validate(&self, assets: &AssetIndex) -> Result<(), DocumentError> {
448        if !self.path.starts_with_directory(ASSETS_DIR) {
449            return Err(DocumentError::RouterOutsideAssets {
450                path: self.path.clone(),
451            });
452        }
453        if !assets.entries.iter().any(|entry| entry.path == self.path) {
454            return Err(DocumentError::RouterMissingAsset {
455                path: self.path.clone(),
456            });
457        }
458        Ok(())
459    }
460}
461
462/// Decode the one schema-tagged document retained in an installed bundle.
463pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
464    serde_json::from_slice(bytes).map_err(BundleError::from)
465}