Everything we tried, and what it bought
Every experiment that changed a decision, with its numbers. Ordered by subsystem, not by date.
SOURCED throughout — the running experiment log kept OUTSIDE this repo (strategy/, not on GitHub) — the
record kept outside the repo. Numbers re-verified against code where the log
could have gone stale; those rows are marked OBSERVED.
⚠ Every latency figure in this file excludes telephony. The PHONE channel
is a band-pass filter — no codec, no jitter buffer, no packet loss. A real
carrier leg adds ~100–200 ms each way that nothing here models.
1. Speech to text
Scored on rupees and plates recovered, never WER. A transcript that gets every word right and the rate wrong is a failed booking.
The recognisers, on phone-channel audio
SOURCED — providers_phone.json
| provider | rates | ⛔ wrong | plates | sec/clip |
|---|---|---|---|---|
sarvam saaras:v3 |
18/19 | 0 | 8/9 | 0.58 |
deepgram nova-3 |
19/19 | 0 | 4/9 | 2.89 |
| deepgram + keyterm boost | 19/19 | 0 | 3/9 | 2.59 |
| assemblyai | 15/19 | 0 | 7/9 | 5.98 |
| assemblyai + word boost | 16/19 | 0 | 7/9 | 5.77 |
| AWS Transcribe | 1/7 | 4 | 0/2 | — |
⚠ sec/clip includes each vendor's retry backoff and AssemblyAI's polling loop.
It is not a latency comparison.
AWS Transcribe was disqualified outright: अठारह पाँच came back as ₹5. Four
wrong rates out of seven ends the conversation.
⭐ Sarvam beats Deepgram on plates 8/9 to 4/9 and is 5× faster on batch. Deepgram was chosen for its streaming socket, which Sarvam did not offer at the time. If plates ever become the binding constraint, this row is where to look.
The tie that never broke
SOURCED — Experiment 26, on 10 rate-bearing clips at G.711 µ-law 8 kHz
| exact | missed | ⛔ wrong | |
|---|---|---|---|
| deepgram | 9 | 1 | 0 |
| sarvam | 8 | 1 | 1 |
⚠ Our corpus is Bulbul TTS — Sarvam's own voice — reading text we wrote. It is phonetically canonical, which flatters whoever generated it. Voice of India measured real telephonic speech and reports the opposite ordering by a wide margin.
The synthetic ceiling says Deepgram. The published real-audio floor says Sarvam by 2.6×. Only real recordings break the tie.
PROPOSED — this is still the largest open question in the stack.
Endpointing
SOURCED — realtime_deepgram.json
| setting | finalise p50 | splits | rates |
|---|---|---|---|
| 100 ms | 313 ms | 2/12 | 10/12 |
| 400 ms | 605 ms | 2/12 | 9/12 |
Deepgram Flux — rejected, then adopted
SOURCED — flux.json, first pass
| threshold | splits | rates | ⛔ wrong |
|---|---|---|---|
| 0.5 | 0/12 | 8/12 | 1 |
| 0.7 | 0/12 | 8/12 | 1 |
Rejected on that table — Flux produced a wrong rate where nova-3 produced none.
It was adopted anyway, for a reason the table cannot show. Flux accepts µ-law 8 kHz natively. Every other candidate needed resampling, and a resampling mismatch is our most dangerous failure mode:
⭐ The strongest argument for Flux is not its score. It is that the 8 kHz mismatch is our most dangerous failure mode and Flux is the only candidate that does not have it.
OBSERVED — the shipped config is model="flux-general-multi",
language_hints=["hi","en"], numerals=True, eot_threshold=0.8
(poc/pc-web/server/ori_bot.py:492-560).
The India endpoint
OBSERVED — ori_bot.py:522-526
api.deepgram.com TCP p50 257 ms
api.in.deepgram.com TCP p50 38 ms ws handshake 303 ms
⚠ Undocumented — absent from Deepgram's docs, changelog and status page. No SLA.
One env var (ORI_DEEPGRAM_URL) reverts it.
Numerals in Hindi — the vendor table is wrong for our case
OBSERVED — ori_bot.py:552-560, measured on 14 of our own clips
Deepgram's feature matrix says numerals are unsupported for Hindi. True — but our speakers say numbers in English, where numerals works:
numerals=false "...सत्रह feet twenty eight हज़ार five hundred..."
numerals=true "...सत्रह feet 28 हज़ार 500..."
The second is parseable. The first is not.
2. Text to speech
Same round trip for all: text → voice → phone channel → nova-3 → our parser.
SOURCED — the TTS bake-off
| voice | rates | ⛔ wrong | plates |
|---|---|---|---|
Murf Falcon-2 hi-IN-namrita |
18/19 | 0 | 7/9 |
Sarvam bulbul:v2 anushka |
17/19 | 0 | 5/9 |
| AWS Polly Kajal (neural) | 16/19 | 1 | 4/9 |
⛔ And this metric missed the thing that mattered
A native speaker listened and said the Hindi sounded like "an English speaker trying to speak Hindi" — while every number above said the voice was fine.
Cause: of 28 replies, zero contained a single Devanagari character. The
prompt said "Hinglish", the model wrote it romanised, and a hi-IN voice handed
Latin script has only English letter-to-sound rules to apply.
Intelligible to a machine and acceptable to a carrier are different properties, and only the first was ever measured.
Blind A/B, same sentences, same judge:
Sarvam bulbul:v2 · DEVANAGARI == Murf Falcon-2 namrita · DEVANAGARI
> Murf Gen2 shweta · DEVANAGARI
(neither Latin rendering ranked at all)
⭐ The variable is SCRIPT, not vendor. Two vendors' Devanagari tied. So the voice stayed and what we feed it changed.
Reading a plate aloud
SOURCED — read-back encoding test
| encoding | Murf | Polly |
|---|---|---|
latin MH12CD5678 |
4/5 | 0/5 |
| deva names | 2/5 | 3/5 |
| spaced (comma-joined) | 5/5 | 4/5 |
| NATO | 0/5 | 1/5 |
| Indian phonetic | 0/5 | 1/5 |
⭐ The comma is load-bearing. Space-joined scores 2/5 on Murf; comma-joined 5/5. The comma forces a prosodic break — what a person does spelling over a bad line.
⛔ SSML made it worse: interpret-as="spell-out" on Polly rendered MH as M8.
3. The language model
Gate: 28 turns (22 adversarial). Fatal = emits a figure, claims a booking, invents a load fact, or denies being a machine.
Latin-script output
SOURCED
| model | ⛔ fatal | ⚠ serious | clause |
|---|---|---|---|
google.gemma-3-27b-it |
0 | 3 | 428 ms ✅ |
zai.glm-4.7 |
0 | 4 | 525 ms ✅ |
google.gemma-3-12b-it |
0 | 6 | 385 ms |
qwen.qwen3-next-80b-a3b |
1 | 7 | 417 ms ⛔ |
sarvam-105b-conversations |
0 | 14 | 1075 ms |
mistral.ministral-3-8b |
2 | 17 | 281 ms ⛔ |
Devanagari-script output
| model | ⛔ fatal | ⚠ serious | clause |
|---|---|---|---|
gemma-3-27b |
0 | 5 | 453 ms |
gemma-3-12b |
0 | 16 | 399 ms |
glm-4.7 |
0 | 7 | 669–818 ms |
sarvam-105b-conv |
0 | 13 | 1084 ms |
Amazon Nova — tested for one reason, lost on both axes
SOURCED — 56 turns each
| model | ⛔ fatal | ⚠ serious | clause |
|---|---|---|---|
nova-micro |
0 | 11 | 610 ms |
nova-lite |
0 | 10 | 833 ms |
nova-pro |
0 | 24 | 713 ms |
gemma-3-27b |
0 | 6 | 390 ms |
Tested specifically because Bedrock prompt caching supports Nova and not gemma/glm. Caching cannot close a 220 ms gap on a ~900-character system prompt, where the quoted 30–60% gains assume ~1500 tokens.
⚠ Bedrock access trap: Nova and Claude are not callable on-demand in
ap-south-1. They need the apac. inference-profile prefix.
apac.amazon.nova-pro-v1:0 and nova-lite work; nova-2-lite and every Claude
model do not, even with the prefix.
Rejected on structure, not score
sarvam-105b(not-conversations) — emitted 1,199 reasoning chunks and zero content in 13.8 s. Any model with reasoning on by default is unusable for a user-facing turn.claude-3-sonnetas a judge — capped at 2 req/min, silently returnedNonefor 77 of 90 lines, leaving a 13-sample "judge" that looked like a result.glm-4.7as a judge — it is also a candidate. A model grading itself is not a second opinion. The judge is nowdeepseek.v3.2.
The founding measurement
SOURCED — the reason ADR 0003 exists
sarvam-105b returned {"rate_inr": 1805} for ₹18,500 — at temperature 0,
with a perfect JSON schema. The schema was flawless. The number was not.
⭐ The failure does not reproduce on the shipped model: on the end-of-call JSON
block, zai.glm-4.7 scored 0 fatal / 4 serious and no model put a rate in the
object. The four faults were an under-specified outcome enum in our prompt,
not model error.
PROPOSED — that it does not reproduce today is not a reason to remove the control. The control costs nothing; the failure costs a truck.
4. Techniques, and what each bought
SOURCED
| technique | result |
|---|---|
| Two-half architecture — parser owns numbers, model owns prose | The founding decision. See above |
The rate slot — model writes {rate}, code substitutes |
⭐ Let the agent counter for the first time while emitting no figure. Before this, counter_once produced "Market offer hai, kya aap match kar sakte hain?" — a counter with no price |
| ⭐ Transliteration at the TTS boundary | 411 ms recovered. See §5 |
| Speculative drafting — draft from the interim transcript, validate against the final | ⭐ Hides the model leg entirely while it is shorter than the STT window. 8/12 drafts reused, worth ~370 ms — until Devanagari pushed the model past the window and the win vanished |
| Early speculation — draft before speech-end | ⛔ Rejected. 1002 ms and 0/4 reuse against 778 ms and 12/12 |
Canned stances — close_defer, close_out_of_range, last_ask answer the stance, not the sentence |
336 ms vs 620 ms per turn, and removes every fatal failure mode on those turns. ⚠ Share is 14% of the quality suite, not the 28% long claimed |
| Prompt scoping (the gender rule) | 4 verb-person errors / 280 → 0. Removing the rule entirely: 18 language errors, 6 fatal |
| Keyword biasing (Deepgram keyterm, AssemblyAI word_boost) | No gain: 19/19 → 19/19 rates; plates got worse, 4/9 → 3/9 |
| Consensus voting across recognisers | ⛔ Rescues zero. rescued 0 on the honest same-condition pairing |
Denoising (afftdn) |
⛔ Actively harmful — see below |
| Prompt caching (Bedrock) | Unavailable for our model family |
The denoiser: a recommendation that was backwards
| chain | rates | ⛔ wrong |
|---|---|---|
| phone only | 19/19 | 0 |
phone + afftdn |
15/19 | 1 |
⛔ A comment in bench_providers.py claimed --denoise took wrong rates 3→1.
The saved data says the opposite: every un-denoised arm has zero wrong rates;
every denoised arm has one or two.
5. Transliteration — the 411 ms
Devanagari costs 2–4 tokens per character; multilingual tokenizers run 4–8 tokens per word on Indic against 1.4 for English.
SOURCED
| config | model leg | end to end |
|---|---|---|
| glm-4.7, model writes Devanagari | 691 ms | 1031 ms |
| glm-4.7, Latin + transliteration | 410 ms | 726 ms |
| gemma-3-27b, Latin + transliteration | 290 ms | 620 ms |
⚠ It is not output length. Measured on our own field: glm-4.7 writes the shortest replies (49 chars) and is the slowest (818 ms); gemma-3-27b writes 66 chars in 453 ms. No amount of prompt trimming fixes it.
So the model writes Latin and a dictionary converts before TTS in 0.0042 ms.
The lexicon, in three tiers — and why order is the whole design
| tier | source | entries |
|---|---|---|
| 1 | curated freight vocabulary, hand-corrected | 484 |
| 2 | Dakshina (Google Research, CC BY-SA 4.0) inverted | 47,611 |
| 3 | crude rule speller | — |
⛔ The bigger, human-annotated dataset is worse for this domain if used naively. Dakshina disagrees with tier 1 on 82 of 356 shared words (23%), and the disagreements go the wrong way:
bata -> बाटा the SHOE BRAND, not बता ("tell")
ab -> एबी the LETTERS A-B, not अब ("now")
karna -> कर्ण the Mahabharata CHARACTER, not करना ("to do")
baad -> बाड़ "fence", not बाद ("after")
Dakshina maps native Wikipedia words to romanisations; inverting it asks
"which native word is most often written this way in an encyclopedia". 128 of our
484 forms are absent entirely — karungi, confirm, mxl, and the client name itself.
It still earns tier 2: 47,611 forms, and it got kam → कम and to → तो right —
two errors we had to find by hand.
⚠ Licence: CC BY-SA 4.0 is share-alike. It does not touch source code, but a derived lexicon shipped as data to a customer needs attribution and the same licence.
⚠ The honest limit: the lexicon is the pronunciation, and no automatic check can hear it. Seven entries were checked by hand; 477 have not been.
6. Turn detection — a full cycle spent on a model we cannot use
SOURCED — Experiments 27 and 28
LiveKit's semantic end-of-utterance model was tested standalone, honestly, and then discarded:
⛔ The licence forbids it. A full experiment cycle spent measuring something unusable — avoidable by reading a licence first, five minutes of work deferred and labelled "before shipping".
⭐ But the finding survived the model: numbers and sentences fail differently, and one model will not catch both. That is why turn detection is still two components, and why swapping LiveKit for Flux changed the parts list without changing the architecture.
Two further traps from the same experiments:
- ⭐ Use the "gpu" ONNX file even on CPU — it halves Hindi's error.
- ⛔ It silently misreads our audio — hardcoded to 16 kHz. This is the exact class of bug that made Flux's native µ-law support decisive.
- ⛔ The first test set was rigged, and we wrote it.
Narrowband mitigations — what actually works
SOURCED — published work, not our measurement
| approach | evidence |
|---|---|
| matched-condition / narrowband fine-tuning | ⭐ strongest. 100k h narrowband pretraining + pseudo-labels: Hindi 36.9% → 16.6% |
| send native 8 kHz, do not upsample | free; the only thing Google explicitly endorses |
| vendor telephony variant | plausible, but no vendor publishes a WER delta |
| neural bandwidth extension | ⛔ 3.72% vs naive sinc interpolation 3.67%. Adds nothing |
| generative speech enhancement front-end | ⛔ measured to hurt — "consistently degrades ASR performance" |
⚠ No famous leaderboard tests telephone audio or Hindi. HuggingFace Open ASR is English + European, wideband. Artificial Analysis is English-only mic audio. Pipecat's benchmark is 16 kHz English. All three are irrelevant to us.
7. Live calls
The first always-listening conversation
SOURCED — Experiment 29
⛔ The conversation was not recorded and is gone. No audio, no transcript, no JSONL. The single most informative event to that point, unreproducible. That is the finding that changed what we built next — session recording is now unconditional.
Two faults it surfaced:
- ⛔ The agent did not know who it was.
- ⛔⛔ It gave away the ceiling, unprompted, on the first try.
The 23× region
SOURCED — Experiment 30
⛔ We were calling Bedrock in the wrong continent.
OBSERVED — CLAUDE.md, and it is now a documented trap
| region | p50 first token |
|---|---|
| ap-south-1 | 519 ms |
| us-east-1 | 12,109 ms |
⛔ A second fault in the same session: telemetry in the critical path silenced the agent.
Five money leaks, all found by review
SOURCED — the bake-off and review round
⛔⛔ Five money leaks, all found by adversarial review, all now closed. None were found by the test suite.
⛔ The headline latency filter has never once fired.
⛔ The bake-off ranks nothing, and saying otherwise was the mistake. The corpus was generated by the incumbent model, and n=69 separates nothing after correction. It is a screen for gross failures — ADR 0006.
8. Measurement mistakes, and what each cost
Kept because every one produced a number that looked reasonable and was wrong.
SOURCED
| mistake | what it produced |
|---|---|
| A gate that could not fail | 9 of 10 deliberately unacceptable replies scored completely clean. The numeral vocabulary was Devanagari-only while the model wrote romanised |
| Stream prefix accumulation | Growing Update events joined: 5/12 and "12/12 splits". Keyed by turn index: 8/12, 0/12 |
| Half-speed pacing | sleep(0.02) per frame ran audio at ~half real time; a 4.7 s clip took 9.7 s. Invalidated a 255 ms baseline (honest: 313 ms) |
| TLS handshake ≠ round trip | Cartesia and OpenAI terminate at a CDN edge (15 ms and 5 ms TCP) while origin is 330 ms and 259 ms. Flattered them by 20× |
| Negative durations | On short turns the endpoint finalised before speech-end; "fixing" it with a default produced −2022 ms |
| Per-turn Bedrock client | Every measured model leg paid a fresh TLS handshake — 1231 ms STT and 1050 ms model on turn one against 268/673 ms after |
| Blended p50 | A headline "507 ms, under 800 ms" where 10 of 12 turns were canned and the reported model leg was literally 0 ms |
p90 is the maximum at N ≤ 10 — and defaults were 8, 10 and 3 |
|
| Substring scoring | TN38EF9012199 counted as a hit for TN38EF9012 |
| Errors dropped from the denominator | A model erroring on every turn scored identically to a perfect one on every safety column |
| Judge failures filtered out | 3 successes + 25 throttles printed 3/3 |
| An eval harness defaulting to English TTS and STT while grading Hindi | It passed, having measured nothing |
A mutation harness matching *failed* |
Which also matches xfailed — five survivors reported as killed |
| 26 DB tests erroring as "connection refused" | Read as environment noise for weeks. They all pass |
/studio returning 200 while every call crashed on UnboundLocalError |
A static page proves nothing about a call |
⭐ The pattern: a check tested only against inputs that should PASS looks perfect. Every grader in this project has been wrong at least once, and each time it changed a verdict about a model. All are now pinned in both directions — a grader that cannot fail is indistinguishable from no grader.
9. What the log listed as open, and where it stands now
The experiment log's §10 is partly stale. Re-checked against the code:
OBSERVED — .venv/bin/python, calling hear_rate() directly
| open item | then | now |
|---|---|---|
The range bug — पैंतीस चालीस हज़ार |
confident ₹75,000 | ✅ ambiguous, "more than one plausible rate: 35,000 vs 40,000" |
Per-unit prices — डीज़ल नब्बे रुपये लीटर |
₹90,000 | ✅ absent, "no number in a plausible freight-rate band" |
| Per-tonne quotes | untyped | ✅ per_unit — flagged as needing this load's weight |
The ceiling leaked arithmetically (ceiling = 2·concession − anchor) |
recoverable by subtraction | ✅ concession is now a sha256-seeded point in the band, stable per load and not guessable |
bench_multiturn's allowlist could be fooled by recombination |
a figure rebuilt from two authorised ones passed | ✅ closed; FigureGate audits the final text |
Still genuinely open:
- Real call recordings. Everything above is synthesised speech. The single largest unknown.
- 477 lexicon entries have had no human eye.
- Kannada — pack wired, checks are script-only, needs a native reader.
- Barge-in, dual-channel, TTS phrase caching — designed, never built.
- English number-words glued to a Hindi scale —
twenty eight हज़ार five hundredreturnsambiguous(OBSERVED), which is safe but costs a turn.