Systems and subsystems
Detector contract and safe runner
Every detector takes the same read-only clip and returns the same kind of result: a score that rises with synthetic likelihood, one sentence of evidence, named features and a status. A wrapper guarantees that a crashing detector becomes a neutral, flagged result and never costs a file its row.
In plain words
Ten detectors from four people had to plug into one pipeline in a weekend. The contract is the agreement that made that possible: what a detector gets, what it must return, and what happens when it fails. The rules are enforced, not assumed: a score outside [0, 1] or a missing evidence sentence raises an error rather than being quietly fixed, so a detector cannot slip a bad value into fusion.
What goes in and comes out
- In: a
ClipContext: the path, the decoded audio (read-only, decoded once), the header probe (computed once), and a per-clip memo cache that detectors share expensive intermediates through. - Out: a
DetectorResult(name, score, evidence, features, status, error). - Through:
safe_run(detector, ctx), which never raises.
Where it lives
src/hearsay/detectors/base.py: ClipContext, DetectorResult, the Detector protocol, safe_run, and the Registry. The engineered detectors register themselves through engineered.py.
Rules and where each is set
scoremust be a real number, not a bool, finite, in [0, 1]; violations raiseValueErrorand values are never clipped (base.py, lines 125–127).evidencemust be a non-empty string;featuresa mapping of non-empty names to finite floats (copied, so a detector cannot mutate a result after the fact);statusone ofok,skipped,error, and only an error carries an error string (base.py, lines 128–143).- The neutral score is 0.5source: src/hearsay/detectors/base.py line 40 (GitHub; assumes main is public).
safe_runturns a raised exception, a wrong return type, a name mismatch or a non-ok status fromrunintostatus="error"at that score with the evidence "detector failed: …"; a detector whoseapplies()is false isskippedwithout running (base.py, lines 163–192). Keyboard interrupts still propagate. - The audio is decoded once and marked read-only; a decode failure is cached and re-raised without a second FFmpeg call (base.py, lines 70–84).
- The registry rejects duplicate or empty names and iterates in sorted name order, so fusion columns are stable (base.py, lines 195–217).
- Detectors never read filenames or filesystem timestamps, because git, zip and Docker rewrite them (a project rule in CLAUDE.md).
Status
Built and frozen. A change to DetectorResult fields must name the affected detector owners; none was made after the D-track began.
Numbers
Every learned detector exports one file per detector with columns path, fold, split, score and logit over the same 21,671source: README.md, section the-detector-contract rows (the training clips and the test files); fusion reads only those exports, so any detector can be swapped or ablated without touching the others.
Known limits
The neutral 0.5source: src/hearsay/detectors/base.py line 40 (GitHub; assumes main is public) on a skipped or errored result is a placeholder, not evidence: the fusion code imputes such a column at its training centre instead (see the fusion rule). The role labels in the runner's code tag Spectra-AASIST as "fused" although the rule only lets it suppress; the reports and this site say "suppressor".
Tests that pin it
tests/test_detector_contract.py, thirty-odd cases named B1–B9: invalid scores rejected and never clipped, valid scores coerced to float, score direction, exceptions become error results, bad return values become errors, evidence required, bad features rejected, registry rejects duplicates and sorts, the same clip scored twice is identical, audio is read-only, decoded once, decode failure cached, memo computes once, not-applicable is skipped, results unhashable but comparable, status and error coherence. tests/test_engineered_registry.py pins the seven registered engineered names.
Sources
CLAUDE.md, "Detector contract"; Architecture §5; the README.