Skip to main content

parla_clean/clean/
mod.rs

1//! Limpeza determinística pós-transcrição (ADR-0009, v2 — ADR-0010).
2//!
3//! Pipeline local, SEMPRE ativo, que deixa o texto digitado mais limpo sem
4//! mudar o conteúdo: remove muletas ("tipo", "né", "assim", "então",
5//! "sabe", "tá"...), deduplica repetições do ASR, normaliza pontuação e
6//! capitalização e corrige variantes de grafia do vocabulário do usuário
7//! ("sematlman" → "Sam Altman", "cloud code" → "Claude Code",
8//! "git hub" → "GitHub").
9//!
10//! ## Invariantes globais (verificadas por teste de propriedade)
11//!
12//! - **I1 — nada é inventado**: cada palavra do output é substring
13//!   alfanumérica contígua de uma palavra do input ou do vocabulário.
14//!   (Limitação conhecida: chars com uppercase multi-char — ẞ/ß/ǰ — são
15//!   cobertos por testes unitários de fold, não pelo fuzz: o casefolding
16//!   deles quebra a invariante literal de substring. Ver ADR-0010.)
17//! - **I2 — pontuação terminal sobrevive**: se o input termina em
18//!   `.`/`!`/`?` e a última palavra não é tag declarativa, o output
19//!   termina em pontuação terminal (bug 1 da v1 corrigido por regra:
20//!   "né?" preserva a pergunta; "sabe?" é tag declarativa e ganha ponto);
21//! - **I3 — totalidade e fidelidade**: qualquer `&str` de entrada produz
22//!   saída sem panic, e `tokenize → render` é byte-a-byte lossless;
23//! - **I4 — idempotência**: `clean(clean(x)) == clean(x)`.
24//!
25//! ## Decisões da v2 (ADR-0010)
26//!
27//! - regras como DADOS (`rules.rs`), não `match` arms — sem recompilar
28//!   para ajustar muletas/guardas/variantes;
29//! - tokenização rica e lossless (`tokens.rs`) — URLs, números pt-BR e
30//!   e-mails nunca são confundidos com muletas ("tipo" em
31//!   "tipo" em "<https://tipo.com>" fica);
32//! - comparação de caixa SEMPRE Unicode completo (`fold`), nunca
33//!   ASCII-case nem `.to_lowercase().next()` (bugs 2 e 3 da v1);
34//! - spans de deleção fundidos explicitamente (bug 4 da v1);
35//! - continua TUDO em Rust puro (só std), sem dependência nova;
36//!   complexidade O(n·m) no pior caso com prefilter O(n) no caso típico.
37//!
38//! Medição (i5-10400F, Windows, release 14/08/2026): ~0,47 µs/char —
39//! frase típica de ditado (~150 chars) ≈ 70 µs; 3.360 chars ≈ 1,6 ms
40//! (ADR-0010; o "menos de 1 ms" do ADR-0009 agora é medido, não prometido).
41
42pub mod passes;
43pub mod rules;
44pub mod tokens;
45
46// Harness de avaliação (corpus de ouro + P/R/F1): compila só em testes —
47// é o gate de CI quando o projeto for publicado (ADR-0010).
48#[cfg(test)]
49pub mod eval;
50
51pub use rules::KNOWN_VARIANTS;
52pub use passes::replace_word_matches;
53
54use passes::PassStats;
55use tokens::TokenStream;
56
57/// Configuração da limpeza. Flags por passada + muletas extras do usuário
58/// (regra genérica: só posição isolada). O default é o comportamento
59/// completo documentado no ADR-0009.
60#[derive(Debug, Clone)]
61pub struct CleanConfig {
62    pub fix_variants: bool,
63    pub remove_fillers: bool,
64    pub dedupe_repetitions: bool,
65    pub normalize: bool,
66    /// Muletas do usuário (ex.: "mano", "percebe") — sem recompilar;
67    /// campo reservado para a UI de configurações.
68    pub user_fillers: Vec<String>,
69}
70
71impl Default for CleanConfig {
72    fn default() -> Self {
73        Self {
74            fix_variants: true,
75            remove_fillers: true,
76            dedupe_repetitions: true,
77            normalize: true,
78            user_fillers: Vec::new(),
79        }
80    }
81}
82
83/// Resultado de uma limpeza: texto + contadores por passada (base da
84/// calibração A/B prevista no ADR-0009, fase 2).
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CleanResult {
87    pub text: String,
88    pub stats: PassStats,
89}
90
91/// Passada completa de limpeza com a configuração padrão — a entrada
92/// principal da crate.
93pub fn clean_text(text: &str, vocab: &[String]) -> String {
94    clean_text_with(text, vocab, &CleanConfig::default()).text
95}
96
97/// Passada completa de limpeza. Ordem importa (fixo e documentado):
98/// 1. variantes do vocabulário — ANTES de tudo: "o sematlman" vira
99///    "O Sam Altman" (caixa correta no início de frase) e nada reescrito
100///    é confundido com muleta depois;
101/// 2. muletas;
102/// 3. repetições do ASR;
103/// 4. normalização (espaços, pontuação, capitalização, ponto final).
104///
105/// Passadas desligadas pela config são puladas; o texto não muda de
106/// conteúdo em nenhum caminho (I1).
107pub fn clean_text_with(text: &str, vocab: &[String], config: &CleanConfig) -> CleanResult {
108    let trimmed = text.trim();
109    if trimmed.is_empty() {
110        return CleanResult {
111            text: text.to_string(),
112            stats: PassStats::default(),
113        };
114    }
115    let mut stats = PassStats::default();
116
117    // 1. variantes (sobre o texto: casamento flat, ver passes.rs)
118    let t = if config.fix_variants {
119        let (t, n) = passes::fix_variants(trimmed, vocab);
120        stats.variants_fixed = n;
121        t
122    } else {
123        trimmed.to_string()
124    };
125
126    // 2–3. muletas + repetições (sobre tokens; UMA tokenização)
127    let mut stream = TokenStream::tokenize(&t);
128    let mut trailing_filler = false;
129    if config.remove_fillers {
130        // PONTO FIXO: remover uma muleta pode isolar a vizinha ("assim
131        // tipo assim," → o "assim" inicial só fica isolado depois que
132        // "tipo assim" cai). Repete até estabilizar (cada iteração
133        // remove >= 1 muleta ou para; teto de segurança 8).
134        // Sem isso a limpeza não é idempotente (regressão real achada
135        // pelo teste de propriedade I4).
136        let mut iterations = 0usize;
137        loop {
138            let (s, trailing, n) = passes::remove_fillers(&stream, config);
139            stream = s;
140            if n > 0 {
141                stats.fillers_removed += n;
142                trailing_filler = trailing;
143            }
144            iterations += 1;
145            if n == 0 {
146                break;
147            }
148            if iterations >= 8 {
149                // canary (observabilidade, re-revisão externa): teto de
150                // segurança atingido com muletas ainda sendo removidas —
151                // sinal de regra mal formada encadeando; em produção o
152                // log avisa antes do usuário reportar
153                log::warn!(
154                    "clean: teto de 8 iterações de muletas atingido — regras podem estar encadeando"
155                );
156                break;
157            }
158        }
159    }
160    if config.dedupe_repetitions {
161        let (s, n) = passes::dedupe_repetitions(&stream);
162        stream = s;
163        stats.repetitions_removed = n;
164    }
165
166    // 4. normalização (token-aware: URLs/e-mails são atômicos)
167    let text = if config.normalize {
168        passes::normalize(&stream, trailing_filler)
169    } else {
170        stream.render()
171    };
172    CleanResult { text, stats }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // -----------------------------------------------------------------
180    // Testes de propriedade (gerador seedado — invariantes I1–I4)
181    // -----------------------------------------------------------------
182
183    /// SplitMix64: PRNG determinístico e rápido — os testes de
184    /// propriedade rodam SEM dependência (proptest ficou de fora de
185    /// propósito, ver ADR-0010) com a mesma garantia para uma semente.
186    struct Rng(u64);
187
188    impl Rng {
189        fn next_u64(&mut self) -> u64 {
190            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
191            let mut z = self.0;
192            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
193            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
194            z ^ (z >> 31)
195        }
196
197        fn below(&mut self, n: usize) -> usize {
198            (self.next_u64() % n as u64) as usize
199        }
200
201        fn pick(&mut self, items: &[char]) -> char {
202            items[self.below(items.len())]
203        }
204    }
205
206    const ALPHABET: &[char] = &[
207        'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
208        't', 'u', 'v', 'x', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'L', 'M', 'N',
209        'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'X', 'Z', 'á', 'é', 'í', 'ó', 'ú', 'ã', 'õ', 'â',
210        'ê', 'ô', 'ç', 'Á', 'É', 'Í', 'Ó', 'Ú', 'Ã', 'Õ', 'Ç', 'İ', '0', '1', '2', '3', '4', '5',
211        '6', '7', '8', '9', ' ', ' ', ',', '.', '!', '?', ';', ':', '(', ')', '@', '/', '-', '_',
212        '🎉', '—', '\u{301}',
213        // ẞ/ß/ǰ ficam de fora de propósito: o UPPERCASE deles é multi-char
214        // (ß→"SS", ǰ→"J̌"), o que quebraria a invariante I1 de substring —
215        // esses casos têm testes unitários dedicados (fold e tokenizer)
216    ];
217
218    /// Gera uma string aleatória, às vezes com palavras que estressam as
219    /// regras (muletas, variantes, repetições) no meio.
220    fn random_text(rng: &mut Rng, fillers: &[&str]) -> String {
221        let len = rng.below(90);
222        let mut s = String::with_capacity(len * 2);
223        for _ in 0..len {
224            if rng.below(10) == 0 {
225                s.push_str(fillers[rng.below(fillers.len())]);
226            } else {
227                s.push(rng.pick(ALPHABET));
228            }
229        }
230        s
231    }
232
233    /// Palavras de conteúdo: separa em QUALQUER não-alfanumérico (espaço
234    /// ou pontuação — ".@" entre palavras não pode fundi-las).
235    fn content_words(s: &str) -> Vec<String> {
236        s.split(|c: char| !c.is_alphanumeric())
237            .filter(|w| !w.is_empty())
238            .map(|w| w.to_lowercase())
239            .collect()
240    }
241
242    const SAMPLE_VOCAB: &[&str] = &[
243        "GitHub", "Sam Altman", "Claude Code", "José", "São Paulo", "DeepSeek", "OpenAI",
244    ];
245
246    const DECLARATIVE_TAGS: &[&str] = &["sabe", "entendeu", "viu", "tá"];
247
248    #[test]
249    fn property_never_panics_and_invents_nothing() {
250        let mut rng = Rng(0xC0FF_EE00_2026_0814);
251        let fillers: Vec<&str> = rules::DEFAULT_FILLERS
252            .iter()
253            .map(|r| r.word)
254            .chain(["né?", "sabe?", "tipo assim,"])
255            .collect();
256        let vocab_pool: Vec<String> = SAMPLE_VOCAB.iter().map(|s| s.to_string()).collect();
257        for _ in 0..3000 {
258            let mut vocab: Vec<String> = Vec::new();
259            for _ in 0..rng.below(4) {
260                vocab.push(vocab_pool[rng.below(vocab_pool.len())].clone());
261            }
262            let input = random_text(&mut rng, &fillers);
263            let result = clean_text_with(&input, &vocab, &CleanConfig::default());
264            let out = &result.text;
265
266            // I3: nenhum panic aconteceu por construção; I1: nada inventado.
267            // Semântica de SUBSTRING: o normalize pode separar uma palavra
268            // na pontuação ("?329" → "?" + " 329"), então cada palavra do
269            // output precisa ser substring alfanumérica contígua de alguma
270            // palavra do input ou do vocabulário.
271            let input_words = content_words(&input);
272            let vocab_words: Vec<String> =
273                vocab.iter().flat_map(|v| content_words(v)).collect();
274            for w in content_words(out) {
275                let contained = input_words.iter().any(|iw| iw.contains(&w))
276                    || vocab_words.iter().any(|vw| vw.contains(&w));
277                assert!(
278                    contained,
279                    "output inventou '{w}' — input: {input:?} — output: {out:?}"
280                );
281            }
282
283            // I4: idempotente
284            let again = clean_text_with(out, &vocab, &CleanConfig::default()).text;
285            assert_eq!(again, *out, "não-idempotente — input: {input:?}");
286        }
287    }
288
289    #[test]
290    fn property_terminal_punctuation_survives() {
291        let mut rng = Rng(0xDEAD_BEEF_2026_0001);
292        let fillers: Vec<&str> = rules::DEFAULT_FILLERS
293            .iter()
294            .map(|r| r.word)
295            .chain(["né", "sabe", "tá"])
296            .collect();
297        for _ in 0..3000 {
298            let input = random_text(&mut rng, &fillers);
299            let trimmed = input.trim_end();
300            let last = trimmed.chars().last();
301            if !matches!(last, Some('.') | Some('!') | Some('?')) {
302                continue;
303            }
304            let last_word = trimmed
305                .split_whitespace()
306                .last()
307                .map(|w| w.to_lowercase())
308                .unwrap_or_default();
309            if DECLARATIVE_TAGS.contains(&last_word.as_str()) {
310                continue; // tag declarativa ganha ponto — comportamento esperado
311            }
312            let out = clean_text(trimmed, &[]);
313            assert!(
314                out.ends_with(['.', '!', '?']),
315                "pontuação terminal perdida — input: {input:?} — output: {out:?}"
316            );
317        }
318    }
319
320    // -----------------------------------------------------------------
321    // Smoke de desempenho — guarda grosseira contra regressão quadrática
322    // (o número real medido está no ADR-0010)
323    // -----------------------------------------------------------------
324
325    #[test]
326    fn long_dictation_cleans_well_under_budget() {
327        let base = "tipo, eu acho que a gente deveria melhorar o GitHub e o projeto do Sam Altman, né? ";
328        let mut text = String::new();
329        for _ in 0..40 {
330            text.push_str(base);
331        }
332        let vocab = vec!["GitHub".to_string(), "Sam Altman".to_string()];
333        let start = std::time::Instant::now();
334        let n = 50usize;
335        for _ in 0..n {
336            clean_text_with(&text, &vocab, &CleanConfig::default());
337        }
338        let elapsed = start.elapsed();
339        let per_run = elapsed / n as u32;
340        // ~100k chars × n iterações; orçamento generoso (debug, CI lento)
341        assert!(
342            elapsed.as_millis() < 5_000,
343            "limpeza lenta demais: {elapsed:?} para ~100k chars"
344        );
345        println!(
346            "clean em ~100k chars: {per_run:?} por passada (~{} chars)",
347            text.len()
348        );
349    }
350}