parla-clean 0.1.0

Deterministic post-processing for Brazilian Portuguese speech transcription: filler removal, vocabulary variant correction, ASR deduplication
Documentation
//! Limpeza determinística pós-transcrição (ADR-0009, v2 — ADR-0010).
//!
//! Pipeline local, SEMPRE ativo, que deixa o texto digitado mais limpo sem
//! mudar o conteúdo: remove muletas ("tipo", "né", "assim", "então",
//! "sabe", "tá"...), deduplica repetições do ASR, normaliza pontuação e
//! capitalização e corrige variantes de grafia do vocabulário do usuário
//! ("sematlman" → "Sam Altman", "cloud code" → "Claude Code",
//! "git hub" → "GitHub").
//!
//! ## Invariantes globais (verificadas por teste de propriedade)
//!
//! - **I1 — nada é inventado**: cada palavra do output é substring
//!   alfanumérica contígua de uma palavra do input ou do vocabulário.
//!   (Limitação conhecida: chars com uppercase multi-char — ẞ/ß/ǰ — são
//!   cobertos por testes unitários de fold, não pelo fuzz: o casefolding
//!   deles quebra a invariante literal de substring. Ver ADR-0010.)
//! - **I2 — pontuação terminal sobrevive**: se o input termina em
//!   `.`/`!`/`?` e a última palavra não é tag declarativa, o output
//!   termina em pontuação terminal (bug 1 da v1 corrigido por regra:
//!   "né?" preserva a pergunta; "sabe?" é tag declarativa e ganha ponto);
//! - **I3 — totalidade e fidelidade**: qualquer `&str` de entrada produz
//!   saída sem panic, e `tokenize → render` é byte-a-byte lossless;
//! - **I4 — idempotência**: `clean(clean(x)) == clean(x)`.
//!
//! ## Decisões da v2 (ADR-0010)
//!
//! - regras como DADOS (`rules.rs`), não `match` arms — sem recompilar
//!   para ajustar muletas/guardas/variantes;
//! - tokenização rica e lossless (`tokens.rs`) — URLs, números pt-BR e
//!   e-mails nunca são confundidos com muletas ("tipo" em
//!   "tipo" em "<https://tipo.com>" fica);
//! - comparação de caixa SEMPRE Unicode completo (`fold`), nunca
//!   ASCII-case nem `.to_lowercase().next()` (bugs 2 e 3 da v1);
//! - spans de deleção fundidos explicitamente (bug 4 da v1);
//! - continua TUDO em Rust puro (só std), sem dependência nova;
//!   complexidade O(n·m) no pior caso com prefilter O(n) no caso típico.
//!
//! Medição (i5-10400F, Windows, release 14/08/2026): ~0,47 µs/char —
//! frase típica de ditado (~150 chars) ≈ 70 µs; 3.360 chars ≈ 1,6 ms
//! (ADR-0010; o "menos de 1 ms" do ADR-0009 agora é medido, não prometido).

pub mod passes;
pub mod rules;
pub mod tokens;

// Harness de avaliação (corpus de ouro + P/R/F1): compila só em testes —
// é o gate de CI quando o projeto for publicado (ADR-0010).
#[cfg(test)]
pub mod eval;

pub use rules::KNOWN_VARIANTS;
pub use passes::replace_word_matches;

use passes::PassStats;
use tokens::TokenStream;

/// Configuração da limpeza. Flags por passada + muletas extras do usuário
/// (regra genérica: só posição isolada). O default é o comportamento
/// completo documentado no ADR-0009.
#[derive(Debug, Clone)]
pub struct CleanConfig {
    pub fix_variants: bool,
    pub remove_fillers: bool,
    pub dedupe_repetitions: bool,
    pub normalize: bool,
    /// Muletas do usuário (ex.: "mano", "percebe") — sem recompilar;
    /// campo reservado para a UI de configurações.
    pub user_fillers: Vec<String>,
}

impl Default for CleanConfig {
    fn default() -> Self {
        Self {
            fix_variants: true,
            remove_fillers: true,
            dedupe_repetitions: true,
            normalize: true,
            user_fillers: Vec::new(),
        }
    }
}

/// Resultado de uma limpeza: texto + contadores por passada (base da
/// calibração A/B prevista no ADR-0009, fase 2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CleanResult {
    pub text: String,
    pub stats: PassStats,
}

/// Passada completa de limpeza com a configuração padrão — a entrada
/// principal da crate.
pub fn clean_text(text: &str, vocab: &[String]) -> String {
    clean_text_with(text, vocab, &CleanConfig::default()).text
}

/// Passada completa de limpeza. Ordem importa (fixo e documentado):
/// 1. variantes do vocabulário — ANTES de tudo: "o sematlman" vira
///    "O Sam Altman" (caixa correta no início de frase) e nada reescrito
///    é confundido com muleta depois;
/// 2. muletas;
/// 3. repetições do ASR;
/// 4. normalização (espaços, pontuação, capitalização, ponto final).
///
/// Passadas desligadas pela config são puladas; o texto não muda de
/// conteúdo em nenhum caminho (I1).
pub fn clean_text_with(text: &str, vocab: &[String], config: &CleanConfig) -> CleanResult {
    let trimmed = text.trim();
    if trimmed.is_empty() {
        return CleanResult {
            text: text.to_string(),
            stats: PassStats::default(),
        };
    }
    let mut stats = PassStats::default();

    // 1. variantes (sobre o texto: casamento flat, ver passes.rs)
    let t = if config.fix_variants {
        let (t, n) = passes::fix_variants(trimmed, vocab);
        stats.variants_fixed = n;
        t
    } else {
        trimmed.to_string()
    };

    // 2–3. muletas + repetições (sobre tokens; UMA tokenização)
    let mut stream = TokenStream::tokenize(&t);
    let mut trailing_filler = false;
    if config.remove_fillers {
        // PONTO FIXO: remover uma muleta pode isolar a vizinha ("assim
        // tipo assim," → o "assim" inicial só fica isolado depois que
        // "tipo assim" cai). Repete até estabilizar (cada iteração
        // remove >= 1 muleta ou para; teto de segurança 8).
        // Sem isso a limpeza não é idempotente (regressão real achada
        // pelo teste de propriedade I4).
        let mut iterations = 0usize;
        loop {
            let (s, trailing, n) = passes::remove_fillers(&stream, config);
            stream = s;
            if n > 0 {
                stats.fillers_removed += n;
                trailing_filler = trailing;
            }
            iterations += 1;
            if n == 0 {
                break;
            }
            if iterations >= 8 {
                // canary (observabilidade, re-revisão externa): teto de
                // segurança atingido com muletas ainda sendo removidas —
                // sinal de regra mal formada encadeando; em produção o
                // log avisa antes do usuário reportar
                log::warn!(
                    "clean: teto de 8 iterações de muletas atingido — regras podem estar encadeando"
                );
                break;
            }
        }
    }
    if config.dedupe_repetitions {
        let (s, n) = passes::dedupe_repetitions(&stream);
        stream = s;
        stats.repetitions_removed = n;
    }

    // 4. normalização (token-aware: URLs/e-mails são atômicos)
    let text = if config.normalize {
        passes::normalize(&stream, trailing_filler)
    } else {
        stream.render()
    };
    CleanResult { text, stats }
}

#[cfg(test)]
mod tests {
    use super::*;

    // -----------------------------------------------------------------
    // Testes de propriedade (gerador seedado — invariantes I1–I4)
    // -----------------------------------------------------------------

    /// SplitMix64: PRNG determinístico e rápido — os testes de
    /// propriedade rodam SEM dependência (proptest ficou de fora de
    /// propósito, ver ADR-0010) com a mesma garantia para uma semente.
    struct Rng(u64);

    impl Rng {
        fn next_u64(&mut self) -> u64 {
            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = self.0;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            z ^ (z >> 31)
        }

        fn below(&mut self, n: usize) -> usize {
            (self.next_u64() % n as u64) as usize
        }

        fn pick(&mut self, items: &[char]) -> char {
            items[self.below(items.len())]
        }
    }

    const ALPHABET: &[char] = &[
        'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
        't', 'u', 'v', 'x', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'L', 'M', 'N',
        'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'X', 'Z', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'õ', 'â',
        'ê', 'ô', 'ç', 'Á', 'É', 'Í', 'Ó', 'Ú', 'Ã', 'Õ', 'Ç', 'İ', '0', '1', '2', '3', '4', '5',
        '6', '7', '8', '9', ' ', ' ', ',', '.', '!', '?', ';', ':', '(', ')', '@', '/', '-', '_',
        '🎉', '', '\u{301}',
        // ẞ/ß/ǰ ficam de fora de propósito: o UPPERCASE deles é multi-char
        // (ß→"SS", ǰ→"J̌"), o que quebraria a invariante I1 de substring —
        // esses casos têm testes unitários dedicados (fold e tokenizer)
    ];

    /// Gera uma string aleatória, às vezes com palavras que estressam as
    /// regras (muletas, variantes, repetições) no meio.
    fn random_text(rng: &mut Rng, fillers: &[&str]) -> String {
        let len = rng.below(90);
        let mut s = String::with_capacity(len * 2);
        for _ in 0..len {
            if rng.below(10) == 0 {
                s.push_str(fillers[rng.below(fillers.len())]);
            } else {
                s.push(rng.pick(ALPHABET));
            }
        }
        s
    }

