# Canary 1B v2 — Setup, Sweep-Reproduktion und Decoding-Schalter

Selbsterhaltende Anleitung für **NVIDIA Canary 1B v2** in doctate.
Alles, was zur Installation, zur Reproduktion der bisherigen
Sweep-Ergebnisse und zur Konfiguration der Decoding-Schalter nötig
ist, steht in diesem Dokument. Detail- und Hintergrund-Material ist
als Appendix angehängt.

## 1. Was und warum

- **Modell:** `nvidia/canary-1b-v2`. FastConformer-Encoder + AED
  (Attention-Encoder-Decoder), ca. 1 Mrd. Parameter. Vier Sprachen
  (en/de/fr/es), beste Deutsch-Performance unter den freien Modellen
  ≤ 12 GB VRAM.
- **Status in doctate:** zweiter ASR-Service neben Whisper, **nicht**
  Drop-in-Replacement. Whisper bleibt am `/asr`-Endpoint, Canary läuft
  separat als nativer FastAPI-Service auf Port 9002 für Vergleichs-
  und Forschungsläufe.
- **Lizenz:** CC-BY-4.0 (kommerzielle Nutzung erlaubt, Attribution
  nötig).

## 2. Installation — Containerservice auf Minerva

Dies ist der Produktionspfad. Der Service läuft 24/7 auf
`minerva.lan:9002`. Modellgewichte (~3 GB) werden im Volume
`/opt/stacks/doctate-canary/models` gecached, sodass Restarts schnell
sind.

### 2.1 Voraussetzungen auf der Zielmaschine

- Linux mit Docker und `docker compose`-Plugin
- NVIDIA-Treiber + `nvidia-container-toolkit` (für GPU-Zugriff aus
  Containern)
- Mindestens 12 GB GPU-VRAM für bf16-Inferenz
- Mindestens 5 GB freier Plattenplatz (3 GB Modell, ca. 4.5 GB
  Container-Image)
- SSH-Zugriff für `deploy.sh`

### 2.2 Container-Stack

Der Stack besteht aus fünf Files unter `canary/` im Repo (Inhalte
siehe Appendix A):

| Datei | Zweck |
|---|---|
| `Dockerfile` | Image-Definition (cuda-devel + Python + ffmpeg + Pip-Deps) |
| `requirements.txt` | NeMo, FastAPI, HF-Hub mit Versions-Pins |
| `docker-compose.yml` | Service-Definition (Port 9002, GPU-Reservierung, Modell-Volume) |
| `main.py` | FastAPI-App mit `/health`, `/info`, `POST /inference` |
| `deploy.sh` | rsync + remote build + Health-Polling (10-min-Timeout) |

### 2.3 Deployment

Aus dem Dev-Repo:

```bash
cd /home/brummel/dev/doctate/canary
./deploy.sh minerva.lan
```

`deploy.sh` führt aus:

1. `rsync` der Container-Files nach `minerva:/opt/stacks/doctate-canary/`
2. `docker compose down` auf der Remote
3. `docker pull nvidia/cuda:12.6.1-devel-ubuntu22.04` (umgeht einen
   TLS-Cert-Bug im Legacy-Builder, der sonst beim Pull innerhalb von
   `docker build` auftritt)
4. `docker build -t doctate-canary .`
5. `docker compose up -d`
6. Pollt `/health` 120 × 5 s = 10 min lang. Beim Erststart muss das
   Modell aus HuggingFace geladen werden — das dauert mehrere Minuten.

Bei Erfolg gibt `deploy.sh` zusätzlich `/info` zurück, sonst die
letzten 60 Container-Log-Zeilen.

### 2.4 API

Der Service hat eine **native** Canary-API (kein Whisper-Mimik):

| Methode | Pfad | Zweck |
|---|---|---|
| `GET` | `/health` | `{"status":"ok","model":...,"device":...,"precision":...}` |
| `GET` | `/info` | Erweiterte Metadaten |
| `POST` | `/inference` | Multipart-Upload, Inferenz, Antwort als Text oder JSON |

