Add motor verdict enforcing gate
This commit is contained in:
parent
c5da00107e
commit
d67c83b794
3 changed files with 347 additions and 2 deletions
47
docs/decisions/ADR-0216-motor-verdict-lowering.md
Normal file
47
docs/decisions/ADR-0216-motor-verdict-lowering.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# ADR-0216: Motor Verdict Lowering Prerequisite
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Domains:** `sensorium/efferent.py`, `sensorium/registry.py`, future motor packs
|
||||||
|
**Depends on:** ADR-0198, ADR-0211
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0198 added the `ModalityRegistry.decode()` efferent path and intentionally
|
||||||
|
left real motor emission fail-closed. The current `DefaultEfferentGate` checks
|
||||||
|
capability and vector shape only; it does not lower a motor versor into the
|
||||||
|
safety, ethics, and tool-scope verdicts required for real action.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
No physical motor decode is authorized until a dedicated implementation provides
|
||||||
|
a verdict-enforcing gate:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(32,) motor versor
|
||||||
|
-> MotorActionIntent
|
||||||
|
-> authority + safety + ethics + tool-scope verdicts
|
||||||
|
-> admit/refuse before SurfaceDecoder.decode()
|
||||||
|
```
|
||||||
|
|
||||||
|
`MotorActionIntent` is a semantic action predicate bundle, not an actuator
|
||||||
|
command. A `VerdictEnforcingEfferentGate` must expose
|
||||||
|
`enforces_action_verdicts=True` and fail closed on absent authority, absent
|
||||||
|
verdict coverage, or any refusal.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- No actuator driver.
|
||||||
|
- No robot interface.
|
||||||
|
- No trajectory executor.
|
||||||
|
- No sandbox opt-in on a physical path.
|
||||||
|
- No ObservationFrame or CRDT write for emitted motor commands.
|
||||||
|
|
||||||
|
## Required Proof
|
||||||
|
|
||||||
|
- The gate refuses before decoder invocation on missing authority or failed
|
||||||
|
verdicts.
|
||||||
|
- Traces include hashes, authority hash, policy hash, and verdict only.
|
||||||
|
- No decoded command payload, trajectory, ndarray, or bytes object enters traces.
|
||||||
|
- Physical action remains disabled until a later motor decoder ADR and lab
|
||||||
|
safety contract.
|
||||||
|
|
@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from sensorium.audio.checksum import sha256_json
|
from sensorium.audio.checksum import sha256_array, sha256_json
|
||||||
from sensorium.protocol import CL41_DIM, AuthorityToken, EfferentVerdict
|
from sensorium.protocol import CL41_DIM, AuthorityToken, EfferentVerdict
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -34,6 +34,91 @@ class EfferentEmissionTrace:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MotorActionIntent:
|
||||||
|
"""Hash-only semantic lowering of a motor versor into an action predicate."""
|
||||||
|
|
||||||
|
pack_id: str
|
||||||
|
predicate_id: str
|
||||||
|
vector_sha256: str
|
||||||
|
intent_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"pack_id": self.pack_id,
|
||||||
|
"predicate_id": self.predicate_id,
|
||||||
|
"vector_sha256": self.vector_sha256,
|
||||||
|
"intent_sha256": self.intent_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ActionVerdictRecord:
|
||||||
|
"""One pre-decode verdict over a lowered motor intent."""
|
||||||
|
|
||||||
|
intent_sha256: str
|
||||||
|
verdict_type: str
|
||||||
|
admitted: bool
|
||||||
|
reason: str
|
||||||
|
policy_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"intent_sha256": self.intent_sha256,
|
||||||
|
"verdict_type": self.verdict_type,
|
||||||
|
"admitted": self.admitted,
|
||||||
|
"reason": self.reason,
|
||||||
|
"policy_sha256": self.policy_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MotorVerdictTrace:
|
||||||
|
"""Trace-safe motor gate decision; no decoded payload or trajectory."""
|
||||||
|
|
||||||
|
pack_id: str
|
||||||
|
intent_sha256: str
|
||||||
|
admitted: bool
|
||||||
|
reason: str
|
||||||
|
authority_sha256: str
|
||||||
|
policy_sha256: str
|
||||||
|
required_verdicts: tuple[str, ...]
|
||||||
|
trace_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"pack_id": self.pack_id,
|
||||||
|
"intent_sha256": self.intent_sha256,
|
||||||
|
"admitted": self.admitted,
|
||||||
|
"reason": self.reason,
|
||||||
|
"authority_sha256": self.authority_sha256,
|
||||||
|
"policy_sha256": self.policy_sha256,
|
||||||
|
"required_verdicts": list(self.required_verdicts),
|
||||||
|
"trace_sha256": self.trace_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lower_motor_action_intent(pack_id: str, mv: np.ndarray) -> MotorActionIntent:
|
||||||
|
"""Lower a motor versor to a semantic predicate, not an actuator command."""
|
||||||
|
vec = np.asarray(mv, dtype=np.float32)
|
||||||
|
if vec.shape != (CL41_DIM,):
|
||||||
|
raise ValueError(f"invalid motor vector shape: {vec.shape}")
|
||||||
|
vector_sha256 = sha256_array(vec)
|
||||||
|
predicate_id = f"motor.intent.{vector_sha256[:16]}"
|
||||||
|
payload = {
|
||||||
|
"kind": "MotorActionIntent",
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"predicate_id": predicate_id,
|
||||||
|
"vector_sha256": vector_sha256,
|
||||||
|
}
|
||||||
|
return MotorActionIntent(
|
||||||
|
pack_id=pack_id,
|
||||||
|
predicate_id=predicate_id,
|
||||||
|
vector_sha256=vector_sha256,
|
||||||
|
intent_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class DefaultEfferentGate:
|
class DefaultEfferentGate:
|
||||||
"""Capability-scoped efferent gate.
|
"""Capability-scoped efferent gate.
|
||||||
|
|
@ -111,3 +196,124 @@ class DefaultEfferentGate:
|
||||||
capability=capability,
|
capability=capability,
|
||||||
trace_sha256=sha256_json(payload),
|
trace_sha256=sha256_json(payload),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VerdictEnforcingEfferentGate:
|
||||||
|
"""ADR-0198 §3 gate: authority plus explicit action verdict coverage."""
|
||||||
|
|
||||||
|
action_verdicts: tuple[ActionVerdictRecord, ...] = ()
|
||||||
|
policy_id: str = "verdict-enforcing-efferent-v1"
|
||||||
|
required_verdicts: tuple[str, ...] = ("safety", "ethics", "tool_scope")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enforces_action_verdicts(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def policy_sha256(self) -> str:
|
||||||
|
return sha256_json({
|
||||||
|
"policy_id": self.policy_id,
|
||||||
|
"required_verdicts": list(self.required_verdicts),
|
||||||
|
"action_verdicts": [
|
||||||
|
verdict.as_dict()
|
||||||
|
for verdict in sorted(
|
||||||
|
self.action_verdicts,
|
||||||
|
key=lambda v: (v.intent_sha256, v.verdict_type, v.policy_sha256),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
def _verdict_index(self) -> dict[tuple[str, str], ActionVerdictRecord]:
|
||||||
|
return {
|
||||||
|
(verdict.intent_sha256, verdict.verdict_type): verdict
|
||||||
|
for verdict in self.action_verdicts
|
||||||
|
}
|
||||||
|
|
||||||
|
def admit(
|
||||||
|
self,
|
||||||
|
pack_id: str,
|
||||||
|
mv: np.ndarray,
|
||||||
|
authority: AuthorityToken,
|
||||||
|
) -> EfferentVerdict:
|
||||||
|
vec = np.asarray(mv, dtype=np.float32)
|
||||||
|
if vec.shape != (CL41_DIM,):
|
||||||
|
return EfferentVerdict(
|
||||||
|
admitted=False,
|
||||||
|
reason=f"invalid efferent vector shape: {vec.shape}",
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=self.policy_sha256,
|
||||||
|
)
|
||||||
|
required = f"decode:{pack_id}"
|
||||||
|
caps = set(authority.capabilities)
|
||||||
|
if required not in caps and "decode:*" not in caps and "*" not in caps:
|
||||||
|
return EfferentVerdict(
|
||||||
|
admitted=False,
|
||||||
|
reason=f"missing capability: {required}",
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=self.policy_sha256,
|
||||||
|
)
|
||||||
|
intent = lower_motor_action_intent(pack_id, vec)
|
||||||
|
verdicts = self._verdict_index()
|
||||||
|
for verdict_type in self.required_verdicts:
|
||||||
|
record = verdicts.get((intent.intent_sha256, verdict_type))
|
||||||
|
if record is None:
|
||||||
|
return EfferentVerdict(
|
||||||
|
admitted=False,
|
||||||
|
reason=f"missing action verdict coverage: {verdict_type}",
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=self.policy_sha256,
|
||||||
|
)
|
||||||
|
if not record.admitted:
|
||||||
|
return EfferentVerdict(
|
||||||
|
admitted=False,
|
||||||
|
reason=f"action verdict refused: {verdict_type}: {record.reason}",
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=self.policy_sha256,
|
||||||
|
)
|
||||||
|
return EfferentVerdict(
|
||||||
|
admitted=True,
|
||||||
|
reason="admitted",
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=self.policy_sha256,
|
||||||
|
)
|
||||||
|
|
||||||
|
def trace(
|
||||||
|
self,
|
||||||
|
pack_id: str,
|
||||||
|
mv: np.ndarray,
|
||||||
|
authority: AuthorityToken,
|
||||||
|
verdict: EfferentVerdict,
|
||||||
|
) -> MotorVerdictTrace:
|
||||||
|
intent = lower_motor_action_intent(pack_id, mv)
|
||||||
|
payload = {
|
||||||
|
"kind": "MotorVerdictTrace",
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"intent_sha256": intent.intent_sha256,
|
||||||
|
"admitted": verdict.admitted,
|
||||||
|
"reason": verdict.reason,
|
||||||
|
"authority_sha256": authority.authority_sha256,
|
||||||
|
"policy_sha256": verdict.policy_sha256,
|
||||||
|
"required_verdicts": list(self.required_verdicts),
|
||||||
|
}
|
||||||
|
return MotorVerdictTrace(
|
||||||
|
pack_id=pack_id,
|
||||||
|
intent_sha256=intent.intent_sha256,
|
||||||
|
admitted=verdict.admitted,
|
||||||
|
reason=verdict.reason,
|
||||||
|
authority_sha256=authority.authority_sha256,
|
||||||
|
policy_sha256=verdict.policy_sha256,
|
||||||
|
required_verdicts=tuple(self.required_verdicts),
|
||||||
|
trace_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ActionVerdictRecord",
|
||||||
|
"DefaultEfferentGate",
|
||||||
|
"EfferentEmissionTrace",
|
||||||
|
"MotorActionIntent",
|
||||||
|
"MotorVerdictTrace",
|
||||||
|
"VerdictEnforcingEfferentGate",
|
||||||
|
"lower_motor_action_intent",
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,14 @@ from __future__ import annotations
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from sensorium.efferent import DefaultEfferentGate
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sensorium.efferent import (
|
||||||
|
ActionVerdictRecord,
|
||||||
|
DefaultEfferentGate,
|
||||||
|
VerdictEnforcingEfferentGate,
|
||||||
|
lower_motor_action_intent,
|
||||||
|
)
|
||||||
from sensorium.protocol import (
|
from sensorium.protocol import (
|
||||||
AuthorityToken,
|
AuthorityToken,
|
||||||
EfferentRefusal,
|
EfferentRefusal,
|
||||||
|
|
@ -132,3 +139,88 @@ def test_registry_admits_decode_through_verdict_enforcing_gate():
|
||||||
reg.mount(_pack(decoder))
|
reg.mount(_pack(decoder))
|
||||||
assert reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test")) == "decoded"
|
assert reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test")) == "decoded"
|
||||||
assert decoder.calls == 1
|
assert decoder.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def _admitted_records(intent_sha256: str) -> tuple[ActionVerdictRecord, ...]:
|
||||||
|
return (
|
||||||
|
ActionVerdictRecord(intent_sha256, "safety", True, "safe", "safety-policy"),
|
||||||
|
ActionVerdictRecord(intent_sha256, "ethics", True, "ethical", "ethics-policy"),
|
||||||
|
ActionVerdictRecord(intent_sha256, "tool_scope", True, "in scope", "tool-policy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_adr_0216_motor_verdict_lowering_is_documented():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
adr = root / "docs" / "decisions" / "ADR-0216-motor-verdict-lowering.md"
|
||||||
|
assert adr.exists()
|
||||||
|
text = adr.read_text(encoding="utf-8")
|
||||||
|
assert "MotorActionIntent" in text
|
||||||
|
assert "VerdictEnforcingEfferentGate" in text
|
||||||
|
assert "No physical motor decode is authorized" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_motor_action_intent_is_hash_only_and_stable():
|
||||||
|
intent = lower_motor_action_intent("motor_test", _mv())
|
||||||
|
same = lower_motor_action_intent("motor_test", _mv())
|
||||||
|
payload = intent.as_dict()
|
||||||
|
assert intent.intent_sha256 == same.intent_sha256
|
||||||
|
assert intent.predicate_id.startswith("motor.intent.")
|
||||||
|
assert "decoded" not in str(payload)
|
||||||
|
assert "trajectory" not in str(payload)
|
||||||
|
for value in payload.values():
|
||||||
|
assert not isinstance(value, (np.ndarray, bytes, bytearray))
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_enforcing_gate_refuses_missing_coverage_before_decoder():
|
||||||
|
decoder = _Decoder()
|
||||||
|
gate = VerdictEnforcingEfferentGate()
|
||||||
|
reg = ModalityRegistry(efferent_gate=gate)
|
||||||
|
reg.mount(_pack(decoder))
|
||||||
|
with pytest.raises(EfferentRefusal, match="missing action verdict coverage"):
|
||||||
|
reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test"))
|
||||||
|
assert decoder.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_enforcing_gate_refuses_failed_verdict_before_decoder():
|
||||||
|
decoder = _Decoder()
|
||||||
|
intent = lower_motor_action_intent("motor_test", _mv())
|
||||||
|
records = (
|
||||||
|
ActionVerdictRecord(intent.intent_sha256, "safety", True, "safe", "safety-policy"),
|
||||||
|
ActionVerdictRecord(intent.intent_sha256, "ethics", False, "ethical refusal", "ethics-policy"),
|
||||||
|
ActionVerdictRecord(intent.intent_sha256, "tool_scope", True, "in scope", "tool-policy"),
|
||||||
|
)
|
||||||
|
reg = ModalityRegistry(efferent_gate=VerdictEnforcingEfferentGate(records))
|
||||||
|
reg.mount(_pack(decoder))
|
||||||
|
with pytest.raises(EfferentRefusal, match="action verdict refused: ethics"):
|
||||||
|
reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test"))
|
||||||
|
assert decoder.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_enforcing_gate_admits_only_with_authority_and_verdicts():
|
||||||
|
decoder = _Decoder()
|
||||||
|
intent = lower_motor_action_intent("motor_test", _mv())
|
||||||
|
gate = VerdictEnforcingEfferentGate(_admitted_records(intent.intent_sha256))
|
||||||
|
reg = ModalityRegistry(efferent_gate=gate)
|
||||||
|
reg.mount(_pack(decoder))
|
||||||
|
|
||||||
|
with pytest.raises(EfferentRefusal, match="missing capability"):
|
||||||
|
reg.decode("motor_test", _mv(), authority=_authority("decode:other"))
|
||||||
|
assert decoder.calls == 0
|
||||||
|
|
||||||
|
assert reg.decode("motor_test", _mv(), authority=_authority("decode:motor_test")) == "decoded"
|
||||||
|
assert decoder.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_verdict_enforcing_trace_is_hash_only():
|
||||||
|
intent = lower_motor_action_intent("motor_test", _mv())
|
||||||
|
gate = VerdictEnforcingEfferentGate(_admitted_records(intent.intent_sha256))
|
||||||
|
authority = _authority("decode:motor_test")
|
||||||
|
verdict = gate.admit("motor_test", _mv(), authority)
|
||||||
|
trace = gate.trace("motor_test", _mv(), authority, verdict).as_dict()
|
||||||
|
assert trace["admitted"] is True
|
||||||
|
assert trace["intent_sha256"] == intent.intent_sha256
|
||||||
|
assert "decoded" not in str(trace)
|
||||||
|
assert "trajectory" not in str(trace)
|
||||||
|
assert "action_trace" not in str(trace)
|
||||||
|
for value in trace.values():
|
||||||
|
assert not isinstance(value, (np.ndarray, bytes, bytearray))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue