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.
253 lines
9.4 KiB
Python
253 lines
9.4 KiB
Python
"""
|
|
sensorium/audio/teachers.py — teacher / shadow lanes (ADR-0181 PR-6).
|
|
|
|
Teachers **label or align**; they never define the substrate and never fold
|
|
embeddings into the main versor path (eval-plan §4, verbatim):
|
|
|
|
Use teachers to label or align.
|
|
Never let teachers define the substrate.
|
|
Never fold teacher embeddings directly into the main versor path.
|
|
Only admit teacher outputs through typed, versioned, checksumed hints.
|
|
|
|
A teacher emits typed, versioned, checksummed `TeacherHint`s. The only admission
|
|
path is `attach_teacher_hints`, which appends them to the IR's `content_anchors`
|
|
as `content.*` events. `content.*` is **not** an operator-registry key, so
|
|
`compile_events` skips them (compiler.py: "Events whose type has no operator are
|
|
skipped"): a hint contributes IR evidence and a different `ir_sha256`, but the
|
|
**versor and `projection_sha256` are byte-identical** with or without it. That is
|
|
the structural guarantee that teachers are shadow, proven failably in
|
|
`tests/test_audio_teachers.py`.
|
|
|
|
Real model lanes (Whisper / NeMo / CLAP / EnCodec) are declared and gated behind
|
|
optional extras; their adapters are import-guarded and degrade gracefully when
|
|
the extra is absent. The deterministic `StubTranscriptTeacher` is the reference
|
|
implementation of the contract and needs no model weights.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
from dataclasses import dataclass, replace
|
|
from typing import Callable, Literal, Protocol, Sequence, runtime_checkable
|
|
|
|
from sensorium.audio.checksum import sha256_json
|
|
from sensorium.audio.parser import ir_sha256_of
|
|
from sensorium.audio.types import AudioIR, AudioSignal, AuditoryEvent
|
|
|
|
HintType = Literal["transcript", "alignment", "sound_event", "lang_id", "reconstruction"]
|
|
|
|
# The IR namespace teacher hints live under. Must never collide with an operator
|
|
# key (speech.* / pause.* / prosody.* / turn.* / nonspeech.*), or a hint would
|
|
# lower to a rotor and become substrate.
|
|
CONTENT_ANCHOR_PREFIX = "content"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TeacherHint:
|
|
"""A typed, versioned, checksummed teacher annotation (eval-plan §4).
|
|
|
|
`payload` is typed/quantized ints or short strings — never a raw embedding.
|
|
`hint_sha256` checksums the canonical payload so the hint is content-addressed
|
|
and tamper-evident.
|
|
"""
|
|
|
|
lane_id: str
|
|
lane_version: str
|
|
hint_type: HintType
|
|
start_hop: int
|
|
end_hop: int
|
|
payload: tuple[tuple[str, int | str], ...]
|
|
confidence_q: int # 0..255
|
|
hint_sha256: str
|
|
|
|
@classmethod
|
|
def make(
|
|
cls,
|
|
*,
|
|
lane_id: str,
|
|
lane_version: str,
|
|
hint_type: HintType,
|
|
start_hop: int,
|
|
end_hop: int,
|
|
payload: tuple[tuple[str, int | str], ...] = (),
|
|
confidence_q: int = 0,
|
|
) -> TeacherHint:
|
|
digest = sha256_json(
|
|
{
|
|
"lane_id": lane_id,
|
|
"lane_version": lane_version,
|
|
"hint_type": hint_type,
|
|
"start_hop": start_hop,
|
|
"end_hop": end_hop,
|
|
"payload": [list(p) for p in payload],
|
|
"confidence_q": confidence_q,
|
|
}
|
|
)
|
|
return cls(
|
|
lane_id=lane_id,
|
|
lane_version=lane_version,
|
|
hint_type=hint_type,
|
|
start_hop=start_hop,
|
|
end_hop=end_hop,
|
|
payload=payload,
|
|
confidence_q=confidence_q,
|
|
hint_sha256=digest,
|
|
)
|
|
|
|
@property
|
|
def evidence_id(self) -> str:
|
|
return f"{self.lane_id}:{self.lane_version}:{self.hint_sha256}"
|
|
|
|
def to_anchor(self) -> AuditoryEvent:
|
|
"""Lower to a `content.*` IR anchor — evidence only, never a rotor."""
|
|
attrs: tuple[tuple[str, int | str], ...] = (
|
|
("lane_id", self.lane_id),
|
|
("lane_version", self.lane_version),
|
|
("hint_type", self.hint_type),
|
|
("confidence_q", self.confidence_q),
|
|
*self.payload,
|
|
)
|
|
return AuditoryEvent(
|
|
event_type=f"{CONTENT_ANCHOR_PREFIX}.{self.hint_type}",
|
|
start_hop=self.start_hop,
|
|
end_hop=self.end_hop,
|
|
attrs=attrs,
|
|
evidence_ids=(self.evidence_id,),
|
|
)
|
|
|
|
|
|
@runtime_checkable
|
|
class AudioTeacher(Protocol):
|
|
"""A shadow-lane annotator. `annotate` must be pure on the signal — no
|
|
cross-modal or global state — mirroring the compiler's determinism so a hint
|
|
never depends on ingest order."""
|
|
|
|
lane_id: str
|
|
lane_version: str
|
|
|
|
def annotate(self, signal: AudioSignal) -> tuple[TeacherHint, ...]: ...
|
|
|
|
|
|
def attach_teacher_hints(ir: AudioIR, hints: Sequence[TeacherHint]) -> AudioIR:
|
|
"""Admit teacher hints into an IR as `content_anchors` (the ONLY admission
|
|
path). Returns a NEW AudioIR with `ir_sha256` recomputed; the input is
|
|
unchanged (immutable). The versor is unaffected — `content.*` anchors carry
|
|
no operator (proven in tests)."""
|
|
if not hints:
|
|
return ir
|
|
anchors = tuple(h.to_anchor() for h in hints)
|
|
merged = tuple(
|
|
sorted(
|
|
(*ir.content_anchors, *anchors),
|
|
key=lambda e: (e.start_hop, e.end_hop, e.event_type, e.evidence_ids),
|
|
)
|
|
)
|
|
augmented = replace(ir, content_anchors=merged)
|
|
return replace(augmented, ir_sha256=ir_sha256_of(augmented))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reference teacher (no model weights) — the working contract instance.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class StubTranscriptTeacher:
|
|
"""Deterministic reference teacher. Emits one whole-signal transcript hint
|
|
whose token is derived from the canonical hash — pure on the signal, so the
|
|
same signal always yields the same hint. Exercises the contract end-to-end
|
|
without a real ASR dependency."""
|
|
|
|
lane_id: str = "stub_transcript"
|
|
lane_version: str = "stub-v1"
|
|
hop_samples: int = 240 # 10 ms @ 24 kHz
|
|
|
|
def annotate(self, signal: AudioSignal) -> tuple[TeacherHint, ...]:
|
|
n_hops = max(1, int(len(signal.samples) // self.hop_samples))
|
|
token = signal.canonical_sha256[:8]
|
|
return (
|
|
TeacherHint.make(
|
|
lane_id=self.lane_id,
|
|
lane_version=self.lane_version,
|
|
hint_type="transcript",
|
|
start_hop=0,
|
|
end_hop=n_hops,
|
|
payload=(("token", token),),
|
|
confidence_q=128,
|
|
),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real model lanes — declared, gated, import-guarded (eval-plan §4 table).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TeacherUnavailable(RuntimeError):
|
|
"""Raised when a declared teacher lane cannot be constructed — the extra is
|
|
not installed, or its adapter has not been wired yet."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TeacherLaneSpec:
|
|
lane_id: str
|
|
extra: str # pip extra that provides the model
|
|
probe_module: str # importable module used to detect availability
|
|
role: str # what the lane is good for (never substrate)
|
|
builder: Callable[[], AudioTeacher] | None = None # None until an adapter lands
|
|
|
|
|
|
# eval-plan §4: best role in CORE / why not the substrate. Adapters are deferred
|
|
# until the extra + weights are present; the lane is declared and gated now.
|
|
KNOWN_TEACHER_LANES: dict[str, TeacherLaneSpec] = {
|
|
"whisper": TeacherLaneSpec(
|
|
"whisper", "audio-whisper", "whisper",
|
|
"offline transcript evidence, weak lexical labels, language ID",
|
|
),
|
|
"nemo": TeacherLaneSpec(
|
|
"nemo", "audio-nemo", "nemo",
|
|
"timestamp/alignment teacher + streaming transcript evidence",
|
|
),
|
|
"clap": TeacherLaneSpec(
|
|
"clap", "audio-clap", "laion_clap",
|
|
"coarse sound-event labels, audio-text alignment",
|
|
),
|
|
"encodec": TeacherLaneSpec(
|
|
"encodec", "audio-encodec", "encodec",
|
|
"reconstruction shadow lane, transport, future speech-to-speech",
|
|
),
|
|
}
|
|
|
|
|
|
def is_lane_available(lane_id: str) -> bool:
|
|
"""True iff the lane's optional dependency is importable. Does not import it."""
|
|
spec = KNOWN_TEACHER_LANES.get(lane_id)
|
|
if spec is None:
|
|
return False
|
|
return importlib.util.find_spec(spec.probe_module) is not None
|
|
|
|
|
|
def load_teacher(lane_id: str) -> AudioTeacher:
|
|
"""Construct a teacher for a declared lane, or fail loudly with guidance.
|
|
|
|
Raises `ValueError` for an unknown lane, `TeacherUnavailable` when the extra
|
|
is missing or no adapter is wired yet. Never returns a partially-built or
|
|
silent-fallback teacher — a teacher must be a real, typed-hint producer or
|
|
nothing at all.
|
|
"""
|
|
spec = KNOWN_TEACHER_LANES.get(lane_id)
|
|
if spec is None:
|
|
known = ", ".join(sorted(KNOWN_TEACHER_LANES))
|
|
raise ValueError(f"unknown teacher lane {lane_id!r}; known lanes: {known}")
|
|
if spec.builder is None:
|
|
raise TeacherUnavailable(
|
|
f"teacher lane {lane_id!r} is declared but its adapter is not wired "
|
|
f"yet (install `{spec.extra}` and register a builder). "
|
|
f"Role: {spec.role}."
|
|
)
|
|
if not is_lane_available(lane_id):
|
|
raise TeacherUnavailable(
|
|
f"teacher lane {lane_id!r} requires the optional extra "
|
|
f"`{spec.extra}` (module {spec.probe_module!r} not importable)."
|
|
)
|
|
return spec.builder()
|