Form-Felder von `POST /inference`:

| Feld | Default | Werte |
|---|---|---|
| `file` | — | Upload (mp3/m4a/opus/wav/...). Intern via ffmpeg → 16 kHz mono WAV |
| `language` | `de` | `en`/`de`/`fr`/`es` |
| `pnc` | `yes` | `yes`/`no` (Punctuation/Capitalization) |
| `timestamps` | `no` | `yes`/`no` (segment-level, nur in `json`-Antwort) |
| `response_format` | `text` | `text` (PlainText) / `json` |

Smoke-Test:

```bash
curl http://minerva.lan:9002/health
curl -F file=@dictation.m4a -F language=de \
  http://minerva.lan:9002/inference
```

### 2.5 Env Vars

| Variable | Default | Zweck |
|---|---|---|
| `CANARY_MODEL` | `nvidia/canary-1b-v2` | beliebiges NeMo-ASR-Modell |
| `CANARY_DEVICE` | `cuda` | `cuda` oder `cpu` |
| `CANARY_PRECISION` | `bf16` | `bf16` / `fp16` / `fp32` |
| `CANARY_MODELS_DIR` | `/models` | HF-Cache (volume-persistent) |
| `CANARY_CHUNK_LEN_SECS` | `40.0` | Chunk-Fensterlänge für Long-Form |
| `CANARY_CHUNKED_THRESHOLD_SECS` | `25.0` | ab welcher Audiolänge gechunkt wird |
| `HF_HOME` | wird auf `CANARY_MODELS_DIR` gesetzt | HuggingFace-Cache |

### 2.6 VRAM-Budget

Canary 1B v2 in bf16 belegt **ca. 9.7 GB** auf RTX 3060 mit
`batch_size=8` für die buffered Pipeline. Das passt **nicht**
gleichzeitig mit Whisper + Ollama auf eine 12-GB-Karte. Für
Sweep-Läufe (Abschnitt 3) muss der Service kurzzeitig pausiert
werden:

```bash
ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose stop"
# ... Sweep-Run hier ...
ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose start"
```

## 3. Reproduktion der Sweep-Ergebnisse vom 2026-04-30

Der Validation-Sweep hat **101 Einzeldiktate**, **47
Per-Case-Concats** und **1 globales 38.7-min-Concat** durch Canary
gejagt, um die `dur=0`-Halluzinations-Heuristik gegen ein echtes
Korpus zu validieren.

**Ergebnis** (Disposition siehe Abschnitt 5):

| Quantity | Value |
|---|---:|
| dur=0 Vorkommen | 81 |
| davon echte Halluzinationen | 2 |
| False Positives (echtes Transkript) | 79 |
| Precision | **≈ 2.5 %** |

Diese Schritte regenerieren die Daten von Grund auf. Die Sweep-
Skripte liegen in `experiments/canary_sweep/`.

### 3.1 Cases einsammeln

```bash
mkdir -p experiments/canary_sweep/cases experiments/canary_sweep/concat
cd /home/brummel/dev/doctate/tmpdata/dr_mueller
find . -name "*.m4a" | while read m4a_rel; do
  case_uuid=$(echo "$m4a_rel" | cut -d/ -f2)
  stem=$(basename "$m4a_rel" .m4a)
  dest="/home/brummel/dev/doctate/experiments/canary_sweep/cases/$case_uuid"
  mkdir -p "$dest"
  cp "./$case_uuid/$stem.m4a" "$dest/$stem.m4a"
  [ -f "./$case_uuid/$stem.json" ] && cp "./$case_uuid/$stem.json" "$dest/$stem.whisper.json"
done
```

Resultat: `cases/<uuid>/<stem>.m4a` (101 Files) plus 53 zugehörige
`<stem>.whisper.json` aus dem Server-State (Rest sind ältere
Aufnahmen, deren Whisper-Output nur in `document.md` zusammengeführt
wurde).

### 3.2 Per-Case- und globalen Concat bauen

