Compare commits

..

No commits in common. "feat/simulation-polygon-s0" and "main" have entirely different histories.

16 changed files with 1 additions and 4139 deletions

View File

@ -121,34 +121,6 @@ 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.
SIM S0 is accepted on the reviewed D-only worker. Its strict profile fixes
loopback-only process isolation, exact provider pins, a 2 ms physics step,
1×/2× RTF/resource budgets and disabled real/direct actuator authority. Two
accepted-profile stock-rover runs and nine digest-bound evidence claims passed;
the target doctor returned `GO`. The doctor remains read-only and a development
host without the private evidence pack still returns `INCOMPLETE`:
```bash
uv run missioncore-sim s0 doctor \
--evidence /path/to/private/evidence.yaml \
--json
```
This accepts simulation infrastructure, not navigation behavior, safety
behavior, S1 command authority or real actuator control.
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
@ -247,10 +219,6 @@ 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

View File

@ -3,7 +3,7 @@
This plan supersedes the app-dependent experiment order in the reference Bible.
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
## Current checkpoint — 2026-07-24
## Current checkpoint — 2026-07-20
| Stage | Result |
| --- | --- |
@ -24,7 +24,6 @@ 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 | SIM S0 GO — dedicated Docker-free D-only worker, exact accepted pins, stock Ackermann PX4/ROS 2/Gazebo telemetry, authoritative clock, pause/2 ms step, 1×/2× RTF/resource baselines, repeated clean lifecycle and all nine digest-bound evidence claims passed target doctor. S1 orchestrator/control, navigation/safety acceptance and real actuator authority remain absent |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
@ -122,37 +121,6 @@ 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, SIM S0, is accepted:
- exact Windows 11 / dedicated `MissionCore-Sim` 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/2 ms step/1×/2× resource evidence;
- target doctor `GO` for profile SHA-256
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`.
The repository doctor is side-effect free:
```text
uv run missioncore-sim s0 doctor --json
```
Without the exact admitted target-worker evidence its correct result remains
`INCOMPLETE`. S1 starts from the accepted S0 version/storage/time/process
boundary and must add server-owned lifecycle, canonical rover commands,
TTL/watchdog/failsafe evidence and immutable qualification runs.
## Stage 0 — repository and host baseline
Deliverables:

View File

@ -1,563 +0,0 @@
# 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
2746 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 committed on the Polygon branch;
- SIM S0 has a strict accepted qualification profile and read-only doctor;
- the dedicated D-only worker distribution has been created and the accepted
PX4/Gazebo/ROS 2/Nav2 stack has been installed without using Docker;
- PX4 v1.17.0 and the stock Ackermann rover have completed repeated factual
smoke runs with ROS 2 vehicle status, Gazebo clock and clean shutdown;
- the exact accepted-profile generation has passed pause freeze, one 2 ms
single-step, 1×/2× speed and structured resource cases;
- all nine digest-bound S0 evidence claims validate and the target doctor
returns `GO` for profile SHA-256
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
- `actuator_authority=false`;
- `navigation_or_safety_accepted=false`.
The doctor reports `GO` only on the matching target with the exact confined
worker evidence. A repository manifest alone remains insufficient.
## 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 accepted line is:
- Windows 11 AI worker;
- dedicated `MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically on D;
- no Docker dependency in S0, so active shared Docker co-tenants remain untouched;
- ROS 2 Jazzy;
- Gazebo Harmonic;
- PX4 Autopilot v1.17.0;
- matching `px4_msgs` v1.17.0 generation;
- Micro XRCE-DDS Agent v2.4.3;
- Nav2 Jazzy;
- PX4 Gazebo stock rover models.
The exact source commits and package versions are resolved in the profile.
Target installation, PX4 build, DDS telemetry, Nav2/ROS package discovery and
repeated 1×/2× stock-rover evidence have moved every component pin to
`accepted`. A future pin change creates a new profile generation and invalidates
evidence tied to the prior SHA-256.
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:
- the dedicated `MissionCore-Sim` WSL distribution/VHD;
- Docker data and images if a later profile elects to use containers; the S0
profile explicitly does not;
- 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.
S0 runtime processes execute in a new loopback-only Linux network namespace.
Micro XRCE-DDS Agent v2.4.3 has no address-selection flag and therefore binds
UDP wildcard inside that namespace; the namespace contains only `lo`, so the
effective exposure remains loopback-only. ROS domain 0 is isolated by the same
boundary. This distinction is part of the port evidence and must not be
misreported as a host-wide `127.0.0.1` bind.
The stock rover world declares a 2 ms physics step. S0 pause acceptance permits
zero simulation-time advance; single-step requires exactly +2,000,000 ns and
+1 iteration. Speed factors 1× and 2× are measured over four seconds with a
±20% RTF tolerance. Resource sampling runs every 500 ms with an owned-process
budget of 2 GiB RSS and 800% CPU, at least 8 GiB available WSL memory and the
existing 200 GiB D stop floor. GPU values are recorded as global co-tenant
context and are not misattributed to headless S0.
## 16. Delivery plan
| Phase | Estimate | Exit condition |
| --- | ---: | --- |
| P0 | 24 days | Product thesis, SRS, ADR and canonical contracts accepted |
| S0 | 35 days | Target compatibility, D-only runtime, exact pins, clock/process/resource evidence |
| S1 | 712 days | Stock Ackermann and Differential lifecycle/control/failsafe evidence |
| S2 | 1525 days | LiDAR/odometry/Nav2 baseline, evaluator and obstacle scenarios |
| S2B | 58 days | Representative 30-world BARN pilot |
| S3 | 2035 days | K1-like virtual sensors and Mission Core perception closed loop |
| S4 | 1020 days | Canonical replay/shadow adapters and reports |
| S5 | 2040 days | Attested RAVNOVES00 digital twin |
| S6 | 2040+ 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 of the dedicated WSL VHD, sources, builds, caches
and artifacts;
- confirmation that S0 uses the dedicated D-only WSL and does not use the
existing C-backed Docker Desktop runtime;
- 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`, `INCOMPLETE` 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.

View File

@ -1,102 +0,0 @@
# ADR 0015: Simulation Polygon as a qualification boundary
## Status
Accepted for architecture; SIM S0 qualification returned `GO` 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 worker through a dedicated
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically under
`D:\NDC_MISSIONCORE`.
12. SIM S0 does not depend on the existing Docker Desktop runtime. Its WSL
backend is C-backed and currently serves unrelated co-tenants.
13. S1 uses exact `px4_msgs` directly. The experimental
`px4-ros2-interface-lib` is not a mandatory S1 dependency.
14. A top-level Polygon UI is gated on accepted contracts and a proven S1
backend lifecycle with persisted run history.
15. Real actuator authority requires a new decision and physical safety gate.
16. S0 processes run inside an ephemeral loopback-only Linux network namespace.
Micro XRCE-DDS Agent's UDP wildcard bind is acceptable only inside that
boundary; no S0 listener may be reachable from the worker LAN or Windows
host network.
## 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 qualification doctor is deliberately read-only:
- `simulation/s0/qualification-profile.yaml` is the accepted S0 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 first target bootstrap also includes `simulation/s0/worker-smoke.sh`. It
creates the reviewed loopback-only namespace, starts only the pinned stock-rover
providers, observes ROS 2 vehicle status and Gazebo clock, requests PX4 shutdown,
checks for owned-process residue and writes D-only factual artifacts. It never
arms the vehicle or sends an actuator command.
`simulation/s0/gazebo_time_control.py` owns the S0 pause/single-step/1×/2× RTF
and resource acceptance probe. `simulation/s0/build_evidence_manifest.py`
admits only exact-profile, D-confined, passing 1×/2× runs into the digest-bound
evidence generation. These scripts are qualification tooling; the S1
Simulation Orchestrator remains the future product lifecycle owner.
The doctor reports `INCOMPLETE` until it is running on the reviewed target with
resolved/accepted pins and all required evidence. On the accepted profile
generation and nine-claim confined evidence set it returned `GO`; it cannot
infer that result from repository state alone.

View File

@ -1,126 +0,0 @@
# Lab 006: SIM S0 worker bootstrap and stock-rover smoke
Date: 2026-07-24 (Europe/Moscow).
## Scope
The reviewed Windows 11 AI worker received a dedicated Docker-free
`MissionCore-Sim` WSL2 Ubuntu 24.04 distribution physically stored at
`D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim`. Source, build, cache,
runtime, artifacts and datasets are all under the same D-only root. Existing
Docker Desktop co-tenants were inventoried but not stopped, reconfigured or
used.
This lab grants no real or simulated actuator authority. Every smoke run kept
`actuator_authority=false`; it observed the stock rover while disarmed.
## Redacted worker inventory
- Windows 11 Pro, build 26200;
- WSL2 kernel `6.18.33.1-microsoft-standard-WSL2`;
- Ubuntu 24.04.4 LTS, x86_64;
- 13th Gen Intel Core i9-13900KF, 32 WSL-visible logical CPUs;
- 50,427,760,640 WSL-visible RAM bytes and 12,884,901,888 swap bytes;
- NVIDIA GeForce RTX 4090, driver 610.47, 24,564 MiB VRAM;
- D capacity 2,000,381,014,016 bytes;
- D free after bootstrap and builds: 392,758,685,696 bytes.
The remaining D capacity is above the 300 GiB S0 start threshold and 200 GiB
stop threshold. This does not waive continuous disk monitoring.
## Installed candidate generation
- ROS 2 Jazzy `ros-base=0.11.0-1noble.20260616.084325`;
- `ros-gz=1.0.22-1noble.20260616.074726`;
- Gazebo Harmonic `1.0.0-1~noble`, Gazebo Sim `8.14.0-1~noble`,
SDFormat `14.9.0-1~noble`;
- PX4 Autopilot v1.17.0 commit
`d6f12ad1c4f70ad3230afd7d86e971421e02fef4`;
- `px4_msgs` v1.17.0 commit
`86d8239e962f6939e05c3737784f60c02fa884db`;
- PX4 Gazebo models commit
`b6127f4ec20de867e215fb5f78ae88b80f371909`;
- Micro XRCE-DDS Agent v2.4.3 commit
`73622810d984349b80bbac0ef55fc0b694d62222`;
- Nav2 `navigation2=1.3.12-1noble.20260615.181551` and
`nav2-bringup=1.3.12-1noble.20260616.082701`.
PX4 SITL and `px4_msgs` were built successfully from the pinned D-only source.
The Micro XRCE-DDS Agent was built from source and installed under the D-only
runtime root. Nav2 is installed but has not yet passed an S2 navigation case.
## Observed smoke result
Two consecutive canonical runs and one preliminary resource run passed:
- `20260724T134400Z-stock-rover`;
- `20260724T134600Z-stock-rover-repeat`;
- `20260724T135000Z-stock-rover-baseline`.
The final smoke-script generation also passed as
`20260724T135800Z-stock-rover-final` and added a runtime listener inventory plus
the point resource snapshot.
Each run started PX4 v1.17.0 with Gazebo world `rover` and model
`rover_ackermann_0`, established a Micro XRCE-DDS session, observed
`/fmu/out/vehicle_status_v1`, observed Gazebo `/clock`, requested PX4 shutdown
and found no owned runtime residue. The captured vehicle state was disarmed,
`failsafe=false`, with pre-flight checks passing.
Each provider set ran inside a new Linux network namespace containing only
`127.0.0.1` and `::1`. The Agent's UDP `0.0.0.0:8888` bind was therefore
effective only inside that loopback boundary. No PX4, Gazebo or Agent service
was exposed to the LAN.
The two canonical artifact directories are 68 KiB each. The preliminary
resource run completed in 38.75 seconds. One point sample observed approximately
202 MiB RSS for headless Gazebo, 19 MiB for PX4 and 17 MiB for the Agent. The
worker GPU was already occupied by unrelated co-tenants; no S0 GPU allocation
was attributable in this headless run. These point observations are not the
accepted 1× or 2× resource baselines.
## Diagnostics retained
Earlier bootstrap attempts remain outside Git under the D-only artifact root:
- one check raced the asynchronous DDS connection even though the session
subsequently established;
- one used an unsupported ROS 2 Jazzy long option;
- one used ROS domain 42 while PX4 v1.17.0 published on domain 0.
The smoke contract now waits for the Agent session plus the PX4
`vehicle_status_v1` writer, uses Jazzy `topic list -t`, and relies on the
loopback-only namespace to isolate domain 0.
An initial malformed interactive log repeatedly captured PX4 terminal repaint
output and reached approximately 1.1 GiB. That generated diagnostic artifact
was removed, then the case was rerun non-interactively; the valid standalone
PX4 log was approximately 8 KiB.
## Target doctor
The exact candidate profile was executed through the read-only doctor inside
`MissionCore-Sim`. Profile SHA-256:
`70bf0df44c85dd57eb3cedb98a474a46ba498c6ffc60ce0d8a06829e28587df1`.
Target identity, authority, clock, port/process registries, all seven D-only
mutable roots, the disk threshold and all eight required executables passed.
Version acceptance and the nine-item digest-bound evidence set remain
incomplete, so the doctor correctly returned `INCOMPLETE`.
## Verdict
`INCOMPLETE`.
Worker compatibility, D-only placement, exact candidate pins, PX4 build,
stock-rover launch, DDS telemetry, authoritative clock observation and repeated
clean lifecycle are factually proven. S0 still requires:
- pause/resume and deterministic single-step evidence;
- 1× speed/real-time-factor acceptance;
- isolated 2× resource/coexistence baseline;
- a complete digest-bound evidence manifest;
- final `GO`, `INCOMPLETE` or `BLOCKED` decision.
This lab does not accept Nav2 behavior, navigation safety, real-time guarantees,
or real actuator control.

View File

@ -1,126 +0,0 @@
# Lab 007: SIM S0 acceptance
Date: 2026-07-24 (Europe/Moscow).
## Scope
This lab closes the infrastructure qualification gate for Mission Core
Polygon. It validates the exact D-only worker profile, stock PX4 Ackermann
runtime, ROS 2 telemetry, Gazebo time control, 1×/2× resource behavior,
deterministic shutdown and digest-bound evidence admission.
It does not accept S1 orchestration, rover command behavior, Nav2 navigation,
safety behavior, HIL, physical shadow or real actuator control.
`actuator_authority=false` throughout.
## Accepted profile
- profile ID: `ai-worker-px4-gazebo-v1`;
- profile SHA-256:
`aeecf5005301db230e1340b85215cbbd862b72974a22b8c43767c28be8453e2f`;
- evidence tooling commit:
`2548bb57403411ce9c8374151e3bfa34ac0866ee`;
- target: dedicated Docker-free `MissionCore-Sim` WSL2 Ubuntu 24.04 on D;
- all seven component pins: resolved and `accepted`;
- all seven mutable roots: present under `/mnt/d/NDC_MISSIONCORE`;
- disk at final doctor: 365.55 GiB free, above the 300 GiB start and
200 GiB stop thresholds.
Exact ROS 2, Gazebo, PX4, `px4_msgs`, PX4 Gazebo model, Micro XRCE-DDS Agent and
Nav2 versions remain recorded in the qualification profile and Lab 006.
## Accepted runs
The exact accepted-profile generation produced two immutable runs:
- `20260724T155000Z-accepted-1x`;
- `20260724T160000Z-accepted-2x`.
Both runs passed:
- stock world `rover`, model `rover_ackermann_0`;
- PX4 v1.17.0 and Micro XRCE-DDS session;
- ROS 2 `/fmu/out/vehicle_status_v1`;
- authoritative Gazebo `/clock`;
- loopback-only network namespace;
- pause, single-step and declared speed behavior;
- structured resource baseline;
- clean shutdown with no owned process residue.
The vehicle stayed disarmed. No actuator command was issued.
## Time acceptance
| Case | Pause advance | Single step | Measured RTF | Accepted range |
| --- | ---: | ---: | ---: | ---: |
| 1× | 0 ns | +2,000,000 ns / +1 iteration | 0.981913 | 0.81.2 |
| 2× | 0 ns | +2,000,000 ns / +1 iteration | 1.967258 | 1.62.4 |
Gazebo `/clock` is the closed-loop simulation authority. The zero pause
advance and exact 2 ms single-step are observed deltas, not inferred settings.
## Resource acceptance
| Metric | 1× | 2× | Contract |
| --- | ---: | ---: | ---: |
| Samples at 500 ms | 15 | 15 | at least 6 |
| Peak owned RSS | 407,629,824 B | 408,756,224 B | at most 2 GiB |
| Peak owned CPU | 101.8% | 169.2% | at most 800% |
| Minimum available WSL RAM | 28,437,897,216 B | 28,662,071,296 B | at least 8 GiB |
| Minimum D free | 392,613,560,320 B | 392,568,233,984 B | at least 200 GiB |
| Maximum swap used | 0 B | 0 B | observed |
Global GPU context peaked at 13,579 MiB used and 52% utilization in both cases.
Those values include unrelated co-tenants and are intentionally not attributed
to headless S0.
## Evidence admission
The private D-only evidence generation contains nine redacted JSON artifacts
and one manifest, approximately 100 KiB total:
1. worker inventory;
2. D-only physical placement;
3. PX4 stock-rover launch;
4. ROS 2 telemetry;
5. authoritative simulation clock;
6. pause/step/speed;
7. clean stop with no orphans;
8. 1× resource baseline;
9. 2× resource baseline.
Every manifest entry is `pass`, names a confined regular file and binds its
SHA-256. The manifest binds the accepted profile SHA-256. Failed diagnostic
generations were not reused.
## Target doctor
The read-only doctor returned:
```text
verdict=go
target_host_match=true
profile-contract=pass
authority-contract=pass
clock-contract=pass
port-registry=pass
process-registry=pass
target-host=pass
d-only-storage=pass
disk-budget=pass
version-lock=pass
required-tools=pass
factual-evidence=pass
```
The persisted redacted `doctor.json` SHA-256 is
`6a2571bd7af0e85b1345201068ccd4bffa215f84a8a9f62a4357d5dbfa236c6c`.
## Verdict
`GO` for SIM S0 infrastructure qualification.
The next permitted product phase is SIM S1: server-owned start/pause/resume/
step/reset/stop, immutable `QualificationRun`, canonical Ackermann and
Differential rover commands, TTL/heartbeat/watchdog, stop-on-loss and captured
PX4 failsafe behavior. No real-control permission is created by this verdict.

View File

@ -1,162 +0,0 @@
# 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 the dedicated `MissionCore-Sim` WSL2
Ubuntu 24.04 distribution physically installed on D.
- All mutable bytes must be under `D:\NDC_MISSIONCORE`.
- Do not place a WSL distribution, source, build, cache, log, bag, ULog,
Gazebo artifact or dataset on C.
- Do not use or reconfigure the existing Docker Desktop runtime in S0. Its
backend is C-backed and serves unrelated co-tenants.
- Do not expose S0 services beyond loopback.
- Run providers inside the ephemeral loopback-only network namespace created
by `simulation/s0/worker-smoke.sh`. Micro XRCE-DDS Agent binds UDP wildcard
inside that namespace because v2.4.3 has no address-selection option.
- 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`.
## Stock-rover smoke
Copy the reviewed script to the D-only worker source root, then invoke it as
root inside `MissionCore-Sim` with a unique immutable run ID:
```bash
simulation/s0/worker-smoke.sh <UTC-run-id>-stock-rover <1|2>
```
The script:
- creates a new network namespace containing only loopback;
- starts Micro XRCE-DDS Agent, PX4 SITL and the stock Gazebo Ackermann world;
- verifies the DDS session, `/fmu/out/vehicle_status_v1` and Gazebo `/clock`;
- proves pause freeze and exactly one 2 ms physics step;
- measures the declared 1× or 2× real-time factor over four seconds;
- samples owned CPU/RSS, available RAM, swap, D free space and global co-tenant
GPU load at 500 ms intervals;
- records Gazebo service/topic inventory and runtime listeners;
- sends the PX4 `shutdown` command and rejects owned-process residue.
Run both factors as separate immutable cases. Do not reuse a run ID. A failed
run remains diagnostic evidence; rerun with a new ID after correction.
## 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 location of the dedicated WSL distribution and confirmation that
the Docker-free S0 profile is active.
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: <sha256 of qualification-profile.yaml>
checks:
- id: worker-inventory
status: pass
artifact: worker-inventory.redacted.json
sha256: <artifact 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.
Build the redacted manifest only from two passing runs made with the exact
accepted profile generation:
```bash
python3 simulation/s0/build_evidence_manifest.py \
--profile /mnt/d/NDC_MISSIONCORE/simulation/source/qualification-profile.yaml \
--run-1x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-1x-run> \
--run-2x /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/<accepted-2x-run> \
--output-dir /mnt/d/NDC_MISSIONCORE/simulation/artifacts/s0/evidence/<generation> \
--d-root /mnt/d/NDC_MISSIONCORE \
--source-root /mnt/d/NDC_MISSIONCORE/simulation/source \
--repository-commit <full-commit-sha> \
--wsl-windows-path 'D:\NDC_MISSIONCORE\simulation\wsl\MissionCore-Sim'
```
The generator refuses an existing output directory, non-passing runs,
candidate pins, commit drift, incomplete Nav2/ROS discovery or any input/output
outside the reviewed D root.
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.

