Skip to content

Commit 420f54d

Browse files
committed
feat(settlement): 结算双触发判据与重复合并纯函数
1 parent b17d9d7 commit 420f54d

2 files changed

Lines changed: 227 additions & 0 deletions

File tree

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

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
//! 组结算纯函数(v0.11.3;L3 骨折层的组级版——防沼泽仪式)。
2+
//!
3+
//! @ai-context: 组沼泽化 ★★★★★ 死法防御——阈值/周期双触发器 + 重复合并判据;
4+
//! 结算必须是用户可见的仪式(command/UI 层保证),本层只提供判据。
5+
//! @ai-context: 纯逻辑无 IO;golden 用例覆盖阈值/周期边界与合并判据。
6+
7+
/// 阈值触发:组内条目数上限(超过即该结算)。
8+
pub const ITEM_THRESHOLD: usize = 50;
9+
/// 周期触发最小条目数(条目太少不值得结算仪式)。
10+
pub const CYCLE_ITEM_MIN: usize = 20;
11+
/// 周期触发天数(距上次结算超过此天数)。
12+
pub const CYCLE_DAYS: i64 = 90;
13+
/// 重复判定相似度阈值(字符 bigram 包含度系数)。
14+
pub const MERGE_SIMILARITY: f32 = 0.7;
15+
/// 归档候选老化天数(无卡绑定且超过此天数未更新)。
16+
pub const ARCHIVE_AGE_DAYS: i64 = 180;
17+
18+
/// 结算触发信号。
19+
#[derive(Debug, Clone, Copy, PartialEq)]
20+
pub struct SettlementSignals {
21+
/// 组内活跃条目数(碎片+笔记)
22+
pub item_count: usize,
23+
/// 上次结算时刻(Unix 秒;None=从未结算——按建组时刻由调用方折算)
24+
pub last_settled_at: Option<i64>,
25+
/// 组创建时刻(Unix 秒——从未结算时的周期基准)
26+
pub created_at: i64,
27+
/// 当前时刻(Unix 秒)
28+
pub now_secs: i64,
29+
}
30+
31+
/// 结算触发判定(阈值 OR 周期;双触发器防"永远不结算"与"频繁打扰"两极)。
32+
///
33+
/// @ai-context: 阈值:item_count ≥ 50 立即该结算(沼泽化前兆);
34+
/// 周期:≥20 条且 ≥90 天未结算(低膨胀也定期对账);
35+
/// 条目 <20 且未过阈值 → 不打扰(仪式有成本)。
36+
pub fn settlement_due(s: &SettlementSignals) -> bool {
37+
if s.item_count >= ITEM_THRESHOLD {
38+
return true;
39+
}
40+
if s.item_count < CYCLE_ITEM_MIN {
41+
return false;
42+
}
43+
let baseline = s.last_settled_at.unwrap_or(s.created_at);
44+
let days = (s.now_secs - baseline) / 86_400;
45+
days >= CYCLE_DAYS
46+
}
47+
48+
/// 文本规范化(相似度前置:小写 + 去空白/标点——口径与 asr_dedupe 同源哲学)。
49+
fn normalize_for_sim(text: &str) -> String {
50+
text.to_lowercase()
51+
.chars()
52+
.filter(|c| c.is_alphanumeric())
53+
.collect()
54+
}
55+
56+
/// 字符 bigram 集合(长度 <2 退化为单字符集)。
57+
fn bigrams(norm: &str) -> std::collections::HashSet<String> {
58+
let chars: Vec<char> = norm.chars().collect();
59+
let mut set = std::collections::HashSet::new();
60+
if chars.len() < 2 {
61+
for c in &chars {
62+
set.insert(c.to_string());
63+
}
64+
return set;
65+
}
66+
for w in chars.windows(2) {
67+
set.insert(w.iter().collect());
68+
}
69+
set
70+
}
71+
72+
/// 文本相似度(字符 bigram 包含度系数 inter/min,0..1;空文本 → 0 诚实)。
73+
///
74+
/// @ai-context: 用包含度而非 Jaccard(inter/union)——重复碎片常长短不一
75+
/// (短句是长句的子集),Jaccard 被长度差稀释会漏判;包含度
76+
/// 对"短句完全含于长句"给高分,与重复的真实形态对齐。
77+
pub fn text_similarity(a: &str, b: &str) -> f32 {
78+
let (na, nb) = (normalize_for_sim(a), normalize_for_sim(b));
79+
if na.is_empty() || nb.is_empty() {
80+
return 0.0;
81+
}
82+
let (sa, sb) = (bigrams(&na), bigrams(&nb));
83+
let inter = sa.intersection(&sb).count() as f32;
84+
let min_size = sa.len().min(sb.len()) as f32;
85+
if min_size == 0.0 {
86+
0.0
87+
} else {
88+
inter / min_size
89+
}
90+
}
91+
92+
/// 重复合并对(id, 文本)列表 → (保留 id, 丢弃 id) 对列表。
93+
///
94+
/// @ai-context: 贪心一对一(每条最多进一对,防链式合并丢内容);保留项取
95+
/// 文本较长者(信息量下界),丢弃项由结算执行归档(不删除——可恢复)。
96+
pub fn find_merge_pairs(items: &[(i64, String)]) -> Vec<(i64, i64)> {
97+
let mut used = std::collections::HashSet::new();
98+
let mut pairs = Vec::new();
99+
for i in 0..items.len() {
100+
if used.contains(&items[i].0) {
101+
continue;
102+
}
103+
for j in (i + 1)..items.len() {
104+
if used.contains(&items[j].0) {
105+
continue;
106+
}
107+
if text_similarity(&items[i].1, &items[j].1) >= MERGE_SIMILARITY {
108+
// 保留较长文本(信息量下界)
109+
let (keep, drop) = if items[i].1.chars().count() >= items[j].1.chars().count() {
110+
(items[i].0, items[j].0)
111+
} else {
112+
(items[j].0, items[i].0)
113+
};
114+
pairs.push((keep, drop));
115+
used.insert(items[i].0);
116+
used.insert(items[j].0);
117+
break;
118+
}
119+
}
120+
}
121+
pairs
122+
}
123+
124+
#[cfg(test)]
125+
#[path = "settlement_tests.rs"]
126+
mod tests;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
//! settlement 单测(AAA 模式;阈值/周期边界 + 合并判据 golden 用例)。
2+
3+
use crate::settlement::{
4+
find_merge_pairs, settlement_due, text_similarity, SettlementSignals,
5+
};
6+
7+
/// 基准:30 条、建组 100 天前、从未结算、当前时刻。
8+
fn base_signals() -> SettlementSignals {
9+
SettlementSignals {
10+
item_count: 30,
11+
last_settled_at: None,
12+
created_at: 1_000_000,
13+
now_secs: 1_000_000 + 100 * 86_400,
14+
}
15+
}
16+
17+
#[test]
18+
fn threshold_triggers_regardless_of_time() {
19+
// Arrange:刚建组但已 50 条(重度膨胀)
20+
let s = SettlementSignals {
21+
item_count: 50,
22+
last_settled_at: None,
23+
created_at: 1_000_000,
24+
now_secs: 1_000_001,
25+
};
26+
// Act/Assert:阈值触发(沼泽化前兆不等周期)
27+
assert!(settlement_due(&s));
28+
}
29+
30+
#[test]
31+
fn cycle_triggers_after_ninety_days() {
32+
// Arrange:30 条、建组 100 天从未结算
33+
let s = base_signals();
34+
// Act/Assert:周期触发(≥20 条且 ≥90 天)
35+
assert!(settlement_due(&s));
36+
}
37+
38+
#[test]
39+
fn recently_settled_not_due() {
40+
// Arrange:30 条、10 天前刚结算
41+
let s = SettlementSignals {
42+
last_settled_at: Some(1_000_000 + 90 * 86_400),
43+
..base_signals()
44+
};
45+
// Act/Assert:周期重置不打扰
46+
assert!(!settlement_due(&s));
47+
}
48+
49+
#[test]
50+
fn small_group_never_bothered() {
51+
// Arrange:10 条老组(仪式有成本,条目太少不值得)
52+
let s = SettlementSignals { item_count: 10, ..base_signals() };
53+
// Act/Assert
54+
assert!(!settlement_due(&s));
55+
}
56+
57+
#[test]
58+
fn similarity_identical_and_disjoint() {
59+
// Arrange/Act/Assert:全同=1,无关≈0
60+
assert!(text_similarity("眼影晕染技巧", "眼影晕染技巧") > 0.99);
61+
assert!(text_similarity("眼影晕染技巧", "Python 装饰器原理") < 0.2);
62+
assert_eq!(text_similarity("", "任何文本"), 0.0);
63+
}
64+
65+
#[test]
66+
fn similarity_near_duplicate_above_threshold() {
67+
// Arrange:轻微改写的重复碎片(典型重复形态)
68+
let a = "眼影晕染要用松软的刷子少量多次";
69+
let b = "眼影晕染要用松软的刷子,少量多次!";
70+
// Act/Assert:标点差异不影响重复判定
71+
assert!(text_similarity(a, b) >= 0.7, "sim={}", text_similarity(a, b));
72+
}
73+
74+
#[test]
75+
fn merge_pairs_keep_longer_and_greedy() {
76+
// Arrange:三条——前两条重复,第三条独立
77+
let items = vec![
78+
(1, "眼影晕染要用松软的刷子".to_string()),
79+
(2, "眼影晕染要用松软的刷子少量多次才不会脏".to_string()),
80+
(3, "Python 装饰器的执行时机".to_string()),
81+
];
82+
// Act
83+
let pairs = find_merge_pairs(&items);
84+
// Assert:一对(保留较长的 id=2,丢弃 id=1);独立项不配对
85+
assert_eq!(pairs.len(), 1);
86+
assert_eq!(pairs[0], (2, 1));
87+
}
88+
89+
#[test]
90+
fn merge_pairs_each_item_at_most_once() {
91+
// Arrange:三条互相近似(防链式合并丢内容)
92+
let items = vec![
93+
(1, "化妆前要做好保湿打底".to_string()),
94+
(2, "化妆前要做好保湿打底工作".to_string()),
95+
(3, "化妆前要做好保湿打底步骤".to_string()),
96+
];
97+
// Act
98+
let pairs = find_merge_pairs(&items);
99+
// Assert:贪心一对一——最多一对,第三条留待下轮结算
100+
assert_eq!(pairs.len(), 1);
101+
}

0 commit comments

Comments
 (0)