`build_per_case_concat.py` und `build_concat.py` ffmpeg-stitchen
alle Audios zu einem WAV pro Case bzw. zu einem 38.7-min-Konglomerat
über alle Cases. Beide Skripte schreiben jeweils ein
`concat.manifest.json` mit der Reihenfolge und den Time-Offsets der
Einzelschnipsel:

```bash
cd experiments/canary_sweep
python build_per_case_concat.py    # cases/<uuid>/concat.wav
python build_concat.py             # concat/all.wav (38.7 min)
```

Beide sind idempotent.

### 3.3 GPU-Konflikt klären, Sweep ausführen

Der laufende `doctate-canary`-Service belegt fast die ganze GPU. Vor
dem Sweep einmal stoppen:

```bash
ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose stop"
```

Daten + Skripte hochladen:

```bash
rsync -av experiments/canary_sweep/ minerva.lan:/tmp/canary_sweep/
```

**Sweep über die 101 Einzelaudios** — Mode A (Single-Shot, mit
Word-Confidence):

```bash
ssh minerva.lan "docker run --rm --name canary-sweep --gpus all \
  -v /tmp/canary_sweep:/work \
  -v /opt/stacks/doctate-canary/models:/models \
  -e HF_HOME=/models \
  doctate-canary:latest \
  python3 /work/run_canary.py /work/cases /work/cases"
```

**Per-Case-Concats** — adaptiv (≤ 25 s → Single-Shot, > 25 s →
Buffered):

```bash
ssh minerva.lan "docker run --rm --name canary-percase --gpus all \
  -v /tmp/canary_sweep:/work \
  -v /opt/stacks/doctate-canary/models:/models \
  -e HF_HOME=/models \
  doctate-canary:latest \
  python3 /work/run_canary_per_case.py /work/cases"
```

**Globaler 38.7-min-Concat** — Buffered ist Pflicht (Single-Shot
geht in OOM):

```bash
ssh minerva.lan "docker run --rm --name canary-concat-buffered --gpus all \
  -v /tmp/canary_sweep:/work \
  -v /opt/stacks/doctate-canary/models:/models \
  -e HF_HOME=/models \
  doctate-canary:latest \
  python3 /work/run_canary_buffered.py \
    /work/concat/all.wav /work/concat/all.canary.json"
```

Service wieder hochfahren:

```bash
ssh minerva.lan "cd /opt/stacks/doctate-canary && docker compose start"
```

### 3.4 Outputs zurücksyncen

```bash
rsync -av --include='*/' \
  --include='*.canary.json' --include='concat.canary.json' \
  --exclude='*' \
  minerva.lan:/tmp/canary_sweep/cases/ \
  experiments/canary_sweep/cases/

rsync -av minerva.lan:/tmp/canary_sweep/concat/all.canary.json \
  experiments/canary_sweep/concat/
```

### 3.5 dur=0-Inventar erzeugen

`dur_zero_inventory.py` walks alle 149 `*.canary.json` Files,
extrahiert jedes Wort mit `start_offset == end_offset`, und gibt
ein strukturiertes JSON sowie einen Markdown-Report aus:

```bash
cd experiments/canary_sweep
python dur_zero_inventory.py
```

Schreibt `dur_zero_inventory.json` (alle 81 Records mit Kontext) und
`dur_zero_inventory.md` (gruppiert nach File mit 3-Wörter-Kontext um
jeden Treffer).

## 4. Decoding-Schalter — was, wann, wie

Canary 1B v2 hat zwei Inferenz-Pipelines mit **unterschiedlichen
Output-Schemata**. Die Wahl bestimmt, welche Felder im Hypothesis-
Objekt gefüllt werden.

### 4.1 Pipeline-Wahl

| Pipeline | Audiolänge | Aufruf | Hypothesis-Felder |
|---|---|---|---|
| **Single-Shot (Mode A)** | ≤ 25 s | `model.transcribe([wav], …, return_hypotheses=True)` | text, score, timestamp (word+segment), tokens, **word_confidence**, **token_confidence** |
| **Buffered** | > 25 s | `FrameBatchMultiTaskAED` + `get_buffered_pred_feat_multitaskAED` | text, score, timestamp (word+segment) — **kein** word_confidence, keine tokens |

