ADR-110: Ask who speaks AFTER we can hear them¶
Status: Accepted Date: 2026-07-14 Deciders: Marko Related: #876 (Elon Musk named as a speaker), #1169 (speaker/person quality), ADR-109
Context¶
The pipeline asks the identity question before the answer exists¶
processing.py:827 _detect_speakers_for_episode() <-- "who are the speakers?"
episode_processor:2082 download_media_for_transcription() media not downloaded yet
episode_processor:1613 transcribe_with_segments() no transcript yet
episode_processor:1632 apply_diarization_to_result() no voices yet
And the interface cannot take a transcript even if we wanted to give it one:
def detect_speakers(self, episode_title, episode_description, known_hosts)
So we ask an LLM "who speaks in this episode?" while showing it only the show notes, before a
single word of audio has been transcribed. Nobody could answer that from that evidence. The model
does the only thing available to it: it returns the people the notes mention. That is how
Elon Musk — named in a Hard Fork description solely as the man suing OpenAI — was returned as a
speaker, assigned to a voice cluster, and published as the author of a real guest's words (#876).
The gate we built to stop it is circular¶
corroborate_guests checks the model's proposal against the same show notes the proposal was
derived from. Validating a claim against the evidence it was guessed from can only catch names
invented from nowhere; it cannot distinguish a person who speaks from a person who is discussed,
because the text being checked does not say. So it falls back to a regex — "is there an interview
cue next to this name" — and desk shows never write one.
Measured, via the detector prod actually runs (gemini-2.5-flash-lite), over 50 episodes / 5 shows:
| mean unattributed talk per episode | 24.0% |
| episodes with >= 25% of talk attributable to nobody | 21 / 50 |
| names gemini proposed that corroboration DELETED | 70 |
| ...of which whole first+last names | 69 of 70 |
Who we deleted:
5x Sierra Juarez 3x Alex Goldmark 2x Marianne McCune 1x Joe Leahy
4x Rob Armstrong 3x Jess Jiang 2x Jimmy Keeley 1x Robin Wigglesworth
3x Cena Loffredo 3x Emma Peaslee 2x Jay Powell 1x Richard Waters
3x Robert Rodriguez 1x Kevin Warsh
Rob Armstrong is the co-host of FT Unhedged. Alex Goldmark is Planet Money's executive producer; Jess Jiang, Sierra Juarez, Marianne McCune and Emma Peaslee are its reporters. They are in the episode, talking. The gate also correctly deletes Jay Powell and Kevin Warsh, who are discussed and absent — but it cannot tell the two groups apart, so it deletes the newsroom to catch the Fed chair.
The extractor was never the bottleneck. 69 of 70 proposed names are whole and correct. We throw them away.
And the thing the gate was holding back¶
The gate has to be that strict because downstream, _name_guest_voices paints surviving names
onto voices positionally, in talk-time order. That is the invention mechanism — a name lands on a
voice with no evidence tying it there. The gate is a sandbag in front of that hole.
Decision¶
Move the identity decision to where the evidence is: after diarization.
-
Keep the pre-transcription detector as a cheap PROPOSAL. Metadata-only is fine for the job it can actually do — listing the people the episode names. It is good at it (69/70 whole names). Nothing about its interface changes.
-
Add a post-diarization RESOLUTION step (
resolve_speakers_from_conversation), given what the question requires: - the names the metadata stated (detector + feed + config hosts),
- the first ~60 seconds of each voice's own turns,
and asked the question that has an answer:
For each VOICE, which stated person is it — and which stated people never speak?
The model must point at a voice and justify it from that voice's words. It cannot paint a name on a voice it has not heard.
-
Never bind a name to a voice without evidence. After resolution, a name attaches to a voice only via: its own self-introduction, an on-air introduction by the host, the LLM resolution above, or a forced match (exactly one candidate name and exactly one candidate voice — no choice, so no guess). Delete positional talk-time painting. Anything unbound stays
unknown: an honest defect, never a wrong name. -
The regex path stays, as the no-LLM fallback.
airgapped,local,devandreprocess_dgx_no_llmrunspeaker_detector_provider: spacyand keep the deterministic cue matcher exactly as it is. LLM where there is an LLM; deterministic cues where there is not — the same split diarization and summarization already use. -
A voice that TALKS ABOUT somebody is not that somebody — and that is CHECKED, not asked for. The retrieval that makes this work is also what misleads the model: it hands over passages labelled with the voice that spoke them, and a model reads a name sitting beside a voice as association. On FT Unhedged's "The Fed holds steady" it gave 53.5% of the episode to Jay Powell — SPEAKER_01 is Rob Armstrong, the co-host, discussing him. A prompt is not an enforcement mechanism (#876), so the answer is verified: if a voice utters a name and never introduces itself with it, the assignment is discarded. Across 90 episodes this fired 40 times, and it is what removes Jay Powell, Donald Trump, Steve Witkoff and Mariska Hargitay.
Measured¶
90 distinct episodes, 9 shows, prod detector (gemini-2.5-flash-lite), full roster.
The corpus is 90 episodes, not the 160 quoted in earlier notes: Hard Fork's 10 episodes sat in 8 run directories from our own diarization sweeps, so a naive glob counted them 8x and weighted a well-named co-hosted show to half the sample.
| before | after | |
|---|---|---|
named (person) |
65.17% | 71.91% |
our defect (unknown) |
31.63% | 25.41% |
| episodes with >= 25% of talk unattributed | 45 / 90 | 36 / 90 |
24 names newly admitted, every one via llm_resolution and every one read. Spot-checked against
the audio's own words: Tucker Carlson (73.3% — the NYT Interview podcast, genuinely him:
"Thanks for having me. Most people don't come to this part of Maine"), Qasar Younis, Robert
Armstrong (x3), Joe Leahy, swyx and Alessio (the Latent Space hosts, whom the feed
never names).
Alternatives considered¶
A. Relax the corroboration regex¶
Add more cue patterns until the reporters survive. Rejected. It treats the symptom: the gate is
being asked to decide, from show notes, something show notes do not state. Every new cue widens the
door for Jay Powell at the same rate as it admits Rob Armstrong, because to a regex they look
identical. The precision/recall trade has no good point on it.
B. The "anchor" rule — one confirmed guest vouches for the people named beside him¶
Built, measured over 160 episodes, and removed on 2026-07-14. It admitted 8 names: 3 real guests (Qasar Younis, Dan Gural, Marc Andreessen) and 5 who were never in the room — including HB Reese, the founder of Reese's, discussed by a Planet Money episode, dead since 1956, given a voice. Plus a bare "Marc" landing on a second voice in the Marc Andreessen episode, and "Bill" / "Er" on voices with 0.0% of the talk.
That is #876 rebuilt. It is preserved as a counter-example test
(test_the_metadata_NEVER_names_a_voice_that_did_not_say_it) so nobody re-derives it.
C. Swap spaCy for an LLM as the NER method¶
Rejected — this is already the shipped design, and it is not the problem. prod_dgx_* already
run gemini; experiment_dgx_only runs ollama; spaCy is already the airgapped option. The LLM is
already producing near-perfect names. Changing the proposer cannot help when the loss happens after
the proposal.
Consequences¶
Good. The identity question is asked where the answer exists. The corroboration regex stops
deleting real hosts. Positional painting — the mechanism behind every wrong name we have shipped —
is gone. unknown becomes a true defect count because the alternative to a name is now silence, not
a guess.
Cost. One extra LLM call per episode, after diarization (~160 calls per corpus rebuild; at flash-lite prices this is cents). Airgapped profiles pay nothing and lose nothing.
Risk. The LLM could still misbind a voice. Mitigated by: it must choose from the STATED names
only (it cannot invent), self-intro and on-air introduction still win over it where they exist, and
the corpus warrant audit (scripts/audit/corpus_speaker_audit.py) replays the whole 160-episode
corpus with zero GPU on every rule change — every name it admits gets read before it ships. That
audit is what caught HB Reese.
Not addressed here. Cold opens (an episode that starts with a guest teaser clip defeats "the opening voice is the host"). Separate issue, separate fix.