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    AssetIndex, BinaryReference, BundleError, DocumentError, RuntimeParticipant, SelectionError,
16};
17
18/// The scheduler policy persisted for one runtime participant instance.
19///
20/// This belongs to the compiled runtime bundle because it is a runtime
21/// selection fact, not a process-contract/launch parser type.
22#[derive(
23    phoxal_macros::DescribeWire, Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize,
24)]
25#[serde(rename_all = "snake_case")]
26pub enum ParticipantClock {
27    /// Follow the host's boot-anchored real clock.
28    Real,
29    /// Follow the simulation world clock supplied by the runtime.
30    Simulation,
31    /// Do not schedule robot-time steps.
32    Clockless,
33}
34
35impl fmt::Display for ParticipantClock {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str(match self {
38            Self::Real => "real",
39            Self::Simulation => "simulation",
40            Self::Clockless => "clockless",
41        })
42    }
43}
44
45/// A schema-tagged persisted runtime document.
46#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
47#[serde(tag = "schema", deny_unknown_fields)]
48pub enum RuntimeDocument {
49    /// The first runtime bundle schema. Older/future schemas are refused
50    /// rather than guessed at by a runtime process.
51    #[serde(rename = "phoxal/runtime-bundle/v0")]
52    V0(Runtime),
53}
54
55impl<'de> Deserialize<'de> for RuntimeDocument {
56    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
57        #[derive(Deserialize)]
58        #[serde(tag = "schema", deny_unknown_fields)]
59        enum Wire {
60            #[serde(rename = "phoxal/runtime-bundle/v0")]
61            V0(Runtime),
62        }
63
64        match Wire::deserialize(deserializer)? {
65            Wire::V0(runtime) => Ok(Self::new(runtime)),
66        }
67    }
68}
69
70impl RuntimeDocument {
71    /// Wrap one already-validated runtime document.
72    #[must_use]
73    pub const fn new(runtime: Runtime) -> Self {
74        Self::V0(runtime)
75    }
76
77    /// The runtime payload.
78    #[must_use]
79    pub const fn runtime(&self) -> &Runtime {
80        match self {
81            Self::V0(runtime) => runtime,
82        }
83    }
84
85    /// The canonical robot identity persisted by this document.
86    #[must_use]
87    pub fn robot_id(&self) -> &phoxal_model::identity::RobotId {
88        self.runtime().robot.id()
89    }
90
91    /// The canonical compiled robot.
92    #[must_use]
93    pub fn robot(&self) -> &Robot {
94        &self.runtime().robot
95    }
96
97    /// The one compatibility line validated for this execution.
98    #[must_use]
99    pub fn framework_line(&self) -> CompatibilityLine {
100        self.runtime().framework_line()
101    }
102
103    /// The final participant set, in persisted order.
104    #[must_use]
105    pub fn participants(&self) -> &[RuntimeParticipant] {
106        &self.runtime().participants
107    }
108
109    /// The reusable executable artifacts selected by participant instances.
110    #[must_use]
111    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
112        &self.runtime().artifacts
113    }
114
115    /// Find the exact persisted participant selected by a process boundary.
116    pub fn participant(&self, id: &ParticipantId) -> Result<&RuntimeParticipant, SelectionError> {
117        self.participants()
118            .iter()
119            .find(|participant| participant.id == *id)
120            .ok_or_else(|| SelectionError::Unknown {
121                requested: id.clone(),
122            })
123    }
124}
125
126/// The persisted final runtime graph and all framework-owned runtime facts.
127#[derive(phoxal_macros::DescribeWire, Clone, Debug, Serialize)]
128#[serde(deny_unknown_fields)]
129pub struct Runtime {
130    /// The complete canonical model. Its `id` is the sole persisted RobotId;
131    /// there is no namespace or duplicate top-level identity field.
132    pub(crate) robot: Robot,
133    /// The reusable staged executables and their embedded compatibility
134    /// contracts. Multiple participant instances may point to one entry.
135    pub(crate) artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
136    /// The exact process instances the executor must launch, in final
137    /// persisted form.
138    pub(crate) participants: Vec<RuntimeParticipant>,
139    /// The participant-readable asset index and integrity facts.
140    pub(crate) assets: AssetIndex,
141}
142
143impl<'de> Deserialize<'de> for Runtime {
144    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
145        #[derive(Deserialize)]
146        #[serde(deny_unknown_fields)]
147        struct Wire {
148            robot: Robot,
149            artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
150            participants: Vec<RuntimeParticipant>,
151            assets: AssetIndex,
152        }
153
154        let wire = Wire::deserialize(deserializer)?;
155        Self::new(wire.robot, wire.artifacts, wire.participants, wire.assets)
156            .map_err(serde::de::Error::custom)
157    }
158}
159
160impl Runtime {
161    /// Construct the complete in-memory runtime document, validating its
162    /// cross-field invariants exactly once.
163    pub fn new(
164        robot: Robot,
165        artifacts: BTreeMap<ParticipantArtifactId, BinaryReference>,
166        participants: Vec<RuntimeParticipant>,
167        assets: AssetIndex,
168    ) -> Result<Self, DocumentError> {
169        let runtime = Self {
170            robot,
171            artifacts,
172            participants,
173            assets,
174        };
175        runtime.validate()?;
176        Ok(runtime)
177    }
178
179    /// The canonical compiled robot.
180    #[must_use]
181    pub const fn robot(&self) -> &Robot {
182        &self.robot
183    }
184
185    /// The reusable executable artifacts retained by this runtime.
186    #[must_use]
187    pub fn artifacts(&self) -> &BTreeMap<ParticipantArtifactId, BinaryReference> {
188        &self.artifacts
189    }
190
191    /// The final participant set, in persisted order.
192    #[must_use]
193    pub fn participants(&self) -> &[RuntimeParticipant] {
194        &self.participants
195    }
196
197    /// The participant-readable asset index.
198    #[must_use]
199    pub const fn assets(&self) -> &AssetIndex {
200        &self.assets
201    }
202
203    /// The one compatibility line every launched participant was built on.
204    ///
205    /// This is the line, not a version, because that is all the document can
206    /// honestly promise: validation proves the selected artifacts share a
207    /// line, and they may have been built from different trains on it. The
208    /// exact train behind each artifact stays readable through
209    /// [`Self::artifacts`], which is where a provenance report or a diagnostic
210    /// reads it from.
211    ///
212    /// Runtime construction proves this invariant and every valid runtime has
213    /// a brain participant, so the lookup cannot fail after validation.
214    #[must_use]
215    #[expect(
216        clippy::expect_used,
217        reason = "Runtime is constructible only after validation proves at least one selected participant and its artifact"
218    )]
219    pub fn framework_line(&self) -> CompatibilityLine {
220        self.participants
221            .first()
222            .and_then(|participant| self.artifacts.get(&participant.artifact))
223            .map(|artifact| artifact.contract().framework.compatibility_line())
224            .expect("validated runtime has a selected participant artifact")
225    }
226
227    fn validate(&self) -> Result<(), DocumentError> {
228        if self.participants.len() > crate::MAX_RUNTIME_PARTICIPANTS {
229            return Err(DocumentError::TooManyParticipants {
230                count: self.participants.len(),
231            });
232        }
233        let mut ids = BTreeSet::new();
234        let mut artifact_paths = BTreeSet::new();
235        let mut validators = BTreeMap::new();
236        for (id, artifact) in &self.artifacts {
237            artifact.validate(id)?;
238            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
239            {
240                if id.as_str() != "brain" {
241                    return Err(DocumentError::BrainArtifactId { actual: id.clone() });
242                }
243                if artifact.path().as_str() != "bin/brain" {
244                    return Err(DocumentError::BrainArtifactPath {
245                        actual: artifact.path().clone(),
246                    });
247                }
248            }
249            if !artifact_paths.insert(artifact.path.clone()) {
250                return Err(DocumentError::DuplicateBinary {
251                    path: artifact.path.clone(),
252                });
253            }
254            let validator =
255                jsonschema::validator_for(&artifact.contract.config_schema).map_err(|error| {
256                    DocumentError::InvalidConfigSchema {
257                        artifact: id.clone(),
258                        error: error.to_string(),
259                    }
260                })?;
261            validate_requirement(artifact.contract(), id, &self.robot)?;
262            validators.insert(id, validator);
263        }
264        let mut referenced_artifacts = BTreeSet::new();
265        let mut brain = None;
266        let mut simulator_count = 0_u8;
267        let mut framework: Option<FrameworkVersion> = None;
268        for participant in &self.participants {
269            let artifact = self.artifacts.get(&participant.artifact).ok_or_else(|| {
270                DocumentError::UnknownArtifact {
271                    participant: participant.id.clone(),
272                    artifact: participant.artifact.clone(),
273                }
274            })?;
275            let validator = validators.get(&participant.artifact).ok_or_else(|| {
276                DocumentError::UnknownArtifact {
277                    participant: participant.id.clone(),
278                    artifact: participant.artifact.clone(),
279                }
280            })?;
281            participant.validate(&self.robot, artifact, validator)?;
282            // One execution runs one compatibility line. Artifacts may have
283            // been built from different trains on that line, because trains on
284            // one line speak the same contracts; a bundle spanning two lines
285            // has no valid launch. The first selected artifact's train is kept
286            // as the reported one so the diagnostic names a stable side.
287            let artifact_framework = artifact.contract().framework;
288            let expected = *framework.get_or_insert(artifact_framework);
289            if !expected.is_compatible_with(artifact_framework) {
290                return Err(DocumentError::MixedFrameworkLine {
291                    artifact: participant.artifact.clone(),
292                    expected,
293                    actual: artifact_framework,
294                });
295            }
296            if artifact.contract().kind == phoxal_runtime_contract::metadata::ParticipantKind::Brain
297            {
298                if participant.id.as_str() != "brain" {
299                    return Err(DocumentError::BrainIdMismatch {
300                        actual: participant.id.clone(),
301                    });
302                }
303                if brain.replace(participant.id.clone()).is_some() {
304                    return Err(DocumentError::DuplicateBrain);
305                }
306            }
307            if artifact.contract().kind
308                == phoxal_runtime_contract::metadata::ParticipantKind::Simulator
309            {
310                simulator_count = simulator_count.saturating_add(1);
311            }
312            referenced_artifacts.insert(participant.artifact.clone());
313            if !ids.insert(participant.id.clone()) {
314                return Err(DocumentError::DuplicateParticipant {
315                    id: participant.id.clone(),
316                });
317            }
318        }
319        if brain.is_none() {
320            return Err(DocumentError::MissingBrain);
321        }
322        if self.robot.clock() == phoxal_model::Clock::Simulated {
323            match simulator_count {
324                0 => return Err(DocumentError::MissingSimulator),
325                1 => {}
326                _ => return Err(DocumentError::DuplicateSimulator),
327            }
328        }
329        if let Some(artifact) = self
330            .artifacts
331            .keys()
332            .find(|id| !referenced_artifacts.contains(*id))
333        {
334            return Err(DocumentError::UnusedArtifact {
335                artifact: artifact.clone(),
336            });
337        }
338        self.assets.validate()?;
339        Ok(())
340    }
341}
342
343/// Validate one artifact's static topology requirement against the canonical
344/// robot once, independently of how many runtime instances select it.
345pub(crate) fn validate_requirement(
346    contract: &ParticipantContract,
347    artifact: &ParticipantArtifactId,
348    robot: &Robot,
349) -> Result<(), DocumentError> {
350    let Some(requirement) = contract.requirement else {
351        return Ok(());
352    };
353    match requirement {
354        ParticipantRequirement::DifferentialDriveVelocity => {
355            let phoxal_model::robot::KinematicConfig::Differential {
356                left_actuators,
357                right_actuators,
358                ..
359            } = robot.motion().kinematic()
360            else {
361                return Err(DocumentError::RequirementKinematicsMismatch {
362                    artifact: artifact.clone(),
363                    requirement,
364                    actual: robot.motion().kinematic().kind(),
365                });
366            };
367            validate_drive_side(artifact, "left_actuators", left_actuators, robot)?;
368            validate_drive_side(artifact, "right_actuators", right_actuators, robot)
369        }
370    }
371}
372
373fn validate_drive_side(
374    artifact: &ParticipantArtifactId,
375    side: &'static str,
376    actuators: &[CapabilityRef],
377    robot: &Robot,
378) -> Result<(), DocumentError> {
379    if actuators.is_empty() {
380        return Err(DocumentError::RequirementActuatorListEmpty {
381            artifact: artifact.clone(),
382            side,
383        });
384    }
385    for reference in actuators {
386        let (motor, _) = robot.require_motor(reference).map_err(|error| {
387            DocumentError::RequirementActuatorInvalid {
388                artifact: artifact.clone(),
389                actuator: reference.clone(),
390                error: error.to_string(),
391            }
392        })?;
393        if motor.command != MotorCommand::Velocity {
394            return Err(DocumentError::RequirementMotorModeMismatch {
395                artifact: artifact.clone(),
396                actuator: reference.clone(),
397                expected: MotorCommand::Velocity,
398                actual: motor.command,
399            });
400        }
401    }
402    Ok(())
403}
404
405/// Decode the one schema-tagged document retained in an installed bundle.
406pub(crate) fn decode(bytes: &[u8]) -> Result<RuntimeDocument, BundleError> {
407    serde_json::from_slice(bytes).map_err(BundleError::from)
408}