Die Schwelle ist `CHUNKED_THRESHOLD_SECS=25.0` im Service. Hauptlimit
der buffered Pipeline: NeMo's `_join_hypotheses` in
`nemo/collections/asr/parts/utils/streaming_utils.py` mergt nur
`text`, `y_sequence` und `timestamp` über die Chunks — Confidence-
Felder werden wegen fehlender `_join_word_confidence`-Methode
verworfen. Das ist eine NeMo-Implementations-Lücke, kein
Konfigurationsfehler. Die Pro-Chunk-Confidence ginge intern, die
Streaming-Aggregation wirft sie nur weg.

### 4.2 Timestamps an/aus

**Schalter:** kwarg `timestamps=True` auf `model.transcribe()` bzw.
auf `get_buffered_pred_feat_multitaskAED()`.

**Was sie liefern:**

```json
{
  "timestamp": {
    "word":    [{"word": "Therapie.", "start_offset": 46, "end_offset": 51,
                 "start": 3.68, "end": 4.08}, …],
    "segment": [{"segment": "Therapie.",  …}, …]
  }
}
```

`*_offset` sind Encoder-Frames (FastConformer-Stride 80 ms),
`start`/`end` sind Sekunden. Die Word-Timestamps stammen aus dem
**CTC-Submodell-Forced-Alignment**, nicht aus dem AED selbst.

**Kosten:** Vernachlässigbar. Das CTC-Submodell läuft eh, der
Schalter erhält nur dessen Output, anstatt ihn zu verwerfen. Im
buffered Pfad gilt allerdings die NeMo-Empfehlung
`chunk_len_in_secs=10.0` für genauere Word-Timestamps; mit unseren
40-s-Chunks sind Word-Offsets weniger präzise (innerhalb eines
Chunks ok, an Chunk-Übergängen verlieren sie Genauigkeit).

**Default im Service:** `timestamps=no` (segment-level, nur im JSON-
Response wenn `response_format=json`). Wer Word-Timestamps für
Forschungszwecke braucht (z.B. dur=0-Inventar), muss am
`transcribe()`-Call selbst flippen — siehe Sweep-Skripte in
Abschnitt 3.

### 4.3 Confidence an/aus

**Schalter:** vier Stellen, alle vier sind nötig — eine fehlt → Wert
ist `None`:

```python
# 1. Decoding-Strategie auf greedy umstellen.
#    Beam liefert keine Confidence — confidence_cfg wird im
#    Beam-Pfad ignoriert.
cfg = OmegaConf.to_container(model.cfg.decoding, resolve=True)
cfg["strategy"] = "greedy"

# 2. confidence_cfg-Flags setzen
cfg.setdefault("confidence_cfg", {})
cfg["confidence_cfg"]["preserve_word_confidence"] = True
cfg["confidence_cfg"]["preserve_token_confidence"] = True
cfg["confidence_cfg"]["preserve_frame_confidence"] = True
cfg["confidence_cfg"]["aggregation"] = "min"

# 3. Greedy-spezifische Flags
cfg.setdefault("greedy", {})
cfg["greedy"]["preserve_token_confidence"] = True
cfg["greedy"]["preserve_alignments"] = True
model.change_decoding_strategy(OmegaConf.create(cfg))

# 4. transcribe() mit return_hypotheses=True (DER stille
#    Stolperdraht!)
hyps = model.transcribe([wav], …, return_hypotheses=True)
```

**Der stille Stolperdraht:** `return_hypotheses` ist standardmäßig
`False`. Dann liefert NeMo Strings statt `Hypothesis`-Objekten —
ohne dass irgendein Flag oder Log darauf hinweist. Egal welche
Confidence-Flags man setzt: ohne `return_hypotheses=True` ist das
Feld `None`. Das herauszufinden hat in der Diagnostik-Phase mehrere
konfundierte Ablations-Runden gekostet.

