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}