parla-clean 0.1.0

Deterministic post-processing for Brazilian Portuguese speech transcription: filler removal, vocabulary variant correction, ASR deduplication
Documentation
> Extracted from the Parla project for standalone publication.

# ADR-0010 — Refatoração do módulo `clean` (v2: tokenização rica, regras como dados, invariantes)

- Data: 2026-08-14
- Status: Aceito
- Escopo: `src-tauri/src/clean/` (ex-`clean.rs`, ADR-0009)

## Contexto

Uma revisão externa independente do `clean.rs` (735 linhas) apontou bugs
reais e limites arquiteturais. Validando o relatório contra o código e com
teste de propriedade seedado, a v1 tinha:

1. **Bug — pontuação terminal engolida**: "a gente precisa disso né?" virava
   "A gente precisa disso." (pergunta virava afirmação) — o `?` era tratado
   como parte da muleta.
2. **Bug — comparação Unicode frágil**: `to_lowercase().next()` descartava o
   segundo char de foldings multi-char (İ → "i̇"), e `eq_ignore_ascii_case`
   quebrava com vocabulário acentuado ("José", "SÃO PAULO").
3. **Bug — spans sem merge explícito**: a deleção de fillers adjacentes
   funcionava por acidente (`if e > pos`), sem cobertura de teste.
4. **Bug — corrupção de links**: "https://tipo.com" tinha o "tipo" interno
   tratado como muleta; o colapso de espaços abria um espaço após o `:` do
   "https:" e a capitalização tocava o "com" → "https: //tipo.Com".
5. **Não-idempotência (achada pelo teste de propriedade I4)**: remoção em
   cadeia — "assim tipo assim," remove a dupla e o "assim" inicial só cai
   numa segunda passada; ",,!Gtá" vira ",!Gtá" e só na passada seguinte "!";
   o dedupe de números sem espaço colava vizinhos ("ÓQ6 6pi" → "ÓQ6pi").
6. **Arquitetura**: regras hardcoded em `match` arms; tokenizer que só via
   runs alfabéticos (números, URLs e pontuação invisíveis); "O(n)" prometido
   sem medir.

## Opções consideradas

### A — Correção mínima dos bugs, mantendo a estrutura (rejeitada)

Consertar os 4 bugs da v1 sem tocar na arquitetura. Rápida, mas mantém:
regras em código, tokenizer pobre (números/URLs continuam invisíveis para
as regras), zero métricas por passada e nenhum harness de avaliação — o
ADR-0009 promete calibração A/B na fase 2, impossível sem contadores.

### B — Pipeline com trait objects + regras em TOML+serde + aho-corasick + proptest/criterion/cargo-fuzz (rejeitada)

A receita completa do relatório externo. Tecnicamente defensável, mas:
- `toml` + `serde` em runtime para regras que o usuário NÃO edita por
  arquivo (o app tem UI de vocabulário; não há `%APPDATA%/rules.toml`);
- `aho-corasick` para um problema de ~10 termos × 3 variantes — o prefilter
  `contains` deixa o caso típico ~O(n) sem dependência;
- trait objects (`Box<dyn Pass>`) para 4 passadas de ordem fixa;
- proptest/criterion/cargo-fuzz = 3 dependências de dev para garantias que
  um gerador seedado (SplitMix64) e medição manual cobrem.

Custo: dependências novas contra a restrição de produto "só std, exe
único"; complexidade sem benefício mensurável nesta fase.

### C — Módulo `clean/` com tokenização rica, regras como dados e invariantes testadas (ESCOLHIDA)

## Decisão

1. **Split em módulo**: `clean.rs``clean/` (`mod.rs` orquestrador +
   `tokens.rs` + `rules.rs` + `passes.rs` + `eval.rs`). API pública
   preservada: `clean_text`, `KNOWN_VARIANTS`, `replace_word_matches`
   (consumidores `lib.rs`, `groq.rs`, `stt.rs` intocados).
2. **Regras como DADOS** em `rules.rs`: `FillerRule` (posições, guardas,
   compounds, `keep_terminal`, `requires_prev`, `requires_after_comma`) +
   tabela `DEFAULT_FILLERS` — sem `match` de regra em código; ajuste de
   muleta/guarda/variante nunca toca lógica. Sem TOML: `user_fillers` na
   config cobre extensão em runtime (reservado para a UI).