**Wert:** Tsallis-Entropie mit `aggregation=min`, theoretisch im
Bereich [0, 1] mit höher = sicherer. In der Praxis Magnituden um
3·10⁻⁵ als Median, einzelne Wörter bei float32-Min (1.4·10⁻⁴⁵).
Numerisch nutzbar nur über die relative Reihenfolge, nicht über
absolute Schwellen.

**Wichtig — Confidence ist als Halluzinations-Signal nicht
brauchbar.** Echtes medizinisches Vokabular ("Bisoprolol",
"Ramipril") und Dosierungsdigits ("100", "101") haben dieselben
extrem niedrigen Confidence-Werte wie tatsächliche Halluzinationen.
Confidence kann "unsicheren echten Inhalt" und "halluzinierten
Inhalt" nicht trennen. Detail siehe Appendix C.

### 4.4 Was zusammen geht — Ablations-Tabelle

Die kondensierte Wahrheitstabelle für die wichtigen Knöpfe:

| Strategie | Chunking | timestamps | return_hypotheses | text | confidence | xatt |
|---|---|---|---|---|---|---|
| beam | ON | True | False (default) | full | None | None |
| greedy | ON | True | False | full | None | None |
| greedy | ON | True | **True** | full | **gefüllt** | None |
| greedy | OFF | False | True | **degradiert** | gefüllt | gefüllt |

**Lehre:** Confidence + Timestamps + voller Text gehen zusammen,
sobald `strategy=greedy` und `return_hypotheses=True`.
Cross-Attention (`xatt_scores`) ist die einzige Information, die
`enable_chunking=False` erfordert — und damit verliert man bei
Audios > 40 s die Textqualität, was Cross-Attention für Production
disqualifiziert.

### 4.5 Cross-Attention (informativ, nicht in Production)

Falls jemand jemals `xatt_scores` braucht: zusätzlich zu den
Confidence-Flags `MultiTaskTranscriptionConfig(enable_chunking=False)`
via `override_config` auf `transcribe()`. Liefert pro Output-Token
eine Verteilung über Encoder-Frames; Shannon-Entropy pro Token
unterscheidet (auf einer einzelnen 76-s-Aufnahme) Halluzination
(Entropie ~4.7) von echtem Inhalt (~3.0). Validation auf breiterem
Korpus fehlt; nicht weiterverfolgt, weil dur=0 + Confidence schon
als Signale gefallen sind.

## 5. Bekannte Sackgassen

| Idee | Verworfen weil |
|---|---|
| **`dur=0` als Halluzinations-Filter oder Advisory-Marker** | 79/81 FP auf dr_mueller — siehe Appendix C |
| **Word-Confidence** als Filter oder Marker | medizinisches Vokabular und Digits haben dieselben niedrigen Werte wie Halluzinationen — siehe Appendix C |
| Whisper `suppress_tokens=\d+` gegen Digit-Halluzinationen | erzeugt Halluzinations-Loops; `\d` allein lässt Digit-Sequenzen ganz verschwinden |
| Whisper-Hotwords aus einzelnen Test-Cases | halluziniert in anderen Cases (Curve-Fitting auf Einzelbeispielen) |
| Buffered Pipeline mit Confidence-Output | NeMo `_join_hypotheses` mergt Confidence-Felder nicht (siehe Abschnitt 4.1) |

Falls Halluzinations-Detektion irgendwann nötig wird: das Signal
muss aus *außerhalb* der Per-Word-Telemetrie eines einzelnen ASR-
Modells kommen. Die naheliegenden Kandidaten:

- LLM-Konsolidierung mit medizinischem Domänen-Wissen (geschieht
  implizit bereits, könnte explizit gemacht werden)
- Cross-Modell-Vergleich: AED-Text vs CTC-Standalone, oder Canary
  vs Whisper, mit Divergenz als Signal
- Domänen-Regeln: nicht-existierende Medikamentennamen,
  unplausible Dosierungswerte etc. — aktuell vom Gazetteer
  pre-LLM erledigt

## 6. Domänen-Stolperfallen

