core/sensorium/logs/importer.py
Shay 7f5958dbd5 chore(sensorium): ratify environment-falsification roadmap — witness-importer hardening + ADR-0198 reconciliation
Review/ratification pass over the 6-phase afferent environment-falsification
roadmap (rebased onto D-inclusive main). The roadmap validated green: smoke (90),
architectural invariants (56), the full sensorium suite, all phase tests, and the
four eval lanes (environment-falsification / event-vision / vision / sensorimotor)
report failed:0. A five-lens adversarial review found the efferent gate
fail-closed (default-deny, conjunctive safety∧ethics∧tool_scope, refuses before
the decoder, hash-only traces, no real decoder, no overclaim), the afferent
Phases 1-5 read-only (runtime import-guards + frozen dataclasses), and the
expected-artifact obligations deterministic + meaningfully-failing. Two
reconciliations from that review:

1. Witness-log importer trust boundary (sensorium/logs/importer.py). The offline
   JSONL importer now rejects malformed input with a deterministic ValueError
   instead of a raw JSONDecodeError/KeyError, names the 1-based line on a bad
   JSON line, rejects non-object records and non-integer ticks, and size-caps the
   file (8 MiB) so an oversized log is refused rather than read whole. The trust
   boundary is now stated in the module + function docstrings. Behavior for VALID
   logs is byte-identical (the environment-falsification lane hashes are
   unchanged). New rejection-path tests added.

2. ADR-0198 reconciliation. The §3 verdict-enforcing GATE mechanism
   (VerdictEnforcingEfferentGate + MotorActionIntent) has now landed, so the
   Implementation Status is amended to say so — while being explicit that the §3
   lowering itself (deriving verdicts from the ADR-0029/0033/0036/0037 governance
   packs), the motor decoder (every adapter is decoder=None), and ratification by
   the dedicated motor governance ADR (now drafted as ADR-0216, Proposed) all
   remain deferred. No physical motor decode is authorized.

Disjoint from the GSM8K serving path and from Step D (generate/determine|realize,
chat/runtime, core/config) — clean rebase, no overlap.
2026-06-06 12:54:31 -07:00

315 lines
11 KiB
Python