3. **Bug 1 resolvido por regra** (`keep_terminal`): "né"/"né não" preservam
   a interrogação ("disso né?" → "Disso?"); tags declarativas ("sabe?",
   "tá?") ganham ponto ("O projeto está bom.") — pontuação pertence à
   FRASE só quando a frase é realmente interrogativa.
4. **Tokenizer rico e lossless** (`tokens.rs`): Word/Number/Url/Email/
   Punct/Whitespace/Other; números pt-BR ("1.000,50", "14h30"), URLs e
   e-mails são atômicos — muletas dentro de links nunca caem; domínio exige
   TLD plausível (2–20 letras ASCII — TLDs reais de 2026 como
   `.technology`/`.consulting`/`.photography` passam; o lixo continua
   rejeitado pelas guardas de pontuação/alfabeto) para a tokenização ser
   estável entre passadas (idempotência).
5. **Unicode completo**: comparação sempre via `fold` (to_lowercase
   completo, nunca ASCII-case nem `.next()`); boundaries por token kind.
6. **Span merge explícito** + guarda anti-cola: vírgula à direita só é
   engolida se não houver palavra colada sem espaço depois dela
   ("assim,sabe,t." → "assim, t.", nunca "assimt.").
7. **Ponto fixo de muletas**: `remove_fillers` repete até estabilizar
   (teto 8 iterações) — a limpeza é idempotente por construção; atingir o
   teto em produção emite `log::warn!` (canary de regra mal formada).
8. **Invariantes I1–I4** (nada inventado / pontuação terminal / totalidade
   e fidelidade / idempotência) verificadas por teste de propriedade com
   gerador SplitMix64 seedado — 3.000 casos por execução, ZERO dependência
   nova. Corpus de ouro word-level com P/R/F1 em `eval.rs` (31 casos
   taggeados, todos exatos) + **gate de CI**: `guarda` com FP=0 é
   não-negociável; as demais categorias comparam contra o baseline
   versionado em `tests/golden/baseline.json` e têm piso de 0.95.
9. **Medição honesta**: release, i5-10400F, 14/08/2026 — ~0,47 µs/char
   (frase típica de 150 chars ≈ 70 µs; 3.360 chars ≈ 1,6 ms). O "<1 ms" do
   ADR-0009 é agora medido, não prometido.

## Consequências

- **Positivas**: 4 bugs da v1 corrigidos + 3 regressões novas achadas pelo
  fuzzing (cola de palavras, cadeia de muletas, vírgula residual); URLs/
  e-mails/números protegidos por construção; 169 testes (incluindo
  propriedade + corpus + gate de CI com baseline versionado) contra ~39 da
  v1; medição documentada; base pronta para a fase 2 (stats por passada
  alimentam o A/B do ADR-0009). Re-revisão externa (2ª rodada): TLD subiu
  de 2–6 para 2–20 (TLDs reais longos), canary log no teto do ponto fixo,
  cobertura de ", né," com vírgulas dos dois lados e limitação de ẞ/ß/ǰ
  documentada no cabeçalho do módulo.
- **Negativas**: ~1.100 linhas (módulo maior que o arquivo); o fuzzing
  exclui ẞ/ß/ǰ do alfabeto aleatório (uppercase multi-char quebraria a
  invariante de substring — cobertos por testes unitários do fold);
  "muito muito bom" e "14h30, 14h30" continuam intocados (conservadorismo
  Precision > Recall).
- **Compatibilidade**: `clean_text(text, vocab)` inalterado; `KNOWN_VARIANTS`
  re-exportada; `replace_word_matches` mantida para `stt::join_vocab_terms`;
  nenhuma dependência adicionada (só std).
- **Mudanças de comportamento deliberadas** (vs v1): "disso né?" → "Disso?"
  (era "Disso."); "né não?" preserva "?"; dedupe de número 2x ("14h30
  14h30" → "14h30"); "1.000,50 reais" não capitaliza "Reais" (o '.' interno
  de número não é fim de frase); links nunca são espaçados/capitalizados.

## Fontes

- Relatório de revisão externa (analista neutro) — bugs 1–4 e gaps
  arquiteturais validados contra o código; receita parcialmente rejeitada
  (ver "Opções consideradas").
- Uh-Mazing 2026 (arXiv 2608.02138), Walker & Liebling (Google 2022),
  Ferguson et al. 2015, Snover et al. 2004, STIL 2024 — já documentados no
  ADR-0009; a v2 não muda a fundamentação linguística, só a execução.
- Medição local: release, i5-10400F, Windows, 2026-08-14.