### 6.1 "1-0-1"-Notation

Deutsche medizinische Diktate sprechen Dosierungsschemata als
Digit-Triple ("eins null eins" = morgens 1, mittags 0, abends 1).
Varianten: "1-0-0", "½-0-½", "1-0-0-1" (Vier-Slot). Canarys BPE-
Tokenizer kollabiert das oft zu einem einzigen Token "101", "100"
etc. — derselbe Mechanismus wie der allgemeine Digit-Collapse-
Effekt.

**Konsequenz:** Bare 3-Digit-Tokens im Medikationskontext sind
**echter Inhalt**, nicht Halluzination. Eine Pipeline-Stufe, die
"100" oder "101" als Halluzination markiert (z.B. weil Confidence
niedrig ist), zerstört reale Dosierungsinformation — patient-
sicherheitsrelevant.

**Validierungsbeispiel** (case_c414cf52, Ramipril 5 mg 1-0-1):
Der AED produzierte zunächst "100." für gesprochenes "1-0-1"
(Digit-Collapse mit falscher Korrektur), dann eine Selbst-Korrektur
mit "5 milligramm 101" — beide erscheinen im Transkript. Das "101"
ist die korrekte Wiedergabe, das "100." der Fehler. Ein Filter,
der nur niedrig-confidente Digits droppt, würde beide vernichten.

### 6.2 Stille zwischen Schnipsel-Concats

Beim globalen 38.7-min-Concat (Buffered Pipeline) meldet NeMo für
mehrere Chunks `text of utterance with ID: audio_X is empty`. Das
sind die Übergangs-Stellen zwischen aneinandergehängten Audios mit
Stille — kein Bug, sondern erwartet. Die Output-Hypothese hat
trotzdem 1555 Wörter, also keine Information verloren.

---

## Appendix A — Container-Files (vollständige Inhalte)

### Dockerfile

```dockerfile
FROM nvidia/cuda:12.6.1-devel-ubuntu22.04

ENV DEBIAN_FRONTEND=noninteractive \
    PIP_NO_CACHE_DIR=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

RUN apt-get update && apt-get install -y --no-install-recommends \
    python3 python3-pip python3-dev \
    ffmpeg \
    libsndfile1 \
    git \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && \
    pip install -r requirements.txt

COPY main.py .

ENV CANARY_MODEL=nvidia/canary-1b-v2 \
    CANARY_DEVICE=cuda \
    CANARY_PRECISION=bf16 \
    CANARY_MODELS_DIR=/models \
    HF_HOME=/models

VOLUME ["/models"]
EXPOSE 9002

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9002", "--workers", "1"]
```

### requirements.txt

```
nemo_toolkit[asr]>=2.7.3,<3.0
huggingface_hub>=0.26.0,<1.0
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
```

**Begründung der Pins:** `nemo_toolkit ≥ 2.7.3` ist Pflicht, weil in
NeMo 2.4.x ein Bug verhinderte, dass `FrameBatchMultiTaskAED` mit
Canary 1B v2 funktioniert (`CanaryBPETokenizer.vocabulary` fehlte).
In 2.5+ ist der Fall `tokenizer.vocab_size`-Fallback eingebaut. Wir
pinnen nach unten auf 2.7.3 (in Production verifiziert) und nach
oben auf < 3.0 (kein erwartet-stabiler Bruch).

### docker-compose.yml

```yaml
services:
  doctate-canary:
    image: doctate-canary:latest
    container_name: doctate-canary
    environment:
      - CANARY_MODEL=nvidia/canary-1b-v2
      - CANARY_PRECISION=bf16
      - NVIDIA_VISIBLE_DEVICES=all
      - NVIDIA_DRIVER_CAPABILITIES=compute,utility
    volumes:
      - /opt/stacks/doctate-canary/models:/models
    ports:
      - "9002:9002"
    shm_size: 2g
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    restart: unless-stopped
```

### deploy.sh

