core/tests/test_audio_teachers.py
Shay f017785a6d
feat(adr-0181-p6): audio teacher/shadow lanes — typed hints, never substrate (#479)
Implements ADR-0181 PR-6 (eval-plan §4): teachers label or align; they never
define the substrate and never fold embeddings into the versor path.

- sensorium/audio/teachers.py:
  - TeacherHint: typed, versioned, checksummed annotation (no raw embeddings).
  - AudioTeacher protocol (pure on the signal).
  - attach_teacher_hints: the ONLY admission path — appends content.* anchors to
    the IR's content_anchors (immutable, recomputes ir_sha256). content.* is not
    an operator key, so compile_events skips it: versor + projection_sha256 stay
    byte-identical; only the ir leg of the merge_key moves (evidence recorded).
  - KNOWN_TEACHER_LANES (whisper/nemo/clap/encodec): declared + gated behind
    optional extras; load_teacher import-guards and fails loudly (never a silent
    fallback). StubTranscriptTeacher is the deterministic reference instance.
- parser.py: extract _ir_payload + ir_sha256_of (DRY single source of truth for
  ir_sha256; byte-identical to parse() output — regression-guarded).
- pyproject.toml: audio-whisper/nemo/clap/encodec optional extras (never
  runtime-required).

16 failable proof tests in tests/test_audio_teachers.py. Load-bearing:
test_teacher_hint_does_not_change_versor. Mutation-verified — giving a teacher
anchor an operator event_type (folding it into the versor) fails the
versor-invariance proof; reverted, all pass.

Additive only (ADR-0013): no core layer touched. Audio suite 57/57; eval-gate
ir_sha256 pins unchanged by the parser refactor; architectural invariants 40/40.
Real model adapters are deferred until extras+weights are present; this PR ships
the policy, the typed-hint contract, and the shadow-only guarantee.
2026-05-29 13:36:33 -07:00

175 lines
6 KiB
Python

"""
ADR-0181 PR-6 — teacher / shadow lane proof obligations.
The load-bearing rule (eval-plan §4): teachers label or align, they NEVER define
the substrate. The structural guarantee is that admitting any teacher hint leaves
the **versor and `projection_sha256` byte-identical** — a hint adds IR evidence
(`content_anchors`) and moves only `ir_sha256`, never the geometry that crosses
the ProjectionHead boundary.
Per CLAUDE.md §Schema-Defined Proof Obligations, the tests FAIL LOUDLY under the
violation each names: if a teacher anchor ever lowered to a rotor (e.g. attached
under an operator category instead of `content.*`), the substrate-invariance test
breaks; if `attach_teacher_hints` silently dropped hints, the evidence-recorded
test breaks.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from evals.audio_sensorium.synth import synthesize
from sensorium.audio.canonical import canonicalize
from sensorium.audio.compiler import AudioCompiler
from sensorium.audio.operators import DEFAULT_OPERATOR_REGISTRY
from sensorium.audio.parser import ir_sha256_of
from sensorium.audio.teachers import (
KNOWN_TEACHER_LANES,
StubTranscriptTeacher,
TeacherHint,
TeacherUnavailable,
attach_teacher_hints,
is_lane_available,
load_teacher,
)
SR = 24_000
_EVAL_DIR = Path("evals/audio_sensorium")
def _fixtures() -> list[dict]:
return json.loads((_EVAL_DIR / "fixtures.json").read_text())["fixtures"]
@pytest.fixture(scope="module")
def compiler() -> AudioCompiler:
return AudioCompiler()
@pytest.fixture(scope="module")
def signal():
# a voiced fixture so the base versor is non-trivial (real rotors fold in)
fx = next(f for f in _fixtures() if f["id"] == "rise_question")
return canonicalize(synthesize(fx), SR)
@pytest.fixture
def base_unit(compiler, signal):
return compiler.compile_signal(signal)
@pytest.fixture
def hints(signal):
return StubTranscriptTeacher().annotate(signal)
# --- the load-bearing rule: teachers never define the substrate ------------
def test_teacher_hint_does_not_change_versor(compiler, base_unit, hints):
augmented_ir = attach_teacher_hints(base_unit.audio_ir, hints)
taught = compiler.compile_ir(augmented_ir)
# substrate is invariant to the teacher
assert np.array_equal(base_unit.versor, taught.versor)
assert base_unit.projection_sha256 == taught.projection_sha256
assert taught.versor_condition < 1e-6
def test_teacher_hint_is_recorded_as_evidence(compiler, base_unit, hints):
augmented_ir = attach_teacher_hints(base_unit.audio_ir, hints)
taught = compiler.compile_ir(augmented_ir)
# evidence is recorded => ir_sha256 (and only the ir leg of merge_key) moves
assert taught.ir_sha256 != base_unit.ir_sha256
assert taught.merge_key != base_unit.merge_key
assert taught.merge_key[2] == base_unit.merge_key[2] # projection leg unchanged
assert len(augmented_ir.content_anchors) == len(hints)
def test_teacher_anchors_carry_no_operator(base_unit, hints):
"""Structural guarantee: content.* anchors are not operator keys, so
compile_events skips them (no rotor)."""
augmented_ir = attach_teacher_hints(base_unit.audio_ir, hints)
for anchor in augmented_ir.content_anchors:
assert anchor.event_type.startswith("content.")
assert anchor.event_type not in DEFAULT_OPERATOR_REGISTRY
def test_attach_is_immutable(base_unit, hints):
original_anchors = base_unit.audio_ir.content_anchors
original_sha = base_unit.audio_ir.ir_sha256
_ = attach_teacher_hints(base_unit.audio_ir, hints)
assert base_unit.audio_ir.content_anchors == original_anchors
assert base_unit.audio_ir.ir_sha256 == original_sha
def test_attach_empty_is_noop(base_unit):
assert attach_teacher_hints(base_unit.audio_ir, ()) is base_unit.audio_ir
# --- ir_sha256_of refactor regression guard --------------------------------
def test_ir_sha256_of_matches_parse(base_unit):
# the extracted hash function must reproduce what parse() stored
assert ir_sha256_of(base_unit.audio_ir) == base_unit.ir_sha256
def test_attached_ir_sha_is_recomputable(base_unit, hints):
augmented = attach_teacher_hints(base_unit.audio_ir, hints)
assert ir_sha256_of(augmented) == augmented.ir_sha256
# --- hint typing / versioning / checksum -----------------------------------
def test_hint_is_versioned_and_checksummed():
a = TeacherHint.make(
lane_id="x", lane_version="v1", hint_type="transcript",
start_hop=0, end_hop=5, payload=(("token", "abcd"),), confidence_q=100,
)
same = TeacherHint.make(
lane_id="x", lane_version="v1", hint_type="transcript",
start_hop=0, end_hop=5, payload=(("token", "abcd"),), confidence_q=100,
)
diff = TeacherHint.make(
lane_id="x", lane_version="v1", hint_type="transcript",
start_hop=0, end_hop=5, payload=(("token", "WXYZ"),), confidence_q=100,
)
assert a.hint_sha256 == same.hint_sha256 # content-addressed, stable
assert a.hint_sha256 != diff.hint_sha256 # changes with payload
assert a.evidence_id == f"x:v1:{a.hint_sha256}"
def test_stub_teacher_is_deterministic(signal):
t = StubTranscriptTeacher()
assert t.annotate(signal)[0].hint_sha256 == t.annotate(signal)[0].hint_sha256
# --- lane registry: declared, gated, graceful ------------------------------
def test_known_lanes_match_eval_plan():
assert set(KNOWN_TEACHER_LANES) == {"whisper", "nemo", "clap", "encodec"}
def test_load_unknown_lane_raises_value_error():
with pytest.raises(ValueError, match="unknown teacher lane"):
load_teacher("does_not_exist")
def test_load_declared_lane_without_adapter_is_unavailable():
# lanes are declared + gated; no adapter is wired yet -> loud, never silent
with pytest.raises(TeacherUnavailable):
load_teacher("whisper")
def test_is_lane_available_does_not_raise():
# pure probe; returns a bool for known and unknown lanes
assert isinstance(is_lane_available("whisper"), bool)
assert is_lane_available("does_not_exist") is False