From 9cabeeb40d9bf877639a102e867bf3a27750e309 Mon Sep 17 00:00:00 2001 From: Shay Date: Wed, 3 Jun 2026 20:53:05 -0700 Subject: [PATCH] Add sensorium eval and governance runway --- core/cli.py | 41 ++++ evals/sensorimotor_sensorium/__init__.py | 1 + .../sensorimotor_sensorium/expected_ir.jsonl | 3 + .../expected_projection.json | 113 +++++++++ evals/sensorimotor_sensorium/fixtures.json | 32 +++ .../generate_expected.py | 41 ++++ evals/sensorimotor_sensorium/synth.py | 16 ++ evals/sensorium/__init__.py | 5 + evals/sensorium/report.py | 219 ++++++++++++++++++ packs/sensorimotor/__init__.py | 1 + packs/sensorimotor/loader.py | 85 +++++++ .../sensorimotor_core_v1/basis_map.json | 26 +++ .../sensorimotor_core_v1/checksums.json | 6 + .../sensorimotor_core_v1/manifest.json | 22 ++ sensorium/__init__.py | 3 + sensorium/adapters/sensorimotor.py | 71 ++++++ sensorium/efferent.py | 104 +++++++++ sensorium/environment/__init__.py | 3 +- sensorium/environment/harness.py | 54 +++++ sensorium/sensorimotor/compiler.py | 9 +- tests/test_efferent_gate.py | 92 ++++++++ tests/test_observation_frame_harness.py | 29 +++ tests/test_sensorimotor_pack_manifest.py | 76 ++++++ tests/test_sensorium_eval_cli.py | 25 ++ tests/test_sensorium_eval_report.py | 19 ++ 25 files changed, 1093 insertions(+), 3 deletions(-) create mode 100644 evals/sensorimotor_sensorium/__init__.py create mode 100644 evals/sensorimotor_sensorium/expected_ir.jsonl create mode 100644 evals/sensorimotor_sensorium/expected_projection.json create mode 100644 evals/sensorimotor_sensorium/fixtures.json create mode 100644 evals/sensorimotor_sensorium/generate_expected.py create mode 100644 evals/sensorimotor_sensorium/synth.py create mode 100644 evals/sensorium/__init__.py create mode 100644 evals/sensorium/report.py create mode 100644 packs/sensorimotor/__init__.py create mode 100644 packs/sensorimotor/loader.py create mode 100644 packs/sensorimotor/sensorimotor_core_v1/basis_map.json create mode 100644 packs/sensorimotor/sensorimotor_core_v1/checksums.json create mode 100644 packs/sensorimotor/sensorimotor_core_v1/manifest.json create mode 100644 sensorium/adapters/sensorimotor.py create mode 100644 sensorium/efferent.py create mode 100644 sensorium/environment/harness.py create mode 100644 tests/test_efferent_gate.py create mode 100644 tests/test_observation_frame_harness.py create mode 100644 tests/test_sensorimotor_pack_manifest.py create mode 100644 tests/test_sensorium_eval_cli.py create mode 100644 tests/test_sensorium_eval_report.py diff --git a/core/cli.py b/core/cli.py index e6a21712..6801b6ec 100644 --- a/core/cli.py +++ b/core/cli.py @@ -2349,6 +2349,8 @@ def cmd_doctor(args: argparse.Namespace) -> int: def cmd_eval(args: argparse.Namespace) -> int: """Run an eval lane by name, or list available lanes.""" + if getattr(args, "lane", None) == "sensorium": + return cmd_eval_sensorium(args) if getattr(args, "lane", None) == "math-contemplation": return cmd_eval_math_contemplation(args) @@ -2456,6 +2458,39 @@ def cmd_eval(args: argparse.Namespace) -> int: return 0 +def cmd_eval_sensorium(args: argparse.Namespace) -> int: + """Run deterministic sensorium modality evidence reports.""" + from evals.sensorium import build_sensorium_report + + modality = getattr(args, "modality", "vision") or "vision" + try: + report = build_sensorium_report(modality) + except ValueError as exc: + _die(str(exc), code=2) + + if getattr(args, "json", False): + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print(f"lane : {report['lane']}") + print(f"modality : {report['modality']}") + print(f"pack_id : {report['pack_id']}") + print(f"gate_engaged : {report['gate_engaged']}") + print(f"gate_closed : {report['gate_closed']}") + print(f"cases : {report['total']}") + print(f"passed : {report['passed']}") + print(f"failed : {report['failed']}") + + if getattr(args, "report", None): + report_path = Path(args.report) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + ) + print(f"\nreport written: {report_path}", file=sys.stderr) + + return 0 if report["failed"] == 0 and report["gate_closed"] else 1 + + # --------------------------------------------------------------------------- # ADR-0172 W3 — math-contemplation CLI lane # --------------------------------------------------------------------------- @@ -4889,6 +4924,12 @@ def build_parser() -> argparse.ArgumentParser: eval_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON") eval_cmd.add_argument("--save", action="store_true", help="write result to lane results/ directory") eval_cmd.add_argument("--report", metavar="PATH", help="write JSON report to file") + eval_cmd.add_argument( + "--modality", + choices=["audio", "vision", "sensorimotor"], + default="vision", + help="sensorium lane modality to evaluate (default: vision)", + ) eval_cmd.add_argument( "--audit", metavar="PATH", diff --git a/evals/sensorimotor_sensorium/__init__.py b/evals/sensorimotor_sensorium/__init__.py new file mode 100644 index 00000000..47752138 --- /dev/null +++ b/evals/sensorimotor_sensorium/__init__.py @@ -0,0 +1 @@ +"""Deterministic sensorimotor compiler eval lane.""" diff --git a/evals/sensorimotor_sensorium/expected_ir.jsonl b/evals/sensorimotor_sensorium/expected_ir.jsonl new file mode 100644 index 00000000..eeec887a --- /dev/null +++ b/evals/sensorimotor_sensorium/expected_ir.jsonl @@ -0,0 +1,3 @@ +{"canonical_sha256": "b4f4b643018ef6dd7ffdaf02f83c25a7c58daa2b5b13c0be1406546d6e4a70a4", "event_count": 5, "event_types": ["proprio.pose", "proprio.velocity", "haptic.force_torque", "haptic.contact", "actuator.state"], "id": "resting_pose", "ir_sha256": "297bc879305553c1c1eb39153cf48e7222a10f94b279b3122cd2d6e64c92bb14"} +{"canonical_sha256": "1edc1e022efdf1e1208f574329ae4a82d94ada16a225029dab1fd13c2d92f98c", "event_count": 5, "event_types": ["proprio.pose", "proprio.velocity", "haptic.force_torque", "haptic.contact", "actuator.state"], "id": "moving_contact", "ir_sha256": "1f24bcd14847aef44ed3c4baccfddbc5cc71eb6e1a0c741403c348e707225dbb"} +{"canonical_sha256": "fade3485eb40896ace76afefc4397ba872ff9e2e17b2c9c818fe696ec5096b93", "event_count": 5, "event_types": ["proprio.pose", "proprio.velocity", "haptic.force_torque", "haptic.contact", "actuator.state"], "id": "force_spike", "ir_sha256": "d75fd4d67f7a7484db900b81d1fa2b2f8499458feaa8a9593feddd1d43881e63"} diff --git a/evals/sensorimotor_sensorium/expected_projection.json b/evals/sensorimotor_sensorium/expected_projection.json new file mode 100644 index 00000000..b2159140 --- /dev/null +++ b/evals/sensorimotor_sensorium/expected_projection.json @@ -0,0 +1,113 @@ +{ + "force_spike": { + "projection_sha256": "989f9381e35fef12f30f64b7153d647cd1159bb3b0029d8d535033aeb9d31d5c", + "reference_versor": [ + 0.8165946006774902, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.053018417209386826, + 0.23315252363681793, + 0.33966025710105896, + 0.0, + 0.19017764925956726, + 0.3096834123134613, + 0.0, + -0.16899415850639343, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0016558070201426744, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "moving_contact": { + "projection_sha256": "a35c264e112f57df6cda1d4f8056963cb3ea7d207981eb17d639222aa8ce2168", + "reference_versor": [ + 0.8357952833175659, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.00914173573255539, + 0.24707065522670746, + 0.2951143980026245, + 0.0, + 0.1899629831314087, + 0.3036644458770752, + 0.0, + -0.15599043667316437, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.024398060515522957, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + }, + "resting_pose": { + "projection_sha256": "da6a31469c1d6e4a7ba827b6728e7d2f4348b389cc81e4ffdb96cd85ba037f56", + "reference_versor": [ + 0.8604249954223633, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.0035402218345552683, + 0.22752714157104492, + 0.24097634851932526, + 0.0, + 0.21282944083213806, + 0.2927039563655853, + 0.0, + -0.1361951380968094, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + -0.017234591767191887, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + } +} diff --git a/evals/sensorimotor_sensorium/fixtures.json b/evals/sensorimotor_sensorium/fixtures.json new file mode 100644 index 00000000..725b3f5c --- /dev/null +++ b/evals/sensorimotor_sensorium/fixtures.json @@ -0,0 +1,32 @@ +{ + "comment": "Deterministic quantized proprioceptive fixture specs. These are afferent feedback cases, never motor commands.", + "fixtures": [ + { + "id": "resting_pose", + "pose_q": [0, 0, 0], + "velocity_q": [0, 0, 0], + "force_torque_q": [0, 0, 0], + "contact_q": [0, 0], + "actuator_state_q": [1, 1], + "expect": "stationary proprioceptive state" + }, + { + "id": "moving_contact", + "pose_q": [10, -4, 3], + "velocity_q": [2, 0, -1], + "force_torque_q": [3, 5, 8], + "contact_q": [1, 0, 1], + "actuator_state_q": [7, 8], + "expect": "moving state with contact" + }, + { + "id": "force_spike", + "pose_q": [1, 1, 1], + "velocity_q": [0, 0, 0], + "force_torque_q": [20, -15, 4], + "contact_q": [1, 1], + "actuator_state_q": [9, 9], + "expect": "force torque evidence" + } + ] +} diff --git a/evals/sensorimotor_sensorium/generate_expected.py b/evals/sensorimotor_sensorium/generate_expected.py new file mode 100644 index 00000000..b9ac528f --- /dev/null +++ b/evals/sensorimotor_sensorium/generate_expected.py @@ -0,0 +1,41 @@ +"""Regenerate frozen expected artifacts for sensorimotor eval.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from evals.sensorimotor_sensorium.synth import synthesize +from sensorium.sensorimotor import SensorimotorCompiler + +_HERE = Path(__file__).resolve().parent + + +def main() -> None: + spec = json.loads((_HERE / "fixtures.json").read_text()) + compiler = SensorimotorCompiler() + lines: list[str] = [] + projection: dict[str, dict] = {} + for fx in spec["fixtures"]: + signal = synthesize(fx) + unit = compiler.compile_signal(signal) + lines.append(json.dumps({ + "id": fx["id"], + "canonical_sha256": unit.canonical_sha256, + "ir_sha256": unit.ir_sha256, + "event_count": len(unit.sensorimotor_ir.events), + "event_types": [event.event_type for event in unit.sensorimotor_ir.events], + }, sort_keys=True)) + projection[fx["id"]] = { + "projection_sha256": unit.projection_sha256, + "reference_versor": [float(x) for x in unit.versor.tolist()], + } + (_HERE / "expected_ir.jsonl").write_text("\n".join(lines) + "\n") + (_HERE / "expected_projection.json").write_text( + json.dumps(projection, indent=2, sort_keys=True) + "\n" + ) + print(f"wrote expected artifacts for {len(spec['fixtures'])} fixtures") + + +if __name__ == "__main__": + main() diff --git a/evals/sensorimotor_sensorium/synth.py b/evals/sensorimotor_sensorium/synth.py new file mode 100644 index 00000000..40d28a3d --- /dev/null +++ b/evals/sensorimotor_sensorium/synth.py @@ -0,0 +1,16 @@ +"""Fixture conversion for quantized sensorimotor eval specs.""" + +from __future__ import annotations + +from sensorium.sensorimotor import ProprioceptiveSignal, canonicalize_proprioception + + +def synthesize(spec: dict) -> ProprioceptiveSignal: + return canonicalize_proprioception( + pose_q=tuple(int(v) for v in spec.get("pose_q", ())), + velocity_q=tuple(int(v) for v in spec.get("velocity_q", ())), + force_torque_q=tuple(int(v) for v in spec.get("force_torque_q", ())), + contact_q=tuple(int(v) for v in spec.get("contact_q", ())), + actuator_state_q=tuple(int(v) for v in spec.get("actuator_state_q", ())), + source_id=str(spec["id"]), + ) diff --git a/evals/sensorium/__init__.py b/evals/sensorium/__init__.py new file mode 100644 index 00000000..ba203c9f --- /dev/null +++ b/evals/sensorium/__init__.py @@ -0,0 +1,5 @@ +"""Unified sensorium eval report surface.""" + +from evals.sensorium.report import build_sensorium_report + +__all__ = ["build_sensorium_report"] diff --git a/evals/sensorium/report.py b/evals/sensorium/report.py new file mode 100644 index 00000000..3eb1b1a0 --- /dev/null +++ b/evals/sensorium/report.py @@ -0,0 +1,219 @@ +"""Deterministic sensorium eval reports for modality compiler lanes.""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Literal + +import numpy as np + +from evals.audio_sensorium.synth import synthesize as synthesize_audio +from evals.sensorimotor_sensorium.synth import synthesize as synthesize_sensorimotor +from evals.vision_sensorium.synth import synthesize as synthesize_vision +from sensorium.adapters.audio import make_audio_pack +from sensorium.adapters.sensorimotor import make_sensorimotor_pack +from sensorium.adapters.vision import make_vision_pack +from sensorium.audio.canonical import canonicalize as canonicalize_audio +from sensorium.audio.compiler import AudioCompiler +from sensorium.audio.trace import audio_evidence_trace +from sensorium.audio.types import AudioIR +from sensorium.registry import ModalityRegistry +from sensorium.sensorimotor import SensorimotorCompiler, sensorimotor_evidence_trace +from sensorium.vision import VisionCompiler, canonicalize_image, vision_evidence_trace +from sensorium.vision.grid import iter_tile_signals +from sensorium.vision.types import VisionIR + +ModalityName = Literal["audio", "vision", "sensorimotor"] +_ROOT = Path(__file__).resolve().parents[2] +_AUDIO_DIR = _ROOT / "evals" / "audio_sensorium" +_VISION_DIR = _ROOT / "evals" / "vision_sensorium" +_SENSORIMOTOR_DIR = _ROOT / "evals" / "sensorimotor_sensorium" +_AUDIO_SR = 24_000 +_TOL = 1e-6 + + +def _json(path: Path): + return json.loads(path.read_text()) + + +def _jsonl_by_id(path: Path) -> dict[str, dict]: + out: dict[str, dict] = {} + for line in path.read_text().splitlines(): + if line.strip(): + row = json.loads(line) + out[row["id"]] = row + return out + + +def _audio_counts(ir: AudioIR) -> dict[str, int]: + events = ( + *ir.speech_spans, + *ir.pause_spans, + *ir.prosody_arcs, + *ir.turn_events, + *ir.non_speech_events, + *ir.content_anchors, + ) + return dict(sorted(Counter(e.event_type for e in events).items())) + + +def _vision_counts(ir: VisionIR) -> dict[str, int]: + events = ( + *ir.regions, + *ir.contour_arcs, + *ir.orient_events, + *ir.texture_atoms, + *ir.salient_events, + *ir.content_anchors, + ) + return dict(sorted(Counter(e.event_type for e in events).items())) + + +def _trace_safe(trace: dict[str, object]) -> bool: + return all(not isinstance(value, (np.ndarray, bytes, bytearray)) for value in trace.values()) + + +def _audio_report() -> dict[str, object]: + fixtures = _json(_AUDIO_DIR / "fixtures.json")["fixtures"] + expected_ir = _jsonl_by_id(_AUDIO_DIR / "expected_ir.jsonl") + expected_proj = _json(_AUDIO_DIR / "expected_projection.json") + compiler = AudioCompiler() + cases: list[dict[str, object]] = [] + for fx in fixtures: + fid = fx["id"] + unit = compiler.compile_signal(canonicalize_audio(synthesize_audio(fx), _AUDIO_SR)) + replay = compiler.compile_ir(unit.audio_ir) + ref = np.asarray(expected_proj[fid]["reference_versor"], dtype=np.float32) + cases.append({ + "id": fid, + "canonical_sha256": unit.canonical_sha256, + "ir_sha256": unit.ir_sha256, + "projection_sha256": unit.projection_sha256, + "shape_ok": unit.versor.shape == (32,), + "dtype_ok": unit.versor.dtype == np.float32, + "replay_ok": bool(np.array_equal(unit.versor, replay.versor)), + "expected_ir_ok": unit.ir_sha256 == expected_ir[fid]["ir_sha256"], + "expected_projection_ok": bool(np.allclose(unit.versor, ref, atol=_TOL)), + "event_counts_ok": _audio_counts(unit.audio_ir) == expected_ir[fid]["event_type_counts"], + "trace_hygiene_ok": _trace_safe(audio_evidence_trace(unit)), + "versor_condition": unit.versor_condition, + }) + reg = ModalityRegistry() + sample = canonicalize_audio(synthesize_audio(fixtures[0]), _AUDIO_SR) + reg.mount(make_audio_pack("audio_core_v1"), sample=sample) + gate_closed = False + try: + reg.project("audio_core_v1", sample) + except RuntimeError: + gate_closed = True + return _report("audio", "audio_core_v1", cases, gate_closed) + + +def _vision_report() -> dict[str, object]: + fixtures = _json(_VISION_DIR / "fixtures.json")["fixtures"] + expected_ir = _jsonl_by_id(_VISION_DIR / "expected_ir.jsonl") + expected_proj = _json(_VISION_DIR / "expected_projection.json") + compiler = VisionCompiler() + cases: list[dict[str, object]] = [] + for fx in fixtures: + fid = fx["id"] + image = canonicalize_image(synthesize_vision(fx)) + units = compiler.compile_image(image) + counts = Counter() + units_ok = True + projection_ok = True + trace_ok = True + for idx, unit in enumerate(units): + replay = compiler.compile_ir(unit.vision_ir) + units_ok = units_ok and unit.versor.shape == (32,) and unit.versor.dtype == np.float32 + units_ok = units_ok and np.array_equal(unit.versor, replay.versor) + ref = np.asarray(expected_proj[fid][idx]["reference_versor"], dtype=np.float32) + projection_ok = projection_ok and unit.projection_sha256 == expected_proj[fid][idx]["projection_sha256"] + projection_ok = projection_ok and np.allclose(unit.versor, ref, atol=_TOL) + trace_ok = trace_ok and _trace_safe(vision_evidence_trace(unit)) + counts.update(_vision_counts(unit.vision_ir)) + cases.append({ + "id": fid, + "canonical_sha256": image.canonical_sha256, + "unit_count": len(units), + "unit_count_ok": len(units) == expected_ir[fid]["unit_count"], + "units_ok": bool(units_ok), + "expected_projection_ok": bool(projection_ok), + "event_counts_ok": dict(sorted(counts.items())) == expected_ir[fid]["event_type_counts"], + "trace_hygiene_ok": bool(trace_ok), + }) + reg = ModalityRegistry() + sample = iter_tile_signals(canonicalize_image(synthesize_vision(fixtures[0])))[0] + reg.mount(make_vision_pack("vision_core_v1"), sample=sample) + gate_closed = False + try: + reg.project("vision_core_v1", sample) + except RuntimeError: + gate_closed = True + return _report("vision", "vision_core_v1", cases, gate_closed) + + +def _sensorimotor_report() -> dict[str, object]: + fixtures = _json(_SENSORIMOTOR_DIR / "fixtures.json")["fixtures"] + expected_ir = _jsonl_by_id(_SENSORIMOTOR_DIR / "expected_ir.jsonl") + expected_proj = _json(_SENSORIMOTOR_DIR / "expected_projection.json") + compiler = SensorimotorCompiler() + cases: list[dict[str, object]] = [] + for fx in fixtures: + fid = fx["id"] + unit = compiler.compile_signal(synthesize_sensorimotor(fx)) + replay = compiler.compile_ir(unit.sensorimotor_ir) + ref = np.asarray(expected_proj[fid]["reference_versor"], dtype=np.float32) + cases.append({ + "id": fid, + "canonical_sha256": unit.canonical_sha256, + "ir_sha256": unit.ir_sha256, + "projection_sha256": unit.projection_sha256, + "shape_ok": unit.versor.shape == (32,), + "dtype_ok": unit.versor.dtype == np.float32, + "replay_ok": bool(np.array_equal(unit.versor, replay.versor)), + "expected_ir_ok": unit.ir_sha256 == expected_ir[fid]["ir_sha256"], + "expected_projection_ok": bool(np.allclose(unit.versor, ref, atol=_TOL)), + "event_types_ok": [e.event_type for e in unit.sensorimotor_ir.events] == expected_ir[fid]["event_types"], + "trace_hygiene_ok": _trace_safe(sensorimotor_evidence_trace(unit)), + "versor_condition": unit.versor_condition, + }) + reg = ModalityRegistry() + sample = synthesize_sensorimotor(fixtures[0]) + reg.mount(make_sensorimotor_pack("sensorimotor_core_v1"), sample=sample) + gate_closed = False + try: + reg.project("sensorimotor_core_v1", sample) + except RuntimeError: + gate_closed = True + return _report("sensorimotor", "sensorimotor_core_v1", cases, gate_closed) + + +def _report(modality: str, pack_id: str, cases: list[dict[str, object]], gate_closed: bool) -> dict[str, object]: + pass_count = sum( + 1 for case in cases + if all(value is True for key, value in case.items() if key.endswith("_ok")) + ) + return { + "lane": "sensorium", + "modality": modality, + "pack_id": pack_id, + "gate_engaged": False, + "gate_closed": gate_closed, + "total": len(cases), + "passed": pass_count, + "failed": len(cases) - pass_count, + "cases": cases, + } + + +def build_sensorium_report(modality: ModalityName) -> dict[str, object]: + if modality == "audio": + return _audio_report() + if modality == "vision": + return _vision_report() + if modality == "sensorimotor": + return _sensorimotor_report() + raise ValueError(f"unsupported sensorium modality: {modality!r}") diff --git a/packs/sensorimotor/__init__.py b/packs/sensorimotor/__init__.py new file mode 100644 index 00000000..74a7a920 --- /dev/null +++ b/packs/sensorimotor/__init__.py @@ -0,0 +1 @@ +"""Sensorimotor pack artifacts.""" diff --git a/packs/sensorimotor/loader.py b/packs/sensorimotor/loader.py new file mode 100644 index 00000000..607b573e --- /dev/null +++ b/packs/sensorimotor/loader.py @@ -0,0 +1,85 @@ +"""Sensorimotor pack loader with fail-closed checksum verification.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +from sensorium.audio.checksum import sha256_json + +_PACKS_ROOT = Path(__file__).resolve().parent + + +class SensorimotorPackError(ValueError): + """Raised when a sensorimotor pack is missing, malformed, or tampered.""" + + +def _validate_pack_id(pack_id: object) -> str: + from core._safe_display import safe_pack_id as _disp + + if not isinstance(pack_id, str): + raise SensorimotorPackError(f"pack_id must be a string, got {_disp(pack_id)!r}") + if pack_id == "": + raise SensorimotorPackError("pack_id must not be empty") + if ".." in pack_id: + raise SensorimotorPackError(f"pack_id must not contain '..': {_disp(pack_id)!r}") + if "/" in pack_id or "\\" in pack_id: + raise SensorimotorPackError(f"pack_id must be a simple pack id, not a path: {_disp(pack_id)!r}") + if pack_id.startswith("."): + raise SensorimotorPackError(f"pack_id must not start with '.': {_disp(pack_id)!r}") + for ch in pack_id: + if not (ch.isascii() and (ch.isalnum() or ch in {"_", "-"})): + raise SensorimotorPackError(f"pack_id must be alphanumeric/_/-, got {_disp(pack_id)!r}") + return pack_id + + +@dataclass(frozen=True, slots=True) +class LoadedSensorimotorPack: + pack_id: str + manifest: dict + manifest_sha256: str + basis_map: dict + + +def _verify_checksums(pack_dir: Path) -> None: + checks_path = pack_dir / "checksums.json" + if not checks_path.is_file(): + raise SensorimotorPackError(f"checksums.json missing for pack at {pack_dir.name}") + checks = json.loads(checks_path.read_text()) + for fname, expected in checks.get("files", {}).items(): + fpath = pack_dir / fname + if not fpath.is_file(): + raise SensorimotorPackError(f"pack file '{fname}' named in checksums.json is missing") + actual = "sha256:" + hashlib.sha256(fpath.read_bytes()).hexdigest() + if actual != expected: + raise SensorimotorPackError( + f"checksum mismatch for '{fname}': expected {expected}, got {actual}" + ) + + +def load_sensorimotor_pack( + pack_id: str = "sensorimotor_core_v1", + *, + packs_root: Path | None = None, + verify: bool = True, +) -> LoadedSensorimotorPack: + safe_id = _validate_pack_id(pack_id) + root = packs_root if packs_root is not None else _PACKS_ROOT + pack_dir = (root / safe_id).resolve() + if not str(pack_dir).startswith(str(root.resolve())): + raise SensorimotorPackError(f"resolved pack path escapes packs root: {safe_id!r}") + if not pack_dir.is_dir(): + raise SensorimotorPackError(f"no sensorimotor pack mounted at {safe_id!r}") + if verify: + _verify_checksums(pack_dir) + + manifest = json.loads((pack_dir / "manifest.json").read_text()) + basis_map = json.loads((pack_dir / "basis_map.json").read_text()) + manifest_sha256 = sha256_json({ + "pack_id": safe_id, + "basis_version": manifest.get("basis_version", "sensorimotor-basis-v1"), + "events": list(manifest.get("event_order", ())), + }) + return LoadedSensorimotorPack(safe_id, manifest, manifest_sha256, basis_map) diff --git a/packs/sensorimotor/sensorimotor_core_v1/basis_map.json b/packs/sensorimotor/sensorimotor_core_v1/basis_map.json new file mode 100644 index 00000000..11049c79 --- /dev/null +++ b/packs/sensorimotor/sensorimotor_core_v1/basis_map.json @@ -0,0 +1,26 @@ +{ + "basis_version": "sensorimotor-basis-v1", + "comment": "Afferent proprioceptive facts lower to elliptic bivector rotors only; this pack contains no efferent decoder or command surface.", + "events": { + "proprio.pose": { + "blade_index": 6, + "base_theta_q": 48 + }, + "proprio.velocity": { + "blade_index": 7, + "base_theta_q": 64 + }, + "haptic.force_torque": { + "blade_index": 8, + "base_theta_q": 80 + }, + "haptic.contact": { + "blade_index": 10, + "base_theta_q": 96 + }, + "actuator.state": { + "blade_index": 11, + "base_theta_q": 112 + } + } +} diff --git a/packs/sensorimotor/sensorimotor_core_v1/checksums.json b/packs/sensorimotor/sensorimotor_core_v1/checksums.json new file mode 100644 index 00000000..24b2b939 --- /dev/null +++ b/packs/sensorimotor/sensorimotor_core_v1/checksums.json @@ -0,0 +1,6 @@ +{ + "files": { + "basis_map.json": "sha256:e0c174988bae3f3480258b474fd4ec79bfb70fca46210faa29f8db10a1e3cc9e", + "manifest.json": "sha256:073768936418c3a8ee3e49a65de3d0351c387b37f95a4499c7cc1d1ee93ee822" + } +} diff --git a/packs/sensorimotor/sensorimotor_core_v1/manifest.json b/packs/sensorimotor/sensorimotor_core_v1/manifest.json new file mode 100644 index 00000000..7b950695 --- /dev/null +++ b/packs/sensorimotor/sensorimotor_core_v1/manifest.json @@ -0,0 +1,22 @@ +{ + "pack_id": "sensorimotor_core_v1", + "modality": "sensorimotor", + "compiler_version": "sensorimotor-compiler-v1", + "basis_version": "sensorimotor-basis-v1", + "cl41_dim": 32, + "gate_engaged": false, + "projection_unit": "quantized_proprioceptive_signal", + "efferent": false, + "event_order": [ + "proprio.pose", + "proprio.velocity", + "haptic.force_torque", + "haptic.contact", + "actuator.state" + ], + "rules": [ + "sensorimotor/proprioception is afferent feedback", + "motor commands remain efferent and governed by EfferentGate", + "no robot, actuator, trajectory executor, tool invocation, or skill invocation is present" + ] +} diff --git a/sensorium/__init__.py b/sensorium/__init__.py index d38d58b1..971cca57 100644 --- a/sensorium/__init__.py +++ b/sensorium/__init__.py @@ -27,6 +27,7 @@ from sensorium.protocol import ( ProjectionHead, SurfaceDecoder, ) +from sensorium.efferent import DefaultEfferentGate, EfferentEmissionTrace from sensorium.registry import ModalityRegistry __all__ = [ @@ -35,6 +36,8 @@ __all__ = [ "EfferentGate", "EfferentRefusal", "EfferentVerdict", + "DefaultEfferentGate", + "EfferentEmissionTrace", "ProjectionHead", "SurfaceDecoder", "ModalityVocabulary", diff --git a/sensorium/adapters/sensorimotor.py b/sensorium/adapters/sensorimotor.py new file mode 100644 index 00000000..ab1bb377 --- /dev/null +++ b/sensorium/adapters/sensorimotor.py @@ -0,0 +1,71 @@ +"""Sensorimotor modality adapter. + +Sensorimotor v1 is afferent proprioceptive feedback only. It provides a +ProjectionHead so compiled feedback can enter the shared manifold, but it does +not provide a SurfaceDecoder or any motor command path. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from sensorium.protocol import CL41_DIM, Modality, ModalityPack, ModalityVocabulary +from sensorium.sensorimotor.compiler import SensorimotorCompiler +from sensorium.sensorimotor.types import ProprioceptiveSignal + + +@dataclass(frozen=True, slots=True) +class SensorimotorProjectionHead: + """ProjectionHead for quantized afferent ProprioceptiveSignal values.""" + + compiler: SensorimotorCompiler + modality: Modality = Modality.SENSORIMOTOR + + @property + def embedding_dim(self) -> int: + return CL41_DIM + + def project(self, signal: ProprioceptiveSignal) -> np.ndarray: + out = self.compiler.compile_signal(signal).versor + if out.shape != (CL41_DIM,): + raise ValueError(f"expected ({CL41_DIM},), got {out.shape}") + if out.dtype != np.float32: + raise TypeError(f"expected float32, got {out.dtype}") + return out + + def project_batch(self, signals: list[ProprioceptiveSignal]) -> np.ndarray: + return np.stack([self.project(signal) for signal in signals], axis=0) + + def verify_unitarity(self, sample: ProprioceptiveSignal) -> bool: + try: + return self.compiler.compile_signal(sample).versor_condition < 1e-6 + except Exception: + return False + + +def make_sensorimotor_pack( + pack_id: str = "sensorimotor_core_v1", + *, + gate_engaged: bool = False, + checksum_verified: bool = False, + packs_root=None, +) -> ModalityPack: + from packs.sensorimotor.loader import load_sensorimotor_pack + + loaded = load_sensorimotor_pack(pack_id, packs_root=packs_root) + compiler = SensorimotorCompiler( + loaded.pack_id, + pack_manifest_sha256=loaded.manifest_sha256, + ) + return ModalityPack( + pack_id=loaded.pack_id, + modality_type=Modality.SENSORIMOTOR, + projection=SensorimotorProjectionHead(compiler), + decoder=None, + vocabulary=ModalityVocabulary(), + grammar_scaffold=None, + checksum_verified=checksum_verified, + gate_engaged=gate_engaged, + ) diff --git a/sensorium/efferent.py b/sensorium/efferent.py new file mode 100644 index 00000000..0e9dc87b --- /dev/null +++ b/sensorium/efferent.py @@ -0,0 +1,104 @@ +"""Concrete efferent gate policy and trace-safe decision records.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from sensorium.audio.checksum import sha256_json +from sensorium.protocol import CL41_DIM, AuthorityToken, EfferentVerdict + + +@dataclass(frozen=True, slots=True) +class EfferentEmissionTrace: + """Trace-safe record of an efferent admission or refusal.""" + + pack_id: str + admitted: bool + reason: str + authority_sha256: str + policy_sha256: str + capability: str + trace_sha256: str + + def as_dict(self) -> dict[str, object]: + return { + "pack_id": self.pack_id, + "admitted": self.admitted, + "reason": self.reason, + "authority_sha256": self.authority_sha256, + "policy_sha256": self.policy_sha256, + "capability": self.capability, + "trace_sha256": self.trace_sha256, + } + + +@dataclass(frozen=True, slots=True) +class DefaultEfferentGate: + """Capability-scoped efferent gate. + + Admission requires a valid ``(32,)`` vector and one of: + ``decode:``, ``decode:*``, or ``*`` in the authority token. + """ + + policy_id: str = "default-efferent-v1" + + @property + def policy_sha256(self) -> str: + return sha256_json({ + "policy_id": self.policy_id, + "required_capability": "decode:", + "wildcards": ["decode:*", "*"], + "shape": [CL41_DIM], + }) + + 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) + admitted = required in caps or "decode:*" in caps or "*" in caps + return EfferentVerdict( + admitted=admitted, + reason="admitted" if admitted else f"missing capability: {required}", + authority_sha256=authority.authority_sha256, + policy_sha256=self.policy_sha256, + ) + + def trace( + self, + pack_id: str, + authority: AuthorityToken, + verdict: EfferentVerdict, + ) -> EfferentEmissionTrace: + capability = f"decode:{pack_id}" + payload = { + "kind": "EfferentEmissionTrace", + "pack_id": pack_id, + "admitted": verdict.admitted, + "reason": verdict.reason, + "authority_sha256": authority.authority_sha256, + "policy_sha256": verdict.policy_sha256, + "capability": capability, + } + return EfferentEmissionTrace( + pack_id=pack_id, + admitted=verdict.admitted, + reason=verdict.reason, + authority_sha256=authority.authority_sha256, + policy_sha256=verdict.policy_sha256, + capability=capability, + trace_sha256=sha256_json(payload), + ) diff --git a/sensorium/environment/__init__.py b/sensorium/environment/__init__.py index d077e09f..9c909434 100644 --- a/sensorium/environment/__init__.py +++ b/sensorium/environment/__init__.py @@ -1,5 +1,6 @@ """Environmental observation contracts for sensorium units.""" from sensorium.environment.frame import ObservationFrame, build_observation_frame +from sensorium.environment.harness import build_fixture_observation_frame -__all__ = ["ObservationFrame", "build_observation_frame"] +__all__ = ["ObservationFrame", "build_fixture_observation_frame", "build_observation_frame"] diff --git a/sensorium/environment/harness.py b/sensorium/environment/harness.py new file mode 100644 index 00000000..e9dd0145 --- /dev/null +++ b/sensorium/environment/harness.py @@ -0,0 +1,54 @@ +"""Deterministic fixture harness for environmental observation frames.""" + +from __future__ import annotations + +from sensorium.environment.frame import ObservationFrame, build_observation_frame + +_AUDIO_FIXTURE = {"id": "env_tone", "kind": "tone", "ms": 300, "hz": 150, "sweep": 90, "amp": 0.5} +_VISION_FIXTURE = {"id": "env_corner", "kind": "corner", "size": 32} +_SENSORIMOTOR_FIXTURE = { + "id": "env_contact", + "pose_q": [10, -4, 3], + "velocity_q": [2, 0, -1], + "force_torque_q": [3, 5, 8], + "contact_q": [1, 0, 1], + "actuator_state_q": [7, 8], +} + + +def build_fixture_observation_frame( + *, + monotonic_tick: int = 0, + source_clock: str = "fixture-clock", + causal_parent_ids: tuple[str, ...] = (), +) -> ObservationFrame: + """Build a deterministic mixed-modality ObservationFrame. + + The frame contains already-compiled afferent units only: one audio unit, + one vision tile unit, and one sensorimotor feedback unit. + """ + + from evals.audio_sensorium.synth import synthesize as synthesize_audio + from evals.sensorimotor_sensorium.synth import synthesize as synthesize_sensorimotor + from evals.vision_sensorium.synth import synthesize as synthesize_vision + from sensorium.audio.canonical import canonicalize as canonicalize_audio + from sensorium.audio.compiler import AudioCompiler + from sensorium.sensorimotor.compiler import SensorimotorCompiler + from sensorium.vision import VisionCompiler, canonicalize_image + from sensorium.vision.grid import iter_tile_signals + + audio_unit = AudioCompiler().compile_signal( + canonicalize_audio(synthesize_audio(_AUDIO_FIXTURE), 24_000) + ) + vision_image = canonicalize_image(synthesize_vision(_VISION_FIXTURE)) + vision_tile = iter_tile_signals(vision_image)[0] + vision_unit = VisionCompiler().compile_tile(vision_tile) + sensorimotor_unit = SensorimotorCompiler().compile_signal( + synthesize_sensorimotor(_SENSORIMOTOR_FIXTURE) + ) + return build_observation_frame( + monotonic_tick=monotonic_tick, + source_clock=source_clock, + units=(audio_unit, vision_unit, sensorimotor_unit), + causal_parent_ids=causal_parent_ids, + ) diff --git a/sensorium/sensorimotor/compiler.py b/sensorium/sensorimotor/compiler.py index 5f43a095..89d936b1 100644 --- a/sensorium/sensorimotor/compiler.py +++ b/sensorium/sensorimotor/compiler.py @@ -130,9 +130,14 @@ class SensorimotorCompiler: modality = "sensorimotor" - def __init__(self, pack_id: str = "sensorimotor_core_v1") -> None: + def __init__( + self, + pack_id: str = "sensorimotor_core_v1", + *, + pack_manifest_sha256: str | None = None, + ) -> None: self._pack_id = pack_id - self._manifest_sha256 = sha256_json({ + self._manifest_sha256 = pack_manifest_sha256 or sha256_json({ "pack_id": pack_id, "basis_version": "sensorimotor-basis-v1", "events": list(_EVENT_ORDER), diff --git a/tests/test_efferent_gate.py b/tests/test_efferent_gate.py new file mode 100644 index 00000000..4f812219 --- /dev/null +++ b/tests/test_efferent_gate.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from sensorium.efferent import DefaultEfferentGate +from sensorium.protocol import AuthorityToken, EfferentRefusal, Modality, ModalityPack, ModalityVocabulary +from sensorium.registry import ModalityRegistry + + +class _Decoder: + modality = Modality.MOTOR + + def __init__(self) -> None: + self.calls = 0 + + def decode(self, mv: np.ndarray) -> str: + self.calls += 1 + return "decoded" + + def decode_batch(self, mvs: np.ndarray) -> list[str]: + self.calls += len(mvs) + return ["decoded" for _ in range(len(mvs))] + + +def _mv() -> np.ndarray: + out = np.zeros(32, dtype=np.float32) + out[0] = 1.0 + return out + + +def _authority(*capabilities: str) -> AuthorityToken: + return AuthorityToken( + principal_id="test-principal", + capabilities=tuple(capabilities), + issued_at_revision="test-revision", + ) + + +def _pack(decoder: _Decoder) -> ModalityPack: + return ModalityPack( + pack_id="motor_test", + modality_type=Modality.MOTOR, + vocabulary=ModalityVocabulary(), + grammar_scaffold=None, + checksum_verified=True, + decoder=decoder, + gate_engaged=True, + ) + + +def test_default_efferent_gate_admits_exact_and_wildcard_capabilities(): + gate = DefaultEfferentGate() + assert gate.admit("motor_test", _mv(), _authority("decode:motor_test")).admitted + assert gate.admit("motor_test", _mv(), _authority("decode:*")).admitted + assert gate.admit("motor_test", _mv(), _authority("*")).admitted + + +def test_default_efferent_gate_denies_missing_capability_and_bad_shape(): + gate = DefaultEfferentGate() + denied = gate.admit("motor_test", _mv(), _authority("decode:other")) + assert denied.admitted is False + assert "missing capability" in denied.reason + + malformed = gate.admit("motor_test", np.zeros(31, dtype=np.float32), _authority("decode:motor_test")) + assert malformed.admitted is False + assert "invalid efferent vector shape" in malformed.reason + + +def test_default_efferent_trace_is_hash_only(): + gate = DefaultEfferentGate() + authority = _authority("decode:motor_test") + verdict = gate.admit("motor_test", _mv(), authority) + trace = gate.trace("motor_test", authority, verdict).as_dict() + assert trace["admitted"] is True + assert trace["capability"] == "decode:motor_test" + assert "mv" not in trace + for value in trace.values(): + assert not isinstance(value, (np.ndarray, bytes, bytearray)) + + +def test_registry_uses_default_efferent_gate_before_decoder(): + decoder = _Decoder() + reg = ModalityRegistry(efferent_gate=DefaultEfferentGate()) + 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 diff --git a/tests/test_observation_frame_harness.py b/tests/test_observation_frame_harness.py new file mode 100644 index 00000000..de550fbe --- /dev/null +++ b/tests/test_observation_frame_harness.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import numpy as np + +from sensorium.environment import build_fixture_observation_frame + + +def test_fixture_observation_frame_is_mixed_modality_and_deterministic(): + f1 = build_fixture_observation_frame(monotonic_tick=7, source_clock="test-clock") + f2 = build_fixture_observation_frame(monotonic_tick=7, source_clock="test-clock") + assert f1.frame_id == f2.frame_id + assert f1.environment_sha256 == f2.environment_sha256 + assert f1.trace_hash == f2.trace_hash + assert {unit.pack_id for unit in f1.units} == { + "audio_core_v1", + "sensorimotor_core_v1", + "vision_core_v1", + } + for unit in f1.units: + assert unit.versor.shape == (32,) + assert unit.versor.dtype == np.float32 + assert unit.versor_condition < 1e-6 + + +def test_fixture_observation_frame_tick_changes_identity_not_unit_set(): + f1 = build_fixture_observation_frame(monotonic_tick=7) + f2 = build_fixture_observation_frame(monotonic_tick=8) + assert f1.trace_hash != f2.trace_hash + assert [unit.merge_key for unit in f1.units] == [unit.merge_key for unit in f2.units] diff --git a/tests/test_sensorimotor_pack_manifest.py b/tests/test_sensorimotor_pack_manifest.py new file mode 100644 index 00000000..42fbd307 --- /dev/null +++ b/tests/test_sensorimotor_pack_manifest.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import numpy as np +import pytest + +from evals.sensorimotor_sensorium.synth import synthesize +from packs.sensorimotor.loader import SensorimotorPackError, load_sensorimotor_pack +from sensorium.adapters.sensorimotor import SensorimotorProjectionHead, make_sensorimotor_pack +from sensorium.protocol import Modality +from sensorium.registry import ModalityRegistry +from sensorium.sensorimotor import SensorimotorCompiler + + +def _fixture_signal(): + return synthesize({ + "id": "pack_probe", + "pose_q": [1, 2, 3], + "velocity_q": [0, 0, 1], + "force_torque_q": [5, 8, 13], + "contact_q": [1, 0], + "actuator_state_q": [3, 5], + }) + + +def test_sensorimotor_pack_loads_and_mounts_closed_by_default(): + loaded = load_sensorimotor_pack("sensorimotor_core_v1") + assert loaded.pack_id == "sensorimotor_core_v1" + assert loaded.manifest["modality"] == "sensorimotor" + assert loaded.manifest["gate_engaged"] is False + + pack = make_sensorimotor_pack("sensorimotor_core_v1") + assert pack.modality_type is Modality.SENSORIMOTOR + assert pack.decoder is None + assert pack.gate_engaged is False + + reg = ModalityRegistry() + reg.mount(pack, sample=_fixture_signal()) + with pytest.raises(RuntimeError, match="gate is not engaged"): + reg.project("sensorimotor_core_v1", _fixture_signal()) + + +def test_sensorimotor_projection_head_is_deterministic_when_engaged(): + sample = _fixture_signal() + head = SensorimotorProjectionHead(SensorimotorCompiler()) + assert head.verify_unitarity(sample) + mv = head.project(sample) + assert mv.shape == (32,) + assert mv.dtype == np.float32 + assert np.array_equal(mv, head.project(sample)) + + reg = ModalityRegistry() + reg.mount( + make_sensorimotor_pack( + "sensorimotor_core_v1", + gate_engaged=True, + checksum_verified=True, + ), + sample=sample, + ) + assert np.array_equal(reg.project("sensorimotor_core_v1", sample), mv) + + +def test_sensorimotor_pack_rejects_path_traversal_and_checksum_mismatch(tmp_path: Path): + with pytest.raises(SensorimotorPackError): + load_sensorimotor_pack("../sensorimotor_core_v1") + + src = Path("packs/sensorimotor/sensorimotor_core_v1") + root = tmp_path / "packs" + shutil.copytree(src, root / "sensorimotor_core_v1") + manifest = root / "sensorimotor_core_v1" / "manifest.json" + manifest.write_text(manifest.read_text().replace('"cl41_dim": 32', '"cl41_dim": 31')) + with pytest.raises(SensorimotorPackError, match="checksum mismatch"): + load_sensorimotor_pack("sensorimotor_core_v1", packs_root=root) diff --git a/tests/test_sensorium_eval_cli.py b/tests/test_sensorium_eval_cli.py new file mode 100644 index 00000000..177cd496 --- /dev/null +++ b/tests/test_sensorium_eval_cli.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json + +from core.cli import main + + +def test_core_eval_sensorium_json_reports_selected_modality(capsys): + assert main(["eval", "sensorium", "--modality", "sensorimotor", "--json"]) == 0 + out = capsys.readouterr().out + report = json.loads(out) + assert report["lane"] == "sensorium" + assert report["modality"] == "sensorimotor" + assert report["pack_id"] == "sensorimotor_core_v1" + assert report["gate_closed"] is True + assert report["failed"] == 0 + + +def test_core_eval_sensorium_text_summary(capsys): + assert main(["eval", "sensorium", "--modality", "vision"]) == 0 + out = capsys.readouterr().out + assert "lane : sensorium" in out + assert "modality : vision" in out + assert "gate_closed : True" in out + assert "failed : 0" in out diff --git a/tests/test_sensorium_eval_report.py b/tests/test_sensorium_eval_report.py new file mode 100644 index 00000000..dfb7b3be --- /dev/null +++ b/tests/test_sensorium_eval_report.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import json + +from evals.sensorium import build_sensorium_report + + +def test_sensorium_reports_are_deterministic_and_gate_closed(): + for modality in ("audio", "vision", "sensorimotor"): + first = build_sensorium_report(modality) + second = build_sensorium_report(modality) + assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True) + assert first["lane"] == "sensorium" + assert first["modality"] == modality + assert first["gate_engaged"] is False + assert first["gate_closed"] is True + assert first["total"] > 0 + assert first["failed"] == 0 + assert first["passed"] == first["total"]