Skip to content

Commit 11b3bb8

Browse files
committed
feat(asr): asr_eval 纯函数层 M1(混淆画像/SRT 样本/报告回归门)
1 parent d7e636f commit 11b3bb8

4 files changed

Lines changed: 573 additions & 1 deletion

File tree

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//! ASR 同音混淆画像(v0.20.0 / REQ-263,asr_eval 纯函数层 M1)。
2+
//!
3+
//! @ai-context: 目的——harness 把"转写假设 vs 参考文本"的字符级差异沉淀为
4+
//! 混淆画像(同音/近形错对 top-N),供 P2 纠错闭环(OCR 侧
5+
//! ocr_confusion 哲学迁移:共现才替换、确认制)与 P1 热词加固
6+
//! 喂候选。本模块只做统计,不做纠错决策。
7+
//! @ai-context: 口径与 cer.rs 一致——先 strip_punct(标点空白不计,
8+
//! 聚焦内容字),再做字符级编辑距离回溯,把替换对/插删计数
9+
//! 分离开:Sub=参考字 a 被假设字 b 顶替;Ins=假设多出的字;
10+
//! Del=参考字缺失(漏识)。
11+
//! @ai-context: 边界——O(n×m) 回溯矩阵,单段文本过长会爆内存:>MAX_DIFF_CHARS
12+
//! 跳过画像(只保留距离统计),画像在段级做、不在整篇拼接上做
13+
//! (调用方契约,见模块头注与 harness 文档)。
14+
//! @ai-context: 纯函数无 IO,全部可单测;消费方为 bin/asr_eval.rs(crate 外),
15+
//! lib 内无调用方 → dead_code 豁免登记(同 cer.rs 先例)。
16+
17+
// 消费方在 bin(asr_eval.rs),lib 内无调用方
18+
#![allow(dead_code)]
19+
20+
use crate::asr_rescore::strip_punct;
21+
22+
/// 画像单段长度上限(O(n×m) 内存护栏;段级调用远小于此)。
23+
const MAX_DIFF_CHARS: usize = 2000;
24+
25+
/// 编辑操作序列(参考 → 假设的对齐结果)。
26+
#[derive(Debug, Clone, PartialEq, Eq)]
27+
pub enum EditOp {
28+
/// 对齐且相同。
29+
Match(char),
30+
/// 参考字 `a` 被假设字 `b` 顶替。
31+
Sub(char, char),
32+
/// 假设多出的字(插入)。
33+
Ins(char),
34+
/// 参考字缺失(删除/漏识)。
35+
Del(char),
36+
}
37+
38+
/// 单条文本的混淆画像。
39+
#[derive(Debug, Clone, PartialEq, Eq, Default)]
40+
pub struct ConfusionProfile {
41+
/// 替换错对(含一次以上出现的重复项,供跨样本聚合)。
42+
pub substitutions: Vec<(char, char)>,
43+
/// 插入数(幻觉字)。
44+
pub insertions: usize,
45+
/// 删除数(漏识字)。
46+
pub deletions: usize,
47+
}
48+
49+
/// 跨样本画像聚合器(harness 主累加结构)。
50+
///
51+
/// @ai-context: 用 Vec 计数而非 HashMap——画像规模小(错对 top-N),
52+
/// 保序确定性便于单测与报告稳定输出。
53+
#[derive(Debug, Clone, Default)]
54+
pub struct ConfusionAggregator {
55+
/// (参考字, 假设字) → 次数(线性查找;画像规模小可接受)。
56+
counts: Vec<((char, char), usize)>,
57+
}
58+
59+
impl ConfusionAggregator {
60+
/// 合并一条画像入聚合。
61+
pub fn add(&mut self, p: &ConfusionProfile) {
62+
for pair in &p.substitutions {
63+
match self.counts.iter_mut().find(|(k, _)| k == pair) {
64+
Some((_, n)) => *n += 1,
65+
None => self.counts.push((*pair, 1)),
66+
}
67+
}
68+
}
69+
70+
/// 取出次数最高的替换对(含并列;只读不改)。并列时按 (a,b) 字典序稳定。
71+
pub fn top_n(&self, n: usize) -> Vec<((char, char), usize)> {
72+
let mut sorted = self.counts.clone();
73+
sorted.sort_by(|l, r| {
74+
r.1.cmp(&l.1)
75+
.then_with(|| l.0.cmp(&r.0))
76+
});
77+
sorted.truncate(n);
78+
sorted
79+
}
80+
}
81+
82+
/// 字符级差异回溯(纯函数):返回参考 → 假设的对齐操作序列。
83+
///
84+
/// @ai-context: 标准 DP(sub=1/ins=1/del=1/match=0)后向回溯,代价最小路径;
85+
/// 同代价时优先 Match > Sub > Del > Ins(确定性)。
86+
pub fn diff_ops(reference: &[char], hypothesis: &[char]) -> Vec<EditOp> {
87+
let (n, m) = (reference.len(), hypothesis.len());
88+
// 代价表按行滚动省内存;回溯需要完整矩阵 → 保留 u32 矩阵
89+
// (长度已由 strip 与 MAX_DIFF_CHARS 护栏约束)。
90+
let mut dp = vec![0u32; (n + 1) * (m + 1)];
91+
let at = |i: usize, j: usize| i * (m + 1) + j;
92+
for i in 0..=n {
93+
dp[at(i, 0)] = i as u32;
94+
}
95+
for j in 0..=m {
96+
dp[at(0, j)] = j as u32;
97+
}
98+
for i in 1..=n {
99+
for j in 1..=m {
100+
let cost = if reference[i - 1] == hypothesis[j - 1] { 0 } else { 1 };
101+
dp[at(i, j)] = (dp[at(i - 1, j - 1)] + cost)
102+
.min(dp[at(i - 1, j)] + 1)
103+
.min(dp[at(i, j - 1)] + 1);
104+
}
105+
}
106+
let mut ops = Vec::with_capacity(n.max(m));
107+
let (mut i, mut j) = (n, m);
108+
while i > 0 || j > 0 {
109+
let cur = dp[at(i, j)];
110+
// 对角(替换/匹配):代价下标仅在 i>0 && j>0 时有效——先判界再取字
111+
if i > 0 && j > 0 {
112+
let diag = dp[at(i - 1, j - 1)];
113+
let sub_cost = if reference[i - 1] == hypothesis[j - 1] { 0 } else { 1 };
114+
if cur == diag + sub_cost {
115+
if sub_cost == 0 {
116+
ops.push(EditOp::Match(reference[i - 1]));
117+
} else {
118+
ops.push(EditOp::Sub(reference[i - 1], hypothesis[j - 1]));
119+
}
120+
i -= 1;
121+
j -= 1;
122+
continue;
123+
}
124+
}
125+
// 上(删除):参考字缺失
126+
if i > 0 {
127+
let up = dp[at(i - 1, j)];
128+
if cur == up + 1 {
129+
ops.push(EditOp::Del(reference[i - 1]));
130+
i -= 1;
131+
continue;
132+
}
133+
}
134+
// 左(插入):假设多出——DP 三者取最小,前两支不成立则左支必然最优
135+
ops.push(EditOp::Ins(hypothesis[j - 1]));
136+
j -= 1;
137+
}
138+
ops.reverse();
139+
ops
140+
}
141+
142+
/// 单条文本画像(纯函数):strip 口径后做差异回溯并归类计数。
143+
///
144+
/// @ai-context: 任一侧超过 MAX_DIFF_CHARS → 返回空画像(诚实降级:
145+
/// 长文只走 CER 距离,画像必须段级做,见模块头注)。
146+
pub fn profile(reference: &str, hypothesis: &str) -> ConfusionProfile {
147+
let ref_chars = strip_punct(reference);
148+
let hyp_chars = strip_punct(hypothesis);
149+
if ref_chars.len() > MAX_DIFF_CHARS || hyp_chars.len() > MAX_DIFF_CHARS {
150+
return ConfusionProfile::default();
151+
}
152+
let mut p = ConfusionProfile::default();
153+
for op in diff_ops(&ref_chars, &hyp_chars) {
154+
match op {
155+
EditOp::Match(_) => {}
156+
EditOp::Sub(a, b) => p.substitutions.push((a, b)),
157+
EditOp::Ins(_) => p.insertions += 1,
158+
EditOp::Del(_) => p.deletions += 1,
159+
}
160+
}
161+
p
162+
}
163+
164+
#[cfg(test)]
165+
mod tests {
166+
use super::*;
167+
168+
// ── diff_ops 基础 ──
169+
170+
#[test]
171+
fn identical_text_all_match() {
172+
let chars: Vec<char> = "完全一致".chars().collect();
173+
let ops = diff_ops(&chars, &chars);
174+
assert!(ops.iter().all(|op| matches!(op, EditOp::Match(_))));
175+
assert_eq!(ops.len(), 4);
176+
}
177+
178+
#[test]
179+
fn single_substitution_detected() {
180+
// 熵减 → 熵增:末字被顶替
181+
let a: Vec<char> = "熵减".chars().collect();
182+
let b: Vec<char> = "熵增".chars().collect();
183+
let ops = diff_ops(&a, &b);
184+
assert_eq!(
185+
ops,
186+
vec![EditOp::Match('熵'), EditOp::Sub('减', '增')]
187+
);
188+
}
189+
190+
#[test]
191+
fn insertion_counted_separately() {
192+
// 假设多一个"的":插入
193+
let a: Vec<char> = "概念".chars().collect();
194+
let b: Vec<char> = "的概念".chars().collect();
195+
let ops = diff_ops(&a, &b);
196+
assert!(ops.iter().any(|op| matches!(op, EditOp::Ins('的'))));
197+
assert!(!ops.iter().any(|op| matches!(op, EditOp::Del(_))));
198+
}
199+
200+
#[test]
201+
fn deletion_counted_separately() {
202+
let a: Vec<char> = "的概念".chars().collect();
203+
let b: Vec<char> = "概念".chars().collect();
204+
let ops = diff_ops(&a, &b);
205+
assert!(ops.iter().any(|op| matches!(op, EditOp::Del('的'))));
206+
assert!(!ops.iter().any(|op| matches!(op, EditOp::Ins(_))));
207+
}
208+
209+
// ── profile(strip 口径)──
210+
211+
#[test]
212+
fn profile_punctuation_ignored() {
213+
// 标点差异不产生替换对(与 CER strip_punct 同口径)
214+
let p = profile("今天讲熵减。", "今天讲熵减,");
215+
assert_eq!(p.substitutions, Vec::<(char, char)>::new());
216+
assert_eq!(p.insertions, 0);
217+
assert_eq!(p.deletions, 0);
218+
}
219+
220+
#[test]
221+
fn profile_homophone_pair_recorded() {
222+
// 同音错(示范数据):"必须" 被写成 "毕需"
223+
let p = profile("必须掌握", "毕需掌握");
224+
assert!(p.substitutions.contains(&('须', '需')), "{:?}", p.substitutions);
225+
}
226+
227+
#[test]
228+
fn profile_overlong_input_degrades_empty() {
229+
// 超长护栏:诚实降级为空画像(不爆内存)
230+
let long_a = "字".repeat(MAX_DIFF_CHARS + 1);
231+
let p = profile(&long_a, &long_a);
232+
assert_eq!(p.substitutions, Vec::<(char, char)>::new());
233+
}
234+
235+
// ── 聚合器 ──
236+
237+
#[test]
238+
fn aggregator_sums_and_orders() {
239+
let mut agg = ConfusionAggregator::default();
240+
let p1 = profile("必须掌握", "毕需掌握");
241+
let p2 = profile("必须要会", "毕需要会");
242+
agg.add(&p1);
243+
agg.add(&p2);
244+
let top = agg.top_n(5);
245+
// 两个错对各出现 2 次;并列按 (a,b) 字典序稳定('必'<'须')
246+
assert_eq!(top.first().map(|(k, n)| (*k, *n)), Some((('必', '毕'), 2)));
247+
assert!(top.iter().any(|(k, n)| *k == ('须', '需') && *n == 2), "{top:?}");
248+
}
249+
250+
#[test]
251+
fn aggregator_top_n_caps_and_empty() {
252+
let agg = ConfusionAggregator::default();
253+
assert!(agg.top_n(10).is_empty());
254+
let p = profile("必须掌握", "毕需掌握");
255+
let mut agg2 = ConfusionAggregator::default();
256+
agg2.add(&p);
257+
assert_eq!(agg2.top_n(0).len(), 0);
258+
assert_eq!(agg2.top_n(1).len(), 1);
259+
}
260+
}

