Scoring harness: voxint score¶
The harness (src/voxint/harness/) is the offline quality-measurement layer:
pure, DB-free cores plus file-based CLI adapters. Nothing in it touches
settings, the database, or the worker. voxint score … runs on any machine
against plain JSON/JSONL files (pip install voxint is all it needs; no
Docker stack).
All JSON documents (aliases, enrollment, thresholds) and all output records
carry "schema_version": 1; input JSONL streams (name-accuracy items,
agreement slots) are versioned by their command's contract rather than per
record. Unknown extra fields are ignored; missing/malformed required fields
(including non-finite numbers and negative durations) are an error reported
with file and line number, exit code 2. Reports are written atomically (temp
file + rename) with deterministic key ordering, so identical inputs produce
byte-identical outputs. A small synthetic dataset exercising all three
commands lives in examples/.
Why these scorers exist¶
Structural diarization metrics (DER/JER, permutation-optimal WER variants) optimally relabel speakers before scoring, so they are blind to whether the name shown to a user is the right person. The harness measures exactly that:
score name-accuracyscores assigned display names against ground truth with a strict person-matcher (a bare first name is not proof of identity), and compares two runs with paired statistics.score agreementreports one embedding voter's conservative acoustic verdict: is this curated host's voice actually present in this item, judged by cosine against a held-out voiceprint, independent of any LLM or name surface.score ensemblefuses two voters' verdicts. Verdicts only: the ensemble layer cannot see vectors, so cross-embedding-space comparison is structurally impossible (see "The cross-space invariant" below).
voxint score name-accuracy¶
voxint score name-accuracy items.jsonl [--baseline base.jsonl] \
[--aliases aliases.json] [--target-accuracy 0.95] [--seed 0] [--out report.json]
Input: items JSONL (one object per line)¶
{"item_id": "ep-001",
"slots": {
"SPEAKER_00": {"assigned_name": "Dana Fox", "truth": "Dana Fox",
"confidence": 0.91, "duration": 412.5},
"SPEAKER_01": {"assigned_name": null, "truth": "__ABSTAIN__"}}}
item_id: unique non-empty string; a duplicate is an error.slots: non-empty object of slot label → fields.assigned_name: the display name the system produced, ornull/a placeholder (speaker_…,auto_…,unknown…) for an abstention.truth: a real person name,"__ABSTAIN__"(no name should be assigned), or"__NEITHER_DETERMINABLE__"/null(unscoreable → excluded).confidence(optional): feeds the descriptive risk-coverage curve only.duration(optional): the slot's weight in the duration-weighted metrics; omitted slots weigh 1.0.
Aliases JSON (optional)¶
{"schema_version": 1,
"aliases": {"Daniela Fox": ["Dana Fox", "D. Fox"]}}
Both names appearing under one canonical entry (the key counts as a member)
match at alias strength.
Verdicts¶
Per slot: TP (correct person), FP_WRONG (a different real person),
FP_OVERNAME (named where truth is abstain), FN (missed a real person),
TN (correct abstain), EXCLUDED (unscoreable). Name matching is
Unicode-aware (NFKC + casefold) and strict: id equality > alias table > exact
string > surname + given(-initial); single-token containment never matches.
Output report¶
One JSON object: counts and precision/recall/F1 (plain and duration-weighted),
a confusion matrix, slot accuracy with a 95% Wilson CI, per_item verdicts,
and, when any confidence was supplied, a descriptive risk_coverage curve
(never a gate input: confidence is not proven calibrated).
With --baseline, both files must cover identical item_ids and slot labels
and agree on every slot's truth (paired statistics are meaningless across
diverging ground truth, so a mismatch is an error); the report gains a paired
block: exact McNemar on discordant slot pairs plus
an item-clustered bootstrap CI on the mean per-slot delta (deterministic for a
given --seed).
voxint score agreement¶
voxint score agreement --slots slots.jsonl --enrollment enrollment.json \
--thresholds thresholds.json [--out verdicts.jsonl]
Enrollment JSON: one embedding space per file¶
{"schema_version": 1, "embedding_space": "acme-voice-v1", "dims": 192,
"voiceprints": {
"host-dana": {"embedding": [0.01, "…"], "enrollment_items": 5,
"held_out": true, "source_item_ids": ["ep-002", "ep-003"]}}}
embedding_space: the model/space tag; every vector in this file and in the slots file is bound to it.held_out: attests the voiceprint was built only from other items. A false value abstains every use (session_leakage_risk).source_item_ids: the items the voiceprint was built from. Scoring an item in this list abstains (session_leakage_risk): a voiceprint must never judge the item it was built from.enrollment_itemsbelow the thresholds'min_enrollment_itemsabstains (weak_enrollment).
Thresholds JSON¶
{"schema_version": 1, "tau": 0.62, "margin": 0.08, "min_duration": 45.0,
"min_segments": 6, "low_band": 0.35, "neg_min_total_duration": 300.0,
"min_enrollment_items": 3}
Validated on load: low_band <= tau, cosines within [-1, 1], non-negative
floors. Choose values by impostor-trial calibration
(voxint.harness.agreement.far_frr_at + one-sided Wilson bounds).
Slots JSONL¶
{"item_id": "ep-001", "kind": "curated", "host_id": "host-dana",
"embedding_space": "acme-voice-v1", "total_speech": 1810.0,
"slots": {"SPEAKER_00": {"embedding": [0.02, "…"], "duration": 412.5,
"segments": 44}}}
kind:curated(scorehost_id's voiceprint) ornegative_control(a no-host channel: score all usable voiceprints, expecting absence).embedding_spaceis required on every record and must equal the enrollment file's. The record proves its space rather than inheriting a tag, so vectors from a different (even equal-dimensional) model are rejected.- Embeddings are validated (finite, non-zero,
dims-length).
Output verdicts JSONL¶
One object per item: verdict (CONFIDENT_HOST_PRESENT,
NO_CURATED_HOST_DETECTED, or ABSTAIN + reason), evidence
(host_slot, top_cosine, runner_up_cosine, margin, duration/segments),
a contradiction flag (curated host confidently absent on their own channel,
or present on a negative control: candidate channel-fact errors for human
review), and the embedding_space. Verdicts are silver evidence, never
gold truth; the bias is deliberately conservative (abstain on near-ties, short
slots, weak/leaking enrollment, low cosine).
voxint score ensemble¶
voxint score ensemble titanet-verdicts.jsonl other-verdicts.jsonl [--out fused.jsonl]
Joins two agreement outputs on item_id (must cover identical items with
matching kinds, and the two files must be in different embedding spaces;
two runs of the same model are not independent voters). Records are validated
semantically before fusion: verdicts must fit the item kind, a
confident-present must carry its winning host_slot, contradiction must be
a literal boolean. Then it AND-gates them: both confident on the same slot →
SILVER_HOST_PRESENT; both confidently absent (negative controls) →
SILVER_NO_HOST; any contradiction, slot mismatch, or single-voter confidence
→ FLAG_REVIEW; otherwise ABSTAIN. Voter names in reasons are the files'
embedding-space tags.
The cross-space invariant¶
Different embedding models emit vectors in different spaces, and two spaces can share a dimensionality, so a dims check is not an isolation check. The harness enforces isolation structurally:
- Every vector is a
TaggedVectorcarrying itsembedding_space; every cosine entry point (voxint.harness.vectors.cosine) refuses mismatched spaces before touching numpy. - One agreement invocation handles exactly one space (the enrollment file defines it; a slots record scored against it inherits it).
- Voter fusion (
voxint.harness.ensemble) accepts only typed verdicts (no numpy import, no vector parameter), so cross-space comparison cannot be expressed at the ensemble layer.
This mirrors the pipeline-side invariant in docs/architecture.md
(speaker matching filters by embedding_space); the guardrail tests in
tests/unit/test_harness_vectors.py and tests/unit/test_harness_ensemble.py
pin it.
Library-only pieces¶
voxint.harness.gate_metrics.assemble_gate_metrics: paired baseline/candidate verdict records → release-gate counts (host regressions, correct-to-wrong swaps, over-naming introduction, audited-subset regressions, one-sided-95% Wilson upper bound on the item regression rate). Apaired_tallywithn_items0 yields an upper bound of 1.0: "no information" must read as unprovable, not as safe.voxint.harness.goldset_strataprovides deterministic (SHA-256-spread) priority-ordered stratified sampling plus provenance-gated auto-labeling: a channel fact auto-labels a host truth only when the host's voiceprint is groundable on that item; everything else routes to a human label queue.
These have no CLI yet; if one grows a public workflow, it gets a score
subcommand and a contract section here.
Attribution evaluation (issue #113)¶
Three modules that measure end-to-end speaker attribution accuracy against corpus gold labels (AMI). Pure, DB-free, composable in sequence.
-
voxint.harness.ami_recurrence: parses AMImeetings.xmlfor cross-session speaker recurrence.base_session_idgroups scenario suffixes (a/b/c/d) into one day-session.check_kill_criterionapplies two explicit viability gates: baseline (>=8 cross-session speakers, >=50 genuine pairs) and calibration (>=50 independent clusters, expected to fail on AMI alone). The recurrence report feeds the protocol manifest. -
voxint.harness.attribution_protocol: protocol manifest dataclasses (AttributionProtocolRow,ProtocolManifest) defining which meetings, speakers, and channels form the evaluation corpus.validate_session_honestychecks that enrollment and test data are base-session-disjoint (fail-closed). JSON round-trip viaserialize_manifest/parse_manifest; thespeakersdetail onRecurrenceReportis intentionally non-persistent. -
voxint.harness.attribution_aligner: the spine. Builds a complete gold-speaker x predicted-slot duration-overlap matrix (with configurable collar, default 0.25s). Classifies each slot: genuine, impostor, mixed, or unscoreable (purity/coverage/margin/eligibility). Purity and coverage are clamped to [0, 1] to handle overlapping same-label intervals.build_trialsproducescalibration.Trialobjects withtruth_source="corpus_gold"and cluster_id = gold speaker.aggregate_trialscomputes speaker-clustered FAR/FRR with Wilson CIs, auto-attribution coverage, and alignment attrition counts.
Attribution evaluation driver (tools/eval_attribution.py)¶
A maintainer tool for offline attribution scoring. Four subcommands:
protocol: inspect/validate a protocol manifest.align: reads a self-contained input manifest pointing to gold RTTMs, hypothesis RTTMs, per-label match evidence, and an enrollment map. Runsalign_slots+build_trials, writes a trials JSON with alignment provenance per meeting. Turn eligibility is sourced from the evidence (eligible_turns), not inferred from RTTM segment counts. Emits warnings to stderr for meetings without evidence or orphan evidence labels.score: trials JSON in, attribution metrics JSON out. Records the effectiveMatchingGatesin the output for reproducibility. Optionally accepts a--gatesoverride.report: renders one or more metrics JSONs into a dated Markdown report. Multiple runs compute a noise-floor spread. The report honestly states limitations (close-talk only, baseline not certification, Wilson CIs descriptive only).
All outputs use sort_keys, allow_nan=False, and schema_version: 1.
The driver wraps all I/O and deserialization errors as EvalError (exit
code 2). A frozen regression pack (tests/parity/fixtures/attribution/)
commits synthetic trials and expected metrics so scorer determinism is
CI-gated without a GPU.
uv run python tools/eval_attribution.py protocol --manifest protocol.json
uv run python tools/eval_attribution.py align \
--manifest align-input.json --out trials.json
uv run python tools/eval_attribution.py score \
--trials trials.json --out metrics.json
uv run python tools/eval_attribution.py report \
--run metrics.json --date 2026-09-05 --out report.md
Calibrating the auto-display band (issue #114)¶
The three-band policy (voxint.speakers.policy) auto-displays a proposal only
when it clears the grounded gate. Issue #114 calibrates that gate against corpus
gold with a rule fixed before any score is seen. The rule text lives verbatim in
the voxint.harness.calibration module docstring; the tooling below applies it.
Trials and clusters¶
build_trials tags every scoreable slot with a detail:
| detail | gold speaker enrolled | machine top candidate | counts toward |
|---|---|---|---|
genuine |
yes | equals the gold speaker | FRR, coverage |
impostor_closed |
yes | a different roster speaker | FAR |
impostor_open |
no | any roster speaker | FAR |
unscoreable |
any | none, or alignment failed | attrition only |
A slot with a gold speaker who is not on the roster used to be unscoreable; that hid open-set false accepts, the harm the auto-display band exists to bound.
Independence is a cluster count where a cluster is one gold speaker. The floor
(MIN_INDEPENDENT_CLUSTERS = 50) applies to impostor clusters, because the
certified claim is about false accepts. Genuine clusters are reported alongside
with descriptive intervals and carry no floor: the AMI corpus has at most 35
recurring speakers, so a symmetric floor could never be met there. Both counts
appear in check_independence, sweep points, and the attribution summary, and
the FAR bound is the one-sided 95% Wilson upper bound at cluster level
(wilson_upper_one_sided).
Protocol roles and the dev/confirm split (schema 2)¶
tools/generate_attribution_protocol.py --open-set N --gold-rttm-dir DIR
writes a schema-2 manifest where every meeting carries a role
(enrollment, test_genuine, test_open) and a split (enrollment,
dev, confirm):
- Open-set meetings are AMI meetings with no cross-session speaker and a gold
RTTM, picked by ascending SHA-256 of
split_seed:meeting_id. Every participant is un-enrolled, so their slots becomeimpostor_opentrials. - Dev and confirm are assigned per base session from metadata only. Test sessions that share a cross-session speaker form one component, so a genuine speaker never straddles the halves; components and open-set sessions are ordered by seeded hash and alternated so both halves are balanced.
validate_split_honestyfails the generation on any leak (a speaker in both halves, an enrollment meeting in a scored split, an enrolled speaker inside an open-set meeting).
Schema-1 manifests are refused with a regenerate message. Align inputs and
trials carry meeting_role and meeting_split; enrollment meetings are never
scored.
One GPU pass, offline roster and matching¶
The corpus is run through the pipeline once with an empty roster and
AUTO_ENROLL=false, so every run stores its per-turn embeddings and records
no_roster evidence. Everything after that is CPU work through production code:
uv run python tools/build_attribution_roster.py \
--protocol protocol.json --runs runs.json \
--gold-rttm-dir gold_rttm --out-dir roster/
uv run python tools/rematch_runs.py --protocol protocol.json --runs runs.json
build_attribution_roster.py gold-aligns each enrollment run, keeps slots that
pass the aligner's purity, coverage, margin, and eligibility floors, and enrolls
the single best slot per cross-session speaker through the operator enrollment
path (enroll_new_speaker, replay-safe idempotency keys). It writes
enrolled_speaker_map.json and roster_fingerprint.json, and refuses to run on
a roster that already holds foreign speakers unless --allow-existing.
rematch_runs.py calls refresh_run_matches for the test-role runs only;
enrollment-role runs are excluded by design. Because the matcher core
is evaluate_run, the rematched evidence is exactly what the worker would have
written with that roster.
Select on dev, certify on confirm¶
uv run python -m tools.calibrate_policy select \
--trials attribution-trials.json --out selection.json
uv run python -m tools.calibrate_policy certify \
--trials attribution-trials.json --candidate selection.json \
--out certification.json
select scores the 32 pre-registered candidates (grounded cosine x grounded
margin, everything else held at the base gates) on the dev trials. A candidate
is feasible with zero auto_wrong and a cluster-level one-sided FAR upper bound
at or below 5%; among feasible candidates the rule maximises genuine-cluster
coverage, then minimises review load, then takes the stricter gate. No feasible
candidate is a NO_DECISION.
certify scores the locked candidate once on the confirm trials. CERTIFIED
requires at least 50 impostor clusters, zero auto_wrong, and the FAR bound;
anything else is NO_DECISION with every failing reason
(insufficient_impostor_clusters, auto_wrong_observed,
far_bound_exceeded). The output also carries the PRE (base gates) and POST
(candidate) tallies on the identical trials, the full band-change listing,
FRR, coverage, and descriptive Wilson intervals, so the report is a gold
PRE/POST diff rather than an opinion. select and certify filter on
meeting_split; sweep and compare read the same file unfiltered for
exploratory use.
AMI corpus calibration result¶
The first calibration run on 170 AMI Mix-Headset meetings returned
NO_DECISION: the confirm split had 49 impostor clusters (floor: 50) and a
one-sided 95% Wilson FAR upper bound of 5.23% (ceiling: 5.00%). Zero
auto_wrong across all 311 scoreable trials. The 50-cluster floor was
imported from the recurrence viability check and is internally inconsistent
with the Wilson method: zero errors need at least 52 clusters to clear 5%.
Grounded-gate defaults remain at their pre-calibration values
(grounded_min_cosine=0.70, grounded_min_margin=0.08). Full analysis:
docs/reports/attribution-calibration-2026-09-13.md.
Evidence pack: tests/parity/fixtures/attribution/calibration/.
Feeding the harness from live runs (voxint.harness_export)¶
The harness scores files; it never reads a database. voxint.harness_export is
the one package allowed to read the database and render live pipeline runs into
these input shapes, so it lives OUTSIDE voxint.harness to keep that package
DB-free. It exports STORED evidence only: it re-uses the matcher's own
eligibility and centroid helpers over the stored per-turn vectors, and never
re-runs TitaNet or the matcher decision, so an exported baseline reflects
production numerics exactly.
name_accuracy_items(session, run_ids, *, truth_anchoring)renders one name-accuracy item per run, one slot per diarization label.assigned_nameis the matcher's automatic attribution, a grounded cosine proposal, read from the machine evidence INDEPENDENTLY of the read-time resolution: a label a human later adjudicated still reports what the machine would have shown, so a human ruling never masks the prediction being measured.truthis the human ruling (assign,__ABSTAIN__for exclude,__NEITHER_DETERMINABLE__for unknown, and absent for a label no human confirmed). Each slot also carries amatchprovenance block (the full accept/reject/ineligible decision, withmarginnull for a single-speaker roster, neverInfinity) that the scorer ignores but a later confidence-policy pass can re-derive any band from.truth_anchoringrecords whether the truth was fixed independently of the proposal (a corpus) or after the operator saw it (their own material); the database cannot prove this, so the caller declares it.agreement_enrollment(session, embedding_space, ...)andagreement_slots(session, run_ids, ...)render the enroll-and-re-identify contract. Voiceprints are the active roster's production centroids; per-voice slot vectors are the production label centroids.held_outis asserted only when every contributing enrollment row records its source run, andsource_item_idslists those runs, so the harness leakage gate abstains on any item a voiceprint was built from.evidence_snapshot(session, settings, run_ids, *, exported_at, git_sha=None)records the code version, embedding-space identity, matching gates, and roster digest beside a baseline. It is an EXPORT-TIME snapshot, not a historical replay: the database does not retain the gates or roster centroids used when each run was matched, so the gates are labelledgates_at_exportand the roster digest is an export-time fingerprint. A baseline whose snapshot no longer matches the live gates or roster is stale and must be regenerated.
The DB to JSONL mapping is unit-tested and round-tripped through the real
score name-accuracy and score agreement commands.
Driving an export (tools/export_match_evidence.py)¶
The maintainer tool tools/export_match_evidence.py selects runs and writes the
files. It reads a small run-selection manifest, calls the exporters above, and
writes the artifacts atomically with the same deterministic, no-NaN
serialization the harness uses, so a repeated export from an unchanged database
is byte-for-byte identical. It is a maintainer tool, deliberately NOT a voxint
subcommand: the score command stays DB-free by contract, so the one piece that
reads the database sits outside it (alongside tools/qualify_local_llm.py).
The manifest (schema_version 1) declares one required embedding_space shared
by every artifact and at least one lane:
{
"schema_version": 1,
"embedding_space": "titanet-large-v2",
"name_accuracy": {
"truth_anchoring": "independent",
"run_ids": ["<run-uuid>", "..."]
},
"agreement": {
"runs": [
{"run_id": "<run-uuid>", "kind": "curated", "host_id": "<speaker-uuid>"},
{"run_id": "<run-uuid>", "kind": "negative_control"}
],
"roster_speaker_ids": ["<speaker-uuid>", "..."]
}
}
truth_anchoring is independent (a corpus annotated without seeing Voxint's
proposal) or post_proposal (the operator ruled after seeing it). A curated
agreement run must name its host_id; a negative_control run must not.
roster_speaker_ids is optional and defaults to the whole active roster. Run it
with:
uv run python -m tools.export_match_evidence \
--manifest baseline-selection.json --out-dir docs/reports/baseline-export
It writes snapshot.json always, name_accuracy_items.jsonl for the
name-accuracy lane, and enrollment.json plus agreement_slots.jsonl for the
agreement lane. It records the current git HEAD in the snapshot and refuses to
run on a working tree with uncommitted tracked changes (pass --allow-dirty to
override), so the snapshot's code sha means what it says. The agreement
thresholds file that score agreement also needs is a calibration artifact, not
DB-derived, so the driver does not produce it.
Relation to the private ancestors¶
The cores are fresh implementations of scorers developed in the private
upstream project. Deliberate public-API divergences: identity ids are opaque strings
(not integers), name normalization is Unicode NFKC + casefold (not
ASCII-lowercase), goldset hashing is SHA-256 (not MD5), the surface
reconciliation layer (private storage schema) was not ported, and gate-metrics
key names are generic (baseline/candidate/audit).