fix(simulation): persist command authority provenance
This commit is contained in:
parent
fc8c81dbc4
commit
12fba7126d
|
|
@ -3,6 +3,7 @@
|
||||||
from k1link.simulation.contracts import (
|
from k1link.simulation.contracts import (
|
||||||
AckermannControlSetpoint,
|
AckermannControlSetpoint,
|
||||||
AuthorityProfile,
|
AuthorityProfile,
|
||||||
|
CommandAuthorityScope,
|
||||||
ControlProfile,
|
ControlProfile,
|
||||||
ControlSetpoint,
|
ControlSetpoint,
|
||||||
DifferentialControlSetpoint,
|
DifferentialControlSetpoint,
|
||||||
|
|
@ -38,6 +39,7 @@ __all__ = [
|
||||||
"AckermannControlSetpoint",
|
"AckermannControlSetpoint",
|
||||||
"AuthorityProfile",
|
"AuthorityProfile",
|
||||||
"CheckStatus",
|
"CheckStatus",
|
||||||
|
"CommandAuthorityScope",
|
||||||
"ControlProfile",
|
"ControlProfile",
|
||||||
"ControlSetpoint",
|
"ControlSetpoint",
|
||||||
"DifferentialControlSetpoint",
|
"DifferentialControlSetpoint",
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ class ControlProfile(StrEnum):
|
||||||
ROVER_SPEED_YAW_RATE_V1 = "rover-speed-yaw-rate/v1"
|
ROVER_SPEED_YAW_RATE_V1 = "rover-speed-yaw-rate/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class CommandAuthorityScope(StrEnum):
|
||||||
|
VIRTUAL_ONLY = "virtual-only"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ProviderPin:
|
class ProviderPin:
|
||||||
identifier: str
|
identifier: str
|
||||||
|
|
@ -418,6 +422,8 @@ class AckermannControlSetpoint:
|
||||||
speed_mps: float
|
speed_mps: float
|
||||||
steering_normalized: float
|
steering_normalized: float
|
||||||
profile: ControlProfile = ControlProfile.ROVER_SPEED_STEERING_V1
|
profile: ControlProfile = ControlProfile.ROVER_SPEED_STEERING_V1
|
||||||
|
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
|
||||||
|
source: str = "simulation-orchestrator"
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_command_header(
|
_command_header(
|
||||||
|
|
@ -434,6 +440,7 @@ class AckermannControlSetpoint:
|
||||||
raise SimulationContractError("normalized steering must be within -1..1")
|
raise SimulationContractError("normalized steering must be within -1..1")
|
||||||
if self.profile is not ControlProfile.ROVER_SPEED_STEERING_V1:
|
if self.profile is not ControlProfile.ROVER_SPEED_STEERING_V1:
|
||||||
raise SimulationContractError("Ackermann command has an incompatible profile")
|
raise SimulationContractError("Ackermann command has an incompatible profile")
|
||||||
|
_command_provenance(self.authority_scope, self.source)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -442,6 +449,8 @@ class AckermannControlSetpoint:
|
||||||
"command_id": self.command_id,
|
"command_id": self.command_id,
|
||||||
"sequence": self.sequence,
|
"sequence": self.sequence,
|
||||||
"profile": self.profile.value,
|
"profile": self.profile.value,
|
||||||
|
"authority_scope": self.authority_scope.value,
|
||||||
|
"source": self.source,
|
||||||
"issued_at_sim_ns": self.issued_at_sim_ns,
|
"issued_at_sim_ns": self.issued_at_sim_ns,
|
||||||
"valid_until_sim_ns": self.valid_until_sim_ns,
|
"valid_until_sim_ns": self.valid_until_sim_ns,
|
||||||
"authority_generation": self.authority_generation,
|
"authority_generation": self.authority_generation,
|
||||||
|
|
@ -461,6 +470,8 @@ class DifferentialControlSetpoint:
|
||||||
speed_mps: float
|
speed_mps: float
|
||||||
yaw_rate_rps: float
|
yaw_rate_rps: float
|
||||||
profile: ControlProfile = ControlProfile.ROVER_SPEED_YAW_RATE_V1
|
profile: ControlProfile = ControlProfile.ROVER_SPEED_YAW_RATE_V1
|
||||||
|
authority_scope: CommandAuthorityScope = CommandAuthorityScope.VIRTUAL_ONLY
|
||||||
|
source: str = "simulation-orchestrator"
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_command_header(
|
_command_header(
|
||||||
|
|
@ -475,6 +486,7 @@ class DifferentialControlSetpoint:
|
||||||
_finite(self.yaw_rate_rps, "yaw rate")
|
_finite(self.yaw_rate_rps, "yaw rate")
|
||||||
if self.profile is not ControlProfile.ROVER_SPEED_YAW_RATE_V1:
|
if self.profile is not ControlProfile.ROVER_SPEED_YAW_RATE_V1:
|
||||||
raise SimulationContractError("differential command has an incompatible profile")
|
raise SimulationContractError("differential command has an incompatible profile")
|
||||||
|
_command_provenance(self.authority_scope, self.source)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, object]:
|
def to_dict(self) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -483,6 +495,8 @@ class DifferentialControlSetpoint:
|
||||||
"command_id": self.command_id,
|
"command_id": self.command_id,
|
||||||
"sequence": self.sequence,
|
"sequence": self.sequence,
|
||||||
"profile": self.profile.value,
|
"profile": self.profile.value,
|
||||||
|
"authority_scope": self.authority_scope.value,
|
||||||
|
"source": self.source,
|
||||||
"issued_at_sim_ns": self.issued_at_sim_ns,
|
"issued_at_sim_ns": self.issued_at_sim_ns,
|
||||||
"valid_until_sim_ns": self.valid_until_sim_ns,
|
"valid_until_sim_ns": self.valid_until_sim_ns,
|
||||||
"authority_generation": self.authority_generation,
|
"authority_generation": self.authority_generation,
|
||||||
|
|
@ -500,8 +514,9 @@ def control_setpoint_from_dict(value: object) -> ControlSetpoint:
|
||||||
raise SimulationContractError("unsupported control-setpoint schema")
|
raise SimulationContractError("unsupported control-setpoint schema")
|
||||||
try:
|
try:
|
||||||
profile = ControlProfile(_string(document, "profile"))
|
profile = ControlProfile(_string(document, "profile"))
|
||||||
|
authority_scope = CommandAuthorityScope(_string(document, "authority_scope"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise SimulationContractError("unknown control profile") from exc
|
raise SimulationContractError("unknown control profile or authority scope") from exc
|
||||||
run_id = _string(document, "run_id")
|
run_id = _string(document, "run_id")
|
||||||
command_id = _string(document, "command_id")
|
command_id = _string(document, "command_id")
|
||||||
sequence = _integer(document, "sequence")
|
sequence = _integer(document, "sequence")
|
||||||
|
|
@ -519,6 +534,8 @@ def control_setpoint_from_dict(value: object) -> ControlSetpoint:
|
||||||
authority_generation=authority_generation,
|
authority_generation=authority_generation,
|
||||||
speed_mps=speed_mps,
|
speed_mps=speed_mps,
|
||||||
steering_normalized=_number(document, "steering_normalized"),
|
steering_normalized=_number(document, "steering_normalized"),
|
||||||
|
authority_scope=authority_scope,
|
||||||
|
source=_string(document, "source"),
|
||||||
)
|
)
|
||||||
return DifferentialControlSetpoint(
|
return DifferentialControlSetpoint(
|
||||||
run_id=run_id,
|
run_id=run_id,
|
||||||
|
|
@ -529,6 +546,8 @@ def control_setpoint_from_dict(value: object) -> ControlSetpoint:
|
||||||
authority_generation=authority_generation,
|
authority_generation=authority_generation,
|
||||||
speed_mps=speed_mps,
|
speed_mps=speed_mps,
|
||||||
yaw_rate_rps=_number(document, "yaw_rate_rps"),
|
yaw_rate_rps=_number(document, "yaw_rate_rps"),
|
||||||
|
authority_scope=authority_scope,
|
||||||
|
source=_string(document, "source"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -552,6 +571,12 @@ def _command_header(
|
||||||
raise SimulationContractError("command authority generation must be positive")
|
raise SimulationContractError("command authority generation must be positive")
|
||||||
|
|
||||||
|
|
||||||
|
def _command_provenance(authority_scope: CommandAuthorityScope, source: str) -> None:
|
||||||
|
if authority_scope is not CommandAuthorityScope.VIRTUAL_ONLY:
|
||||||
|
raise SimulationContractError("S1 commands require virtual-only authority scope")
|
||||||
|
_identifier(source, "command source")
|
||||||
|
|
||||||
|
|
||||||
def _identifier(value: str, label: str) -> str:
|
def _identifier(value: str, label: str) -> str:
|
||||||
if not IDENTIFIER_PATTERN.fullmatch(value):
|
if not IDENTIFIER_PATTERN.fullmatch(value):
|
||||||
raise SimulationContractError(f"{label} is not a safe identifier")
|
raise SimulationContractError(f"{label} is not a safe identifier")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import pytest
|
||||||
from k1link.simulation import (
|
from k1link.simulation import (
|
||||||
AckermannControlSetpoint,
|
AckermannControlSetpoint,
|
||||||
AuthorityProfile,
|
AuthorityProfile,
|
||||||
|
CommandAuthorityScope,
|
||||||
DifferentialControlSetpoint,
|
DifferentialControlSetpoint,
|
||||||
ProviderPin,
|
ProviderPin,
|
||||||
QualificationArtifact,
|
QualificationArtifact,
|
||||||
|
|
@ -223,6 +224,8 @@ def test_ackermann_and_differential_commands_are_typed_and_sequenced(
|
||||||
store.submit_command(second)
|
store.submit_command(second)
|
||||||
|
|
||||||
assert store.list_commands("run-s1-001") == (first, second)
|
assert store.list_commands("run-s1-001") == (first, second)
|
||||||
|
assert first.to_dict()["authority_scope"] == "virtual-only"
|
||||||
|
assert first.to_dict()["source"] == "simulation-orchestrator"
|
||||||
|
|
||||||
|
|
||||||
def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
|
def test_commands_fail_closed_on_state_sequence_authority_and_ttl(tmp_path: Path) -> None:
|
||||||
|
|
@ -280,6 +283,21 @@ def test_command_contract_rejects_nonfinite_and_direct_actuator_authority() -> N
|
||||||
heartbeat_timeout_monotonic_ns=100,
|
heartbeat_timeout_monotonic_ns=100,
|
||||||
direct_actuator_setpoints_allowed=True,
|
direct_actuator_setpoints_allowed=True,
|
||||||
)
|
)
|
||||||
|
with pytest.raises(SimulationContractError, match="virtual-only"):
|
||||||
|
replace(
|
||||||
|
AckermannControlSetpoint(
|
||||||
|
run_id="run-s1-001",
|
||||||
|
command_id="command-scope",
|
||||||
|
sequence=1,
|
||||||
|
issued_at_sim_ns=0,
|
||||||
|
valid_until_sim_ns=100,
|
||||||
|
authority_generation=1,
|
||||||
|
speed_mps=0.0,
|
||||||
|
steering_normalized=0.0,
|
||||||
|
),
|
||||||
|
authority_scope="physical", # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
assert CommandAuthorityScope.VIRTUAL_ONLY.value == "virtual-only"
|
||||||
with pytest.raises(SimulationContractError, match="valid explicit UTC"):
|
with pytest.raises(SimulationContractError, match="valid explicit UTC"):
|
||||||
replace(_run(), created_at_utc="2026-02-30T17:00:00Z")
|
replace(_run(), created_at_utc="2026-02-30T17:00:00Z")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue