Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #39.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# kb-whisper vs pianissimo on one real hour of Swedish speech (tapir#33, 2026-10-01)
|
||||
|
||||
One recording, one speaker: a 63.7-minute Teams session of the maintainer dictating project
|
||||
notes (domain-heavy, English loanwords and product names mixed into Swedish). **No transcript
|
||||
text and no domain terms are recorded here**: this repo is public and the content is private.
|
||||
Only aggregate numbers.
|
||||
|
||||
Backends, both through the LiteLLM gateway and Tapir's own adapter, unchanged:
|
||||
`iguana/kb-whisper` (whisper.cpp, KB's Swedish fine-tune) and `iguana/pianissimo` (KlangAI
|
||||
Swedish Parakeet/FastConformer-TDT, MLX 8-bit, served by a small FastAPI shell on iguana:8102).
|
||||
|
||||
## Result: a near tie on accuracy, a large difference in speed
|
||||
|
||||
| | kb-whisper | pianissimo |
|
||||
|---|---|---|
|
||||
| Wall time for 63.7 min | 4m08s (~15x realtime) | **40 s (~95x realtime)** |
|
||||
| Segments | 1,302 (timed pieces, wrapped at ~60 chars) | 434 (sentences) |
|
||||
| Words (normalised) | 6,606 | 6,510 |
|
||||
| Punctuation | light, every segment ends in a full stop | heavier (138 vs 82 commas) |
|
||||
| Repetition loops | none | none |
|
||||
|
||||
Word-level agreement between them: **92.4%** as first computed, **93.1%** after the segment
|
||||
rejoin fix below. 380 aligned disagreement spans, median one word each.
|
||||
|
||||
**Blind A/B by the speaker** (the only ground truth available), on the 30 spans a model
|
||||
(Claude Opus 5.5) had picked as changing meaning, shuffled and unlabelled: **pianissimo 17,
|
||||
kb-whisper 13**, no "neither", no "can't tell".
|
||||
|
||||
| Category of the 30 | pianissimo | kb-whisper |
|
||||
|---|---|---|
|
||||
| Technical term / English loanword | 8 | 4 |
|
||||
| Number / date / unit | 4 | 1 |
|
||||
| Name / proper noun | 3 | 3 |
|
||||
| Function word / small word | 1 | 2 |
|
||||
| Other (everyday words) | 1 | 3 |
|
||||
|
||||
The reading: pianissimo is better on terminology and numbers, kb-whisper on ordinary Swedish
|
||||
words; names are even. Their errors differ in kind: kb-whisper's tend to be garbled words, a
|
||||
couple were wrong numbers; pianissimo's tend to be fluent substitutions that read as plausible
|
||||
(a wrong institution, an invented person, a dropped sign), which a reader or an LLM is less
|
||||
likely to catch.
|
||||
|
||||
Opus's own lean agreed with the speaker's blind answer on **30 of 30**. That is not a measure
|
||||
of Opus over all 380 spans: it chose spans it could decide, and every one of the 30 carried a
|
||||
lean. Its full classification of the 380 (counts only): function/small word 81, filler or
|
||||
false start 65, technical term 58, compound split/join 46, other 39, name 28, casing or
|
||||
punctuation residue 26, inflection 23, number/date/unit 10, content omission or insertion
|
||||
(>= 3 words) 4.
|
||||
|
||||
## A bug this comparison found in Tapir (fixed)
|
||||
|
||||
kb-whisper's output is wrapped at ~60 characters and **a wrap can fall inside a word**; the
|
||||
continuation segment has no leading space, the response's `text` field joins segments with
|
||||
newlines, and a segment can be a lone `.`. Tapir stored that joined text, so chat received
|
||||
broken words. Measured on real gateway output: 7% of segments (96/1,332) in a synthetic hour,
|
||||
42/1,302 lines in the real one. berget's segments are whole sentences with no leading space, so
|
||||
the two need different joins. `transcribe.joinSegments` now rebuilds the text from the segments.
|
||||
Checked against the real transcript with pianissimo's vocabulary as an independent yardstick:
|
||||
36 spans changed, 31 now entirely known words, words unknown to pianissimo 224 -> 177.
|
||||
Five rejoined spans still contain an unknown word (a name or an unlucky glue): small residual
|
||||
risk. Earlier tests used short sentences and could not see this.
|
||||
|
||||
## What this does not show
|
||||
|
||||
- **One recording, one speaker, one domain.** No claim about other voices, accents, crosstalk,
|
||||
or room noise. 17 vs 13 is not a significant difference.
|
||||
- **The other ~350 spans** were not judged by the speaker. They are mostly spelling, compounds,
|
||||
fillers and small words; Opus's counts there are a model's judgement, not ground truth.
|
||||
- Errors **both** models share in text they agree on are invisible to this method.
|
||||
- Speed was measured once each, on an otherwise idle iguana; pianissimo's 40 s includes the
|
||||
gateway hop and a 122 MB WAV upload.
|
||||
- Whether pianissimo survives the infra#273 Metal-OOM contention the way whisper does not was
|
||||
not tested; both run on iguana.
|
||||
|
||||
## What it suggests for Tapir (not decided, filed as an issue)
|
||||
|
||||
Pianissimo is a **second local backend on the same host, through the same adapter**. That
|
||||
makes it a natural middle tier ahead of the cloud fallback (kb-whisper -> pianissimo ->
|
||||
berget), so a wedged whisper no longer sends private audio off the homelab. At 40 s an hour it
|
||||
is also cheap enough to run **both** on every upload and give chat both texts, or flag where
|
||||
they disagree; the maintainer's blind answers show they err in different places.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Audio length ceiling — measured before designing chunking (tapir#33, 2026-10-01)
|
||||
|
||||
Question (#33, "the main technical risk"): at what recording length does Swedish
|
||||
transcription through the LiteLLM gateway first fail, and with what failure mode?
|
||||
Measured **before** any chunking design, with iguana not holding a large ollama model,
|
||||
so the result is the ceiling and not the Metal OOM of infra#273.
|
||||
|
||||
## Answer
|
||||
|
||||
**No transcription ceiling was found at or below 90 minutes.** A single request carrying
|
||||
90 minutes of 16 kHz mono PCM (174 MB) transcribed in one pass in 440 s. The first thing
|
||||
that actually fails is **not whisper**: it is the public gateway ingress, which rejects the
|
||||
upload with `413` at roughly 5.5 minutes of WAV.
|
||||
|
||||
| Route | First failure | Failure mode |
|
||||
|---|---|---|
|
||||
| `https://llm-api.d-ma.be` (openresty ingress) | between 8.0 MB and 10.5 MB body (~4–5.5 min of WAV) | `413 Request Entity Too Large`, HTML body from `openresty`, returned instantly |
|
||||
| `http://koala:30401` (LiteLLM NodePort) → `iguana/kb-whisper` | **none up to 90 min / 174 MB** | — |
|
||||
| `http://koala:30401` → `berget/kb-whisper` | **none up to 22 min / 42 MB** (largest tried) | — |
|
||||
|
||||
The ingress limit is the reason the 8:20 / 15 MB recording in #33 "failed repeatedly" and a
|
||||
30-second slice succeeded. That failure was attributed to a Metal OOM; at least in the
|
||||
size-dependent form described, a body-size cap explains it without one. (The OOM
|
||||
mechanism in infra#273 is separately real; this run did not provoke it.)
|
||||
|
||||
The exact ingress cap was bracketed (8.0 MB passed, 10.5 MB failed), not read from the
|
||||
ingress configuration, which lives in `infra`. "About 10 MB" is an inference.
|
||||
|
||||
## Method
|
||||
|
||||
- **Audio.** No known-good Swedish recording is available that is safe to use: the only real
|
||||
one is a private recording (#31 custody still open) and was
|
||||
deliberately not touched. Instead: macOS `say -v Alva` (Swedish) on iguana, a 1,171-char
|
||||
five-paragraph meeting script → 82.5 s of 16 kHz mono PCM WAV (`ffmpeg -ar 16000 -ac 1`).
|
||||
Concatenated ×4, ×8, ×16, ×32, ×44, ×66 with Python `wave` (the issue's own recipe).
|
||||
Nothing here is a real transcript; none of it is committed.
|
||||
- **Request.** `POST /v1/audio/transcriptions`, multipart `file=`, `model=iguana/kb-whisper`,
|
||||
`response_format=verbose_json`, `-H 'Expect:'` (per #33's traps), master key supplied only
|
||||
through `op run` and never on argv.
|
||||
- **Confound control.** `ollama /api/ps` on iguana was read before every step. Resident:
|
||||
nothing, or `nomic-embed-text` (0.37 GB). Never a large model.
|
||||
|
||||
## Results (`iguana/kb-whisper`, NodePort)
|
||||
|
||||
| Audio | WAV size | HTTP | Wall | Realtime factor | Segments | Chars |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0:30 | 0.96 MB | 200 | 2.8 s | 11× | — | — |
|
||||
| 2:30 / 3:20 / 4:10 | 4.8 / 6.4 / 8.0 MB | 200 (ingress) | 12.9 / 16.0 / 19.9 s | 12× | — | — |
|
||||
| 5:30 (×4) | 10.5 MB | **413 ingress**, 200 NodePort in 27 s | 27 s | 12× | 121 | 4,557 |
|
||||
| 11:00 (×8) | 21.1 MB | 200 | 53 s | 12× | 241 | 9,094 |
|
||||
| 22:00 (×16) | 42.3 MB | 200 | 105 s | 13× | 482 | 18,201 |
|
||||
| 44:01 (×32) | 84.5 MB | 200 | 209 s | 13× | 966 | 36,438 |
|
||||
| 60:32 (×44) | 116.2 MB | 200 | 291 s | 12× | 1,332 | 50,145 |
|
||||
| 90:48 (×66) | 174.3 MB | 200 | 440 s | 12× | 2,005 | 75,165 |
|
||||
|
||||
- Throughput is flat at ~12× realtime. 90 minutes costs ~7.5 minutes of wall time. This is
|
||||
a minutes-long job, not a request/response one (see ADR-033 on ADR-018).
|
||||
- Segments per source repetition stay at 30.1–30.4 from ×4 to ×66, and segment times are
|
||||
monotonic with `last_end` within 0.2 s of `duration`. No drift and no repetition-loop
|
||||
hallucination appeared across 90 minutes. This is a weak check — the input is a repeated
|
||||
loop and synthetic voice — not evidence of accuracy.
|
||||
- **Only `iguana/kb-whisper`'s `x4` run reported `last_end` 360.0 s against `duration`
|
||||
330.2 s.** Whisper's final segment can end past the audio; clamp to `duration`.
|
||||
|
||||
## Findings that change the design
|
||||
|
||||
1. **Use the NodePort route, not the public hostname.** Tapir runs in-cluster beside the
|
||||
gateway; `llm-api.d-ma.be` is the external door and has a body cap. #33 said "use the
|
||||
gateway, never the host" and named the public URL; the gateway is right, that URL is not.
|
||||
`TAPIR_GATEWAY_URL` already points at `koala:30401/v1`.
|
||||
2. **`response_format=srt` survives the LiteLLM hop, but wrapped.** The response is JSON
|
||||
`{"text": "<the SRT>", "usage": …}`, not a bare SRT body — a client that parses the body
|
||||
as SRT gets a parse error on `{`. #33 question 2 is answered: it passes through, inside
|
||||
an envelope.
|
||||
3. **The two backends return different timestamp granularity.** `iguana/kb-whisper`
|
||||
segments are **whole seconds** (`0→1`, `1→4`, `4→7`); `berget/kb-whisper` returns real
|
||||
sub-second times (`0.051→1.192`) and a different `language` token (`swedish` vs `sv`).
|
||||
`verbose_json` and the default JSON were byte-identical on iguana. An adapter that
|
||||
falls back between the two must normalise both, and "which endpoint served this" is
|
||||
a quality fact, not just a log line.
|
||||
4. **The existing chat path cannot answer about most of a long recording.** At
|
||||
828 chars/min (this fixture; real speech is plausibly a little denser),
|
||||
`TAPIR_MAX_TRANSCRIPT_CHARS`'s default of **18,000** is ~21.7 minutes. A 60-minute
|
||||
transcript is 50 k chars, a 90-minute one 75 k. `chat.Service` truncates by keeping
|
||||
the head (`internal/adapters/chat/chat.go`), so questions about the last two-thirds of
|
||||
a one-hour meeting get an answer from a transcript that does not contain it. #33 says
|
||||
chat "retrieves rather than generates" and therefore has no ceiling; it **stuffs and
|
||||
truncates**, and its ceiling is the model context. See ADR-033.
|
||||
5. **Chunking is not needed for v1 on length.** Nothing failed up to 90 minutes. The
|
||||
chunking-and-stitching recipe in #33 (offset timestamps, renumber cues, strip `\r`) is
|
||||
not built. It stays the answer *if* a longer cap is wanted or the OOM mechanism forces
|
||||
smaller requests — see ADR-033's resolution triggers.
|
||||
|
||||
## What this did not measure
|
||||
|
||||
- **Metal OOM under contention.** Deliberately avoided. A big resident ollama model
|
||||
during a long transcription is still the known way to wedge iguana permanently.
|
||||
- **Real speech.** TTS is clean, single-voice, no crosstalk or room noise. WER and
|
||||
speaker overlap in a Teams export are untested. Speed is probably representative;
|
||||
accuracy is not claimed.
|
||||
- **Compressed input.** Only WAV was sent. Whether `m4a`/`mp3`/`opus` can reach whisper
|
||||
without a server-side transcode is not established here (ADR-033 treats it as needing one).
|
||||
- **Concurrency.** One request at a time. Two simultaneous 90-minute jobs on iguana were
|
||||
not tried.
|
||||
- **`berget/kb-whisper` beyond 22 min / 42 MB.** Enough to know the fallback is not
|
||||
size-capped at meeting length for 22 minutes; not proven at 90.
|
||||
- **Time-of-day / other tenants.** One afternoon, one run per length.
|
||||
|
||||
Scripts were throwaway shell/Python in a session scratchpad and are not committed; the
|
||||
method above reproduces them.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Chat over a long transcript — what the offered models really hold (tapir#33, 2026-10-01)
|
||||
|
||||
Question: ADR-033 reuses `chat.Service`, which stuffs the transcript into the prompt and
|
||||
cuts the tail. #33 assumed chat "retrieves rather than generates" and so has no length
|
||||
ceiling. It does not retrieve, so its ceiling is the model's context. What is it, for the
|
||||
models chat actually offers?
|
||||
|
||||
## Answer
|
||||
|
||||
| Model (via the NodePort gateway) | Result | Limit found |
|
||||
|---|---|---|
|
||||
| `koala/phi4-mini` | correct to 30,000 chars; **HTTP 400 at 50,000** (13,332 tokens vs an 8,192-token context) | ~22,000 chars in production, where the client adds `max_tokens: 1500` |
|
||||
| `iguana/gemma4-26b` | correct at 18k, 50k and **90,000 chars** (26.5 k tokens); **at 120,000 chars it silently loses the head** | **90,000 chars** |
|
||||
| `berget/mistral-small` | answered "the transcript does not contain it" for a fact **at the end** of an 18,000-char and a 50,000-char transcript | unreliable here, one run each — see caveats |
|
||||
|
||||
The dangerous one is gemma at 120,000 chars: **no error, no truncation flag** — the reported
|
||||
prompt tokens fell from 26,548 (90 k chars) to 16,387 (120 k chars), and the model answered
|
||||
"it does not say who the chair is" about a sentence at the very start of the transcript. The
|
||||
serving layer truncated the prompt from the front. `chat.Service` reports `Truncated: false`
|
||||
because *it* did not cut anything.
|
||||
|
||||
## What this changes
|
||||
|
||||
1. **The budget for an uploaded transcript is 90,000 chars, not a round 120,000.** That is the
|
||||
best offered model's measured limit, ~100 minutes of speech at the fixture's density.
|
||||
`TAPIR_MAX_UPLOADED_TRANSCRIPT_CHARS` defaults to it.
|
||||
2. **Chat over an upload must not open on the summarizer default.** The offered list is
|
||||
`[SummarizerModel, FallbackModel, CloudFallbackModel]`, so `koala/phi4-mini` is first — and
|
||||
400s on an hour of speech. `TAPIR_UPLOAD_CHAT_MODEL` (default `iguana/gemma4-26b`) is what
|
||||
the upload chat opens with; the switcher still lists every model.
|
||||
3. **A per-model budget does not exist yet.** Someone who switches the dropdown to
|
||||
`phi4-mini` on a long recording gets a loud HTTP 400, shown inline as "couldn't answer".
|
||||
That fails honestly but unhelpfully. Filed as a follow-up.
|
||||
4. **The "retrieval chat" trigger in ADR-033 is now concrete:** any recording past 90,000 chars
|
||||
(≈100 min) cannot be answered by stuffing, for any offered model.
|
||||
|
||||
## Method
|
||||
|
||||
`POST /v1/chat/completions` through `http://koala:30401/v1`, `temperature: 0`,
|
||||
`max_tokens: 200` (production sends 1500 for chat via the summary token cap), the same system
|
||||
framing as `chat.Service`. The transcript is the 60-minute synthetic fixture from
|
||||
`audio-length-ceiling-2026-10-01.md` truncated/repeated to N chars, with one unique sentence
|
||||
planted either at the **end** ("lagret på Kvarngatan ska tömmas … av Pelle", question: who/when)
|
||||
or at the **head** ("kvällens ordförande är Ingrid", question: who chairs). Needle at the
|
||||
end alone would have missed the gemma failure — a serving layer that truncates from the front
|
||||
keeps the tail, so the end-needle passed at 120,000 chars; only the head-needle exposed it.
|
||||
Grounded = the answer contains the planted fact.
|
||||
|
||||
## What this did not measure
|
||||
|
||||
- **Real speech**: the fixture is a repeated 82-second script, so retrieval "difficulty" is
|
||||
artificially easy except for the planted needle. One question per run, one run per cell.
|
||||
- **`berget/mistral-small`'s failure**: it missed an end-needle at 18,000 chars where the
|
||||
others succeeded. One run; could be the model, the prompt framing, or position bias. Not
|
||||
investigated; do not read it as a ranking.
|
||||
- **Latency under load**, **concurrent** chat while whisper is transcribing, and in particular
|
||||
the hazard below.
|
||||
- **`koala/qwen36-35b-a3b` or any frontier model** — not in the offered list.
|
||||
|
||||
## A hazard this surfaces and does not solve
|
||||
|
||||
`iguana/gemma4-26b` is a large ollama model on the same host as `iguana/kb-whisper`. infra#273
|
||||
is precisely that a large resident ollama model can Metal-OOM whisper-server permanently. Making
|
||||
gemma the default *chat* model for uploads means chatting with a transcript can load the model
|
||||
that wedges the transcriber. The primary-fails-over-to-berget design limits the blast radius but
|
||||
does not remove it. Recorded in ADR-033 and filed.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Can a local model adjudicate two ASR transcripts? (tapir#33, 2026-10-01)
|
||||
|
||||
Question: when `iguana/kb-whisper` and `iguana/pianissimo` disagree about a word or phrase,
|
||||
can a **local** model say which reading is right, well enough to merge the two transcripts?
|
||||
Ground truth: the speaker's own blind answers on 30 disagreements of one real recording
|
||||
(see `asr-kb-whisper-vs-pianissimo-2026-10-01.md`; no transcript text is recorded here,
|
||||
this repo is public). Reference: Claude Opus 5.5 agreed with the speaker on all 30.
|
||||
|
||||
## Method
|
||||
|
||||
- **Items:** 30 disagreement spans that Opus had picked as changing meaning, the speaker's
|
||||
verdict as truth (pianissimo right 17, kb-whisper right 13).
|
||||
- **Prompt:** one fixed English instruction (two transcriptions of Swedish speech differ at
|
||||
ONE marked spot; say which reading the speaker most likely said; reply `ANSWER: 1|2|UNSURE`
|
||||
plus one sentence). Each item was asked in **both reading orders** and in **two contexts**:
|
||||
narrow (+-8 words) and wide (+-100 words). 120 calls per model, temperature 0.
|
||||
- **Models:** 11, all local (`koala/*`, `iguana/*`): nothing left the homelab. Run one iguana
|
||||
model at a time, each unloaded afterwards, to avoid the memory pressure behind infra#273;
|
||||
whisper was confirmed healthy afterwards.
|
||||
- **Pass criteria, fixed before the run:** wide context, strict accuracy >= 80% (an
|
||||
unparseable or "unsure" answer counts as wrong), order-swap consistency >= 85%,
|
||||
unparseable <= 10%.
|
||||
|
||||
## Results (wide context, 60 calls per model)
|
||||
|
||||
| Model | Correct | 95% CI | Same pick when order swapped | Unparseable | s/call | Pass |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `iguana/gemma4-31b` | 58 = 97% | 89-99% | 93% | 0 | 10.4 | **yes** |
|
||||
| `iguana/gemma4-26b` | 55 = 92% | 82-96% | 93% | 0 | 2.0 | **yes** |
|
||||
| `iguana/ornith-35b` | 51 = 85% | 74-92% | 77% | 0 | 2.5 | no (consistency) |
|
||||
| `koala/phi4-14b` | 50 = 83% | 72-91% | 80% | 0 | 1.0 | no (consistency) |
|
||||
| `koala/qwen36-35b-a3b` | 49 = 82% | 70-89% | 83% | 0 | 3.3 | no (consistency) |
|
||||
| `iguana/granite4` | 47 = 78% | 66-87% | 83% | 0 | 4.6 | no |
|
||||
| `iguana/qwen3-14b` | 44 = 73% | 61-83% | 87% | 0 | 4.4 | no (accuracy) |
|
||||
| `iguana/gpt-oss-20b` | 40 = 67% | 54-77% | 100% | 17 | 11.2 | no (unparseable) |
|
||||
| `koala/phi4-mini` | 37 = 62% | 49-73% | 24% | 1 | 0.5 | no |
|
||||
| `iguana/gemma4-26b-think` | 23 = 38% | 27-51% | 100% | 37 | 15.3 | no (unparseable) |
|
||||
| `koala/qwen35-9b-fast` | 0 | n/a | n/a | 60 | 7.2 | no (no output) |
|
||||
|
||||
Narrow context only (+-8 words) costs 6-12 points for the models that pass: gemma4-31b 92%,
|
||||
gemma4-26b 83%, ornith-35b 80%. Context is not decoration: Opus also used evidence from
|
||||
elsewhere in the recording (an amount stated earlier, a term that recurs).
|
||||
|
||||
Position bias: the passing models pick "Reading 1" 47-51% of the time. `phi4-mini` picks it 15%
|
||||
and flips its answer when the order is swapped 76% of the time: it carries almost no signal.
|
||||
|
||||
## Reading it
|
||||
|
||||
- **Two models pass:** the gemma4 pair. Both clear the bar and neither shows position bias.
|
||||
26b is ~5x faster; their intervals overlap, so 30 items cannot separate 92% from 97%.
|
||||
- **The thinking models were not tested fairly.** `gpt-oss-20b` and `gemma4-26b-think` spend
|
||||
the 600-token budget reasoning and often never reach the answer line. When gemma4-26b-think
|
||||
did answer, it was right 23 of 23; gpt-oss-20b 93% of the answered. A larger budget would
|
||||
make them eligible; at 15 s a call it would not make them practical.
|
||||
|
||||
## What this does not show
|
||||
|
||||
- **Thirty items, one recording, one speaker, one domain.** The 30 were chosen by Opus as
|
||||
decidable, so they overstate how easy the typical disagreement is; ~350 others are mostly
|
||||
spelling, compounds and fillers, which no ground truth covers.
|
||||
- Opus agreeing 30/30 is not a measure of Opus over all 380 disagreements.
|
||||
- Accuracy was measured at temperature 0 on one prompt; no prompt tuning was tried.
|
||||
- The speaker's answers are the only truth; they are one person's recollection of what they said.
|
||||
|
||||
## Why gemma4-31b is slow, and what was ruled out
|
||||
|
||||
On iguana (M2 Ultra, 64 GB) the dense 31B runs at **103 tok/s prefill, 7.9 tok/s decode**; the
|
||||
26B MoE at 641 and 39.6. Ruled out as the cause: memory pressure (no swap, 45% free, model 100%
|
||||
on GPU), context length (32k vs 4k made no difference), and the **inference engine** — MLX 4-bit
|
||||
of the same model measured 76 tok/s prefill and 8.6 decode, the same within noise. (Terse
|
||||
output saves only ~3.6 s of ~9.3 s because prefill dominates.) Two unrelated engines at the same
|
||||
speed points at the machine: ~140 GB/s effective memory bandwidth on a part rated for 800, and
|
||||
**iguana had macOS Low Power Mode on** (`pmset` `lowpowermode 1`). A first MLX benchmark was
|
||||
wrong because `mlx_lm.server` caches prompts; the figures above are from the corrected run.
|
||||
|
||||
**Confirmed 2026-10-02.** The owner turned Low Power Mode off (`lowpowermode 0`, read back over
|
||||
ssh) and `gemma4:31b` was re-measured with nothing else resident: **217 tok/s prefill, 22.3
|
||||
tok/s decode** (4 runs, spread 22.2-22.6), against 103 and 7.9 with it on, so 2.1x and 2.8x. The
|
||||
slowness was the power mode, not the engine or the model. Caveats on the comparison: the new run
|
||||
used a different, synthetic Swedish prompt (~700 words, the original items held real transcript
|
||||
text and were deleted) and the model answered in 26-43 tokens, not the ~200 of the first run, so
|
||||
it is a same-machine before/after, not a controlled A/B. `gemma4:26b` and the MLX figures were
|
||||
not re-measured. The 31B's 97% judge accuracy is therefore usable at roughly a third of the
|
||||
latency the sweep implied; the 26B-vs-31B choice in ADR-034 was made on the slow numbers and is
|
||||
reopened by this.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Two-engine reconcile, first check on a real recording (tapir#33, ADR-034, 2026-10-02)
|
||||
|
||||
Aggregate numbers only. This repo is public and the recording is private (#31 custody is open), so no
|
||||
transcript text, no disputed words and no per-item reading appears here.
|
||||
|
||||
## What was run
|
||||
|
||||
One real 63.6-minute Teams recording of one speaker dictating project notes (the video track, camera
|
||||
off, was discarded). Build: PR #39 head `7f8e367`. Everything local: `iguana/pianissimo` as base,
|
||||
`iguana/kb-whisper` as the second engine, `iguana/gemma4-31b` as reconciler (iguana Low Power Mode
|
||||
off), cloud fallbacks off, a throwaway Postgres.
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Job time, upload to saved | 21 min 49 s for 63.6 min of audio (20.6 min per hour of audio) |
|
||||
| Differences between the engines | 362 |
|
||||
| Settled automatically (compound splits, up to two filler words) | 80 |
|
||||
| Decided by the model | 282 |
|
||||
| Of those: alternative reading used / base kept / uncertain | 130 / 151 / 1 |
|
||||
| Model call errors | 0 |
|
||||
|
||||
20.6 min per hour is inside the 25-minute resolution trigger in ADR-034, so no smaller adjudicator is
|
||||
needed on speed grounds. The earlier estimate was about 10 min per hour on gemma4-26b; it was not
|
||||
measured and this run used the 31B.
|
||||
|
||||
## Blind check
|
||||
|
||||
30 spots drawn at random (fixed seed) from the 89 replacement spots that could be placed in the base
|
||||
text, across the whole recording (0:44 to 60:57). The two readings were shown as x and y in random
|
||||
order, with the key kept apart from the judge. Judge: Claude Opus, text only, no audio, at the
|
||||
speaker's request ("I trust Opus"). The speaker did not judge these items themselves.
|
||||
|
||||
| Verdict | Items |
|
||||
|---|---|
|
||||
| The reconciler's reading (the alternative) is right | 21 |
|
||||
| The base reading (pianissimo) is right, so the change made it worse | 1 |
|
||||
| Neither reading is right | 3 |
|
||||
| Cannot tell from text (compound, hyphen and number-format differences) | 5 |
|
||||
|
||||
- Right of the items with a verdict: 21 of 22, 95% (95% interval 78% to 99%).
|
||||
- Right of all 30, counting "neither" and "cannot tell" as not right: 21 of 30, 70% (52% to 83%).
|
||||
- Right of the 25 that were not "cannot tell": 21 of 25, 84% (65% to 94%).
|
||||
- Net effect against the base on these 30: 21 better, 1 worse, 8 unchanged in quality.
|
||||
|
||||
## Reading the result
|
||||
|
||||
ADR-034's bar was "right at least 85% of the time". It did not say how ties count, and that was a
|
||||
flaw in the bar, not in the data. The literal reading (all 30) gives 70% and **does not meet** the bar;
|
||||
counting only decided items gives 95% and meets it. Neither is a hidden pass or a hidden failure; both
|
||||
are reported. What is not in doubt: on a random sample the reconciler improved the text far more often
|
||||
than it harmed it (21 to 1), and the 8 unchanged cases are mostly spellings the text cannot settle.
|
||||
|
||||
## What this did not measure
|
||||
|
||||
- **Better single engine.** The second ADR-034 condition, "no worse than the better single engine", is
|
||||
**not answered**. Only changed spots are stored, not the 151 where the model kept pianissimo, so the
|
||||
reconciler cannot be compared with kb-whisper alone. The 21-to-1 result is against pianissimo.
|
||||
- **The speaker as judge.** Opus judged from text. It cannot hear the audio, so it can be wrong where
|
||||
the right word is audible but not inferable (items such as a mis-heard number or a name). Earlier,
|
||||
Opus and the speaker agreed 30 of 30, but on items chosen as decidable; that does not transfer to a
|
||||
random sample.
|
||||
- **Insertions and deletions.** 42 of the 131 stored spots were not sampled: 39 are an added or
|
||||
removed word, which cannot be placed in the base text, and 3 are the uncertain spot or could not be
|
||||
located. The 39 are mostly small words ("och", "det"), so this sample says little about them.
|
||||
- **One recording, one speaker, 30 items.** Wide intervals, and neighbouring spots (three pairs within
|
||||
seconds of each other) are not independent.
|
||||
- **Segments keep the base text.** The saved timed segments hold pianissimo's wording, not the merged
|
||||
text; only the stored full text is merged. Found while building this sample; not yet an issue.
|
||||
Reference in New Issue
Block a user