View File

@ -30,7 +30,6 @@ 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 = [

View File

@ -1,493 +0,0 @@
#!/usr/bin/env python3
"""Build a confined, digest-bound SIM S0 evidence pack from accepted worker runs."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
import yaml
EVIDENCE_SCHEMA = "missioncore.simulation-s0-evidence/v1"
class EvidenceError(RuntimeError):
"""Evidence inputs are incomplete, inconsistent, or outside the reviewed boundary."""
def _run(command: list[str]) -> str:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=15,
)
if result.returncode != 0:
raise EvidenceError(
f"command failed ({result.returncode}): {' '.join(command)}; "
f"stderr={result.stderr.strip()!r}"
)
return result.stdout.strip()
def _load_json(path: Path) -> dict[str, Any]:
loaded = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(loaded, dict):
raise EvidenceError(f"expected a JSON object: {path}")
return loaded
def _load_yaml(path: Path) -> dict[str, Any]:
documents = [
document
for document in yaml.safe_load_all(path.read_text(encoding="utf-8"))
if document is not None
]
if len(documents) != 1 or not isinstance(documents[0], dict):
raise EvidenceError(f"expected a YAML mapping: {path}")
return documents[0]
def _result_env(path: Path) -> dict[str, str]:
result: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
key, separator, value = line.partition("=")
if not separator or not key:
raise EvidenceError(f"invalid result line in {path}: {line!r}")
result[key] = value
required_passes = {
"px4_stock_rover",
"uxrce_dds",
"ros2_vehicle_status",
"gazebo_clock",
"pause_step_speed",
"resource_baseline",
"clean_lifecycle",
}
failed = sorted(key for key in required_passes if result.get(key) != "pass")
if failed:
raise EvidenceError(f"run did not pass required checks: {', '.join(failed)}")
if result.get("actuator_authority") != "false":
raise EvidenceError("run widened actuator authority")
return result
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _package_version(package: str) -> str:
return _run(["dpkg-query", "-W", "-f=${Version}", package])
def _git_commit(path: Path) -> str:
commit = _run(
[
"git",
"-c",
f"safe.directory={path}",
"-C",
str(path),
"rev-parse",
"HEAD",
]
)
if re.fullmatch(r"[a-f0-9]{40}", commit) is None:
raise EvidenceError(f"invalid Git commit at {path}: {commit!r}")
return commit
def _memory_inventory() -> dict[str, int]:
values: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
key, raw = line.split(":", maxsplit=1)
values[key] = int(raw.strip().split()[0]) * 1024
return {
"total_bytes": values["MemTotal"],
"available_bytes": values["MemAvailable"],
"swap_total_bytes": values["SwapTotal"],
"swap_free_bytes": values["SwapFree"],
}
def _gpu_inventory() -> dict[str, str | int] | None:
if shutil.which("nvidia-smi") is None:
return None
output = _run(
[
"nvidia-smi",
"--query-gpu=name,driver_version,memory.total",
"--format=csv,noheader,nounits",
]
)
name, driver, memory_mib = (part.strip() for part in output.splitlines()[0].split(","))
return {
"name": name,
"driver": driver,
"memory_total_mib": int(memory_mib),
}
def _cpu_model() -> str:
for line in Path("/proc/cpuinfo").read_text(encoding="utf-8").splitlines():
if line.startswith("model name"):
return line.split(":", maxsplit=1)[1].strip()
raise EvidenceError("CPU model was not available")
def _component_inventory(profile: dict[str, Any], source_root: Path) -> dict[str, Any]:
components = profile.get("components")
if not isinstance(components, list) or not components:
raise EvidenceError("profile components are missing")
if any(
not isinstance(component, dict) or component.get("qualification_state") != "accepted"
for component in components
):
raise EvidenceError("all profile components must be accepted before evidence admission")
packages = {
"ros-jazzy-ros-base": _package_version("ros-jazzy-ros-base"),
"ros-jazzy-ros-gz": _package_version("ros-jazzy-ros-gz"),
"gz-harmonic": _package_version("gz-harmonic"),
"gz-sim8-cli": _package_version("gz-sim8-cli"),
"libsdformat14": _package_version("libsdformat14"),
"ros-jazzy-navigation2": _package_version("ros-jazzy-navigation2"),
"ros-jazzy-nav2-bringup": _package_version("ros-jazzy-nav2-bringup"),
}
commits = {
"px4-autopilot": _git_commit(source_root / "PX4-Autopilot"),
"px4-msgs": _git_commit(source_root / "px4_msgs"),
"px4-gazebo-models": _git_commit(
source_root / "PX4-Autopilot" / "Tools" / "simulation" / "gz"
),
"micro-xrce-dds-agent": _git_commit(source_root / "Micro-XRCE-DDS-Agent"),
}
declared = {
str(component["id"]): {
"resolved_version": component.get("resolved_version"),
"resolved_commit": component.get("resolved_commit"),
"license": component.get("license"),
"qualification_state": component.get("qualification_state"),
}
for component in components
}
for identifier, commit in commits.items():
if declared[identifier]["resolved_commit"] != commit:
raise EvidenceError(f"worker commit drift for {identifier}")
nav2_prefix = _run(["ros2", "pkg", "prefix", "nav2_bringup"])
px4_msgs_prefix = _run(["ros2", "pkg", "prefix", "px4_msgs"])
nav2_executables = _run(["ros2", "pkg", "executables", "nav2_controller"]).splitlines()
if not nav2_executables:
raise EvidenceError("Nav2 controller executable is unavailable")
return {
"declared": declared,
"installed_packages": packages,
"observed_commits": commits,
"runtime_discovery": {
"nav2_bringup_prefix": nav2_prefix,
"nav2_controller_executables": nav2_executables,
"px4_msgs_prefix": px4_msgs_prefix,
},
}
def _world_model_from_log(path: Path) -> dict[str, str]:
text = path.read_text(encoding="utf-8", errors="replace")
world_model = re.search(r"world:\s*([^,\s]+),\s*model:\s*([^\s]+)", text)
commit = re.search(r"PX4 git-hash:\s*([a-f0-9]{40})", text)
version = re.search(r"PX4 version:\s*(.+)", text)
if world_model is None or commit is None or version is None:
raise EvidenceError(f"PX4 identity is incomplete in {path}")
return {
"world": world_model.group(1),
"model": world_model.group(2),
"px4_commit": commit.group(1),
"px4_version": version.group(1).strip(),
}
def _ensure_confined(path: Path, root: Path, *, label: str) -> Path:
resolved = path.expanduser().resolve(strict=True)
if not resolved.is_relative_to(root):
raise EvidenceError(f"{label} escaped reviewed root {root}: {resolved}")
return resolved
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--run-1x", type=Path, required=True)
parser.add_argument("--run-2x", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--d-root", type=Path, required=True)
parser.add_argument("--source-root", type=Path, required=True)
parser.add_argument("--repository-commit", required=True)
parser.add_argument("--wsl-windows-path", required=True)
return parser.parse_args()
def main() -> int:
args = _arguments()
d_root = args.d_root.expanduser().resolve(strict=True)
profile_path = _ensure_confined(args.profile, d_root, label="profile")
run_1x = _ensure_confined(args.run_1x, d_root, label="1x run")
run_2x = _ensure_confined(args.run_2x, d_root, label="2x run")
source_root = _ensure_confined(args.source_root, d_root, label="source root")
output_dir = args.output_dir.expanduser().resolve(strict=False)
if not output_dir.is_relative_to(d_root):
raise EvidenceError("evidence output escaped the reviewed D-only root")
if output_dir.exists():
raise EvidenceError(f"evidence output already exists: {output_dir}")
if re.fullmatch(r"[a-f0-9]{40}", args.repository_commit) is None:
raise EvidenceError("repository commit must be a full Git SHA")
profile = _load_yaml(profile_path)
profile_sha256 = _sha256(profile_path)
result_1x = _result_env(run_1x / "result.env")
result_2x = _result_env(run_2x / "result.env")
if result_1x.get("speed_factor") != "1" or result_2x.get("speed_factor") != "2":
raise EvidenceError("run speed factors are not the declared 1x/2x pair")
time_1x = _load_json(run_1x / "time-control.json")
time_2x = _load_json(run_2x / "time-control.json")
resource_1x = _load_json(run_1x / "resource-baseline.json")
resource_2x = _load_json(run_2x / "resource-baseline.json")
if any(
report.get("verdict") != "pass" for report in (time_1x, time_2x, resource_1x, resource_2x)
):
raise EvidenceError("time or resource report is not a pass")
output_dir.mkdir(parents=True)
disk = shutil.disk_usage(d_root)
target_host = profile["target_host"]
worker_inventory = {
"redacted": True,
"repository_commit": args.repository_commit,
"profile_sha256": profile_sha256,
"target": {
"system": platform.system(),
"kernel_release": platform.release(),
"architecture": platform.machine(),
"wsl_distribution": os.environ.get("WSL_DISTRO_NAME"),
"linux_pretty_name": _run(
["bash", "-lc", ". /etc/os-release && printf '%s' \"$PRETTY_NAME\""]
),
},
"cpu": {
"model": _cpu_model(),
"logical_processors": os.cpu_count(),
},
"memory": _memory_inventory(),
"gpu": _gpu_inventory(),
"disk": {
"total_bytes": disk.total,
"free_bytes": disk.free,
},
"components": _component_inventory(profile, source_root),
}
if worker_inventory["target"]["wsl_distribution"] != target_host["wsl_distribution"]:
raise EvidenceError("worker distribution does not match the profile")
mutable_paths = profile["storage"]["mutable_paths"]
placement: list[dict[str, Any]] = []
for item in mutable_paths:
path = Path(str(item["wsl_path"])).resolve(strict=True)
if not path.is_relative_to(d_root):
raise EvidenceError(f"mutable path escaped D-only root: {path}")
placement.append(
{
"id": item["id"],
"windows_path": item["windows_path"],
"wsl_path": str(path),
"exists": path.is_dir(),
}
)
d_only = {
"redacted": True,
"profile_sha256": profile_sha256,
"wsl_distribution_windows_path": args.wsl_windows_path,
"wsl_mount_root": str(d_root),
"container_runtime": target_host["container_runtime"],
"existing_docker_desktop_used_or_modified": False,
"mutable_paths": placement,
"disk": {
"total_bytes": disk.total,
"free_bytes": disk.free,
"minimum_free_gib": profile["storage"]["minimum_free_gib"],
"stop_below_gib": profile["storage"]["stop_below_gib"],
},
}
px4_launch = {
"repository_commit": args.repository_commit,
"runs": [
{
"run_id": result_1x["run_id"],
"speed_factor": 1,
"identity": _world_model_from_log(run_1x / "px4-rover.log"),
"result": result_1x,
},
{
"run_id": result_2x["run_id"],
"speed_factor": 2,
"identity": _world_model_from_log(run_2x / "px4-rover.log"),
"result": result_2x,
},
],
}
telemetry = {
"runs": [
{
"run_id": result["run_id"],
"vehicle_status": _load_yaml(run / "vehicle-status.yaml"),
"vehicle_status_topic_present": (
"/fmu/out/vehicle_status_v1"
in (run / "ros-topics.txt").read_text(encoding="utf-8")
),
}
for result, run in ((result_1x, run_1x), (result_2x, run_2x))
]
}
authoritative_clock = {
"authority": profile["clock"],
"runs": [
{
"run_id": result["run_id"],
"clock_sample": _load_yaml(run / "clock.yaml"),
"pause_advance_ns": report["pause"]["observed_advance_ns"],
}
for result, run, report in (
(result_1x, run_1x, time_1x),
(result_2x, run_2x, time_2x),
)
],
}
pause_step_speed = {
"contract": profile["runtime_acceptance"],
"runs": [
{
"run_id": result_1x["run_id"],
"speed_factor": 1,
"pause": time_1x["pause"],
"single_step": time_1x["single_step"],
"speed": time_1x["speed"],
"verdict": time_1x["verdict"],
},
{
"run_id": result_2x["run_id"],
"speed_factor": 2,
"pause": time_2x["pause"],
"single_step": time_2x["single_step"],
"speed": time_2x["speed"],
"verdict": time_2x["verdict"],
},
],
}
clean_stop = {
"runs": [
{
"run_id": result_1x["run_id"],
"clean_lifecycle": result_1x["clean_lifecycle"],
},
{
"run_id": result_2x["run_id"],
"clean_lifecycle": result_2x["clean_lifecycle"],
},
],
"owned_process_residue": False,
}
artifacts: dict[str, tuple[str, dict[str, Any]]] = {
"worker-inventory": (
"worker-inventory.redacted.json",
worker_inventory,
),
"d-only-physical-placement": (
"d-only-physical-placement.redacted.json",
d_only,
),
"px4-stock-rover-launch": (
"px4-stock-rover-launch.redacted.json",
px4_launch,
),
"ros2-telemetry": (
"ros2-telemetry.redacted.json",
telemetry,
),
"authoritative-simulation-clock": (
"authoritative-simulation-clock.redacted.json",
authoritative_clock,
),
"pause-step-speed": (
"pause-step-speed.redacted.json",
pause_step_speed,
),
"clean-stop-no-orphans": (
"clean-stop-no-orphans.redacted.json",
clean_stop,
),
"resource-baseline-1x": (
"resource-baseline-1x.redacted.json",
resource_1x,
),
"resource-baseline-2x": (
"resource-baseline-2x.redacted.json",
resource_2x,
),
}
required = profile.get("required_evidence")
if not isinstance(required, list) or set(required) != set(artifacts):
raise EvidenceError("profile required_evidence does not match the generator")
manifest_checks: list[dict[str, str]] = []
for identifier in required:
filename, payload = artifacts[str(identifier)]
artifact_path = output_dir / filename
_write_json(artifact_path, payload)
manifest_checks.append(
{
"id": str(identifier),
"status": "pass",
"artifact": filename,
"sha256": _sha256(artifact_path),
"note": f"Redacted factual worker evidence for {identifier}.",
}
)
manifest = {
"schema_version": EVIDENCE_SCHEMA,
"profile_sha256": profile_sha256,
"checks": manifest_checks,
}
manifest_path = output_dir / "evidence.yaml"
manifest_path.write_text(
yaml.safe_dump(manifest, sort_keys=False),
encoding="utf-8",
)
print(
json.dumps(
{
"evidence_manifest": str(manifest_path),
"profile_sha256": profile_sha256,
"checks": len(manifest_checks),
},
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,487 +0,0 @@
#!/usr/bin/env python3
"""Qualify Gazebo pause, single-step, speed and resource behavior on SIM S0."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import yaml
TIME_SCHEMA = "missioncore.simulation-time-control/v1"
RESOURCE_SCHEMA = "missioncore.simulation-resource-baseline/v1"
MIB = 1024**2
GIB = 1024**3
class ProbeError(RuntimeError):
"""A runtime observation could not satisfy the reviewed S0 contract."""
@dataclass(frozen=True, slots=True)
class WorldStats:
sim_time_ns: int
real_time_ns: int
iterations: int
paused: bool
real_time_factor: float
step_size_ns: int
def _run(command: list[str], *, timeout: float) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
command,
check=False,
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
raise ProbeError(
f"command failed ({result.returncode}): {' '.join(command)}; "
f"stderr={result.stderr.strip()!r}"
)
return result
def _time_field(message: str, field: str) -> int:
match = re.search(rf"\b{re.escape(field)}\s*\{{(?P<body>.*?)\}}", message, re.DOTALL)
if match is None:
raise ProbeError(f"Gazebo stats omitted {field}")
body = match.group("body")
seconds = re.search(r"\bsec:\s*(-?\d+)", body)
nanoseconds = re.search(r"\bnsec:\s*(-?\d+)", body)
sec_value = int(seconds.group(1)) if seconds is not None else 0
nsec_value = int(nanoseconds.group(1)) if nanoseconds is not None else 0
return sec_value * 1_000_000_000 + nsec_value
def _scalar_field(
message: str,
field: str,
converter: type[int] | type[float],
*,
default: int | float | None = None,
) -> int | float:
match = re.search(rf"\b{re.escape(field)}:\s*([^\s]+)", message)
if match is None:
if default is None:
raise ProbeError(f"Gazebo stats omitted {field}")
return default
return converter(match.group(1))
def _stats_snapshot() -> WorldStats:
result = _run(
["gz", "topic", "--echo", "--topic", "/stats", "-n", "1"],
timeout=8,
)
paused_match = re.search(r"\bpaused:\s*(true|false)", result.stdout)
paused = paused_match is not None and paused_match.group(1) == "true"
return WorldStats(
sim_time_ns=_time_field(result.stdout, "sim_time"),
real_time_ns=_time_field(result.stdout, "real_time"),
iterations=int(_scalar_field(result.stdout, "iterations", int)),
paused=paused,
real_time_factor=float(
_scalar_field(result.stdout, "real_time_factor", float, default=0.0)
),
step_size_ns=_time_field(result.stdout, "step_size"),
)
def _control(world: str, request: str) -> str:
result = _run(
[
"gz",
"service",
"-s",
f"/world/{world}/control",
"--reqtype",
"gz.msgs.WorldControl",
"--reptype",
"gz.msgs.Boolean",
"--timeout",
"5000",
"--req",
request,
],
timeout=8,
)
if "data: true" not in result.stdout:
raise ProbeError(
f"Gazebo rejected world control request {request!r}: {result.stdout.strip()!r}"
)
return result.stdout.strip()
def _wait_for_pause_state(expected: bool, *, timeout: float = 6) -> WorldStats:
deadline = time.monotonic() + timeout
observed: WorldStats | None = None
while time.monotonic() < deadline:
observed = _stats_snapshot()
if observed.paused is expected:
return observed
raise ProbeError(f"Gazebo pause state did not become {expected}; last={observed!r}")
def _wait_for_iteration(minimum: int, *, timeout: float = 6) -> WorldStats:
deadline = time.monotonic() + timeout
observed: WorldStats | None = None
while time.monotonic() < deadline:
observed = _stats_snapshot()
if observed.iterations >= minimum:
return observed
raise ProbeError(f"Gazebo iteration did not reach {minimum}; last={observed!r}")
def _meminfo() -> dict[str, int]:
values: dict[str, int] = {}
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
key, raw = line.split(":", maxsplit=1)
amount = int(raw.strip().split()[0])
values[key] = amount * 1024
return values
def _owned_processes(
*,
pids: set[int],
process_groups: set[int],
) -> list[dict[str, int | float | str]]:
result = _run(
[
"ps",
"-eo",
"pid=,ppid=,pgid=,rss=,pcpu=,comm=",
],
timeout=5,
)
processes: list[dict[str, int | float | str]] = []
for line in result.stdout.splitlines():
fields = line.split(maxsplit=5)
if len(fields) != 6:
continue
pid, ppid, pgid, rss_kib, cpu_percent, command = fields
pid_value = int(pid)
pgid_value = int(pgid)
if pid_value not in pids and pgid_value not in process_groups:
continue
processes.append(
{
"pid": pid_value,
"ppid": int(ppid),
"pgid": pgid_value,
"rss_bytes": int(rss_kib) * 1024,
"cpu_percent": float(cpu_percent),
"command": command,
}
)
return processes
def _gpu_sample() -> dict[str, int] | None:
if shutil.which("nvidia-smi") is None:
return None
result = _run(
[
"nvidia-smi",
"--query-gpu=memory.used,utilization.gpu",
"--format=csv,noheader,nounits",
],
timeout=5,
)
first_line = result.stdout.strip().splitlines()[0]
memory_mib, utilization_percent = (int(value.strip()) for value in first_line.split(","))
return {
"global_memory_used_mib": memory_mib,
"global_utilization_percent": utilization_percent,
}
class ResourceSampler:
def __init__(
self,
*,
owned_pids: set[int],
owned_process_groups: set[int],
storage_root: Path,
interval_seconds: float,
) -> None:
self._owned_pids = owned_pids
self._owned_process_groups = owned_process_groups
self._storage_root = storage_root
self._interval_seconds = interval_seconds
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, name="s0-resource-sampler")
self.samples: list[dict[str, Any]] = []
self.error: str | None = None
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=max(5.0, self._interval_seconds * 4))
if self._thread.is_alive():
raise ProbeError("resource sampler did not stop")
if self.error is not None:
raise ProbeError(f"resource sampler failed: {self.error}")
def _run(self) -> None:
try:
while True:
processes = _owned_processes(
pids=self._owned_pids,
process_groups=self._owned_process_groups,
)
memory = _meminfo()
disk = shutil.disk_usage(self._storage_root)
self.samples.append(
{
"monotonic_ns": time.monotonic_ns(),
"owned_rss_bytes": sum(int(process["rss_bytes"]) for process in processes),
"owned_cpu_percent": sum(
float(process["cpu_percent"]) for process in processes
),
"memory_available_bytes": memory["MemAvailable"],
"swap_used_bytes": memory["SwapTotal"] - memory["SwapFree"],
"disk_free_bytes": disk.free,
"gpu": _gpu_sample(),
"processes": processes,
}
)
if self._stop.wait(self._interval_seconds):
break
except Exception as error: # noqa: BLE001 - preserve factual sampler failure
self.error = f"{type(error).__name__}: {error}"
def _load_contract(profile_path: Path, speed_factor: int) -> dict[str, int | str]:
profile = yaml.safe_load(profile_path.read_text(encoding="utf-8"))
if not isinstance(profile, dict):
raise ProbeError("qualification profile must be a mapping")
runtime = profile.get("runtime_acceptance")
storage = profile.get("storage")
if not isinstance(runtime, dict) or not isinstance(storage, dict):
raise ProbeError("qualification profile omitted runtime_acceptance or storage")
factors = runtime.get("speed_factors")
if not isinstance(factors, list) or speed_factor not in factors:
raise ProbeError(f"speed factor {speed_factor} is not declared by the profile")
return {
"world": str(runtime["world"]),
"model": str(runtime["model"]),
"physics_step_ns": int(runtime["physics_step_ns"]),
"pause_max_advance_ns": int(runtime["pause_max_advance_ns"]),
"rtf_tolerance_percent": int(runtime["rtf_tolerance_percent"]),
"measurement_window_seconds": int(runtime["measurement_window_seconds"]),
"resource_sample_interval_milliseconds": int(
runtime["resource_sample_interval_milliseconds"]
),
"max_owned_rss_mib": int(runtime["max_owned_rss_mib"]),
"max_owned_cpu_percent": int(runtime["max_owned_cpu_percent"]),
"minimum_available_memory_gib": int(runtime["minimum_available_memory_gib"]),
"stop_below_gib": int(storage["stop_below_gib"]),
}
def _resource_report(
*,
samples: list[dict[str, Any]],
contract: dict[str, int | str],
speed_factor: int,
) -> dict[str, Any]:
if not samples:
raise ProbeError("resource sampler produced no samples")
peak_rss = max(int(sample["owned_rss_bytes"]) for sample in samples)
peak_cpu = max(float(sample["owned_cpu_percent"]) for sample in samples)
minimum_memory = min(int(sample["memory_available_bytes"]) for sample in samples)
minimum_disk = min(int(sample["disk_free_bytes"]) for sample in samples)
maximum_swap = max(int(sample["swap_used_bytes"]) for sample in samples)
gpu_samples = [sample["gpu"] for sample in samples if sample["gpu"] is not None]
checks = {
"sample_count": len(samples) >= 6,
"owned_rss": peak_rss <= int(contract["max_owned_rss_mib"]) * MIB,
"owned_cpu": peak_cpu <= int(contract["max_owned_cpu_percent"]),
"available_memory": (minimum_memory >= int(contract["minimum_available_memory_gib"]) * GIB),
"disk_floor": minimum_disk >= int(contract["stop_below_gib"]) * GIB,
}
summary: dict[str, Any] = {
"sample_count": len(samples),
"peak_owned_rss_bytes": peak_rss,
"peak_owned_cpu_percent": peak_cpu,
"minimum_memory_available_bytes": minimum_memory,
"minimum_disk_free_bytes": minimum_disk,
"maximum_swap_used_bytes": maximum_swap,
"gpu_metrics_are_global_and_include_co_tenants": True,
}
if gpu_samples:
summary["peak_global_gpu_memory_used_mib"] = max(
int(sample["global_memory_used_mib"]) for sample in gpu_samples
)
summary["peak_global_gpu_utilization_percent"] = max(
int(sample["global_utilization_percent"]) for sample in gpu_samples
)
return {
"schema_version": RESOURCE_SCHEMA,
"speed_factor": speed_factor,
"actuator_authority": False,
"checks": checks,
"summary": summary,
"samples": samples,
"verdict": "pass" if all(checks.values()) else "fail",
}
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--speed-factor", type=int, required=True)
parser.add_argument("--time-output", type=Path, required=True)
parser.add_argument("--resource-output", type=Path, required=True)
parser.add_argument("--storage-root", type=Path, required=True)
parser.add_argument("--owned-pid", action="append", type=int, default=[])
parser.add_argument("--owned-pgid", action="append", type=int, default=[])
return parser.parse_args()
def main() -> int:
args = _arguments()
contract = _load_contract(args.profile, args.speed_factor)
sampler = ResourceSampler(
owned_pids=set(args.owned_pid),
owned_process_groups=set(args.owned_pgid),
storage_root=args.storage_root,
interval_seconds=int(contract["resource_sample_interval_milliseconds"]) / 1000,
)
time_report: dict[str, Any] = {
"schema_version": TIME_SCHEMA,
"speed_factor": args.speed_factor,
"world": contract["world"],
"model": contract["model"],
"actuator_authority": False,
"contract": contract,
}
exit_code = 1
sampler.start()
try:
world = str(contract["world"])
_control(world, "pause: true")
paused_start = _wait_for_pause_state(True)
time.sleep(1.0)
paused_end = _stats_snapshot()
pause_delta = paused_end.sim_time_ns - paused_start.sim_time_ns
pause_pass = paused_end.paused and 0 <= pause_delta <= int(contract["pause_max_advance_ns"])
step_before = paused_end
_control(world, "pause: true, multi_step: 1")
step_after = _wait_for_iteration(step_before.iterations + 1)
step_delta = step_after.sim_time_ns - step_before.sim_time_ns
iteration_delta = step_after.iterations - step_before.iterations
step_pass = (
step_after.paused
and step_delta == int(contract["physics_step_ns"])
and iteration_delta == 1
)
_control(world, "pause: false")
_wait_for_pause_state(False)
time.sleep(0.75)
speed_start = _stats_snapshot()
time.sleep(int(contract["measurement_window_seconds"]))
speed_end = _stats_snapshot()
sim_delta = speed_end.sim_time_ns - speed_start.sim_time_ns
real_delta = speed_end.real_time_ns - speed_start.real_time_ns
if real_delta <= 0:
raise ProbeError("Gazebo real-time delta was not positive")
measured_rtf = sim_delta / real_delta
tolerance = int(contract["rtf_tolerance_percent"]) / 100
lower = args.speed_factor * (1 - tolerance)
upper = args.speed_factor * (1 + tolerance)
speed_pass = lower <= measured_rtf <= upper
time_report.update(
{
"pause": {
"before": asdict(paused_start),
"after": asdict(paused_end),
"observed_advance_ns": pause_delta,
"pass": pause_pass,
},
"single_step": {
"before": asdict(step_before),
"after": asdict(step_after),
"observed_advance_ns": step_delta,
"observed_iteration_delta": iteration_delta,
"pass": step_pass,
},
"speed": {
"before": asdict(speed_start),
"after": asdict(speed_end),
"simulation_advance_ns": sim_delta,
"real_advance_ns": real_delta,
"measured_rtf": measured_rtf,
"accepted_range": [lower, upper],
"pass": speed_pass,
},
}
)
time_report["verdict"] = "pass" if pause_pass and step_pass and speed_pass else "fail"
except Exception as error: # noqa: BLE001 - persist the factual probe failure
time_report["verdict"] = "fail"
time_report["error"] = f"{type(error).__name__}: {error}"
finally:
try:
_control(str(contract["world"]), "pause: false")
except Exception as resume_error: # noqa: BLE001 - report cleanup failure
time_report["resume_error"] = f"{type(resume_error).__name__}: {resume_error}"
time_report["verdict"] = "fail"
try:
sampler.stop()
resource_report = _resource_report(
samples=sampler.samples,
contract=contract,
speed_factor=args.speed_factor,
)
except Exception as resource_error: # noqa: BLE001
resource_report = {
"schema_version": RESOURCE_SCHEMA,
"speed_factor": args.speed_factor,
"actuator_authority": False,
"verdict": "fail",
"error": f"{type(resource_error).__name__}: {resource_error}",
"samples": sampler.samples,
}
_write_json(args.time_output, time_report)
_write_json(args.resource_output, resource_report)
if time_report["verdict"] == "pass" and resource_report["verdict"] == "pass":
exit_code = 0
print(
json.dumps(
{
"time_verdict": time_report["verdict"],
"resource_verdict": resource_report["verdict"],
"speed_factor": args.speed_factor,
},
sort_keys=True,
)
)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,198 +0,0 @@
schema_version: missioncore.simulation-s0-profile/v1
profile_id: ai-worker-px4-gazebo-v1
target_host:
windows_release: windows-11
virtualization: wsl2
wsl_distribution: MissionCore-Sim
linux_distribution: ubuntu-24.04
architecture: x86_64
container_runtime: none
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\simulation\wsl'
wsl_path: /mnt/d/NDC_MISSIONCORE/simulation/wsl
- 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: 0
namespace: /missioncore/polygon/s0
runtime_acceptance:
world: rover
model: rover_ackermann_0
physics_step_ns: 2000000
pause_max_advance_ns: 0
speed_factors:
- 1
- 2
rtf_tolerance_percent: 20
measurement_window_seconds: 4
resource_sample_interval_milliseconds: 500
max_owned_rss_mib: 2048
max_owned_cpu_percent: 800
minimum_available_memory_gib: 8
components:
- id: ros2
source_url: https://docs.ros.org/en/jazzy/
requested_ref: jazzy
resolved_version: ros-base=0.11.0-1noble.20260616.084325;ros-gz=1.0.22-1noble.20260616.074726
resolved_commit:
license: mixed-apache-2.0-bsd
qualification_state: accepted
- id: gazebo
source_url: https://gazebosim.org/docs/harmonic/
requested_ref: harmonic
resolved_version: gz-harmonic=1.0.0-1~noble;gz-sim=8.14.0-1~noble;sdformat=14.9.0-1~noble
resolved_commit:
license: apache-2.0
qualification_state: accepted
- 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: accepted
- 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: accepted
- id: px4-gazebo-models
source_url: https://github.com/PX4/PX4-gazebo-models
requested_ref: PX4-Autopilot/v1.17.0-submodule
resolved_version: px4-v1.17.0-submodule
resolved_commit: b6127f4ec20de867e215fb5f78ae88b80f371909
license: bsd-3-clause
qualification_state: accepted
- 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: accepted
- id: nav2
source_url: https://github.com/ros-navigation/navigation2
requested_ref: jazzy@2026-07-24
resolved_version: navigation2=1.3.12-1noble.20260615.181551;nav2-bringup=1.3.12-1noble.20260616.082701
resolved_commit: c92ea2fd9008f50c0ec8447610800214b2a0dafb
license: apache-2.0
qualification_state: accepted
ports:
- id: micro-xrce-dds
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 8888
owner: micro-xrce-dds-agent
- id: px4-mavlink-onboard
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 14580
owner: px4-sitl
- id: px4-mavlink-normal
host_scope: loopback-only-netns
protocol: udp
bind: 0.0.0.0
port: 18570
owner: px4-sitl
- id: simulation-orchestrator-api
host_scope: loopback-only-netns
protocol: tcp
bind: 127.0.0.1
port: 18080
owner: simulation-orchestrator
- id: simulation-telemetry
host_scope: loopback-only-netns
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
- gz
- make
- cmake
- ninja
- 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

