From 81761067e9935c3a06db1af8c5f46ccfe4151c69 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 24 Jul 2026 15:29:08 +0300 Subject: [PATCH] feat(simulation): establish polygon S0 qualification --- README.md | 26 + docs/01_IMPLEMENTATION_PLAN.md | 27 + docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md | 535 +++++++++ ...mulation-polygon-qualification-boundary.md | 82 ++ docs/runbooks/SIM_S0_AI_WORKER.md | 112 ++ pyproject.toml | 1 + simulation/s0/qualification-profile.yaml | 183 +++ src/k1link/simulation/__init__.py | 21 + src/k1link/simulation/cli.py | 98 ++ src/k1link/simulation/s0.py | 1006 +++++++++++++++++ tests/test_simulation_s0.py | 227 ++++ 11 files changed, 2318 insertions(+) create mode 100644 docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md create mode 100644 docs/adr/0015-simulation-polygon-qualification-boundary.md create mode 100644 docs/runbooks/SIM_S0_AI_WORKER.md create mode 100644 simulation/s0/qualification-profile.yaml create mode 100644 src/k1link/simulation/__init__.py create mode 100644 src/k1link/simulation/cli.py create mode 100644 src/k1link/simulation/s0.py create mode 100644 tests/test_simulation_s0.py diff --git a/README.md b/README.md index b96cb7a..2a4dbe6 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,28 @@ performance, direct worker transport and bounded live fan-out remain open. See [ADR 0014](docs/adr/0014-bounded-external-perception-worker.md) and the [external worker contract](docs/10_EXTERNAL_PERCEPTION_WORKER.md). +## Simulation Polygon + +Mission Core now has a parallel Polygon product branch for reproducible +autonomy qualification. Mission Core owns scenarios, qualification-run +lifecycle, authority, canonical contracts, provenance and reports; Gazebo, PX4 +SITL, ROS 2, Nav2 and viewers remain replaceable providers. Simulation, replay, +digital twin, HIL and physical shadow keep distinct causal run kinds. + +The current implementation is SIM S0 preflight only. Its strict candidate +profile fixes D-only worker placement, loopback ports, process ownership, +Gazebo simulation time and disabled real/direct actuator authority. The doctor +does not install or launch anything and cannot report `GO` without +digest-bound target-worker evidence: + +```bash +uv run missioncore-sim s0 doctor --json +``` + +See the [Polygon product/SRS](docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md), +[ADR 0015](docs/adr/0015-simulation-polygon-qualification-boundary.md) and the +[SIM S0 worker runbook](docs/runbooks/SIM_S0_AI_WORKER.md). + ## Mission Core Control Station and visualization adapters The browser application is the universal Mission Core Control Station rather than @@ -219,6 +241,10 @@ standalone release install. The frontend consumes sibling `file:` packages from revision or content hash. Publishing/vendoring those packages or enforcing an immutable donor revision remains a packaging and CI prerequisite. +Polygon is planned as a seventh section, but UI delivery is gated on the +accepted clock/frame/authority contracts and a proven S1 backend lifecycle with +persisted run history. It is not present in the current shell. + The Observation spatial workspace embeds the open-source Rerun Web Viewer inside the Mission Core shell. It can open a compatible RRD over same-origin HTTP or a Rerun gRPC/proxy source such as diff --git a/docs/01_IMPLEMENTATION_PLAN.md b/docs/01_IMPLEMENTATION_PLAN.md index 5639bde..c99921b 100644 --- a/docs/01_IMPLEMENTATION_PLAN.md +++ b/docs/01_IMPLEMENTATION_PLAN.md @@ -24,6 +24,7 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result. | Plugin isolation | GO (laboratory control plane) — vendor backend/frontend and optional scene controls are plugin-owned; manifest/runtime descriptor parity, versioned handshake, lifecycle health and transport correlation fail closed while execution remains in-process | | K1 application control | GO (physical staged cycle) — after fixing the PCAP-proven `sint64` time field, one explicit UI launch completed all 14 canonical operations on one control session, reached live `SCANNING + project + init_ready`, displayed real points, then one explicit STOP returned K1 to unbound `READY`. No retry or fallback command was sent. Native-project reuse through LixelGO/USB remains an independent verification | | Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates | +| Simulation Polygon | STARTED, NOT ACCEPTED — product/SRS and ADR 0015 define a separate qualification boundary; SIM S0 has a strict candidate D-only/version/clock/port/process profile and read-only doctor. No target-worker evidence, rover run, navigation/safety acceptance or real actuator authority exists | USB project copying remains optional ground truth rather than a blocker for the now-verified network path. Owner-operated LixelGO traffic verifies the MQTT @@ -121,6 +122,32 @@ control is plugin-owned. A plugin-commanded acquisition sends one canonical STOP and seals locally after protocol-reported standby; an operator-manual acquisition still stops and seals only local reception and reports scanner state as unknown. +## Parallel branch — Simulation Polygon + +The Polygon branch follows +[`docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md`](12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md) +and +[`ADR 0015`](adr/0015-simulation-polygon-qualification-boundary.md). +It does not reorder or weaken the K1 physical-evidence gates in this document. +Its first gate is SIM S0: + +- candidate Windows 11/WSL2 Ubuntu 24.04 worker profile; +- all mutable runtime bytes physically on D; +- ROS 2 Jazzy, Gazebo Harmonic, PX4 v1.17.0, matching `px4_msgs`, + Micro XRCE-DDS and Nav2 compatibility; +- authoritative Gazebo `/clock`; +- loopback port and deterministic process ownership; +- target-worker rover/telemetry/pause/step/stop/resource evidence; +- explicit `GO`, `INCOMPLETE` or `BLOCKED` result. + +The repository doctor is side-effect free: + +```text +uv run missioncore-sim s0 doctor --json +``` + +Without admitted target-worker evidence its correct result is `INCOMPLETE`. + ## Stage 0 — repository and host baseline Deliverables: diff --git a/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md new file mode 100644 index 0000000..13748ce --- /dev/null +++ b/docs/12_SIMULATION_POLYGON_PRODUCT_AND_SRS.md @@ -0,0 +1,535 @@ +# Mission Core Polygon: product definition and system requirements + +Status: canonical working SRS, 2026-07-24. + +Ops source of truth: + +- [MISSIONCOR-38](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-38) is the + product epic and original simulation-branch scope. +- [MISSIONCOR-39](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-39) is the + canonical, changeable architecture plan. +- [MISSIONCOR-40](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-40) is the + active SIM S0 implementation and qualification gate. + +This document and ADR 0015 are the repository truth. A material change to run +kinds, authority, clocks, frames, source-of-record, provider boundaries or phase +gates must update the ADR/SRS and MISSIONCOR-39 together. + +## 1. Product thesis + +Polygon is Mission Core's autonomy qualification and assurance layer. It is not +a simulator embedded for visual effect and it is not a second observation +viewer. + +Polygon makes one product promise: + +> Given a versioned scenario and qualification profile, Mission Core can run or +> replay the autonomy stack, preserve exact provenance and evidence, explain +> each control/safety decision and compare outcomes without granting undeclared +> authority. + +The product value appears in increments: + +| Gate | Value | +| --- | --- | +| P0/S0 | Makes the stack installable, inspectable and reproducible rather than an ad hoc lab | +| S1 | Proves Mission Core can own a complete virtual rover run lifecycle | +| S2 | Produces the first useful navigation baseline and comparable safety/latency evidence | +| S3 | Connects Mission Core perception to a closed-loop consumer and exposes product differentiation | +| S4 | Makes recorded real data useful for regression and shadow-policy analysis | +| S5/S6 | Reduces risk before a real vehicle, without claiming real-world safety certification | + +The first product-useful baseline is P0 through S2. The current estimate is +27–46 engineer-days, excluding waits for hardware, external accounts or a +resolved worker-access blocker. + +## 2. Current truth + +The repository's proven vertical remains K1 observation, durable archive, +recorded perception and diagnostic motion evidence through LAB E26. It has not +accepted navigation behavior, safety behavior or real actuator control. + +Polygon is now a parallel product branch. As of this document: + +- P0 architecture and SRS are being committed; +- SIM S0 has a strict candidate qualification profile and read-only doctor; +- no target-worker S0 evidence has been admitted; +- PX4/Gazebo/ROS 2/Nav2 have not been installed or accepted by this repository; +- no stock rover run has been accepted; +- `actuator_authority=false`; +- `navigation_or_safety_accepted=false`. + +The doctor must report `INCOMPLETE` until exact worker evidence is supplied. A +repository manifest is never enough for a `GO`. + +## 3. Goals + +### 3.1 Product goals + +- Turn perception and planning work into measurable sense-plan-act experiments. +- Compare standard and Mission Core planners against the same versioned inputs. +- Preserve failures, limitations and uncertainty as first-class evidence. +- Reuse the same scenario/run/report concepts across simulation, replay, digital + twin, controller-in-loop, HIL and physical shadow. +- Keep physics, autopilot, middleware, planner and viewer replaceable. +- Create a controlled path toward a future trike without silently widening + authority. + +### 3.2 Engineering goals + +- Deterministic lifecycle ownership with no orphan simulator processes. +- Exact version, environment, input and artifact provenance. +- Explicit clock-domain and coordinate-frame conversions. +- Fail-closed setpoint validity, TTL, heartbeat, watchdog and loss behavior. +- Append-only run history and digest-bound source/derived artifacts. +- Side-effect-free infrastructure diagnosis before installation. + +## 4. Non-goals + +The first delivery does not: + +- grant real actuator authority; +- expose PX4 directly to the browser; +- use direct actuator setpoints; +- certify Nav2, PX4 or Mission Core as a real-time safety system; +- treat replay as closed-loop evidence; +- import all 300 BARN worlds before a stock-world baseline; +- download complete CODa, JRDB or NCLT collections; +- build a custom physics engine; +- make `px4-ros2-interface-lib` mandatory for S1; +- overload the observation-session model for qualification runs; +- implement a top-level UI before the backend S1 gate. + +## 5. Product and system boundaries + +### 5.1 Mission Core ownership + +Mission Core owns: + +- scenario definitions and profile selection; +- qualification-run identity and lifecycle; +- command authority and safety policy; +- canonical state, setpoint, decision and event contracts; +- orchestration of replaceable providers; +- evidence admission, provenance and immutable history; +- evaluation, comparison and qualification reports. + +### 5.2 Provider ownership + +Providers remain replaceable: + +- Gazebo owns simulated world state, sensors, contacts and physics. +- PX4 SITL owns rover control loops, constraints, offboard lifecycle and + autopilot failsafe. +- ROS 2 transports typed telemetry and commands. +- Nav2 supplies the first mature planning/costmap/collision-checking baseline. +- Rerun or another viewer presents derived evidence and never becomes a queue, + orchestrator or source-of-record. +- Dataset adapters expose recorded streams but never pretend alternative + commands changed future recorded observations. + +### 5.3 Neighboring Mission Core contexts + +- Observation owns real and recorded sensor streams plus perception layers. +- Polygon owns scenarios, qualification runs, decisions, simulated state and + comparisons. +- Missions owns goals and routes that may later target a virtual or real + vehicle. +- Data owns source-of-record, derived artifacts, retention and report access. + +Observation `SessionStatus` and its modality model are not extended to describe +simulation. Shared UI components require an explicit data-source contract. + +## 6. Target architecture + +```text +Mission Core UI + | +Simulation API + | +Simulation Orchestrator + +---- scenario/profile registry + +---- run state machine and authority gate + +---- clock, namespace, port and process ownership + +---- artifact/provenance store and evaluator + | + +---- Gazebo adapter ---- Gazebo world/physics/sensors + +---- PX4 adapter ------- PX4 SITL rover controller/failsafe + +---- ROS 2 adapter ----- Micro XRCE-DDS and canonical topics + +---- planner adapter --- Nav2 or Mission Core planner + +---- viewer adapter ---- canonical state/derived report +``` + +Gazebo and PX4 never run inside the web process. The browser never owns process +lifecycle or command authority. Losing the browser cannot terminate the only +copy of run state and cannot bypass the server-side authority gate. + +## 7. Canonical domain model + +### 7.1 Versioned inputs + +`ScenarioDefinition` contains identifiers and references for: + +- world and deterministic actor placement; +- vehicle profile; +- sensor profile; +- route/goal; +- planner profile; +- fault profile; +- metric profile; +- seed and reproducibility tier. + +`VehicleProfile`, `SensorProfile`, `PlannerProfile`, `FaultProfile` and +`MetricProfile` are independently versioned. A run stores the exact resolved +generation and hashes, not only mutable names. + +### 7.2 QualificationRun + +`QualificationRun` is the umbrella aggregate. `SimulationRun` is not used as +the common type because replay, HIL and physical shadow are not simulations. + +Required identity: + +- immutable run ID and episode ID; +- run kind; +- scenario/profile generations and hashes; +- Mission Core commit; +- provider versions, commits or image digests; +- host and resource profile; +- seed and reproducibility tier; +- authority profile; +- start/end UTC provenance; +- authoritative clock domain. + +Reset creates a new episode/run identity. It never reuses a completed identity +or overwrites artifacts. + +### 7.3 Run kinds + +| Kind | Commands affect future inputs | Actuator authority | +| --- | --- | --- | +| `simulation_closed_loop` | Yes, through virtual physics | Virtual only | +| `replay_shadow` | No | None | +| `digital_twin_closed_loop` | Yes, through an attested twin | Virtual only | +| `controller_in_loop` | Yes, through a bounded controller interface | Lab-specific | +| `hil` | Yes, with hardware in the loop | Separate gate | +| `physical_shadow` | No | None | + +No result may be promoted from one kind to another by changing report wording. + +### 7.4 Canonical streams + +- `VehicleState`: pose, twist, acceleration, steering/wheel state, estimator + state and source frames. +- `PerceptionWorldState`: occupancy, objects, motion, confidence, unknown and + uncertainty. +- `ControlSetpoint`: profile, sequence, issued time, validity deadline, + speed/steering or speed/yaw-rate and authority scope. +- `SafetyDecision`: allow/slow/stop, reason, active constraint, TTC/clearance + evidence and input generation. +- `QualificationEvent`: lifecycle, collision/contact, reset, deadline miss, + data loss, watchdog, failsafe and evaluator events. +- `QualificationReport`: verdict, metrics, violations, limitations and evidence + links. + +## 8. Lifecycle requirements + +The minimum lifecycle is: + +```text +admitted -> starting -> running <-> paused -> stopping -> completed + | | + +--------------------+-> failed +``` + +Cancellation is a requested transition, not evidence that processes stopped. +A run reaches a terminal state only after: + +- child-process ownership is reconciled; +- stop/failsafe outcome is captured; +- source artifacts are sealed; +- derived indexes are generated or explicitly marked absent; +- the report records the final limitations. + +Process shutdown order is explicit and deterministic. Orchestrator shutdown is +last. An orphan process is an S0/S1 failure. + +## 9. Clock contract + +For `simulation_closed_loop`: + +- Gazebo `/clock` is authoritative. +- ROS 2 consumers use `use_sim_time=true`. +- PX4 uXRCE-DDS time synchronization is disabled when Gazebo time is used. +- Canonical timestamps are integer nanoseconds and include their clock domain. +- UTC is provenance only. +- TTL, deadline and watchdog calculations declare whether they use simulation + or monotonic host time. +- Pause, resume, single-step and real-time-factor changes are qualification + cases, not UI-only behavior. + +Wall clock must not appear implicitly in planner or safety calculations. + +## 10. Coordinate-frame contract + +Mission Core navigation uses: + +- `map` and `odom`: ENU; +- `base_link`: FLU. + +PX4 uses: + +- world/local: NED; +- body: FRD. + +All PX4 conversion happens at one adapter boundary. Pose, vector, yaw, yaw-rate +and covariance transformations require golden tests. Every canonical message +identifies source and target frame. A frame mismatch fails closed and cannot be +reclassified as planner noise. + +## 11. Authority and safety requirements + +Until a new physical-control card and formal safety gate: + +- `simulation_or_shadow_only=true`; +- `actuator_authority=false`; +- `navigation_or_safety_accepted=false`; +- direct actuator setpoints are rejected; +- supported S1 command profiles are `rover-speed-steering/v1` and + `rover-speed-yaw-rate/v1`; +- each command carries a monotonic sequence and explicit expiry; +- heartbeat loss, TTL expiry, invalid state, planner timeout or link loss causes + a stop/failsafe transition; +- unknown occupancy never becomes free space by omission; +- ground truth is unavailable to the planner and perception runtime; +- ground truth is separately available to the evaluator. + +Nav2 Collision Monitor is an additional software safety layer. It is not a +hard-real-time certified safety controller. + +## 12. Evidence and data requirements + +### 12.1 Source-of-record + +The minimum source set is: + +- exact scenario and qualification profiles; +- stack/version lock; +- run manifest and authority profile; +- PX4 ULog; +- Gazebo log or equivalent world-state/contact evidence; +- rosbag2/MCAP for admitted canonical streams; +- factual host/resource and process-lifecycle evidence. + +### 12.2 Derived artifacts + +Derived data includes: + +- normalized metrics; +- evaluator outputs; +- comparison tables; +- Rerun recording; +- qualification report. + +Presentation state is not source evidence. Derived artifacts are rebuildable and +must retain input hashes. Source and derived generations are append-only. + +### 12.3 Required metrics + +Each applicable run reports: + +- completion and terminal reason; +- collision/contact count and minimum clearance; +- path length, elapsed simulation time and efficiency; +- route/pose error; +- speed/steering tracking; +- stop time and distance; +- TTC and constraint activation; +- replans and oscillation; +- dropped/late messages and deadline misses; +- stage latency p50/p95/max and queue depth; +- real-time factor; +- CPU, GPU, RAM, VRAM and disk usage. + +Inapplicable metrics are explicit, not silently zero. + +## 13. Reproducibility + +- R0: exact inputs and versions are retained; outcome may vary. +- R1: the same inputs/seed/version lock reproduce the same verdict within + declared tolerances. +- R2: defined metrics remain within accepted tolerance bands. + +Bitwise-identical physics is not promised. Comparison is rejected when scenario +schema, metric profile, provider generation or reproducibility tier are +incompatible. + +## 14. Upstream baseline + +The S0 candidate line is: + +- Windows 11 AI worker; +- WSL2 Ubuntu 24.04; +- ROS 2 Jazzy; +- Gazebo Harmonic; +- PX4 Autopilot v1.17.0; +- matching `px4_msgs` v1.17.0 generation; +- Micro XRCE-DDS Agent v2.4.3 candidate; +- Nav2 Jazzy; +- PX4 Gazebo stock rover models. + +The exact source commits already resolved in the candidate profile do not become +accepted merely because they are present. ROS/Gazebo package versions and every +candidate build remain pending target-worker qualification. + +S1 uses direct exact `px4_msgs` integration. The experimental +`px4-ros2-interface-lib` is evaluated later and is not on the S1 critical path. + +## 15. Storage and runtime placement + +All mutable worker bytes must be physically placed under +`D:\NDC_MISSIONCORE`, including: + +- WSL distribution/VHD; +- Docker data and images; +- sources and build products; +- package/model caches; +- runtime and logs; +- bags, ULog and Gazebo evidence; +- datasets and reports. + +The WSL view is `/mnt/d/NDC_MISSIONCORE`. C-drive placement is a blocking +failure. S0 requires at least 300 GiB free before installation and a stop-below +threshold of 200 GiB. Full external datasets are out of S0 scope. + +## 16. Delivery plan + +| Phase | Estimate | Exit condition | +| --- | ---: | --- | +| P0 | 2–4 days | Product thesis, SRS, ADR and canonical contracts accepted | +| S0 | 3–5 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence | +| S1 | 7–12 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence | +| S2 | 15–25 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios | +| S2B | 5–8 days | Representative 30-world BARN pilot | +| S3 | 20–35 days | K1-like virtual sensors and Mission Core perception closed loop | +| S4 | 10–20 days | Canonical replay/shadow adapters and reports | +| S5 | 20–40 days | Attested RAVNOVES00 digital twin | +| S6 | 20–40+ days | Trike model, controller-in-loop/HIL and physical-shadow gate | + +Implementation cards follow this order: + +1. Product SRS and ADR. +2. D-only inventory and stack lock. +3. Clock, frame and authority contracts. +4. Orchestrator lifecycle and artifact store. +5. PX4 stock rover control and failsafe. +6. Nav2 adapter and truth baseline. +7. Evaluator, report and BARN pilot. +8. Virtual K1 and perception. +9. Replay/shadow datasets. +10. RAVNOVES00 twin. +11. Trike, HIL and safety gate. + +## 17. SIM S0 acceptance + +SIM S0 requires all of the following factual evidence: + +- worker inventory; +- physical D-only placement, including WSL and Docker VHD; +- exact component/package pins and licenses; +- one stock PX4 rover launch; +- ROS 2 telemetry; +- authoritative simulation clock; +- pause, resume, single-step and speed-factor behavior; +- clean stop with no orphan processes; +- 1× resource baseline; +- 2× resource baseline; +- explicit `GO`, `PAUSE` or `BLOCKED` report. + +The repository profile and doctor implement preflight and evidence admission. +They do not install packages, start processes, open ports or write a target +report. + +Run the local read-only preflight: + +```bash +uv run missioncore-sim s0 doctor --json +``` + +An optional evidence manifest uses +`missioncore.simulation-s0-evidence/v1`. Each required claim points to a +confined regular artifact and supplies its SHA-256. The profile SHA-256 must +match the exact candidate generation. + +## 18. S1 and S2 acceptance outline + +### S1 + +- Ackermann primary and Differential reference launch from the orchestrator. +- Start, pause, resume, step, reset and stop are server-owned. +- Straight, turn, reverse, deceleration and emergency-stop cases pass. +- TTL, heartbeat, offboard loss and link loss produce a captured safe outcome. +- Pose, speed, steering/controller state and failsafe telemetry are retained. +- Reset creates a new run/episode. +- A repeated seed produces the declared R1 verdict tolerance. + +K1, Nav2 and top-level UI do not block S1 backend acceptance. + +### S2 + +- Gazebo LiDAR/odometry feed the ROS 2/Nav2 baseline. +- Ackermann uses MPPI plus Smac Hybrid/Lattice or an explicitly justified + equivalent. +- Static wall, corridor, bypass, narrow passage, no-data, planner timeout and + control-loss scenarios produce comparable reports. +- PX4 waypoint following without obstacle avoidance and Nav2 are measured on + the same profiles. +- A 30-world BARN pilot follows stock-world acceptance; only geometry, maps, + paths and difficulty are imported from the legacy runtime. + +## 19. Replay and dataset policy + +Recorded datasets support detector, segmentation, tracking, occupancy and +shadow-policy regression. They do not react to alternative commands and cannot +prove a closed-loop avoidance maneuver. + +- CODa and JRDB non-commercial/share-alike restrictions prohibit treating them + as unrestricted commercial runtime dependencies. +- NCLT is large and must be admitted by a size/license manifest. +- SCAND supplies social-navigation demonstrations but remains replay evidence. +- RAVNOVES00 first enters as canonical replay/shadow; only S5 may turn an + attested reconstruction into a digital-twin world. + +Unknown or unobserved space remains unknown/occupied according to the selected +safety policy. + +## 20. UI gate + +Polygon is planned as the seventh top-level bounded context: + +```text +Center | Fleet | Observation | Polygon | Missions | Data | System +``` + +Top-level UI implementation begins only after: + +1. this SRS and the clock/frame/authority contracts are accepted; +2. S1 backend lifecycle is proven; +3. run history is persisted independently of a browser session. + +The future UI may present Scenarios, Run, Control, History, Compare and Report. +It cannot own provider processes, bypass authority checks or write directly to +PX4. + +## 21. Change control + +A material change requires: + +1. an ADR/SRS diff; +2. an update to MISSIONCOR-39; +3. migration and compatibility notes; +4. new or updated tests; +5. fresh evidence before a previously accepted gate remains accepted. + +No checkbox is closed from intent alone. A commit plus factual validation or +admitted evidence is required. diff --git a/docs/adr/0015-simulation-polygon-qualification-boundary.md b/docs/adr/0015-simulation-polygon-qualification-boundary.md new file mode 100644 index 0000000..33f4b81 --- /dev/null +++ b/docs/adr/0015-simulation-polygon-qualification-boundary.md @@ -0,0 +1,82 @@ +# ADR 0015: Simulation Polygon as a qualification boundary + +## Status + +Accepted for architecture and SIM S0 implementation on 2026-07-24. +Navigation behavior, safety behavior and real actuator authority are not +accepted. + +## Context + +Mission Core has proven a K1 observation/archive/perception vertical, but the +perception outputs do not yet have a closed-loop consumer. Adding Gazebo, PX4, +ROS 2 and Nav2 directly to the web process or modeling every experiment as an +observation session would blur product ownership, lifecycle, evidence and +authority. + +Replay, physics simulation, a digital twin, HIL and real shadow also have +different causal meaning. A recorded stream cannot react to a hypothetical +command, while a closed-loop world can. Calling both `SimulationRun` would make +reports easy to misuse. + +## Decision + +1. Polygon is a first-class Mission Core bounded context for autonomy + qualification and assurance. +2. Mission Core owns scenarios, qualification-run lifecycle, authority policy, + canonical contracts, provenance, evidence admission, evaluation and reports. +3. Gazebo, PX4 SITL, ROS 2, Nav2, Rerun and datasets are replaceable providers. +4. `QualificationRun` is the umbrella aggregate. Its kinds explicitly separate + `simulation_closed_loop`, `replay_shadow`, + `digital_twin_closed_loop`, `controller_in_loop`, `hil` and + `physical_shadow`. +5. Simulation Orchestrator is the sole process-lifecycle owner. Gazebo and PX4 + do not run inside the Mission Core web process, and the browser has no direct + PX4 channel. +6. Gazebo `/clock` is authoritative in closed-loop simulation. ROS 2 uses + simulated time and PX4 uXRCE-DDS time synchronization is disabled for this + profile. +7. Mission Core uses ENU/FLU. PX4 uses NED/FRD. Exactly one adapter boundary + converts frames and requires golden tests. +8. The first command boundary permits only rover speed+steering and + speed+yaw-rate profiles with sequence, TTL, heartbeat, watchdog and + stop-on-loss behavior. Direct actuator setpoints are rejected. +9. Source-of-record, derived artifacts and presentation state remain separate. + Reset creates a new immutable run/episode. +10. Ground truth is evaluator-only and is not visible to perception or planning. +11. SIM S0 targets the reviewed Windows 11/WSL2 AI worker with all mutable bytes + physically under `D:\NDC_MISSIONCORE`. +12. S1 uses exact `px4_msgs` directly. The experimental + `px4-ros2-interface-lib` is not a mandatory S1 dependency. +13. A top-level Polygon UI is gated on accepted contracts and a proven S1 + backend lifecycle with persisted run history. +14. Real actuator authority requires a new decision and physical safety gate. + +## Consequences + +- Perception can be measured by downstream decisions without granting it hidden + behavior authority. +- Standard planners and future Mission Core planners can be compared on the + same scenario and metric profiles. +- Replay cannot be presented as closed-loop safety evidence. +- Provider upgrades require explicit compatibility and evidence refresh. +- More up-front domain and artifact work is required before a visual demo. +- The AI worker must satisfy D-only, resource and coexistence constraints before + installation is accepted. +- Simulation and HIL reduce risk but do not certify the real trike. + +## First implementation + +The first implementation is deliberately read-only: + +- `simulation/s0/qualification-profile.yaml` is the candidate stack, + storage, clock, authority, port and process contract. +- `k1link.simulation.s0` strictly validates that contract and optional + digest-bound worker evidence. +- `missioncore-sim s0 doctor` inspects the current host without installing, + starting or modifying anything. +- MISSIONCOR-40 tracks factual worker qualification. + +The doctor reports `INCOMPLETE` until it is running on the reviewed target with +resolved/accepted pins and all required evidence. It cannot infer `GO` from the +repository state. diff --git a/docs/runbooks/SIM_S0_AI_WORKER.md b/docs/runbooks/SIM_S0_AI_WORKER.md new file mode 100644 index 0000000..7d08dab --- /dev/null +++ b/docs/runbooks/SIM_S0_AI_WORKER.md @@ -0,0 +1,112 @@ +# SIM S0 AI worker runbook + +This runbook is for MISSIONCOR-40. It does not authorize system installation, +real vehicle control or a `GO` verdict by itself. + +## Safety and placement + +- Target: reviewed Windows 11 worker with WSL2 Ubuntu 24.04. +- All mutable bytes must be under `D:\NDC_MISSIONCORE`. +- Do not place a WSL distribution, Docker VHD, source, build, cache, log, bag, + ULog, Gazebo artifact or dataset on C. +- Do not expose S0 services beyond loopback. +- Do not download full external datasets. +- Keep `actuator_authority=false`. + +## Repository preflight + +From the repository-local Python 3.12 environment: + +```bash +uv sync --frozen --group dev +uv run missioncore-sim s0 doctor --json +``` + +Before target evidence exists, the expected verdict is `INCOMPLETE`. + +## Required target inventory + +Record a redacted artifact for each of: + +1. Windows release, WSL version, Ubuntu version and architecture. +2. GPU/driver, CPU, RAM and VRAM. +3. Physical locations of the WSL distribution and Docker data/VHD. +4. D total/free bytes before installation. +5. Existing Docker images, WSL distributions and simulation-related processes. +6. Existing listeners and the candidate port registry. +7. Existing Frigate/Ollama/Triton resource use and whether S0 is coexistence or + exclusive-profile testing. + +Do not record credentials, tokens or private network secrets. + +## Candidate stack + +Use `simulation/s0/qualification-profile.yaml` as the input. Resolve and record: + +- exact ROS 2 Jazzy package versions; +- exact Gazebo Harmonic package versions; +- PX4 v1.17.0 commit; +- matching `px4_msgs` commit; +- Micro XRCE-DDS Agent commit/version; +- Nav2 Jazzy commit/package versions; +- PX4 Gazebo model commit; +- licenses and image digests where containers are used. + +Change every component to `accepted` only after target build/runtime evidence +exists. Updating a pin changes the profile SHA-256 and invalidates evidence for +the prior generation. + +## Runtime cases + +The S0 factual cases are: + +1. Launch one stock rover headless. +2. Observe PX4 health and ROS 2 telemetry. +3. Prove Gazebo `/clock` is authoritative. +4. Prove pause freezes simulation time. +5. Prove one single-step advances the declared amount. +6. Prove 1× and 2× speed behavior and measure real-time factor. +7. Stop through the intended lifecycle. +8. Prove no owned process remains. +9. Repeat from a clean start. + +QGroundControl is diagnostic only and cannot become the Mission Core command +owner. + +## Evidence manifest + +Create a private/redacted evidence directory outside normal Git. Its manifest: + +```yaml +schema_version: missioncore.simulation-s0-evidence/v1 +profile_sha256: +checks: + - id: worker-inventory + status: pass + artifact: worker-inventory.redacted.json + sha256: + note: Redacted host and resource inventory. +``` + +The manifest must contain exactly all IDs from `required_evidence`. Artifact +paths are relative, confined regular files. The doctor rejects missing, +additional, changed or cross-generation evidence. + +Validate without writing: + +```bash +uv run missioncore-sim s0 doctor \ + --profile simulation/s0/qualification-profile.yaml \ + --evidence /path/to/redacted/evidence.yaml \ + --json +``` + +## Verdict rules + +- `GO`: target host matches, every pin is accepted, D-only and disk thresholds + pass, required tools exist and all digest-bound evidence validates. +- `INCOMPLETE`: work remains or the doctor is running on a development host. +- `BLOCKED`: contract, storage, tools or evidence fail validation. + +Publish the factual result to MISSIONCOR-40 with exact repository commit, +profile SHA-256, files touched and validation performed. diff --git a/pyproject.toml b/pyproject.toml index 0b37d81..e3055e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ missioncore-plugin-sdk = { path = "packages/plugin-sdk", editable = true } [project.scripts] k1link = "k1link.device_plugins.xgrids_k1.cli:app" +missioncore-sim = "k1link.simulation.cli:app" [dependency-groups] dev = [ diff --git a/simulation/s0/qualification-profile.yaml b/simulation/s0/qualification-profile.yaml new file mode 100644 index 0000000..31ab7f7 --- /dev/null +++ b/simulation/s0/qualification-profile.yaml @@ -0,0 +1,183 @@ +schema_version: missioncore.simulation-s0-profile/v1 +profile_id: ai-worker-px4-gazebo-v1 + +target_host: + windows_release: windows-11 + virtualization: wsl2 + linux_distribution: ubuntu-24.04 + architecture: x86_64 + +storage: + windows_root: 'D:\NDC_MISSIONCORE' + wsl_root: /mnt/d/NDC_MISSIONCORE + minimum_free_gib: 300 + stop_below_gib: 200 + forbid_windows_system_drive: true + mutable_paths: + - id: wsl + windows_path: 'D:\NDC_MISSIONCORE\wsl' + wsl_path: /mnt/d/NDC_MISSIONCORE/wsl + - id: docker + windows_path: 'D:\NDC_MISSIONCORE\docker' + wsl_path: /mnt/d/NDC_MISSIONCORE/docker + - id: source + windows_path: 'D:\NDC_MISSIONCORE\simulation\source' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/source + - id: build + windows_path: 'D:\NDC_MISSIONCORE\simulation\build' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/build + - id: cache + windows_path: 'D:\NDC_MISSIONCORE\simulation\cache' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/cache + - id: runtime + windows_path: 'D:\NDC_MISSIONCORE\simulation\runtime' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/runtime + - id: artifacts + windows_path: 'D:\NDC_MISSIONCORE\simulation\artifacts' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/artifacts + - id: datasets + windows_path: 'D:\NDC_MISSIONCORE\simulation\datasets' + wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/datasets + +clock: + authority: gazebo:/clock + ros_use_sim_time: true + px4_uxrce_dds_sync_enabled: false + canonical_unit: nanoseconds + utc_role: provenance-only + +authority: + simulation_or_shadow_only: true + actuator_authority: false + navigation_or_safety_accepted: false + direct_actuator_setpoints_allowed: false + allowed_setpoint_profiles: + - rover-speed-steering/v1 + - rover-speed-yaw-rate/v1 + +ros: + domain_id: 42 + namespace: /missioncore/polygon/s0 + +components: + - id: ros2 + source_url: https://docs.ros.org/en/jazzy/ + requested_ref: jazzy + resolved_version: + resolved_commit: + license: mixed-apache-2.0-bsd + qualification_state: pending-worker-resolution + - id: gazebo + source_url: https://gazebosim.org/docs/harmonic/ + requested_ref: harmonic + resolved_version: + resolved_commit: + license: apache-2.0 + qualification_state: pending-worker-resolution + - id: px4-autopilot + source_url: https://github.com/PX4/PX4-Autopilot + requested_ref: v1.17.0 + resolved_version: v1.17.0 + resolved_commit: d6f12ad1c4f70ad3230afd7d86e971421e02fef4 + license: bsd-3-clause + qualification_state: candidate + - id: px4-msgs + source_url: https://github.com/PX4/px4_msgs + requested_ref: v1.17.0 + resolved_version: v1.17.0 + resolved_commit: 86d8239e962f6939e05c3737784f60c02fa884db + license: bsd-3-clause + qualification_state: candidate + - id: px4-gazebo-models + source_url: https://github.com/PX4/PX4-gazebo-models + requested_ref: main@2026-07-24 + resolved_version: + resolved_commit: e00d3b9cde682dbcb3bf6f30a2f2b8ef4325dae8 + license: bsd-3-clause + qualification_state: candidate + - id: micro-xrce-dds-agent + source_url: https://github.com/eProsima/Micro-XRCE-DDS-Agent + requested_ref: v2.4.3 + resolved_version: v2.4.3 + resolved_commit: 73622810d984349b80bbac0ef55fc0b694d62222 + license: apache-2.0 + qualification_state: candidate + - id: nav2 + source_url: https://github.com/ros-navigation/navigation2 + requested_ref: jazzy@2026-07-24 + resolved_version: + resolved_commit: c92ea2fd9008f50c0ec8447610800214b2a0dafb + license: apache-2.0 + qualification_state: candidate + +ports: + - id: micro-xrce-dds + host_scope: worker-wsl + protocol: udp + bind: 127.0.0.1 + port: 8888 + owner: micro-xrce-dds-agent + - id: px4-mavlink-sitl + host_scope: worker-wsl + protocol: udp + bind: 127.0.0.1 + port: 14540 + owner: px4-sitl + - id: qgroundcontrol-reference + host_scope: worker-wsl + protocol: udp + bind: 127.0.0.1 + port: 14550 + owner: px4-sitl + - id: simulation-orchestrator-api + host_scope: worker-wsl + protocol: tcp + bind: 127.0.0.1 + port: 18080 + owner: simulation-orchestrator + - id: simulation-telemetry + host_scope: worker-wsl + protocol: tcp + bind: 127.0.0.1 + port: 18081 + owner: simulation-orchestrator + +processes: + - id: simulation-orchestrator + parent: + shutdown_order: 60 + - id: gazebo-server + parent: simulation-orchestrator + shutdown_order: 50 + - id: px4-sitl + parent: simulation-orchestrator + shutdown_order: 40 + - id: micro-xrce-dds-agent + parent: simulation-orchestrator + shutdown_order: 30 + - id: ros2-bridge + parent: simulation-orchestrator + shutdown_order: 20 + - id: nav2 + parent: simulation-orchestrator + shutdown_order: 10 + +required_tools: + - git + - docker + - gz + - make + - colcon + - ros2 + - MicroXRCEAgent + +required_evidence: + - worker-inventory + - d-only-physical-placement + - px4-stock-rover-launch + - ros2-telemetry + - authoritative-simulation-clock + - pause-step-speed + - clean-stop-no-orphans + - resource-baseline-1x + - resource-baseline-2x diff --git a/src/k1link/simulation/__init__.py b/src/k1link/simulation/__init__.py new file mode 100644 index 0000000..846ca53 --- /dev/null +++ b/src/k1link/simulation/__init__.py @@ -0,0 +1,21 @@ +"""Mission Core qualification and simulation boundaries.""" + +from k1link.simulation.s0 import ( + CheckStatus, + DoctorVerdict, + S0DoctorReport, + S0Profile, + S0ProfileError, + load_s0_profile, + run_s0_doctor, +) + +__all__ = [ + "CheckStatus", + "DoctorVerdict", + "S0DoctorReport", + "S0Profile", + "S0ProfileError", + "load_s0_profile", + "run_s0_doctor", +] diff --git a/src/k1link/simulation/cli.py b/src/k1link/simulation/cli.py new file mode 100644 index 0000000..d97309e --- /dev/null +++ b/src/k1link/simulation/cli.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console +from rich.table import Table + +from k1link.simulation import S0ProfileError, run_s0_doctor + +app = typer.Typer( + name="missioncore-sim", + help="Fail-closed qualification tooling for the Mission Core Polygon.", + no_args_is_help=True, +) +s0_app = typer.Typer( + help="SIM S0 compatibility and infrastructure qualification.", + no_args_is_help=True, +) +app.add_typer(s0_app, name="s0") +console = Console() + + +def _default_profile() -> Path: + return Path(__file__).resolve().parents[3] / "simulation" / "s0" / "qualification-profile.yaml" + + +DEFAULT_PROFILE = _default_profile() + + +@s0_app.command("doctor") +def s0_doctor( + profile: Annotated[ + Path, + typer.Option( + "--profile", + help="Strict SIM S0 qualification profile.", + exists=True, + dir_okay=False, + readable=True, + ), + ] = DEFAULT_PROFILE, + evidence: Annotated[ + Path | None, + typer.Option( + "--evidence", + help="Optional digest-bound target-worker evidence manifest.", + exists=True, + dir_okay=False, + readable=True, + ), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", help="Emit the complete report as JSON."), + ] = False, +) -> None: + """Inspect contracts and local target readiness without changing the host.""" + + try: + report = run_s0_doctor(profile, evidence_path=evidence) + except (OSError, S0ProfileError) as exc: + if json_output: + console.print_json( + json.dumps( + { + "schema_version": "missioncore.simulation-s0-doctor/v1", + "verdict": "blocked", + "error": str(exc), + } + ) + ) + else: + console.print(f"[red]SIM S0 profile rejected:[/red] {exc}") + raise typer.Exit(code=2) from exc + + payload = report.to_dict() + if json_output: + console.print_json(json.dumps(payload)) + return + + table = Table(title=f"SIM S0 doctor — {report.profile_id}") + table.add_column("Check") + table.add_column("Status") + table.add_column("Detail") + colors = {"pass": "green", "fail": "red", "unknown": "yellow"} + for check in report.checks: + status = check.status.value + styled_status = f"[{colors[status]}]{status}[/{colors[status]}]" + table.add_row(check.identifier, styled_status, check.detail) + console.print(table) + console.print(f"Verdict: [bold]{report.verdict.value.upper()}[/bold]") + + +if __name__ == "__main__": + app() diff --git a/src/k1link/simulation/s0.py b/src/k1link/simulation/s0.py new file mode 100644 index 0000000..73dd7f4 --- /dev/null +++ b/src/k1link/simulation/s0.py @@ -0,0 +1,1006 @@ +from __future__ import annotations + +import hashlib +import platform +import re +import shutil +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Final + +import yaml + +PROFILE_SCHEMA: Final = "missioncore.simulation-s0-profile/v1" +EVIDENCE_SCHEMA: Final = "missioncore.simulation-s0-evidence/v1" +DOCTOR_SCHEMA: Final = "missioncore.simulation-s0-doctor/v1" +MAX_MANIFEST_BYTES: Final = 256 * 1024 + +_SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") +_SAFE_TOOL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_SHA256 = re.compile(r"^[a-f0-9]{64}$") +_GIT_COMMIT = re.compile(r"^[a-f0-9]{40}$") + + +class S0ProfileError(ValueError): + """The SIM S0 qualification contract is absent, unsafe, or inconsistent.""" + + +class CheckStatus(StrEnum): + PASS = "pass" + FAIL = "fail" + UNKNOWN = "unknown" + + +class DoctorVerdict(StrEnum): + GO = "go" + INCOMPLETE = "incomplete" + BLOCKED = "blocked" + + +@dataclass(frozen=True, slots=True) +class TargetHost: + windows_release: str + virtualization: str + linux_distribution: str + architecture: str + + +@dataclass(frozen=True, slots=True) +class MutablePath: + identifier: str + windows_path: PureWindowsPath + wsl_path: PurePosixPath + + +@dataclass(frozen=True, slots=True) +class StoragePolicy: + windows_root: PureWindowsPath + wsl_root: PurePosixPath + minimum_free_gib: int + stop_below_gib: int + forbid_windows_system_drive: bool + mutable_paths: tuple[MutablePath, ...] + + +@dataclass(frozen=True, slots=True) +class ClockPolicy: + authority: str + ros_use_sim_time: bool + px4_uxrce_dds_sync_enabled: bool + canonical_unit: str + utc_role: str + + +@dataclass(frozen=True, slots=True) +class AuthorityPolicy: + simulation_or_shadow_only: bool + actuator_authority: bool + navigation_or_safety_accepted: bool + direct_actuator_setpoints_allowed: bool + allowed_setpoint_profiles: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class RosProfile: + domain_id: int + namespace: str + + +@dataclass(frozen=True, slots=True) +class ComponentPin: + identifier: str + source_url: str + requested_ref: str + resolved_version: str | None + resolved_commit: str | None + license: str + qualification_state: str + + +@dataclass(frozen=True, slots=True) +class PortBinding: + identifier: str + host_scope: str + protocol: str + bind: str + port: int + owner: str + + +@dataclass(frozen=True, slots=True) +class ProcessOwnership: + identifier: str + parent: str | None + shutdown_order: int + + +@dataclass(frozen=True, slots=True) +class S0Profile: + profile_id: str + target_host: TargetHost + storage: StoragePolicy + clock: ClockPolicy + authority: AuthorityPolicy + ros: RosProfile + components: tuple[ComponentPin, ...] + ports: tuple[PortBinding, ...] + processes: tuple[ProcessOwnership, ...] + required_tools: tuple[str, ...] + required_evidence: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class DoctorCheck: + identifier: str + status: CheckStatus + detail: str + + def to_dict(self) -> dict[str, object]: + return { + "id": self.identifier, + "status": self.status.value, + "detail": self.detail, + } + + +@dataclass(frozen=True, slots=True) +class S0DoctorReport: + profile_id: str + profile_sha256: str + verdict: DoctorVerdict + target_host_match: bool + checks: tuple[DoctorCheck, ...] + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": DOCTOR_SCHEMA, + "profile_id": self.profile_id, + "profile_sha256": self.profile_sha256, + "verdict": self.verdict.value, + "target_host_match": self.target_host_match, + "authority": { + "simulation_or_shadow_only": True, + "actuator_authority": False, + "navigation_or_safety_accepted": False, + "direct_actuator_setpoints_allowed": False, + }, + "host": { + "system": platform.system(), + "release": platform.release(), + "machine": platform.machine(), + }, + "checks": [check.to_dict() for check in self.checks], + } + + +def load_s0_profile(path: Path) -> S0Profile: + """Load and fail closed on an unsafe or incomplete S0 profile.""" + + document = _read_yaml_mapping(path) + _expect_keys( + document, + required={ + "schema_version", + "profile_id", + "target_host", + "storage", + "clock", + "authority", + "ros", + "components", + "ports", + "processes", + "required_tools", + "required_evidence", + }, + context="profile", + ) + if _string(document["schema_version"], "profile.schema_version") != PROFILE_SCHEMA: + raise S0ProfileError("profile schema_version is incompatible") + profile_id = _safe_id(document["profile_id"], "profile.profile_id") + + target_host = _parse_target_host(document["target_host"]) + storage = _parse_storage(document["storage"]) + clock = _parse_clock(document["clock"]) + authority = _parse_authority(document["authority"]) + ros = _parse_ros(document["ros"]) + components = _parse_components(document["components"]) + ports = _parse_ports(document["ports"]) + processes = _parse_processes(document["processes"]) + required_tools = _tool_list(document["required_tools"], "profile.required_tools") + required_evidence = _safe_id_list( + document["required_evidence"], + "profile.required_evidence", + ) + + _validate_port_owners(ports, processes) + _validate_required_processes(processes) + if len(required_evidence) < 1: + raise S0ProfileError("at least one factual S0 evidence requirement is mandatory") + + return S0Profile( + profile_id=profile_id, + target_host=target_host, + storage=storage, + clock=clock, + authority=authority, + ros=ros, + components=components, + ports=ports, + processes=processes, + required_tools=required_tools, + required_evidence=required_evidence, + ) + + +def run_s0_doctor( + profile_path: Path, + *, + evidence_path: Path | None = None, +) -> S0DoctorReport: + """Inspect the local host without installing, starting, or mutating anything.""" + + profile_bytes = _read_bounded_file(profile_path) + profile = load_s0_profile(profile_path) + profile_sha256 = hashlib.sha256(profile_bytes).hexdigest() + target_match, target_detail = _target_host_match(profile.target_host) + + checks: list[DoctorCheck] = [ + DoctorCheck("profile-contract", CheckStatus.PASS, "strict profile validation passed"), + DoctorCheck( + "authority-contract", + CheckStatus.PASS, + "simulation/shadow only; real and direct actuator authority disabled", + ), + DoctorCheck( + "clock-contract", + CheckStatus.PASS, + "Gazebo /clock, ROS use_sim_time and disabled PX4 DDS time sync are explicit", + ), + DoctorCheck( + "port-registry", + CheckStatus.PASS, + f"{len(profile.ports)} unique scoped bindings validated", + ), + DoctorCheck( + "process-registry", + CheckStatus.PASS, + f"{len(profile.processes)} owned processes have deterministic shutdown order", + ), + DoctorCheck( + "target-host", + CheckStatus.PASS if target_match else CheckStatus.UNKNOWN, + target_detail, + ), + ] + checks.extend(_storage_checks(profile.storage, target_match)) + checks.append(_component_check(profile.components)) + checks.append(_tool_check(profile.required_tools, target_match)) + checks.append( + _evidence_check( + profile, + profile_sha256=profile_sha256, + evidence_path=evidence_path, + ) + ) + + statuses = {check.status for check in checks} + if CheckStatus.FAIL in statuses: + verdict = DoctorVerdict.BLOCKED + elif target_match and statuses == {CheckStatus.PASS}: + verdict = DoctorVerdict.GO + else: + verdict = DoctorVerdict.INCOMPLETE + return S0DoctorReport( + profile_id=profile.profile_id, + profile_sha256=profile_sha256, + verdict=verdict, + target_host_match=target_match, + checks=tuple(checks), + ) + + +def _parse_target_host(value: object) -> TargetHost: + data = _mapping(value, "target_host") + _expect_keys( + data, + required={ + "windows_release", + "virtualization", + "linux_distribution", + "architecture", + }, + context="target_host", + ) + target = TargetHost( + windows_release=_string(data["windows_release"], "target_host.windows_release"), + virtualization=_string(data["virtualization"], "target_host.virtualization"), + linux_distribution=_string( + data["linux_distribution"], + "target_host.linux_distribution", + ), + architecture=_string(data["architecture"], "target_host.architecture"), + ) + if target.windows_release != "windows-11" or target.virtualization != "wsl2": + raise S0ProfileError("S0 target must remain the reviewed Windows 11 WSL2 worker") + if target.linux_distribution != "ubuntu-24.04" or target.architecture != "x86_64": + raise S0ProfileError("S0 target Linux distribution or architecture drifted") + return target + + +def _parse_storage(value: object) -> StoragePolicy: + data = _mapping(value, "storage") + _expect_keys( + data, + required={ + "windows_root", + "wsl_root", + "minimum_free_gib", + "stop_below_gib", + "forbid_windows_system_drive", + "mutable_paths", + }, + context="storage", + ) + windows_root = PureWindowsPath(_string(data["windows_root"], "storage.windows_root")) + wsl_root = PurePosixPath(_string(data["wsl_root"], "storage.wsl_root")) + minimum_free_gib = _integer(data["minimum_free_gib"], "storage.minimum_free_gib") + stop_below_gib = _integer(data["stop_below_gib"], "storage.stop_below_gib") + forbid_system = _boolean( + data["forbid_windows_system_drive"], + "storage.forbid_windows_system_drive", + ) + if windows_root != PureWindowsPath(r"D:\NDC_MISSIONCORE"): + raise S0ProfileError("storage.windows_root must be D:\\NDC_MISSIONCORE") + if wsl_root != PurePosixPath("/mnt/d/NDC_MISSIONCORE"): + raise S0ProfileError("storage.wsl_root must be /mnt/d/NDC_MISSIONCORE") + if not forbid_system: + raise S0ProfileError("Windows system-drive mutation must remain forbidden") + if minimum_free_gib <= stop_below_gib or stop_below_gib < 1: + raise S0ProfileError("storage free-space thresholds are inconsistent") + + mutable: list[MutablePath] = [] + seen: set[str] = set() + for index, item in enumerate(_sequence(data["mutable_paths"], "storage.mutable_paths")): + entry = _mapping(item, f"storage.mutable_paths[{index}]") + _expect_keys( + entry, + required={"id", "windows_path", "wsl_path"}, + context=f"storage.mutable_paths[{index}]", + ) + identifier = _safe_id(entry["id"], f"storage.mutable_paths[{index}].id") + if identifier in seen: + raise S0ProfileError("storage mutable path ids must be unique") + seen.add(identifier) + windows_path = PureWindowsPath( + _string( + entry["windows_path"], + f"storage.mutable_paths[{index}].windows_path", + ) + ) + wsl_path = PurePosixPath( + _string(entry["wsl_path"], f"storage.mutable_paths[{index}].wsl_path") + ) + if ( + not windows_path.is_absolute() + or windows_path == windows_root + or not windows_path.is_relative_to(windows_root) + or windows_path.drive.upper() != "D:" + or ".." in windows_path.parts + ): + raise S0ProfileError(f"mutable Windows path {identifier!r} escapes D-only root") + if ( + not wsl_path.is_absolute() + or wsl_path == wsl_root + or not wsl_path.is_relative_to(wsl_root) + or ".." in wsl_path.parts + ): + raise S0ProfileError(f"mutable WSL path {identifier!r} escapes D-only root") + mutable.append(MutablePath(identifier, windows_path, wsl_path)) + if len(mutable) < 1: + raise S0ProfileError("storage.mutable_paths must not be empty") + return StoragePolicy( + windows_root=windows_root, + wsl_root=wsl_root, + minimum_free_gib=minimum_free_gib, + stop_below_gib=stop_below_gib, + forbid_windows_system_drive=forbid_system, + mutable_paths=tuple(mutable), + ) + + +def _parse_clock(value: object) -> ClockPolicy: + data = _mapping(value, "clock") + _expect_keys( + data, + required={ + "authority", + "ros_use_sim_time", + "px4_uxrce_dds_sync_enabled", + "canonical_unit", + "utc_role", + }, + context="clock", + ) + clock = ClockPolicy( + authority=_string(data["authority"], "clock.authority"), + ros_use_sim_time=_boolean(data["ros_use_sim_time"], "clock.ros_use_sim_time"), + px4_uxrce_dds_sync_enabled=_boolean( + data["px4_uxrce_dds_sync_enabled"], + "clock.px4_uxrce_dds_sync_enabled", + ), + canonical_unit=_string(data["canonical_unit"], "clock.canonical_unit"), + utc_role=_string(data["utc_role"], "clock.utc_role"), + ) + if ( + clock.authority != "gazebo:/clock" + or not clock.ros_use_sim_time + or clock.px4_uxrce_dds_sync_enabled + or clock.canonical_unit != "nanoseconds" + or clock.utc_role != "provenance-only" + ): + raise S0ProfileError("clock policy violates the canonical simulation-time contract") + return clock + + +def _parse_authority(value: object) -> AuthorityPolicy: + data = _mapping(value, "authority") + _expect_keys( + data, + required={ + "simulation_or_shadow_only", + "actuator_authority", + "navigation_or_safety_accepted", + "direct_actuator_setpoints_allowed", + "allowed_setpoint_profiles", + }, + context="authority", + ) + authority = AuthorityPolicy( + simulation_or_shadow_only=_boolean( + data["simulation_or_shadow_only"], + "authority.simulation_or_shadow_only", + ), + actuator_authority=_boolean( + data["actuator_authority"], + "authority.actuator_authority", + ), + navigation_or_safety_accepted=_boolean( + data["navigation_or_safety_accepted"], + "authority.navigation_or_safety_accepted", + ), + direct_actuator_setpoints_allowed=_boolean( + data["direct_actuator_setpoints_allowed"], + "authority.direct_actuator_setpoints_allowed", + ), + allowed_setpoint_profiles=tuple( + _string_list( + data["allowed_setpoint_profiles"], + "authority.allowed_setpoint_profiles", + ) + ), + ) + if ( + not authority.simulation_or_shadow_only + or authority.actuator_authority + or authority.navigation_or_safety_accepted + or authority.direct_actuator_setpoints_allowed + ): + raise S0ProfileError("SIM S0 cannot grant real, safety, or direct actuator authority") + if set(authority.allowed_setpoint_profiles) != { + "rover-speed-steering/v1", + "rover-speed-yaw-rate/v1", + }: + raise S0ProfileError("SIM S0 setpoint profiles drifted from the reviewed rover boundary") + return authority + + +def _parse_ros(value: object) -> RosProfile: + data = _mapping(value, "ros") + _expect_keys(data, required={"domain_id", "namespace"}, context="ros") + domain_id = _integer(data["domain_id"], "ros.domain_id") + namespace = _string(data["namespace"], "ros.namespace") + if not 0 <= domain_id <= 232: + raise S0ProfileError("ROS domain id must be in the DDS-safe range 0..232") + if not namespace.startswith("/") or "//" in namespace or namespace.endswith("/"): + raise S0ProfileError("ROS namespace must be canonical and absolute") + return RosProfile(domain_id, namespace) + + +def _parse_components(value: object) -> tuple[ComponentPin, ...]: + items: list[ComponentPin] = [] + seen: set[str] = set() + for index, item in enumerate(_sequence(value, "components")): + data = _mapping(item, f"components[{index}]") + _expect_keys( + data, + required={ + "id", + "source_url", + "requested_ref", + "resolved_version", + "resolved_commit", + "license", + "qualification_state", + }, + context=f"components[{index}]", + ) + identifier = _safe_id(data["id"], f"components[{index}].id") + if identifier in seen: + raise S0ProfileError("component ids must be unique") + seen.add(identifier) + resolved_commit = _optional_string( + data["resolved_commit"], + f"components[{index}].resolved_commit", + ) + if resolved_commit is not None and _GIT_COMMIT.fullmatch(resolved_commit) is None: + raise S0ProfileError(f"component {identifier!r} has an invalid Git commit") + state = _string(data["qualification_state"], f"components[{index}].qualification_state") + if state not in {"candidate", "pending-worker-resolution", "accepted"}: + raise S0ProfileError(f"component {identifier!r} has an invalid qualification state") + items.append( + ComponentPin( + identifier=identifier, + source_url=_https_url(data["source_url"], f"components[{index}].source_url"), + requested_ref=_string( + data["requested_ref"], + f"components[{index}].requested_ref", + ), + resolved_version=_optional_string( + data["resolved_version"], + f"components[{index}].resolved_version", + ), + resolved_commit=resolved_commit, + license=_string(data["license"], f"components[{index}].license"), + qualification_state=state, + ) + ) + if len(items) < 1: + raise S0ProfileError("components must not be empty") + return tuple(items) + + +def _parse_ports(value: object) -> tuple[PortBinding, ...]: + items: list[PortBinding] = [] + identities: set[str] = set() + bindings: set[tuple[str, str, str, int]] = set() + for index, item in enumerate(_sequence(value, "ports")): + data = _mapping(item, f"ports[{index}]") + _expect_keys( + data, + required={"id", "host_scope", "protocol", "bind", "port", "owner"}, + context=f"ports[{index}]", + ) + identifier = _safe_id(data["id"], f"ports[{index}].id") + if identifier in identities: + raise S0ProfileError("port ids must be unique") + identities.add(identifier) + host_scope = _safe_id(data["host_scope"], f"ports[{index}].host_scope") + protocol = _string(data["protocol"], f"ports[{index}].protocol") + bind = _string(data["bind"], f"ports[{index}].bind") + port = _integer(data["port"], f"ports[{index}].port") + owner = _safe_id(data["owner"], f"ports[{index}].owner") + if protocol not in {"tcp", "udp"}: + raise S0ProfileError(f"port {identifier!r} has unsupported protocol") + if bind != "127.0.0.1": + raise S0ProfileError(f"port {identifier!r} must remain loopback-only in S0") + if not 1 <= port <= 65535: + raise S0ProfileError(f"port {identifier!r} is outside 1..65535") + binding = (host_scope, protocol, bind, port) + if binding in bindings: + raise S0ProfileError(f"port {identifier!r} duplicates a scoped binding") + bindings.add(binding) + items.append(PortBinding(identifier, host_scope, protocol, bind, port, owner)) + if len(items) < 1: + raise S0ProfileError("ports must not be empty") + return tuple(items) + + +def _parse_processes(value: object) -> tuple[ProcessOwnership, ...]: + items: list[ProcessOwnership] = [] + identities: set[str] = set() + orders: set[int] = set() + for index, item in enumerate(_sequence(value, "processes")): + data = _mapping(item, f"processes[{index}]") + _expect_keys( + data, + required={"id", "parent", "shutdown_order"}, + context=f"processes[{index}]", + ) + identifier = _safe_id(data["id"], f"processes[{index}].id") + parent = _optional_safe_id(data["parent"], f"processes[{index}].parent") + shutdown_order = _integer( + data["shutdown_order"], + f"processes[{index}].shutdown_order", + ) + if identifier in identities or shutdown_order in orders: + raise S0ProfileError("process ids and shutdown_order values must be unique") + identities.add(identifier) + orders.add(shutdown_order) + items.append(ProcessOwnership(identifier, parent, shutdown_order)) + roots = [item for item in items if item.parent is None] + if len(roots) != 1 or roots[0].identifier != "simulation-orchestrator": + raise S0ProfileError("simulation-orchestrator must be the only process root") + process_ids = {item.identifier for item in items} + for item in items: + if item.parent is not None and item.parent not in process_ids: + raise S0ProfileError(f"process {item.identifier!r} has an unknown parent") + root_order = roots[0].shutdown_order + if any(item.shutdown_order >= root_order for item in items if item.parent is not None): + raise S0ProfileError("the orchestrator must be the final process in shutdown order") + return tuple(items) + + +def _validate_port_owners( + ports: tuple[PortBinding, ...], + processes: tuple[ProcessOwnership, ...], +) -> None: + owners = {process.identifier for process in processes} + for binding in ports: + if binding.owner not in owners: + raise S0ProfileError(f"port {binding.identifier!r} has an unowned process") + + +def _validate_required_processes(processes: tuple[ProcessOwnership, ...]) -> None: + present = {process.identifier for process in processes} + required = { + "simulation-orchestrator", + "gazebo-server", + "px4-sitl", + "micro-xrce-dds-agent", + "ros2-bridge", + "nav2", + } + if present != required: + raise S0ProfileError("process registry must enumerate the complete reviewed S0 graph") + + +def _target_host_match(target: TargetHost) -> tuple[bool, str]: + system = platform.system().lower() + machine = platform.machine().lower() + release = _read_optional_text(Path("/proc/sys/kernel/osrelease")).lower() + os_release = _linux_os_release() + is_wsl2 = system == "linux" and "microsoft" in release and "wsl2" in release + distribution_match = ( + os_release.get("id") == "ubuntu" and os_release.get("version_id") == "24.04" + ) + architecture_match = machine in {"x86_64", "amd64"} + matched = is_wsl2 and distribution_match and architecture_match + if matched: + return True, ( + f"matched {target.windows_release}/{target.virtualization}/" + f"{target.linux_distribution}/{target.architecture}" + ) + return False, ( + "doctor is not running inside the reviewed Windows 11 WSL2 Ubuntu 24.04 " + f"x86_64 target (observed system={system!r}, machine={machine!r})" + ) + + +def _storage_checks(policy: StoragePolicy, target_match: bool) -> list[DoctorCheck]: + if not target_match: + return [ + DoctorCheck( + "d-only-storage", + CheckStatus.UNKNOWN, + "physical D-drive placement can only be attested on the target worker", + ), + DoctorCheck( + "disk-budget", + CheckStatus.UNKNOWN, + "target D-drive free space was not inspected on this host", + ), + ] + root = Path(policy.wsl_root.as_posix()) + if not root.is_dir(): + return [ + DoctorCheck( + "d-only-storage", + CheckStatus.FAIL, + f"required WSL D-drive root is absent: {root}", + ), + DoctorCheck( + "disk-budget", + CheckStatus.FAIL, + "disk budget cannot be measured without the D-drive root", + ), + ] + missing = [ + item.identifier + for item in policy.mutable_paths + if not Path(item.wsl_path.as_posix()).is_dir() + ] + storage_check = DoctorCheck( + "d-only-storage", + CheckStatus.PASS if not missing else CheckStatus.FAIL, + ( + f"all {len(policy.mutable_paths)} mutable roots exist under {root}" + if not missing + else f"missing D-only mutable roots: {', '.join(missing)}" + ), + ) + free_gib = shutil.disk_usage(root).free / (1024**3) + disk_check = DoctorCheck( + "disk-budget", + CheckStatus.PASS if free_gib >= policy.minimum_free_gib else CheckStatus.FAIL, + ( + f"{free_gib:.2f} GiB free; minimum={policy.minimum_free_gib} GiB; " + f"stop-below={policy.stop_below_gib} GiB" + ), + ) + return [storage_check, disk_check] + + +def _component_check(components: tuple[ComponentPin, ...]) -> DoctorCheck: + unresolved = [ + item.identifier + for item in components + if item.qualification_state != "accepted" + or (item.resolved_version is None and item.resolved_commit is None) + ] + if unresolved: + return DoctorCheck( + "version-lock", + CheckStatus.UNKNOWN, + "worker qualification still required for: " + ", ".join(unresolved), + ) + return DoctorCheck( + "version-lock", + CheckStatus.PASS, + f"all {len(components)} component pins are resolved and accepted", + ) + + +def _tool_check(required_tools: tuple[str, ...], target_match: bool) -> DoctorCheck: + if not target_match: + return DoctorCheck( + "required-tools", + CheckStatus.UNKNOWN, + "target worker executables were not inspected on this host", + ) + missing = [name for name in required_tools if shutil.which(name) is None] + if missing: + return DoctorCheck( + "required-tools", + CheckStatus.FAIL, + "missing target executables: " + ", ".join(missing), + ) + return DoctorCheck( + "required-tools", + CheckStatus.PASS, + f"all {len(required_tools)} target executables are discoverable", + ) + + +def _evidence_check( + profile: S0Profile, + *, + profile_sha256: str, + evidence_path: Path | None, +) -> DoctorCheck: + if evidence_path is None: + return DoctorCheck( + "factual-evidence", + CheckStatus.UNKNOWN, + f"{len(profile.required_evidence)} target-run evidence claims are required", + ) + try: + data = _read_yaml_mapping(evidence_path) + _expect_keys( + data, + required={"schema_version", "profile_sha256", "checks"}, + context="evidence", + ) + if _string(data["schema_version"], "evidence.schema_version") != EVIDENCE_SCHEMA: + raise S0ProfileError("evidence schema_version is incompatible") + if _string(data["profile_sha256"], "evidence.profile_sha256") != profile_sha256: + raise S0ProfileError("evidence targets a different S0 profile generation") + evidence_root = evidence_path.expanduser().resolve(strict=True).parent + observed: set[str] = set() + for index, item in enumerate(_sequence(data["checks"], "evidence.checks")): + entry = _mapping(item, f"evidence.checks[{index}]") + _expect_keys( + entry, + required={"id", "status", "artifact", "sha256", "note"}, + context=f"evidence.checks[{index}]", + ) + identifier = _safe_id(entry["id"], f"evidence.checks[{index}].id") + if identifier in observed: + raise S0ProfileError("evidence check ids must be unique") + observed.add(identifier) + if _string(entry["status"], f"evidence.checks[{index}].status") != "pass": + raise S0ProfileError(f"evidence check {identifier!r} is not a pass") + artifact_text = _string( + entry["artifact"], + f"evidence.checks[{index}].artifact", + ) + artifact_relative = PurePosixPath(artifact_text) + if artifact_relative.is_absolute() or ".." in artifact_relative.parts: + raise S0ProfileError(f"evidence artifact {identifier!r} is not confined") + artifact_candidate = evidence_root / Path(*artifact_relative.parts) + if artifact_candidate.is_symlink(): + raise S0ProfileError(f"evidence artifact {identifier!r} cannot be a symlink") + artifact = artifact_candidate.resolve(strict=True) + if ( + not artifact.is_relative_to(evidence_root) + or not artifact.is_file() + ): + raise S0ProfileError(f"evidence artifact {identifier!r} is not a regular file") + expected_sha256 = _string( + entry["sha256"], + f"evidence.checks[{index}].sha256", + ) + if ( + _SHA256.fullmatch(expected_sha256) is None + or _sha256_file(artifact) != expected_sha256 + ): + raise S0ProfileError(f"evidence artifact {identifier!r} digest changed") + _string(entry["note"], f"evidence.checks[{index}].note") + if observed != set(profile.required_evidence): + missing = sorted(set(profile.required_evidence) - observed) + extra = sorted(observed - set(profile.required_evidence)) + raise S0ProfileError( + f"evidence coverage mismatch; missing={missing!r}; extra={extra!r}" + ) + except (OSError, S0ProfileError) as exc: + return DoctorCheck("factual-evidence", CheckStatus.FAIL, str(exc)) + return DoctorCheck( + "factual-evidence", + CheckStatus.PASS, + f"all {len(profile.required_evidence)} digest-bound evidence claims validated", + ) + + +def _read_yaml_mapping(path: Path) -> dict[str, object]: + payload = _read_bounded_file(path) + try: + loaded: object = yaml.safe_load(payload) + except yaml.YAMLError as exc: + raise S0ProfileError(f"invalid YAML in {path}") from exc + return _mapping(loaded, str(path)) + + +def _read_bounded_file(path: Path) -> bytes: + candidate = path.expanduser() + if candidate.is_symlink(): + raise S0ProfileError(f"manifest cannot be a symlink: {path}") + resolved = candidate.resolve(strict=True) + if not resolved.is_file(): + raise S0ProfileError(f"manifest is not a regular file: {path}") + size = resolved.stat().st_size + if size < 1 or size > MAX_MANIFEST_BYTES: + raise S0ProfileError(f"manifest size is outside 1..{MAX_MANIFEST_BYTES} bytes") + return resolved.read_bytes() + + +def _mapping(value: object, context: str) -> dict[str, object]: + if not isinstance(value, dict): + raise S0ProfileError(f"{context} must be a mapping") + result: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise S0ProfileError(f"{context} keys must be strings") + result[key] = item + return result + + +def _sequence(value: object, context: str) -> list[object]: + if not isinstance(value, list): + raise S0ProfileError(f"{context} must be a list") + return list(value) + + +def _expect_keys( + data: dict[str, object], + *, + required: set[str], + context: str, +) -> None: + present = set(data) + if present != required: + raise S0ProfileError( + f"{context} keys mismatch; missing={sorted(required - present)!r}; " + f"extra={sorted(present - required)!r}" + ) + + +def _string(value: object, context: str) -> str: + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise S0ProfileError(f"{context} must be a non-empty trimmed string") + return value + + +def _optional_string(value: object, context: str) -> str | None: + if value is None: + return None + return _string(value, context) + + +def _boolean(value: object, context: str) -> bool: + if not isinstance(value, bool): + raise S0ProfileError(f"{context} must be a boolean") + return value + + +def _integer(value: object, context: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + raise S0ProfileError(f"{context} must be an integer") + return value + + +def _safe_id(value: object, context: str) -> str: + text = _string(value, context) + if _SAFE_ID.fullmatch(text) is None: + raise S0ProfileError(f"{context} is not a safe id") + return text + + +def _optional_safe_id(value: object, context: str) -> str | None: + if value is None: + return None + return _safe_id(value, context) + + +def _safe_id_list(value: object, context: str) -> tuple[str, ...]: + result = tuple( + _safe_id(item, f"{context}[{index}]") + for index, item in enumerate(_sequence(value, context)) + ) + if len(set(result)) != len(result): + raise S0ProfileError(f"{context} values must be unique") + return result + + +def _tool_list(value: object, context: str) -> tuple[str, ...]: + result: list[str] = [] + for index, item in enumerate(_sequence(value, context)): + tool = _string(item, f"{context}[{index}]") + if _SAFE_TOOL.fullmatch(tool) is None: + raise S0ProfileError(f"{context}[{index}] is not a safe executable name") + result.append(tool) + if len(set(result)) != len(result): + raise S0ProfileError(f"{context} values must be unique") + return tuple(result) + + +def _string_list(value: object, context: str) -> tuple[str, ...]: + result = tuple( + _string(item, f"{context}[{index}]") + for index, item in enumerate(_sequence(value, context)) + ) + if len(set(result)) != len(result): + raise S0ProfileError(f"{context} values must be unique") + return result + + +def _https_url(value: object, context: str) -> str: + text = _string(value, context) + if not text.startswith("https://"): + raise S0ProfileError(f"{context} must use HTTPS") + return text + + +def _read_optional_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError: + return "" + + +def _linux_os_release() -> dict[str, str]: + result: dict[str, str] = {} + for line in _read_optional_text(Path("/etc/os-release")).splitlines(): + key, separator, raw_value = line.partition("=") + if not separator: + continue + result[key.lower()] = raw_value.strip().strip('"') + return result + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() diff --git a/tests/test_simulation_s0.py b/tests/test_simulation_s0.py new file mode 100644 index 0000000..f4a3bd9 --- /dev/null +++ b/tests/test_simulation_s0.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest +import yaml +from typer.testing import CliRunner + +from k1link.simulation import ( + CheckStatus, + DoctorVerdict, + S0ProfileError, + load_s0_profile, + run_s0_doctor, +) +from k1link.simulation.cli import app + +PROFILE = Path(__file__).resolve().parents[1] / "simulation" / "s0" / "qualification-profile.yaml" +runner = CliRunner() + + +def _profile_document() -> dict[str, object]: + loaded = yaml.safe_load(PROFILE.read_text(encoding="utf-8")) + assert isinstance(loaded, dict) + return loaded + + +def _write_profile(tmp_path: Path, document: dict[str, object]) -> Path: + path = tmp_path / "profile.yaml" + path.write_text(yaml.safe_dump(document, sort_keys=False), encoding="utf-8") + return path + + +def test_canonical_s0_profile_is_strict_and_safe() -> None: + profile = load_s0_profile(PROFILE) + + assert profile.profile_id == "ai-worker-px4-gazebo-v1" + assert profile.storage.windows_root.drive.upper() == "D:" + assert profile.storage.wsl_root.as_posix() == "/mnt/d/NDC_MISSIONCORE" + assert profile.clock.authority == "gazebo:/clock" + assert profile.clock.ros_use_sim_time is True + assert profile.clock.px4_uxrce_dds_sync_enabled is False + assert profile.authority.simulation_or_shadow_only is True + assert profile.authority.actuator_authority is False + assert profile.authority.navigation_or_safety_accepted is False + assert profile.authority.direct_actuator_setpoints_allowed is False + assert profile.ros.domain_id == 42 + assert {item.identifier for item in profile.components} >= { + "px4-autopilot", + "px4-msgs", + "gazebo", + "nav2", + } + assert {item.identifier for item in profile.processes} == { + "simulation-orchestrator", + "gazebo-server", + "px4-sitl", + "micro-xrce-dds-agent", + "ros2-bridge", + "nav2", + } + + +def test_profile_rejects_real_actuator_authority(tmp_path: Path) -> None: + document = _profile_document() + authority = document["authority"] + assert isinstance(authority, dict) + authority["actuator_authority"] = True + + with pytest.raises(S0ProfileError, match="cannot grant real"): + load_s0_profile(_write_profile(tmp_path, document)) + + +def test_profile_rejects_mutable_c_drive_path(tmp_path: Path) -> None: + document = _profile_document() + storage = document["storage"] + assert isinstance(storage, dict) + mutable = storage["mutable_paths"] + assert isinstance(mutable, list) + first = mutable[0] + assert isinstance(first, dict) + first["windows_path"] = r"C:\NDC_MISSIONCORE\wsl" + + with pytest.raises(S0ProfileError, match="escapes D-only root"): + load_s0_profile(_write_profile(tmp_path, document)) + + +def test_profile_rejects_duplicate_scoped_port(tmp_path: Path) -> None: + document = _profile_document() + ports = document["ports"] + assert isinstance(ports, list) + duplicate = dict(ports[0]) + duplicate["id"] = "duplicate-port" + duplicate["owner"] = "simulation-orchestrator" + ports.append(duplicate) + + with pytest.raises(S0ProfileError, match="duplicates a scoped binding"): + load_s0_profile(_write_profile(tmp_path, document)) + + +def test_doctor_never_claims_go_without_target_evidence(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "k1link.simulation.s0._target_host_match", + lambda target: (False, "test host is not target"), + ) + + report = run_s0_doctor(PROFILE) + + assert report.verdict is DoctorVerdict.INCOMPLETE + assert report.target_host_match is False + assert any( + check.identifier == "factual-evidence" and check.status is CheckStatus.UNKNOWN + for check in report.checks + ) + payload = report.to_dict() + assert payload["authority"] == { + "simulation_or_shadow_only": True, + "actuator_authority": False, + "navigation_or_safety_accepted": False, + "direct_actuator_setpoints_allowed": False, + } + + +def test_doctor_validates_digest_bound_evidence( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "k1link.simulation.s0._target_host_match", + lambda target: (False, "test host is not target"), + ) + profile = load_s0_profile(PROFILE) + checks: list[dict[str, object]] = [] + for identifier in profile.required_evidence: + artifact = tmp_path / f"{identifier}.txt" + artifact.write_text(f"evidence for {identifier}\n", encoding="utf-8") + checks.append( + { + "id": identifier, + "status": "pass", + "artifact": artifact.name, + "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), + "note": "synthetic unit-test evidence", + } + ) + evidence = tmp_path / "evidence.yaml" + evidence.write_text( + yaml.safe_dump( + { + "schema_version": "missioncore.simulation-s0-evidence/v1", + "profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + "checks": checks, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + report = run_s0_doctor(PROFILE, evidence_path=evidence) + + assert report.verdict is DoctorVerdict.INCOMPLETE + assert any( + check.identifier == "factual-evidence" and check.status is CheckStatus.PASS + for check in report.checks + ) + + +def test_doctor_rejects_changed_evidence_artifact( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr( + "k1link.simulation.s0._target_host_match", + lambda target: (False, "test host is not target"), + ) + profile = load_s0_profile(PROFILE) + checks: list[dict[str, object]] = [] + artifacts: list[Path] = [] + for identifier in profile.required_evidence: + artifact = tmp_path / f"{identifier}.txt" + artifact.write_text("original\n", encoding="utf-8") + artifacts.append(artifact) + checks.append( + { + "id": identifier, + "status": "pass", + "artifact": artifact.name, + "sha256": hashlib.sha256(artifact.read_bytes()).hexdigest(), + "note": "synthetic unit-test evidence", + } + ) + evidence = tmp_path / "evidence.yaml" + evidence.write_text( + yaml.safe_dump( + { + "schema_version": "missioncore.simulation-s0-evidence/v1", + "profile_sha256": hashlib.sha256(PROFILE.read_bytes()).hexdigest(), + "checks": checks, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + artifacts[0].write_text("changed\n", encoding="utf-8") + + report = run_s0_doctor(PROFILE, evidence_path=evidence) + + assert report.verdict is DoctorVerdict.BLOCKED + assert any( + check.identifier == "factual-evidence" + and check.status is CheckStatus.FAIL + and "digest changed" in check.detail + for check in report.checks + ) + + +def test_simulation_doctor_cli_emits_json_without_side_effects() -> None: + result = runner.invoke(app, ["s0", "doctor", "--profile", str(PROFILE), "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["schema_version"] == "missioncore.simulation-s0-doctor/v1" + assert payload["profile_id"] == "ai-worker-px4-gazebo-v1" + assert payload["verdict"] in {"incomplete", "blocked"} + assert payload["authority"]["actuator_authority"] is False