    /// Palavras de conteúdo: separa em QUALQUER não-alfanumérico (espaço
    /// ou pontuação — ".@" entre palavras não pode fundi-las).
    fn content_words(s: &str) -> Vec<String> {
        s.split(|c: char| !c.is_alphanumeric())
            .filter(|w| !w.is_empty())
            .map(|w| w.to_lowercase())
            .collect()
    }

    const SAMPLE_VOCAB: &[&str] = &[
        "GitHub", "Sam Altman", "Claude Code", "José", "São Paulo", "DeepSeek", "OpenAI",
    ];

    const DECLARATIVE_TAGS: &[&str] = &["sabe", "entendeu", "viu", ""];

    #[test]
    fn property_never_panics_and_invents_nothing() {
        let mut rng = Rng(0xC0FF_EE00_2026_0814);
        let fillers: Vec<&str> = rules::DEFAULT_FILLERS
            .iter()
            .map(|r| r.word)
            .chain(["né?", "sabe?", "tipo assim,"])
            .collect();
        let vocab_pool: Vec<String> = SAMPLE_VOCAB.iter().map(|s| s.to_string()).collect();
        for _ in 0..3000 {
            let mut vocab: Vec<String> = Vec::new();
            for _ in 0..rng.below(4) {
                vocab.push(vocab_pool[rng.below(vocab_pool.len())].clone());
            }
            let input = random_text(&mut rng, &fillers);
            let result = clean_text_with(&input, &vocab, &CleanConfig::default());
            let out = &result.text;

            // I3: nenhum panic aconteceu por construção; I1: nada inventado.
            // Semântica de SUBSTRING: o normalize pode separar uma palavra
            // na pontuação ("?329" → "?" + " 329"), então cada palavra do
            // output precisa ser substring alfanumérica contígua de alguma
            // palavra do input ou do vocabulário.
            let input_words = content_words(&input);
            let vocab_words: Vec<String> =
                vocab.iter().flat_map(|v| content_words(v)).collect();
            for w in content_words(out) {
                let contained = input_words.iter().any(|iw| iw.contains(&w))
                    || vocab_words.iter().any(|vw| vw.contains(&w));
                assert!(
                    contained,
                    "output inventou '{w}' — input: {input:?} — output: {out:?}"
                );
            }

            // I4: idempotente
            let again = clean_text_with(out, &vocab, &CleanConfig::default()).text;
            assert_eq!(again, *out, "não-idempotente — input: {input:?}");
        }
    }

    #[test]
    fn property_terminal_punctuation_survives() {
        let mut rng = Rng(0xDEAD_BEEF_2026_0001);
        let fillers: Vec<&str> = rules::DEFAULT_FILLERS
            .iter()
            .map(|r| r.word)
            .chain(["", "sabe", ""])
            .collect();
        for _ in 0..3000 {
            let input = random_text(&mut rng, &fillers);
            let trimmed = input.trim_end();
            let last = trimmed.chars().last();
            if !matches!(last, Some('.') | Some('!') | Some('?')) {
                continue;
            }
            let last_word = trimmed
                .split_whitespace()
                .last()
                .map(|w| w.to_lowercase())
                .unwrap_or_default();
            if DECLARATIVE_TAGS.contains(&last_word.as_str()) {
                continue; // tag declarativa ganha ponto — comportamento esperado
            }
            let out = clean_text(trimmed, &[]);
            assert!(
                out.ends_with(['.', '!', '?']),
                "pontuação terminal perdida — input: {input:?} — output: {out:?}"
            );
        }
    }

    // -----------------------------------------------------------------
    // Smoke de desempenho — guarda grosseira contra regressão quadrática
    // (o número real medido está no ADR-0010)
    // -----------------------------------------------------------------

    #[test]
    fn long_dictation_cleans_well_under_budget() {
        let base = "tipo, eu acho que a gente deveria melhorar o GitHub e o projeto do Sam Altman, né? ";
        let mut text = String::new();
        for _ in 0..40 {
            text.push_str(base);
        }
        let vocab = vec!["GitHub".to_string(), "Sam Altman".to_string()];
        let start = std::time::Instant::now();
        let n = 50usize;
        for _ in 0..n {
            clean_text_with(&text, &vocab, &CleanConfig::default());
        }
        let elapsed = start.elapsed();
        let per_run = elapsed / n as u32;
        // ~100k chars × n iterações; orçamento generoso (debug, CI lento)
        assert!(
            elapsed.as_millis() < 5_000,
            "limpeza lenta demais: {elapsed:?} para ~100k chars"
        );
        println!(
            "clean em ~100k chars: {per_run:?} por passada (~{} chars)",
            text.len()
        );
    }
}