From 918eb5552d84a9bf74a6e6c6e1d3192f27526151 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 31 May 2026 20:32:26 -0700 Subject: [PATCH] docs(adr): ADR-0198 motor efferent decoder spike (#514) --- .../ADR-0198-motor-efferent-decoder-spike.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/decisions/ADR-0198-motor-efferent-decoder-spike.md diff --git a/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md b/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md new file mode 100644 index 00000000..50537ce5 --- /dev/null +++ b/docs/decisions/ADR-0198-motor-efferent-decoder-spike.md @@ -0,0 +1,96 @@ +# ADR-0198: Motor as Efferent Modality — Protocol Gap & Governance (Design Spike) + +**Status:** Proposed (design spike — no implementation) +**Date:** 2026-05-31 +**Authors:** Joshua M. Shay, Core R&D Engine +**Domains:** `sensorium/protocol.py`, `sensorium/registry.py`, `packs/motor/` *(future)*, governance packs (ADR-0029/0033/0036/0037) +**Depends on:** ADR-0013 (Sensorium Multimodal Protocol), ADR-0017 (Agency Scope), ADR-0018 (Tool-Use Scope) +**Relationship to vision:** intentionally runs **in parallel** with `vision_core_v1` implementation (ADR-0197 / vision-compiler-spec) because it may force a `sensorium/` protocol change, and that change is cheaper to land before vision concrete sets. + +--- + +## 1. Why motor is not "just another modality" + +Every modality landed so far is **afferent**: signal comes *in*, crosses the `ProjectionHead` boundary, becomes a `(32,)` versor. Motor is **efferent**: meaning leaves the manifold and becomes action in the world. The protocol already anticipates this asymmetry — `sensorium/protocol.py` defines: + +```python +@runtime_checkable +class SurfaceDecoder(Protocol[S]): + modality: Modality + def decode(self, mv: np.ndarray) -> S: ... + def decode_batch(self, mvs: np.ndarray) -> list[S]: ... +``` + +and `ModalityPack` already carries a `decoder: SurfaceDecoder | None` slot. So the *type-level* substrate is ready. But the spike's finding is that the **runtime substrate is not**, in two concrete ways. + +### 1.1 Gap A — the registry has no efferent path + +`ModalityRegistry` (`sensorium/registry.py`) implements `mount`, `get`, `project`, `project_batch`. There is **no `decode` / `decode_batch`**. `project()` refuses a closed gate and validates output shape; nothing analogous exists for emission. A motor pack could be *mounted* today, but there is no governed call site through which a `(32,)` versor could become an action. Adding one is a `sensorium/` protocol change — the thing this spike exists to surface before vision implementation locks the layer. + +### 1.2 Gap B — the input mount-gate does not transfer to output + +For afferent modalities the mount-time gate is `verify_unitarity` — a property of the *projection's construction* (`V·reverse(V) = ±1`). It is correctly run once, at mount, never in the hot path. + +**An efferent gate cannot be mount-time only.** Whether an action is admissible depends on the *content of the decoded command*, not on whether the decoder was built correctly. A decoder can be perfectly unitary-inverse and still emit an action that must be refused. Therefore motor needs **two** gates, not one: + +| Gate | When | Checks | Analog | +|---|---|---|---| +| **Decoder validity** | mount-time | decoder is well-formed; round-trips within tolerance on a probe (`project(decode(mv)) ≈ mv`) | `verify_unitarity` | +| **Efferent admissibility** | **runtime, per-decode** | the *decoded action* is within authority + passes safety/ethics verdicts before it leaves the boundary | **(new — no afferent analog)** | + +Gap B is the load-bearing governance finding: **output gating is per-action, afferent gating is per-mount.** Any design that reuses `verify_unitarity`'s shape for motor is wrong. + +## 2. Proposed protocol change (minimal, additive) + +Add an efferent path to `ModalityRegistry` that mirrors `project()`'s refusal discipline and adds the runtime admissibility gate: + +```python +def decode(self, pack_id: str, mv: np.ndarray, *, authority: AuthorityToken) -> Any: + pack = self.get(pack_id) + if not pack.gate_engaged: + raise RuntimeError(f"Pack '{pack_id}' gate is not engaged.") + if pack.decoder is None: + raise RuntimeError(f"Pack '{pack_id}' has no SurfaceDecoder.") + # NEW: runtime efferent admissibility — BEFORE any surface action is produced. + verdict = self._efferent_gate.admit(pack_id, mv, authority) # §3 + if not verdict.admitted: + raise EfferentRefusal(pack_id, verdict) # fail closed + return pack.decoder.decode(mv) +``` + +Notes: +- `gate_engaged` / `decoder is None` refusals mirror `project()` exactly — no new gating *machinery* there, just the symmetric method. +- `authority: AuthorityToken` is required, never optional — there is no unauthenticated emission path. This is the wiring point for ADR-0017 (agency scope) and ADR-0018 (tool-use scope). +- The admissibility check runs **before** `decoder.decode` — the boundary refuses *in the manifold*, not after a command has been formed. + +## 3. The efferent gate reuses existing governance — it is not a new bespoke checker + +The repo already has the verdict machinery this needs; the spike's recommendation is to **route decoded-action admissibility through it**, not invent a parallel system: + +- **ADR-0029 (safety packs) / ADR-0036 (safety-refusal policy)** — the decoded action is checked against safety packs; refusal is the existing fail-closed path. +- **ADR-0033 (ethics packs) / ADR-0037 (per-predicate ethics refusal)** — per-predicate ethics verdicts apply to the action predicate, not to text. +- **ADR-0017 / ADR-0018** — authority and tool-use scope bound *what* the `AuthorityToken` may authorize. + +`_efferent_gate.admit()` is therefore a thin adapter that lowers a `(32,)` motor versor into the action predicate(s) those packs already evaluate, collects their verdicts, and fails closed on any refusal or on absent authority. The novel surface is the *lowering + fail-closed composition*, not the ethics logic. + +## 4. Open questions to red-line (this is a spike — these are the decisions, not conclusions) + +1. **`S` for motor.** Is the surface type a single action command, a bounded trajectory, or a parameterized skill invocation? This determines whether `decode` is one-shot or streaming, and whether a proprioceptive feedback loop is in-scope for v1. +2. **Feedback is afferent.** Proprioception/result-of-action re-enters as a *sensor* modality. Decision: is the loop **composed at a higher layer** (motor-decode + sensor-encode) or **internal** to the motor pack? Recommendation: composed at a higher layer, to keep `SurfaceDecoder` one-directional and the protocol symmetric. +3. **`AuthorityToken` shape & lifetime.** Per-call, per-session, or capability-scoped? How does it bind to ADR-0017/0018 scopes? +4. **Decoder-validity round-trip tolerance.** What is the mount-time `project(decode(mv)) ≈ mv` tolerance, and is round-trip even required for actions that are intentionally lossy? +5. **Control rate.** Does v1 emit at a fixed control rate (and therefore need a clock/barrier story like the compilers), or is it event-driven one-shot? Recommendation: event-driven one-shot in v1; rate-controlled emission deferred. +6. **Does the protocol change touch the CRDT substrate at all?** Efferent actions are *emitted*, not stored as afferent deltas — but their *occurrence* is trace evidence. Decision: motor writes a `TurnEvent` emission record (hashes + authority + verdict), never a Vault delta. Confirm this keeps ADR-0180 untouched. + +## 5. Sequencing recommendation + +- This spike's **Gap A protocol change** (the `decode()` method + `AuthorityToken`) should be reviewed **before** `vision_core_v1` PR-2, because it modifies `sensorium/registry.py` / `sensorium/protocol.py` — the layer vision builds against. If accepted, vision implements against the post-change protocol and avoids a later migration. +- The **motor compiler/decoder itself** is explicitly **out of scope** here and must not begin until: (a) this protocol change is merged, and (b) a dedicated **motor governance ADR** ratifies the §3 efferent-gate lowering against ADR-0029/0033/0036/0037. Per the project's order, governance precedes the efferent compiler — not because of politeness, but because §1.2 Gap B means there is no gate to engage until it exists. + +## 6. Cross-References + +- ADR-0013 — projection boundary; `SurfaceDecoder` / `ModalityPack.decoder` slot this spike activates. +- ADR-0017, ADR-0018 — agency and tool-use scope; the authority model `decode()` requires. +- ADR-0029, ADR-0033, ADR-0036, ADR-0037 — safety/ethics pack verdicts the efferent gate reuses. +- ADR-0197 — vision compiler; runs in parallel and consumes the protocol change this spike proposes. +- `sensorium/protocol.py`, `sensorium/registry.py` — the contracts amended in §2. \ No newline at end of file