feat(simulation): add S1 qualification run repository
This commit is contained in:
@@ -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")
|
||||
Reference in New Issue
Block a user