Skip to content

Commit 5d1f5cd

Browse files
committed
feat(groups): 结构密度路由纯函数与 golden 用例
1 parent ea6368c commit 5d1f5cd

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
//! 结构密度路由纯函数(v0.11.0 REQ-196;v4 附录B 组路由判据)。
2+
//!
3+
//! @ai-context: 判据锚定「结构承载性」——结构密度高→自成一组(无论长短),
4+
//! 低→归主题组(按 DomainTag 领域粒度)。信号来自四维解耦既有
5+
//! 资产(章节检测/术语表/OCR 密度/形态 unknown/画面档/领域),
6+
//! 加权投票复用 vote_detect/vote_tier 范式(每信号一票→计票→置信)。
7+
//! @ai-context: 路由误判为 ★★★★ 死法(高结构碎片埋进主题组/低结构长视频空洞
8+
//! 自成一组)——冲突/弱信号诚实 NeedConfirm,配合 REQ-198 可见可改;
9+
//! 零信号兜底自成一组(埋没比空洞更伤——独立组可后续移动)。
10+
//! @ai-context: 纯逻辑无 IO;golden 用例先行(TDD),阈值常量具名可调。
11+
12+
use crate::video_profile_domain::DomainKind;
13+
14+
/// 章节密度阈值(章/小时):≥ 此值投高密度票。
15+
const CHAPTER_DENSITY_HIGH: f32 = 2.0;
16+
/// 章节密度阈值(章/小时):< 此值投低密度票。
17+
const CHAPTER_DENSITY_LOW: f32 = 0.5;
18+
/// 术语表条目阈值:≥ 此值投高密度票(词汇表成块=内容自带结构)。
19+
const GLOSSARY_HIGH: usize = 5;
20+
/// OCR 文本密度阈值(0..1):≥ 此值投高密度票(画面承载文字结构)。
21+
const OCR_DENSITY_HIGH: f32 = 0.5;
22+
/// 高密度/低密度判定的最小共振票数(单一信号不足以定路由——vote_tier 同哲学)。
23+
const MIN_RESONANT_VOTES: u32 = 2;
24+
25+
/// 组路由输入信号(全部由既有检测资产计算;None=未观测不投票,不猜)。
26+
#[derive(Debug, Clone, Default, PartialEq)]
27+
pub struct GroupRouteSignals {
28+
/// series_detect 命中(系列内容——assignment 层直判课程组,此处留早退路径)
29+
pub has_series: bool,
30+
/// 章节密度(章/小时;None=未跑章节检测)
31+
pub chapter_density: Option<f32>,
32+
/// 术语表条目数(None=未跑术语提取)
33+
pub glossary_terms: Option<usize>,
34+
/// OCR 文本密度(0..1;None=无 OCR 数据)
35+
pub ocr_text_density: Option<f32>,
36+
/// 形态 unknown(四维解耦诚实未知——低结构信号)
37+
pub profile_unknown: bool,
38+
/// 画面档 rich/mid(video_tier_detect——高结构信号)
39+
pub tier_rich: bool,
40+
/// 领域命中(归主题组的前提;None=低结构时无处可归)
41+
pub domain_kind: Option<DomainKind>,
42+
}
43+
44+
/// 路由动作(三态——诚实不乱判)。
45+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46+
pub enum RouteAction {
47+
/// 结构密度高 → 自成一组(独立组)
48+
OwnGroup,
49+
/// 结构密度低且领域命中 → 归主题组
50+
TopicGroup,
51+
/// 信号冲突/低结构无领域 → 待确认(REQ-198 可见可改兜住误判)
52+
NeedConfirm,
53+
}
54+
55+
/// 路由决策(动作 + 命中信号明细——组卡片路由理由展示数据源)。
56+
#[derive(Debug, Clone, PartialEq)]
57+
pub struct GroupRouteDecision {
58+
pub action: RouteAction,
59+
/// 命中信号明细(中文短语;前端逐条展示)
60+
pub reasons: Vec<String>,
61+
}
62+
63+
/// 结构密度路由(纯函数):计票 → 共振判定 → 三态输出。
64+
///
65+
/// @ai-context: 规则序(先强后弱,命中即返回):
66+
/// 1. 系列命中 → 自成一组(课程组语义在 assignment 层兑现);
67+
/// 2. 高密度 ≥2 票共振 → 自成一组;
68+
/// 3. 高低票并存(冲突)→ 待确认;
69+
/// 4. 低密度 ≥2 票共振 → 有领域归主题组,无领域待确认;
70+
/// 5. 弱/零信号 → 自成一组兜底(独立组可后续移动,不埋没)。
71+
pub fn route_group(signals: &GroupRouteSignals) -> GroupRouteDecision {
72+
if signals.has_series {
73+
return GroupRouteDecision {
74+
action: RouteAction::OwnGroup,
75+
reasons: vec!["系列内容命中(合集/分P)".to_string()],
76+
};
77+
}
78+
let mut high: u32 = 0;
79+
let mut low: u32 = 0;
80+
let mut reasons_high: Vec<String> = Vec::new();
81+
let mut reasons_low: Vec<String> = Vec::new();
82+
if let Some(density) = signals.chapter_density {
83+
if density >= CHAPTER_DENSITY_HIGH {
84+
high += 1;
85+
reasons_high.push(format!("章节密度高({:.1} 章/小时)", density));
86+
} else if density < CHAPTER_DENSITY_LOW {
87+
low += 1;
88+
reasons_low.push("几乎无章节结构".to_string());
89+
}
90+
}
91+
if let Some(terms) = signals.glossary_terms {
92+
if terms >= GLOSSARY_HIGH {
93+
high += 1;
94+
reasons_high.push(format!("术语表成块({} 条)", terms));
95+
} else if terms == 0 {
96+
low += 1;
97+
reasons_low.push("无术语沉淀".to_string());
98+
}
99+
}
100+
if signals.ocr_text_density.is_some_and(|d| d >= OCR_DENSITY_HIGH) {
101+
high += 1;
102+
reasons_high.push(format!(
103+
"画面文字密度高({:.0}%)",
104+
signals.ocr_text_density.unwrap_or(0.0) * 100.0
105+
));
106+
}
107+
if signals.tier_rich {
108+
high += 1;
109+
reasons_high.push("画面档位丰富(板书/PPT 级)".to_string());
110+
}
111+
if signals.profile_unknown {
112+
low += 1;
113+
reasons_low.push("形态未识别".to_string());
114+
}
115+
if high >= MIN_RESONANT_VOTES {
116+
return GroupRouteDecision { action: RouteAction::OwnGroup, reasons: reasons_high };
117+
}
118+
if high >= 1 && low >= 1 {
119+
reasons_high.extend(reasons_low);
120+
return GroupRouteDecision { action: RouteAction::NeedConfirm, reasons: reasons_high };
121+
}
122+
if low >= MIN_RESONANT_VOTES {
123+
return match signals.domain_kind {
124+
Some(kind) => GroupRouteDecision {
125+
action: RouteAction::TopicGroup,
126+
reasons: {
127+
reasons_low.push(format!("领域命中({})", kind.label()));
128+
reasons_low
129+
},
130+
},
131+
// 低结构但无领域——主题组无处可归,诚实待确认(不硬塞大类抽屉)
132+
None => GroupRouteDecision { action: RouteAction::NeedConfirm, reasons: reasons_low },
133+
};
134+
}
135+
// 弱/零信号兜底:自成一组(独立组可后续移动;埋没比空洞更伤)
136+
GroupRouteDecision {
137+
action: RouteAction::OwnGroup,
138+
reasons: vec!["信号不足,默认独立成组(可后续移动)".to_string()],
139+
}
140+
}
141+
142+
#[cfg(test)]
143+
#[path = "group_route_tests.rs"]
144+
mod tests;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//! group_route golden 用例(REQ-196 TDD:路由误判 ★★★★ 死法——单测先行)。
2+
3+
use crate::group_route::{route_group, GroupRouteSignals, RouteAction};
4+
use crate::video_profile_domain::DomainKind;
5+
6+
#[test]
7+
fn series_hit_routes_own_group_directly() {
8+
// Arrange:系列内容(合集/分P)——assignment 层课程组的早退路径
9+
let s = GroupRouteSignals { has_series: true, ..Default::default() };
10+
// Act
11+
let d = route_group(&s);
12+
// Assert:直判自成一组,理由含系列标记
13+
assert_eq!(d.action, RouteAction::OwnGroup);
14+
assert!(d.reasons.iter().any(|r| r.contains("系列")));
15+
}
16+
17+
#[test]
18+
fn rich_lecture_routes_own_group() {
19+
// Arrange:典型网课——章节密集 + 术语成块 + 画面文字丰富(3 张高密度票)
20+
let s = GroupRouteSignals {
21+
chapter_density: Some(4.0),
22+
glossary_terms: Some(8),
23+
ocr_text_density: Some(0.6),
24+
..Default::default()
25+
};
26+
// Act
27+
let d = route_group(&s);
28+
// Assert:高结构共振 → 自成一组,理由非空
29+
assert_eq!(d.action, RouteAction::OwnGroup);
30+
assert!(d.reasons.len() >= 2, "共振票应逐条列出:{:?}", d.reasons);
31+
}
32+
33+
#[test]
34+
fn short_chitchat_with_domain_routes_topic_group() {
35+
// Arrange:低结构闲聊切片——形态未知 + 无章节 + 无术语,领域命中(美妆)
36+
let s = GroupRouteSignals {
37+
profile_unknown: true,
38+
chapter_density: Some(0.0),
39+
glossary_terms: Some(0),
40+
domain_kind: Some(DomainKind::Beauty),
41+
..Default::default()
42+
};
43+
// Act
44+
let d = route_group(&s);
45+
// Assert:低结构共振 + 领域 → 归主题组(v4 §3.3 边缘案例)
46+
assert_eq!(d.action, RouteAction::TopicGroup);
47+
assert!(d.reasons.iter().any(|r| r.contains("领域")));
48+
}
49+
50+
#[test]
51+
fn low_structure_without_domain_needs_confirm() {
52+
// Arrange:低结构共振但领域未命中——主题组无处可归
53+
let s = GroupRouteSignals {
54+
profile_unknown: true,
55+
chapter_density: Some(0.0),
56+
glossary_terms: Some(0),
57+
..Default::default()
58+
};
59+
// Act
60+
let d = route_group(&s);
61+
// Assert:诚实待确认(不硬塞大类抽屉——契约一拒绝大类)
62+
assert_eq!(d.action, RouteAction::NeedConfirm);
63+
}
64+
65+
#[test]
66+
fn conflicting_signals_need_confirm() {
67+
// Arrange:术语成块(高票)与形态未知(低票)并存——信号冲突
68+
let s = GroupRouteSignals {
69+
glossary_terms: Some(8),
70+
profile_unknown: true,
71+
..Default::default()
72+
};
73+
// Act
74+
let d = route_group(&s);
75+
// Assert:冲突不乱判(vote_tier 同哲学),两侧理由均呈现
76+
assert_eq!(d.action, RouteAction::NeedConfirm);
77+
assert!(d.reasons.len() >= 2);
78+
}
79+
80+
#[test]
81+
fn zero_signals_fallback_own_group() {
82+
// Arrange:全默认零信号
83+
let s = GroupRouteSignals::default();
84+
// Act
85+
let d = route_group(&s);
86+
// Assert:兜底自成一组(埋没比空洞更伤——独立组可后续移动)
87+
assert_eq!(d.action, RouteAction::OwnGroup);
88+
assert!(d.reasons.iter().any(|r| r.contains("信号不足")));
89+
}
90+
91+
#[test]
92+
fn ninety_second_derivation_still_own_group() {
93+
// Arrange:90 秒完整公式推导(v4 §3.3 边缘案例)——时长短但结构密度高
94+
let s = GroupRouteSignals {
95+
chapter_density: Some(3.0),
96+
ocr_text_density: Some(0.7),
97+
..Default::default()
98+
};
99+
// Act
100+
let d = route_group(&s);
101+
// Assert:长短不是判据——高密度自成一组(容器化处理)
102+
assert_eq!(d.action, RouteAction::OwnGroup);
103+
}

0 commit comments

Comments
 (0)