View File

@ -1,296 +0,0 @@
#!/usr/bin/env bash
#
# Run the SIM S0 stock-rover smoke case inside a loopback-only network
# namespace. The script writes factual runtime artifacts to D and never grants
# actuator authority.
set -Eeuo pipefail
readonly EXPECTED_DISTRO="MissionCore-Sim"
readonly D_ROOT="${MISSIONCORE_SIM_D_ROOT:-/mnt/d/NDC_MISSIONCORE/simulation}"
readonly PX4_ROOT="${MISSIONCORE_SIM_PX4_ROOT:-${D_ROOT}/source/PX4-Autopilot}"
readonly AGENT_PREFIX="${MISSIONCORE_SIM_AGENT_PREFIX:-${D_ROOT}/runtime/micro-xrce-dds-agent/2.4.3}"
readonly PX4_MSGS_SETUP="${MISSIONCORE_SIM_PX4_MSGS_SETUP:-${D_ROOT}/build/ros2/px4_msgs/install/setup.bash}"
readonly ARTIFACT_ROOT="${MISSIONCORE_SIM_ARTIFACT_ROOT:-${D_ROOT}/artifacts/s0}"
readonly PROFILE_PATH="${MISSIONCORE_SIM_PROFILE_PATH:-${D_ROOT}/source/qualification-profile.yaml}"
readonly TIME_PROBE="${MISSIONCORE_SIM_TIME_PROBE:-${D_ROOT}/source/gazebo_time_control.py}"
readonly RUN_ID="${1:-$(date -u +%Y%m%dT%H%M%SZ)}"
readonly SPEED_FACTOR="${2:-1}"
readonly RUN_DIR="${ARTIFACT_ROOT}/${RUN_ID}"
if [[ "${SPEED_FACTOR}" != "1" && "${SPEED_FACTOR}" != "2" ]]; then
echo "error: speed factor must be one of the reviewed values: 1 or 2" >&2
exit 2
fi
if [[ "${MISSIONCORE_S0_NETNS:-0}" != "1" ]]; then
if [[ "${EUID}" -ne 0 ]]; then
echo "error: initial worker-smoke invocation must run as root to create the network namespace" >&2
exit 2
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: expected WSL distro ${EXPECTED_DISTRO}, observed ${WSL_DISTRO_NAME:-unset}" >&2
exit 2
fi
exec unshare --net -- bash -c '
set -e
ip link set lo up
exec runuser -u missioncore -- env MISSIONCORE_S0_NETNS=1 "$@"
' bash "$0" "$@"
fi
if [[ "${WSL_DISTRO_NAME:-}" != "${EXPECTED_DISTRO}" ]]; then
echo "error: smoke case escaped the reviewed WSL distribution" >&2
exit 2
fi
for required_path in "${PX4_ROOT}" "${AGENT_PREFIX}" "${PX4_MSGS_SETUP}" "${ARTIFACT_ROOT}"; do
if [[ "${required_path}" != /mnt/d/NDC_MISSIONCORE/* ]]; then
echo "error: mutable or executable path escaped D-only root: ${required_path}" >&2
exit 2
fi
done
readonly AGENT_BIN="${AGENT_PREFIX}/bin/MicroXRCEAgent"
readonly AGENT_LOG="${RUN_DIR}/micro-xrce-dds-agent.log"
readonly PX4_LOG="${RUN_DIR}/px4-rover.log"
readonly BRIDGE_LOG="${RUN_DIR}/ros-gz-clock-bridge.log"
readonly TOPICS_LOG="${RUN_DIR}/ros-topics.txt"
readonly VEHICLE_STATUS_LOG="${RUN_DIR}/vehicle-status.yaml"
readonly CLOCK_LOG="${RUN_DIR}/clock.yaml"
readonly NETWORK_LOG="${RUN_DIR}/network.txt"
readonly RESOURCE_LOG="${RUN_DIR}/resource-snapshot.txt"
readonly GZ_SERVICES_LOG="${RUN_DIR}/gazebo-services.txt"
readonly GZ_TOPICS_LOG="${RUN_DIR}/gazebo-topics.txt"
readonly TIME_CONTROL_LOG="${RUN_DIR}/time-control.json"
readonly RESOURCE_BASELINE_LOG="${RUN_DIR}/resource-baseline.json"
readonly TIME_PROBE_STDOUT="${RUN_DIR}/time-probe.stdout"
readonly RESULT_LOG="${RUN_DIR}/result.env"
for required_file in \
"${PX4_ROOT}/build/px4_sitl_default/bin/px4" \
"${PX4_ROOT}/Tools/simulation/gz/worlds/rover.sdf" \
"${AGENT_BIN}" \
"/opt/ros/jazzy/setup.bash" \
"${PX4_MSGS_SETUP}" \
"${PROFILE_PATH}" \
"${TIME_PROBE}"; do
if [[ ! -e "${required_file}" ]]; then
echo "error: required S0 runtime input is missing: ${required_file}" >&2
exit 2
fi
done
mkdir -p "${RUN_DIR}"
agent_pid=""
px4_pgid=""
bridge_pgid=""
stop_process_group() {
local pgid="${1:-}"
if [[ -z "${pgid}" ]]; then
return
fi
if kill -0 -- "-${pgid}" 2>/dev/null; then
kill -INT -- "-${pgid}" 2>/dev/null || true
for _ in {1..20}; do
if ! kill -0 -- "-${pgid}" 2>/dev/null; then
return
fi
sleep 0.25
done
kill -TERM -- "-${pgid}" 2>/dev/null || true
sleep 1
kill -KILL -- "-${pgid}" 2>/dev/null || true
fi
}
cleanup() {
set +e
exec 3>&-
stop_process_group "${bridge_pgid}"
stop_process_group "${px4_pgid}"
if [[ -n "${agent_pid}" ]] && kill -0 "${agent_pid}" 2>/dev/null; then
kill -TERM "${agent_pid}" 2>/dev/null
wait "${agent_pid}" 2>/dev/null
fi
}
trap cleanup EXIT INT TERM
wait_for_pattern() {
local file="$1"
local pattern="$2"
local attempts="$3"
for ((attempt = 0; attempt < attempts; attempt += 1)); do
if grep -Fq "${pattern}" "${file}" 2>/dev/null; then
return 0
fi
sleep 0.5
done
return 1
}
env \
LD_LIBRARY_PATH="${AGENT_PREFIX}/lib" \
"${AGENT_BIN}" udp4 -p 8888 -v 4 >"${AGENT_LOG}" 2>&1 &
agent_pid="$!"
sleep 1
if ! kill -0 "${agent_pid}" 2>/dev/null; then
echo "error: Micro XRCE-DDS Agent exited during startup" >&2
exit 1
fi
{
echo "network_namespace=loopback-only"
ip -brief address
ss -lunp
} >"${NETWORK_LOG}"
coproc PX4_PROCESS {
exec setsid env HEADLESS=1 PX4_SIM_SPEED_FACTOR="${SPEED_FACTOR}" make -C "${PX4_ROOT}" \
px4_sitl gz_rover_ackermann >"${PX4_LOG}" 2>&1
}
px4_pgid="${PX4_PROCESS_PID}"
exec 3>&"${PX4_PROCESS[1]}"
if ! wait_for_pattern "${PX4_LOG}" "Startup script returned successfully" 120; then
echo "error: PX4 stock rover did not reach startup completion" >&2
exit 1
fi
sleep 3
printf '%s\n' \
"ver all" \
"commander status" >&3
if ! wait_for_pattern "${AGENT_LOG}" "session established" 60 \
|| ! wait_for_pattern \
"${PX4_LOG}" \
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
60; then
echo "error: PX4 uXRCE-DDS client did not connect to the loopback agent" >&2
exit 1
fi
printf '%s\n' "uxrce_dds_client status" >&3
if ! wait_for_pattern "${PX4_LOG}" "Running, connected" 20; then
echo "error: PX4 did not report the established uXRCE-DDS session" >&2
exit 1
fi
{
echo
echo "runtime_listeners=loopback-only-netns"
ss -lunp
} >>"${NETWORK_LOG}"
gz service -l >"${GZ_SERVICES_LOG}"
gz topic -l >"${GZ_TOPICS_LOG}"
set +u
# shellcheck source=/dev/null
source /opt/ros/jazzy/setup.bash
# shellcheck source=/dev/null
source "${PX4_MSGS_SETUP}"
set -u
export ROS_DOMAIN_ID=0
export ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
unset ROS_LOCALHOST_ONLY
setsid ros2 run ros_gz_bridge parameter_bridge \
'/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock' >"${BRIDGE_LOG}" 2>&1 &
bridge_pgid="$!"
timeout 20s ros2 topic list -t >"${TOPICS_LOG}"
vehicle_status_topic="/fmu/out/vehicle_status"
if grep -Fq "/fmu/out/vehicle_status_v1" "${TOPICS_LOG}"; then
vehicle_status_topic="/fmu/out/vehicle_status_v1"
fi
timeout 20s ros2 topic echo --once \
"${vehicle_status_topic}" px4_msgs/msg/VehicleStatus >"${VEHICLE_STATUS_LOG}"
timeout 20s ros2 topic echo --once \
/clock rosgraph_msgs/msg/Clock >"${CLOCK_LOG}"
python3 "${TIME_PROBE}" \
--profile "${PROFILE_PATH}" \
--speed-factor "${SPEED_FACTOR}" \
--time-output "${TIME_CONTROL_LOG}" \
--resource-output "${RESOURCE_BASELINE_LOG}" \
--storage-root "${D_ROOT}" \
--owned-pid "${agent_pid}" \
--owned-pgid "${px4_pgid}" \
--owned-pgid "${bridge_pgid}" >"${TIME_PROBE_STDOUT}"
{
echo "sample=non-acceptance-point-snapshot"
free -b
ps -g "${px4_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
ps -p "${agent_pid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
ps -g "${bridge_pgid}" -o pid,ppid,pgid,rss,vsz,pcpu,comm,args
if command -v nvidia-smi >/dev/null; then
nvidia-smi \
--query-gpu=memory.used,utilization.gpu \
--format=csv,noheader,nounits
fi
} >"${RESOURCE_LOG}"
printf '%s\n' "shutdown" >&3
if ! wait_for_pattern "${PX4_LOG}" "Exiting NOW." 20; then
echo "error: PX4 did not acknowledge the shutdown command" >&2
exit 1
fi
cleanup
trap - EXIT INT TERM
px4_status="fail"
dds_status="fail"
vehicle_status="fail"
clock_status="fail"
time_control_status="fail"
resource_baseline_status="fail"
lifecycle_status="fail"
grep -Fq "world: rover, model: rover_ackermann_0" "${PX4_LOG}" && px4_status="pass"
if grep -Fq "session established" "${AGENT_LOG}" \
&& grep -Fq \
"successfully created rt/fmu/out/vehicle_status_v1 data writer" \
"${PX4_LOG}"; then
dds_status="pass"
fi
grep -Fq "arming_state:" "${VEHICLE_STATUS_LOG}" && vehicle_status="pass"
grep -Fq "clock:" "${CLOCK_LOG}" && clock_status="pass"
grep -Fq '"verdict": "pass"' "${TIME_CONTROL_LOG}" && time_control_status="pass"
grep -Fq '"verdict": "pass"' "${RESOURCE_BASELINE_LOG}" \
&& resource_baseline_status="pass"
if ! pgrep -f "${PX4_ROOT}/build/px4_sitl_default/bin/px4" >/dev/null \
&& ! pgrep -f "gz sim.*rover.sdf" >/dev/null \
&& ! pgrep -f "${AGENT_BIN}" >/dev/null; then
lifecycle_status="pass"
fi
{
echo "run_id=${RUN_ID}"
echo "network_scope=loopback-only-netns"
echo "actuator_authority=false"
echo "speed_factor=${SPEED_FACTOR}"
echo "px4_stock_rover=${px4_status}"
echo "uxrce_dds=${dds_status}"
echo "ros2_vehicle_status=${vehicle_status}"
echo "gazebo_clock=${clock_status}"
echo "pause_step_speed=${time_control_status}"
echo "resource_baseline=${resource_baseline_status}"
echo "clean_lifecycle=${lifecycle_status}"
} >"${RESULT_LOG}"
if [[ "${px4_status}" != "pass" \
|| "${dds_status}" != "pass" \
|| "${vehicle_status}" != "pass" \
|| "${clock_status}" != "pass" \
|| "${time_control_status}" != "pass" \
|| "${resource_baseline_status}" != "pass" \
|| "${lifecycle_status}" != "pass" ]]; then
echo "error: one or more S0 smoke checks failed; inspect ${RUN_DIR}" >&2
exit 1
fi
echo "SIM S0 smoke PASS: ${RUN_DIR}"

View File

@ -1,23 +0,0 @@
"""Mission Core qualification and simulation boundaries."""
from k1link.simulation.s0 import (
CheckStatus,
DoctorVerdict,
RuntimeAcceptance,
S0DoctorReport,
S0Profile,
S0ProfileError,
load_s0_profile,
run_s0_doctor,
)
__all__ = [
"CheckStatus",
"DoctorVerdict",
"RuntimeAcceptance",
"S0DoctorReport",
"S0Profile",
"S0ProfileError",
"load_s0_profile",
"run_s0_doctor",
]

View File

@ -1,98 +0,0 @@
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()

File diff suppressed because it is too large Load Diff

View File

@ -1,268 +0,0 @@
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.target_host.wsl_distribution == "MissionCore-Sim"
assert profile.target_host.container_runtime == "none"
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 == 0
assert profile.runtime_acceptance.world == "rover"
assert profile.runtime_acceptance.model == "rover_ackermann_0"
assert profile.runtime_acceptance.physics_step_ns == 2_000_000
assert profile.runtime_acceptance.pause_max_advance_ns == 0
assert profile.runtime_acceptance.speed_factors == (1, 2)
assert profile.runtime_acceptance.rtf_tolerance_percent == 20
assert "docker" not in profile.required_tools
assert {item.identifier for item in profile.components} >= {
"px4-autopilot",
"px4-msgs",
"gazebo",
"nav2",
}
components = {item.identifier: item for item in profile.components}
assert components["px4-gazebo-models"].resolved_commit == (
"b6127f4ec20de867e215fb5f78ae88b80f371909"
)
assert {component.qualification_state for component in components.values()} == {"accepted"}
micro_xrce = next(item for item in profile.ports if item.identifier == "micro-xrce-dds")
assert micro_xrce.host_scope == "loopback-only-netns"
assert micro_xrce.bind == "0.0.0.0"
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_profile_rejects_wildcard_bind_outside_loopback_namespace(
tmp_path: Path,
) -> None:
document = _profile_document()
ports = document["ports"]
assert isinstance(ports, list)
first = ports[0]
assert isinstance(first, dict)
first["host_scope"] = "worker-wsl"
with pytest.raises(S0ProfileError, match="wildcard bind requires loopback-only-netns"):
load_s0_profile(_write_profile(tmp_path, document))
def test_profile_rejects_runtime_speed_contract_drift(tmp_path: Path) -> None:
document = _profile_document()
runtime = document["runtime_acceptance"]
assert isinstance(runtime, dict)
runtime["speed_factors"] = [1, 4]
with pytest.raises(S0ProfileError, match="exactly the 1x and 2x"):
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