feat(simulation): add S1 qualification run repository

This commit is contained in:
DCCONSTRUCTIONS
2026-07-24 17:37:55 +03:00
parent a19053dfd4
commit fc8c81dbc4
8 changed files with 1764 additions and 5 deletions
+409
View File
@@ -0,0 +1,409 @@
from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
import pytest
from k1link.simulation import (
AckermannControlSetpoint,
AuthorityProfile,
DifferentialControlSetpoint,
ProviderPin,
QualificationArtifact,
QualificationRun,
QualificationRunConflictError,
QualificationRunIntegrityError,
QualificationRunStore,
QualificationRunTransitionError,
ReproducibilityTier,
RunKind,
RunState,
SimulationContractError,
)
SHA_A = "a" * 64
SHA_B = "b" * 64
SHA_C = "c" * 64
def _run(
*,
run_id: str = "run-s1-001",
episode_id: str = "episode-s1-001",
kind: RunKind = RunKind.SIMULATION_CLOSED_LOOP,
) -> QualificationRun:
return QualificationRun(
run_id=run_id,
episode_id=episode_id,
kind=kind,
state=RunState.ADMITTED,
scenario_generation="stock-rover-v1",
scenario_sha256=SHA_A,
profile_generation="s1-ackermann-v1",
profile_sha256=SHA_B,
mission_core_commit="a19053dfd47577fb66cc63f605ba29ea2314f6aa",
providers=(
ProviderPin(
identifier="px4-autopilot",
version="v1.17.0",
revision="v1.17.0",
),
ProviderPin(
identifier="gazebo",
version="harmonic",
revision="8.9.0",
),
),
host_profile_id="mission-gpu-s0",
host_profile_sha256=SHA_C,
seed=42,
reproducibility_tier=ReproducibilityTier.R1,
authority=AuthorityProfile(
generation=1,
command_ttl_max_ns=250_000_000,
heartbeat_timeout_monotonic_ns=500_000_000,
),
clock_domain="gazebo:/clock",
created_at_utc="2026-07-24T17:00:00Z",
)
def _start(store: QualificationRunStore, run_id: str = "run-s1-001") -> QualificationRun:
starting = store.transition(
run_id,
RunState.STARTING,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:01Z",
host_monotonic_ns=1,
)
return store.transition(
run_id,
RunState.RUNNING,
expected_revision=starting.revision,
observed_at_utc="2026-07-24T17:00:02Z",
host_monotonic_ns=2,
sim_time_ns=0,
)
def test_run_manifest_is_immutable_and_runtime_replays_from_events(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path / "runs")
created = store.create(_run())
assert created.state is RunState.ADMITTED
manifest_before = (tmp_path / "runs/run-s1-001/manifest.json").read_bytes()
running = _start(store)
restored = QualificationRunStore(tmp_path / "runs").load(running.run_id)
assert restored.state is RunState.RUNNING
assert restored.revision == 2
assert restored.started_at_utc == "2026-07-24T17:00:02Z"
assert (tmp_path / "runs/run-s1-001/manifest.json").read_bytes() == manifest_before
assert len(tuple((tmp_path / "runs/run-s1-001/events").iterdir())) == 2
def test_lifecycle_rejects_stale_illegal_and_post_terminal_changes(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
with pytest.raises(QualificationRunTransitionError, match="not allowed"):
store.transition(
"run-s1-001",
RunState.RUNNING,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:01Z",
host_monotonic_ns=1,
)
running = _start(store)
with pytest.raises(QualificationRunConflictError, match="revision"):
store.transition(
running.run_id,
RunState.PAUSED,
expected_revision=0,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
stopping = store.transition(
running.run_id,
RunState.STOPPING,
expected_revision=running.revision,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
completed = store.transition(
running.run_id,
RunState.COMPLETED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
reason="goal-reached",
)
assert completed.terminal_reason == "goal-reached"
assert completed.ended_at_utc == "2026-07-24T17:00:04Z"
with pytest.raises(QualificationRunTransitionError, match="immutable"):
store.append_event(
completed.run_id,
event_type="metric.observed",
observed_at_utc="2026-07-24T17:00:05Z",
host_monotonic_ns=5,
payload={"name": "distance", "value": 1.0},
)
def test_pause_resume_and_domain_event_share_monotonic_journal(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
running = _start(store)
event = store.append_event(
running.run_id,
event_type="watchdog.heartbeat",
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
sim_time_ns=2_000_000,
payload={"authority_generation": 1},
expected_revision=2,
)
paused = store.transition(
running.run_id,
RunState.PAUSED,
expected_revision=event.sequence,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
sim_time_ns=2_000_000,
)
resumed = store.transition(
running.run_id,
RunState.RUNNING,
expected_revision=paused.revision,
observed_at_utc="2026-07-24T17:00:05Z",
host_monotonic_ns=5,
sim_time_ns=2_000_000,
)
assert event.sequence == 3
assert resumed.revision == 5
assert resumed.started_at_utc == "2026-07-24T17:00:02Z"
def test_ackermann_and_differential_commands_are_typed_and_sequenced(
tmp_path: Path,
) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
first = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=10,
valid_until_sim_ns=200_000_010,
authority_generation=1,
speed_mps=1.5,
steering_normalized=-0.25,
)
second = DifferentialControlSetpoint(
run_id="run-s1-001",
command_id="command-2",
sequence=2,
issued_at_sim_ns=20,
valid_until_sim_ns=200_000_020,
authority_generation=1,
speed_mps=0.5,
yaw_rate_rps=0.2,
)
store.submit_command(first)
store.submit_command(second)
assert store.list_commands("run-s1-001") == (first, second)
def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
command = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=0.0,
)
with pytest.raises(QualificationRunTransitionError, match="only while"):
store.submit_command(command)
_start(store)
with pytest.raises(QualificationRunConflictError, match="authority"):
store.submit_command(replace(command, authority_generation=2))
with pytest.raises(QualificationRunConflictError, match="sequence"):
store.submit_command(replace(command, sequence=2))
with pytest.raises(QualificationRunTransitionError, match="TTL"):
store.submit_command(replace(command, valid_until_sim_ns=250_000_001))
def test_command_contract_rejects_nonfinite_and_direct_actuator_authority() -> None:
with pytest.raises(SimulationContractError, match="finite"):
AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=float("nan"),
steering_normalized=0.0,
)
with pytest.raises(SimulationContractError, match="-1..1"):
AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=1.1,
)
with pytest.raises(SimulationContractError, match="without actuator"):
AuthorityProfile(
generation=1,
command_ttl_max_ns=100,
heartbeat_timeout_monotonic_ns=100,
direct_actuator_setpoints_allowed=True,
)
with pytest.raises(SimulationContractError, match="valid explicit UTC"):
replace(_run(), created_at_utc="2026-02-30T17:00:00Z")
def test_replay_and_ungated_hil_cannot_acquire_command_authority(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run(kind=RunKind.REPLAY_SHADOW))
_start(store)
command = AckermannControlSetpoint(
run_id="run-s1-001",
command_id="command-1",
sequence=1,
issued_at_sim_ns=0,
valid_until_sim_ns=100,
authority_generation=1,
speed_mps=1.0,
steering_normalized=0.0,
)
with pytest.raises(QualificationRunTransitionError, match="cannot causally"):
store.submit_command(command)
with pytest.raises(SimulationContractError, match="separate physical"):
_run(kind=RunKind.HIL)
def test_artifact_index_is_append_only_confined_and_digest_bound(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
admitted = store.register_artifact(
"run-s1-001",
QualificationArtifact(
artifact_id="run-manifest-copy",
kind="source.manifest",
relative_path="source/run-manifest.json",
sha256=SHA_A,
byte_length=123,
source_of_record=True,
),
)
assert admitted.sequence == 1
assert store.load("run-s1-001").artifacts == (admitted,)
with pytest.raises(QualificationRunConflictError, match="already registered"):
store.register_artifact("run-s1-001", admitted)
with pytest.raises(SimulationContractError, match="confined"):
QualificationArtifact(
artifact_id="escape",
kind="source.log",
relative_path="../outside.log",
sha256=SHA_A,
byte_length=1,
source_of_record=True,
)
with pytest.raises(SimulationContractError, match="confined"):
replace(admitted, artifact_id="noncanonical", relative_path="source//artifact.json")
def test_reset_creates_new_episode_identity_without_overwriting_parent(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
running = _start(store)
stopping = store.transition(
running.run_id,
RunState.STOPPING,
expected_revision=running.revision,
observed_at_utc="2026-07-24T17:00:03Z",
host_monotonic_ns=3,
)
store.transition(
running.run_id,
RunState.ABORTED,
expected_revision=stopping.revision,
observed_at_utc="2026-07-24T17:00:04Z",
host_monotonic_ns=4,
reason="operator-reset",
)
reset = store.create_reset_episode(
running.run_id,
run_id="run-s1-002",
episode_id="episode-s1-002",
created_at_utc="2026-07-24T17:00:05Z",
)
assert reset.parent_run_id == "run-s1-001"
assert reset.state is RunState.ADMITTED
assert store.load("run-s1-001").state is RunState.ABORTED
assert {item.run_id for item in store.list_runs()} == {"run-s1-001", "run-s1-002"}
def test_restart_reconciliation_fails_active_run_without_hidden_resume(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
recovered = QualificationRunStore(tmp_path).reconcile_interrupted(
"run-s1-001",
observed_at_utc="2026-07-24T17:01:00Z",
host_monotonic_ns=1_000,
)
assert recovered.state is RunState.FAILED
assert recovered.terminal_reason == "orchestrator-recovery-interrupted"
assert len(QualificationRunStore(tmp_path).list_commands(recovered.run_id)) == 0
def test_corrupt_or_noncontiguous_journal_fails_closed(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
_start(store)
events = tmp_path / "run-s1-001/events"
(events / "00000000000000000002.json").rename(events / "00000000000000000003.json")
with pytest.raises(QualificationRunIntegrityError, match="contiguous"):
store.load("run-s1-001")
def test_manifest_identity_tampering_is_detected(tmp_path: Path) -> None:
store = QualificationRunStore(tmp_path)
store.create(_run())
manifest = tmp_path / "run-s1-001/manifest.json"
document = json.loads(manifest.read_text(encoding="utf-8"))
document["run_id"] = "other-run"
manifest.write_text(json.dumps(document), encoding="utf-8")
with pytest.raises(QualificationRunIntegrityError, match="identity"):
store.load("run-s1-001")