```bash
#!/usr/bin/env bash
set -euo pipefail
HOST="${1:-minerva.lan}"
REMOTE_DIR="/opt/stacks/doctate-canary"
LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"

rsync -av --exclude 'deploy.sh' --exclude 'models/' \
  "$LOCAL_DIR/" "$HOST:$REMOTE_DIR/"

ssh "$HOST" "cd $REMOTE_DIR && \
  docker compose down && \
  docker pull nvidia/cuda:12.6.1-devel-ubuntu22.04 && \
  docker build -t doctate-canary . && \
  docker compose up -d"

for i in $(seq 1 120); do
  if ssh "$HOST" "curl -sf http://localhost:9002/health" 2>/dev/null; then
    echo " OK"; exit 0
  fi
  sleep 5
done
ssh "$HOST" "docker logs doctate-canary --tail 60"
exit 1
```

## Appendix B — Service-Schlüssel-Code (`main.py`-Auszug)

Diese beiden Funktionen aus `canary/main.py` sind die
Implementierungs-Quelle für die Pipeline-Wahl in Abschnitt 4.1:

```python
def _transcribe_buffered(wav_path, duration, language, pnc, timestamps):
    """Long-audio path: explicit chunked inference via FrameBatchMultiTaskAED.

    Writes a temporary single-line JSONL manifest because language
    and pnc must be passed via manifest fields when using the
    buffered API.
    """
    manifest_tmp = tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False)
    json.dump({"audio_filepath": wav_path, "duration": duration,
               "taskname": "asr", "source_lang": language,
               "target_lang": language, "pnc": pnc}, manifest_tmp)
    manifest_tmp.write("\n"); manifest_tmp.close()

    frame_asr = FrameBatchMultiTaskAED(
        asr_model=model, frame_len=CHUNK_LEN_SECS,
        total_buffer=CHUNK_LEN_SECS, batch_size=8,
    )
    with torch.amp.autocast(model.device.type,
                             enabled=use_amp, dtype=DTYPE_MAP[PRECISION]):
        with torch.no_grad():
            hyps = get_buffered_pred_feat_multitaskAED(
                frame_asr, _model_cfg.preprocessor, MODEL_STRIDE_SECS,
                model.device, manifest=manifest_tmp.name,
                filepaths=None, timestamps=timestamps)
    return hyps

# Im /inference-Handler:
chunked = duration > CHUNKED_THRESHOLD_SECS
if chunked:
    hyps = _transcribe_buffered(wav_path, duration, language, pnc, ts_flag)
else:
    hyps = model.transcribe([wav_path], source_lang=language,
                             target_lang=language, pnc=pnc,
                             timestamps=ts_flag)
```

Pre-Conditions auf das Modell-Config-Objekt, einmal beim Service-
Start zu setzen (sonst inkonsistente Feature-Extraktion zwischen
Single-Shot- und Buffered-Pfad):

```python
_model_cfg = copy.deepcopy(model._cfg)
OmegaConf.set_struct(_model_cfg.preprocessor, False)
_model_cfg.preprocessor.dither = 0.0
_model_cfg.preprocessor.pad_to = 0
OmegaConf.set_struct(_model_cfg.preprocessor, True)
assert _model_cfg.preprocessor.normalize == "per_feature"
MODEL_STRIDE_SECS = float(_model_cfg.preprocessor["window_stride"]) * 8
```

## Appendix C — `dur=0` Validation auf dr_mueller

### Methodik

1. Cohorte: 101 m4a-Aufnahmen aus 48 Case-Verzeichnissen.
2. Drei Inferenz-Pässe:
   - **Single Audios** (101 Files): Mode A — Single-Shot mit
     `confidence_cfg`, `return_hypotheses=True`, `timestamps=True`.
   - **Per-Case-Concats** (47 Files): adaptiv (≤ 25 s →
     Single-Shot, > 25 s → Buffered).
   - **Globaler Concat** (1 File, 38.7 min): Buffered.
3. `dur_zero_inventory.py` walks alle 149 `*.canary.json` und
   extrahiert jedes Wort mit `start_offset == end_offset`.