‎app/src-tauri/src/eval_report.rs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//! asr_eval 报告与回归门纯函数层(v0.20.0 / REQ-263,M1)。
2+
//!
3+
//! @ai-context: 目的——harness 的聚合统计与基线回归判定,纯逻辑与 IO 分离:
4+
//! 均值/上下界、相对基线退化判定(退出码契约的数据依据)、
5+
//! 汇总渲染。渲染只产文本(报告/表),不写文件(IO 在 bin)。
6+
//! @ai-context: 回归门口径(v0.20.0 契约)——两侧均有 CER 时才可比:
7+
//! 当前均值 > 基线均值 + 容差 → 判定退化(回归失败);
8+
//! 任一侧不可比(无参考/空)→ None(不误杀,诚实表达)。
9+
//! @ai-context: 消费方为 bin/asr_eval.rs(crate 外),lib 内无调用方 →
10+
//! dead_code 豁免登记(同 cer.rs 先例)。
11+
12+
// 消费方在 bin(asr_eval.rs),lib 内无调用方
13+
#![allow(dead_code)]
14+
15+
/// CER 集合统计。
16+
#[derive(Debug, Clone, Copy, PartialEq)]
17+
pub struct CerStats {
18+
pub n: usize,
19+
pub mean: f32,
20+
pub min: f32,
21+
pub max: f32,
22+
}
23+
24+
/// 聚合统计(纯函数):空集合 → None(无参考样本不产统计)。
25+
pub fn cer_stats(values: &[f32]) -> Option<CerStats> {
26+
if values.is_empty() {
27+
return None;
28+
}
29+
let n = values.len();
30+
let sum: f32 = values.iter().sum();
31+
let min = values.iter().copied().fold(f32::INFINITY, f32::min);
32+
let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
33+
Some(CerStats { n, mean: sum / n as f32, min, max })
34+
}
35+
36+
/// 相对基线回归判定(纯函数):退化超容差 → true(回归失败)。
37+
///
38+
/// @ai-context: tolerance 口径与 cer.rs::recommend_preproc 同哲学
39+
/// (0.01 级防单样本噪声翻转);两侧 None → None。
40+
pub fn is_regression(current: Option<f32>, baseline: Option<f32>, tolerance: f32) -> Option<bool> {
41+
match (current, baseline) {
42+
(Some(c), Some(b)) => Some(c > b + tolerance),
43+
_ => None,
44+
}
45+
}
46+
47+
/// 汇总表渲染(纯函数):样本名 → CER,无参考行带 "-"。
48+
pub fn render_table(rows: &[(&str, Option<f32>)]) -> String {
49+
let mut out = String::from("样本,CER\n");
50+
for (name, cer) in rows {
51+
match cer {
52+
Some(v) => out.push_str(&format!("{name},{v:.4}\n")),
53+
None => out.push_str(&format!("{name},-\n")),
54+
}
55+
}
56+
out
57+
}
58+
59+
/// 对比结论行(A/B 双路 CER 均值):谁优 + 差值;任一侧不可比 → 平局说明。
60+
pub fn ab_verdict(name: &str, a: Option<CerStats>, b: Option<CerStats>) -> String {
61+
let fmt = |s: Option<CerStats>| match s {
62+
Some(v) => format!("{:.4}", v.mean),
63+
None => "不可比".to_string(),
64+
};
65+
match (a, b) {
66+
(Some(x), Some(y)) => {
67+
let winner = if x.mean < y.mean { "开" } else { "关" };
68+
format!("{name}: 开预处理={} 关预处理={} → {winner}优(Δ={:.4})", fmt(Some(x)), fmt(Some(y)), (x.mean - y.mean).abs())
69+
}
70+
_ => format!("{name}: 开={} 关={}(样本不足,不做结论)", fmt(a), fmt(b)),
71+
}
72+
}
73+
74+
#[cfg(test)]
75+
mod tests {
76+
use super::*;
77+
78+
#[test]
79+
fn stats_mean_min_max() {
80+
let s = cer_stats(&[0.10, 0.20, 0.30]).unwrap();
81+
assert_eq!(s.n, 3);
82+
assert!((s.mean - 0.2).abs() < 1e-6);
83+
assert!((s.min - 0.1).abs() < 1e-6);
84+
assert!((s.max - 0.3).abs() < 1e-6);
85+
}
86+
87+
#[test]
88+
fn stats_empty_is_none() {
89+
assert!(cer_stats(&[]).is_none());
90+
}
91+
92+
#[test]
93+
fn regression_beyond_tolerance_fails() {
94+
assert_eq!(is_regression(Some(0.12), Some(0.10), 0.01), Some(true));
95+
assert_eq!(is_regression(Some(0.109), Some(0.10), 0.01), Some(false));
96+
assert_eq!(is_regression(Some(0.10), Some(0.10), 0.0), Some(false));
97+
}
98+
99+
#[test]
100+
fn regression_unknown_side_is_none() {
101+
assert_eq!(is_regression(None, Some(0.1), 0.01), None);
102+
assert_eq!(is_regression(Some(0.1), None, 0.01), None);
103+
assert_eq!(is_regression(None, None, 0.01), None);
104+
}
105+
106+
#[test]
107+
fn table_renders_dash_for_no_reference() {
108+
let out = render_table(&[("a.wav", Some(0.1234)), ("b.wav", None)]);
109+
assert!(out.contains("a.wav,0.1234"), "{out}");
110+
assert!(out.contains("b.wav,-\n"), "{out}");
111+
}
112+
113+
#[test]
114+
fn ab_verdict_declares_winner_when_comparable() {
115+
let a = cer_stats(&[0.10]);
116+
let b = cer_stats(&[0.20]);
117+
let v = ab_verdict("样本集", a, b);
118+
assert!(v.contains("开优"), "{v}");
119+
}
120+
121+
#[test]
122+
fn ab_verdict_no_claim_when_incomparable() {
123+
let v = ab_verdict("样本集", None, None);
124+
assert!(v.contains("不做结论"), "{v}");
125+
}
126+
}

0 commit comments

Comments
 (0)