use std::collections::HashMap;
use super::CleanConfig;
pub struct GoldenCase {
pub input: &'static str,
pub expected: &'static str,
pub vocab: &'static [&'static str],
pub tags: &'static [&'static str],
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Metrics {
pub tp: usize,
pub fp: usize,
pub fn_: usize,
pub cases: usize,
pub exact: usize,
}
impl Metrics {
pub fn precision(&self) -> f64 {
let d = (self.tp + self.fp) as f64;
if d == 0.0 { 1.0 } else { self.tp as f64 / d }
}
pub fn recall(&self) -> f64 {
let d = (self.tp + self.fn_) as f64;
if d == 0.0 { 1.0 } else { self.tp as f64 / d }
}
pub fn f1(&self) -> f64 {
let (p, r) = (self.precision(), self.recall());
if p + r == 0.0 { 0.0 } else { 2.0 * p * r / (p + r) }
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct EvalReport {
pub total: Metrics,
pub per_tag: HashMap<&'static str, Metrics>,
}
pub fn evaluate(cases: &[GoldenCase]) -> EvalReport {
let mut total = Metrics::default();
let mut per_tag: HashMap<&'static str, Metrics> = HashMap::new();
let default_vocab: Vec<String> = Vec::new();
for case in cases {
let vocab: Vec<String> = if case.vocab.is_empty() {
default_vocab.clone()
} else {
case.vocab.iter().map(|s| s.to_string()).collect()
};
let got = super::clean_text_with(case.input, &vocab, &CleanConfig::default()).text;
accumulate(&mut total, case.input, case.expected, &got);
for tag in case.tags {
accumulate(per_tag.entry(tag).or_default(), case.input, case.expected, &got);
}
}
EvalReport { total, per_tag }
}
fn accumulate(m: &mut Metrics, input: &str, expected: &str, got: &str) {
m.cases += 1;
if got == expected {
m.exact += 1;
}
let ref_removed = words_removed(input, expected);
let got_removed = words_removed(input, got);
for w in &ref_removed {
if got_removed.contains(w) {
m.tp += 1;
} else {
m.fn_ += 1;
}
}
for w in &got_removed {
if !ref_removed.contains(w) {
m.fp += 1;
}
}
}
fn words_removed(a: &str, b: &str) -> Vec<String> {
let wa = words(a);
let wb = words(b);
let mut out: Vec<String> = Vec::new();
for w in wa {
if !wb.contains(&w) {
out.push(w);
}
}
out
}
fn words(s: &str) -> Vec<String> {
s.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
.collect()
}
pub const GOLDEN_CORPUS: &[GoldenCase] = &[
GoldenCase {
input: "tipo, o Sam Altman usa o Claude Code",
expected: "O Sam Altman usa o Claude Code",
vocab: &[],
tags: &["filler"],
},
GoldenCase {
input: "tipo assim, eu acho que a gente deveria melhorar isso né",
expected: "Eu acho que a gente deveria melhorar isso.",
vocab: &[],
tags: &["filler"],
},
GoldenCase {
input: "eu acho que, tipo, a gente vai",
expected: "Eu acho que a gente vai",
vocab: &[],
tags: &["filler"],
},
GoldenCase {
input: "a gente precisa disso né?",
expected: "A gente precisa disso?",
vocab: &[],
tags: &["filler", "punct"],
},
GoldenCase {
input: "isso vai dar certo, né não?",
expected: "Isso vai dar certo?",
vocab: &[],
tags: &["filler", "punct"],
},
GoldenCase {
input: "o projeto está bom, sabe?",
expected: "O projeto está bom.",
vocab: &[],
tags: &["filler", "punct"],
},
GoldenCase {
input: "vamos, então, começar",
expected: "Vamos começar",
vocab: &[],
tags: &["filler"],
},
GoldenCase {
input: "olha, isso aqui é importante",
expected: "Isso aqui é importante",
vocab: &[],
tags: &["filler"],
},
GoldenCase {
input: "a reunião é dia 15, tipo, às 14h30 né",
expected: "A reunião é dia 15 às 14h30.",
vocab: &[],
tags: &["filler", "numero"],
},
GoldenCase {
input: "que tipo de pessoa é essa",
expected: "Que tipo de pessoa é essa",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "assim como o João disse",
expected: "Assim como o João disse",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "estudei, então passei",
expected: "Estudei, então passei",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "você sabe o que eu quero dizer",
expected: "Você sabe o que eu quero dizer",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "tá bom, vamos nessa",
expected: "Tá bom, vamos nessa",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "olha isso aqui",
expected: "Olha isso aqui",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "muito muito bom",
expected: "Muito muito bom",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "não, não, não quero",
expected: "Não, não, não quero",
vocab: &[],
tags: &["guarda"],
},
GoldenCase {
input: "o sematlman usa o cloud code",
expected: "O Sam Altman usa o Claude Code",
vocab: &["Sam Altman", "Claude Code"],
tags: &["variante"],
},
GoldenCase {
input: "sam usa github e openai",
expected: "Sam usa GitHub e OpenAI",
vocab: &["GitHub", "ChatGPT", "OpenAI", "Anthropic"],
tags: &["variante"],
},
GoldenCase {
input: "moro em são paulo e falo com o josé",
expected: "Moro em São Paulo e falo com o José",
vocab: &["José", "São Paulo"],
tags: &["variante", "unicode"],
},
GoldenCase {
input: "o sematlman",
expected: "O sematlman",
vocab: &["Claude"],
tags: &["guarda", "variante"],
},
GoldenCase {
input: "eu eu acho que devemos",
expected: "Eu acho que devemos",
vocab: &[],
tags: &["dedupe"],
},
GoldenCase {
input: "OpenAI OpenAI OpenAI e o resto",
expected: "OpenAI e o resto",
vocab: &[],
tags: &["dedupe"],
},
GoldenCase {
input: "a reunião é 14h30 14h30",
expected: "A reunião é 14h30",
vocab: &[],
tags: &["dedupe", "numero"],
},
GoldenCase {
input: "oi ,tudo bem",
expected: "Oi, tudo bem",
vocab: &[],
tags: &["normaliza"],
},
GoldenCase {
input: "primeiro item. segundo item!",
expected: "Primeiro item. Segundo item!",
vocab: &[],
tags: &["normaliza"],
},
GoldenCase {
input: "acesse https://tipo.com e fale com fulano@sabe.com",
expected: "Acesse https://tipo.com e fale com fulano@sabe.com",
vocab: &[],
tags: &["link"],
},
GoldenCase {
input: "visite tipo.com, tá?",
expected: "Visite tipo.com.",
vocab: &[],
tags: &["link"],
},
GoldenCase {
input: "acesse semantic.technology hoje",
expected: "Acesse semantic.technology hoje",
vocab: &[],
tags: &["link"],
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn golden_corpus_all_pass_exactly() {
let r = evaluate(GOLDEN_CORPUS);
assert_eq!(r.total.exact, r.total.cases, "casos divergentes do gold");
assert_eq!(r.total.tp, r.total.tp + r.total.fn_, "recall deve ser 1.0 no corpus de ouro");
}
#[test]
fn metrics_are_perfect_on_golden_corpus() {
let r = evaluate(GOLDEN_CORPUS);
assert_eq!(r.total.precision(), 1.0);
assert_eq!(r.total.recall(), 1.0);
assert_eq!(r.total.f1(), 1.0);
}
#[test]
fn guard_cases_never_remove_content() {
let r = evaluate(GOLDEN_CORPUS);
assert_eq!(r.total.fp, 0, "guarda removida = falso positivo");
let guard = r.per_tag["guarda"];
assert_eq!(guard.fp, 0);
assert_eq!(guard.exact, guard.cases);
}
#[test]
fn per_tag_metrics_segment_categories() {
let r = evaluate(GOLDEN_CORPUS);
for tag in [
"filler", "guarda", "variante", "dedupe", "normaliza", "link",
"punct", "unicode", "numero",
] {
let m = r.per_tag.get(tag).unwrap_or_else(|| panic!("tag '{tag}' sem casos no corpus"));
assert_eq!(m.precision(), 1.0, "precision na tag '{tag}'");
assert_eq!(m.recall(), 1.0, "recall na tag '{tag}'");
assert!(m.cases > 0, "tag '{tag}' sem casos");
}
}
#[test]
fn words_helper_strips_punctuation() {
assert_eq!(words("Oi, tudo bem!"), vec!["oi", "tudo", "bem"]);
assert_eq!(
words_removed("tipo, vamos agora", "Vamos agora"),
vec!["tipo"]
);
}
#[test]
fn ci_gate_guard_is_never_violated() {
let r = evaluate(GOLDEN_CORPUS);
let guard = r.per_tag.get("guarda").expect("tag 'guarda' deve existir");
assert_eq!(guard.precision(), 1.0, "CRÍTICO: precisão em 'guarda' caiu");
assert_eq!(guard.fp, 0, "CRÍTICO: falso positivo em 'guarda'");
assert_eq!(guard.exact, guard.cases, "CRÍTICO: guarda com gold != input");
}
#[test]
fn ci_gate_metrics_stay_above_baseline() {
let r = evaluate(GOLDEN_CORPUS);
let baseline: serde_json::Value =
serde_json::from_str(include_str!("../../tests/golden/baseline.json"))
.expect("baseline.json inválido");
let per_tag = baseline["por_tag"]
.as_object()
.expect("baseline sem 'por_tag'");
let eps = 1e-9;
for (tag, values) in per_tag {
let m = r.per_tag.get(tag.as_str()).unwrap_or_else(|| {
panic!("tag '{tag}' do baseline sem casos no corpus atual")
});
let min_p = values["precision"].as_f64().expect("precision");
let min_r = values["recall"].as_f64().expect("recall");
assert!(
m.precision() + eps >= min_p,
"regressão em '{tag}': precision {} < baseline {min_p}",
m.precision()
);
assert!(
m.recall() + eps >= min_r,
"regressão em '{tag}': recall {} < baseline {min_r}",
m.recall()
);
}
for tag in ["filler", "variante", "dedupe", "normaliza", "link"] {
let m = r.per_tag.get(tag).unwrap();
assert!(m.precision() >= 0.95, "regressão em '{tag}': precision = {}", m.precision());
assert!(m.recall() >= 0.95, "regressão em '{tag}': recall = {}", m.recall());
}
}
}