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

SOURCEDproviders_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

SOURCEDrealtime_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

SOURCEDflux.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

OBSERVEDori_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

OBSERVEDori_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-sonnet as a judge — capped at 2 req/min, silently returned None for 77 of 90 lines, leaving a 13-sample "judge" that looked like a result.
  • glm-4.7 as a judge — it is also a candidate. A model grading itself is not a second opinion. The judge is now deepseek.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 stancesclose_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.

OBSERVEDCLAUDE.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 scaletwenty eight हज़ार five hundred returns ambiguous (OBSERVED), which is safe but costs a turn.