"""Offline JSONL witness-log importer.
Witness logs are evidence transport, not truth. The importer accepts only
payload references and uses a caller-provided resolver to obtain already
bounded afferent compilation units.
Trust boundary: a witness log is UNTRUSTED, operator-supplied OFFLINE input
(never a live device or network handle — those fields are rejected). Every
record is validated at this boundary and a malformed/oversized/ill-typed input
is rejected with a deterministic ``ValueError`` (or ``FileNotFoundError`` for a
missing path) BEFORE any frame is built — it never partially imports, mutates no
runtime/vault/identity state, opens no decode path, and the file is size-capped
so an oversized log cannot exhaust memory.
"""
from __future__ import annotations
import json
from collections import defaultdict
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from sensorium.audio.checksum import sha256_json
from sensorium.compiler.protocol import CompilationUnitLike
from sensorium.environment import ObservationFrame, ObservationUnitRef, build_observation_frame
_RAW_KEYS = {"pixels", "samples", "pcm", "waveform", "raw_bytes", "action_trace", "events"}
_LIVE_KEYS = {"device", "device_handle", "socket", "url", "network", "ros_node", "mcap_reader"}
_ALLOWED_MODALITIES = {"audio", "vision", "event-vision", "sensorimotor"}
#: Hard cap on an offline witness log (generous for a lab session; bounds memory
#: so an oversized/accidental file is rejected deterministically, never read whole).
_MAX_WITNESS_LOG_BYTES = 8 * 1024 * 1024
def _require(row: Mapping[str, Any], key: str) -> Any:
"""Return ``row[key]`` or raise a deterministic ``ValueError`` (never KeyError)."""
if key not in row:
raise ValueError(f"witness record missing required field: {key}")
return row[key]
@dataclass(frozen=True, slots=True)
class WitnessLogManifest:
source_kind: str
source_sha256: str
schema_version: str
record_count: int
manifest_sha256: str
def as_dict(self) -> dict[str, object]:
return {
"source_kind": self.source_kind,
"source_sha256": self.source_sha256,
"schema_version": self.schema_version,
"record_count": self.record_count,
"manifest_sha256": self.manifest_sha256,
}
@dataclass(frozen=True, slots=True)
class WitnessRecord:
tick: int
source_clock: str
modality: str
slot_id: str
payload_ref: str
provenance_sha256: str
def as_dict(self) -> dict[str, object]:
return {
"tick": self.tick,
"source_clock": self.source_clock,
"modality": self.modality,
"slot_id": self.slot_id,
"payload_ref": self.payload_ref,
"provenance_sha256": self.provenance_sha256,
}
@dataclass(frozen=True, slots=True)
class ImportedObservationSequence:
manifest: WitnessLogManifest
frames: tuple[ObservationFrame, ...]
frame_refs: tuple[tuple[str, tuple[ObservationUnitRef, ...]], ...]
trace_hash: str
def as_dict(self) -> dict[str, object]:
return {
"manifest": self.manifest.as_dict(),
"frames": [
{
"frame_id": frame.frame_id,
"monotonic_tick": frame.monotonic_tick,
"source_clock": frame.source_clock,
"environment_sha256": frame.environment_sha256,
"trace_hash": frame.trace_hash,
}
for frame in self.frames
],
"frame_refs": [
{
"frame_id": frame_id,
"slots": [ref.slot_id for ref in refs],
}
for frame_id, refs in self.frame_refs
],
"trace_hash": self.trace_hash,
}
def _safe_ref(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty string")
if ".." in value or "/" in value or "\\" in value:
raise ValueError(f"{field} must not contain path traversal")
for ch in value:
if not (ch.isascii() and (ch.isalnum() or ch in {"_", "-", ":", "."})):
raise ValueError(f"{field} contains an unsafe character")
return value
def _reject_raw_or_live(value: object) -> None:
if isinstance(value, Mapping):
for key, child in value.items():
key_str = str(key)
if key_str in _RAW_KEYS:
raise ValueError(f"witness trace record contains raw payload field: {key_str}")
if key_str in _LIVE_KEYS:
raise ValueError(f"witness trace record contains live device field: {key_str}")
_reject_raw_or_live(child)
elif isinstance(value, (list, tuple)):
for child in value:
_reject_raw_or_live(child)
def _record_from_mapping(row: Mapping[str, Any]) -> WitnessRecord:
if not isinstance(row, Mapping):
raise ValueError(f"witness record must be a JSON object, got {type(row).__name__}")
_reject_raw_or_live(row)
try:
tick = int(_require(row, "tick"))
except (TypeError, ValueError) as exc:
raise ValueError(f"witness tick must be an integer: {exc}") from exc
if tick < 0:
raise ValueError("witness tick must be non-negative")
modality = _safe_ref(_require(row, "modality"), field="modality")
if modality not in _ALLOWED_MODALITIES:
raise ValueError(f"unsupported witness modality: {modality}")
slot_id = _safe_ref(_require(row, "slot_id"), field="slot_id")
payload_ref = _safe_ref(_require(row, "payload_ref"), field="payload_ref")
source_clock = _safe_ref(row.get("source_clock", "witness-jsonl"), field="source_clock")
payload = {
"kind": "WitnessRecord",
"tick": tick,
"source_clock": source_clock,
"modality": modality,
"slot_id": slot_id,
"payload_ref": payload_ref,
}
return WitnessRecord(
tick=tick,
source_clock=source_clock,
modality=modality,
slot_id=slot_id,
payload_ref=payload_ref,
provenance_sha256=sha256_json(payload),
)
def _build_manifest(
records: tuple[WitnessRecord, ...],
*,
source_kind: str,
schema_version: str,
) -> WitnessLogManifest:
source_sha256 = sha256_json({
"kind": "WitnessLog.source",
"records": [record.as_dict() for record in records],
})
payload = {
"kind": "WitnessLogManifest",
"source_kind": source_kind,
"source_sha256": source_sha256,
"schema_version": schema_version,
"record_count": len(records),
}
return WitnessLogManifest(
source_kind=source_kind,
source_sha256=source_sha256,
schema_version=schema_version,
record_count=len(records),
manifest_sha256=sha256_json(payload),
)
def import_witness_records(
rows: Iterable[Mapping[str, Any]],
*,
resolve_payload_ref: Callable[[str], CompilationUnitLike],
source_kind: str = "jsonl-witness-v1",
schema_version: str = "1",
) -> ImportedObservationSequence:
records = tuple(sorted(
(_record_from_mapping(row) for row in rows),
key=lambda record: (
record.tick,
record.source_clock,
record.slot_id,
record.payload_ref,
record.provenance_sha256,
),
))
manifest = _build_manifest(
records,
source_kind=_safe_ref(source_kind, field="source_kind"),
schema_version=_safe_ref(schema_version, field="schema_version"),
)
by_frame: dict[tuple[int, str], list[WitnessRecord]] = defaultdict(list)
for record in records:
by_frame[(record.tick, record.source_clock)].append(record)
frames: list[ObservationFrame] = []
frame_refs: list[tuple[str, tuple[ObservationUnitRef, ...]]] = []
for (tick, source_clock), frame_records in sorted(by_frame.items()):
refs = tuple(
ObservationUnitRef(record.slot_id, resolve_payload_ref(record.payload_ref))
for record in frame_records
)
frame = build_observation_frame(
monotonic_tick=tick,
source_clock=source_clock,
units=tuple(ref.unit for ref in refs),
causal_parent_ids=(manifest.manifest_sha256,),
)
frames.append(frame)
frame_refs.append((frame.frame_id, refs))
trace_hash = sha256_json({
"kind": "ImportedObservationSequence",
"manifest_sha256": manifest.manifest_sha256,
"frame_trace_hashes": [frame.trace_hash for frame in frames],
"frame_ref_slots": [
{"frame_id": frame_id, "slots": [ref.slot_id for ref in refs]}
for frame_id, refs in frame_refs
],
})
return ImportedObservationSequence(
manifest=manifest,
frames=tuple(frames),
frame_refs=tuple(frame_refs),
trace_hash=trace_hash,
)
def _validate_offline_path(path: Path) -> Path:
"""Resolve an OFFLINE witness-log path, rejecting traversal and oversized files.
A witness log is untrusted operator input; the size cap bounds memory so an
oversized/accidental file is rejected deterministically rather than read whole.
"""
if ".." in path.parts:
raise ValueError("witness log path must not contain path traversal")
resolved = path.resolve()
if not resolved.is_file():
raise FileNotFoundError(resolved)
size = resolved.stat().st_size
if size > _MAX_WITNESS_LOG_BYTES:
raise ValueError(
f"witness log too large: {size} bytes exceeds cap {_MAX_WITNESS_LOG_BYTES}"
)
return resolved
def import_witness_jsonl(
path: str | Path,
*,
resolve_payload_ref: Callable[[str], CompilationUnitLike],
schema_version: str = "1",
) -> ImportedObservationSequence:
"""Import an OFFLINE JSONL witness log (one JSON object per non-blank line).
Trust boundary (see module docstring): the path is traversal-checked and
size-capped; a malformed line is rejected with a deterministic ``ValueError``
naming the 1-based line number, BEFORE any frame is built. Valid logs import
byte-identically to the equivalent ``import_witness_records`` call.
"""
log_path = _validate_offline_path(Path(path))
rows: list[Any] = []
for lineno, line in enumerate(
log_path.read_text(encoding="utf-8").splitlines(), start=1
):
if not line.strip():
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError as exc:
raise ValueError(f"witness log line {lineno}: malformed JSON: {exc}") from exc
return import_witness_records(
rows,
resolve_payload_ref=resolve_payload_ref,
source_kind="jsonl-witness-v1",
schema_version=schema_version,
)
__all__ = [
"ImportedObservationSequence",
"WitnessLogManifest",
"WitnessRecord",
"import_witness_jsonl",
"import_witness_records",
]