4. **Ground-Truth-Annotation durch User**: Jedes flagged Vorkommen
   im 3-Wörter-Kontext gegen die zugehörige Audio-Datei gehört.

### Resultat

| Quantity | Value |
|---|---:|
| dur=0 Vorkommen | 81 |
| True Positives (echte Halluzinationen) | 2 |
| False Positives (echtes Transkript) | 79 |
| Precision | **≈ 2.5 %** |

Verteilung nach Quelle × Pipeline:

| Quelle | Pipeline | n_zero_dur |
|---|---|---:|
| single_audio | single_shot | 29 |
| per_case_concat | single_shot | 1 |
| per_case_concat | buffered | 21 |
| global_concat | buffered | 30 |

Die zwei True Positives sind beide in case_c414cf52: die Wörter
"5" und "milligramm" im Cluster `Bisoprolol 5 mg 100 und Ramipril
5 mg 100. [5 milligramm 101] wird vorerst pausiert ...`. Beachte:
"101" in diesem Cluster ist **echter Inhalt** (1-0-1-Schema für
Ramipril, siehe Abschnitt 6.1).

Die 79 False Positives sind dominant unbetonte deutsche
Funktionswörter. Top-Formen (count ≥ 2):

```
6 und        4 einer    4 sich     3 wie    3 für
3 mit        3 einen    2 wird     2 von    2 es
2 um         2 pro      2 einmal   2 im
```

Diese Wörter werden im CTC-Viterbi-Forced-Alignment auf 0-Frame-
Seams zwischen Nachbarwörter gepresst, weil ihre akustische
Realisierung zu kurz oder zu reduziert ist, um eigene Encoder-
Frames zu beanspruchen. Strukturell sieht das identisch zu einer
Halluzination aus — die Alignment-Mathematik allein kann die
beiden nicht trennen.

### Disposition

`dur=0` ist als Halluzinations-Signal nicht verwendbar:

- **Auto-Filter:** Bei 79/81 FP würde das Droppen geflagged Wörter
  in jedem Diktat echten Transkript zerstören, mit dem Upside, in
  einer einzigen Aufnahme zwei Halluzinationen zu erwischen.
  Net-negativ.
- **Advisory `[?word]` Marker** für die LLM-Konsolidierung:
  Gleiches Problem mit niedrigeren Stakes. 79/81 korrekte Wörter
  als unsicher zu markieren verschmutzt den Prompt und trainiert
  die LLM, den Marker zu ignorieren — was seinen Nutzen für die
  echten Halluzinationen kompromittiert.

Word-Confidence verhält sich auf demselben Korpus analog: das
Top-6-niedrigste-Confidence-Wort sind "SC", "Bisoprolol",
"Thorazemit", "5", "ausärztliche", "einmal" — eine Mischung aus
echtem medizinischen Vokabular, echten Dosierungsdigits und
einzelnen tatsächlichen Halluzinationen. Confidence kann "unsicher
echt" und "halluziniert" nicht trennen.

### Implikation für die Pipeline-Architektur

Halluzinations-Detektion über Per-Word-Telemetrie eines einzelnen
ASRs ist ein Sackgasse. Kandidaten für künftige Versuche, falls
nötig:

- Cross-Modell-Agreement (Canary AED vs Canary CTC-Standalone vs
  Whisper) als Divergenz-Signal
- LLM-seitige semantische Plausibilitätsprüfung (passiert implizit,
  könnte explizit gemacht werden)
- Domänenregeln (Medikamenten-Existenz, Dosierungs-Plausibilität)
  — aktuell vom Gazetteer pre-LLM erledigt

## Appendix D — Container-Image bauen ohne `deploy.sh`

Manuell auf einer beliebigen GPU-Maschine:

```bash
cd canary/
docker build -t doctate-canary .
docker run --rm --gpus all \
  -v $(pwd)/models:/models \
  -p 9002:9002 \
  -e HF_HOME=/models \
  doctate-canary
```

Beim Erststart lädt der Container ~3 GB aus HuggingFace ins Volume
`./models`. Nachfolgende Restarts nutzen den Cache.
