1use crate::claims::{claims_conflict, conflicting_ids};
24use crate::ledger::MemoryStore;
25use crate::policy::RecallWeights;
26use crate::trace::{now_ms, PrivacyClass, Trace, TraceKind, TraceStatus};
27use serde::{Deserialize, Serialize};
28use std::collections::{BTreeMap, BTreeSet};
29
30#[derive(Debug, Clone)]
31pub struct RecallQuery {
32 pub text: String,
33 pub tags: Vec<String>,
34 pub kinds: Vec<TraceKind>,
35 pub limit: usize,
36 pub now_ms: u64,
37 pub include_superseded: bool,
40}
41
42impl RecallQuery {
43 pub fn new(text: impl Into<String>) -> Self {
44 Self {
45 text: text.into(),
46 tags: Vec::new(),
47 kinds: Vec::new(),
48 limit: 8,
49 now_ms: now_ms(),
50 include_superseded: false,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RecallChannels {
57 pub lexical: f32,
58 pub tag: f32,
59 pub recency: f32,
60 pub salience: f32,
61 pub confidence: f32,
62 pub provenance: f32,
63 pub prospective: f32,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RecallResult {
68 pub trace_id: String,
71 pub trace: Trace,
72 pub score: f32,
73 pub channels: RecallChannels,
74 #[serde(default)]
76 pub duplicates: usize,
77 #[serde(default)]
80 pub conflicts: Vec<String>,
81 #[serde(default)]
83 pub supersedes: Option<String>,
84 #[serde(default)]
87 pub superseded_by: Option<String>,
88}
89
90impl MemoryStore {
91 pub fn recall(&self, query: &RecallQuery) -> Vec<RecallResult> {
92 let query_terms = tokenize(&query.text);
93 let query_tags: BTreeSet<String> = query
94 .tags
95 .iter()
96 .map(|t| t.trim().to_lowercase())
97 .filter(|t| !t.is_empty())
98 .collect();
99 let has_cue = !query_terms.is_empty() || !query_tags.is_empty();
100 if !has_cue && query.text.chars().any(char::is_alphanumeric) {
102 return Vec::new();
103 }
104 let minimum = self.policy().minimum_recall_score;
105 let limit = query.limit.min(self.policy().recall_limit).max(1);
106 let allow_sensitive = self.policy().allow_sensitive_persistence;
107 let weights = &self.policy().recall;
108 let mut candidates: Vec<RecallResult> = self
109 .all()
110 .filter(|entry| {
111 entry.status == TraceStatus::Active
112 || (query.include_superseded && entry.status == TraceStatus::Superseded)
113 })
114 .map(|entry| &entry.trace)
115 .filter(|trace| {
116 if query.kinds.is_empty() {
117 trace.kind != TraceKind::Prediction
119 } else {
120 query.kinds.contains(&trace.kind)
121 }
122 })
123 .filter(|trace| allow_sensitive || trace.privacy != PrivacyClass::Sensitive)
124 .filter_map(|trace| {
125 score_trace(
126 trace,
127 &query_terms,
128 &query_tags,
129 has_cue,
130 query.now_ms,
131 minimum,
132 weights,
133 )
134 })
135 .collect();
136
137 candidates.sort_by(|a, b| {
138 b.score
139 .partial_cmp(&a.score)
140 .unwrap_or(std::cmp::Ordering::Equal)
141 .then_with(|| b.trace.created_at_ms.cmp(&a.trace.created_at_ms))
142 });
143
144 let mut results = diversify(candidates, limit, weights);
145 self.annotate_links(&mut results, allow_sensitive);
146 results
147 }
148
149 fn annotate_links(&self, results: &mut [RecallResult], allow_sensitive: bool) {
151 let successors: BTreeMap<&str, &str> = self
155 .all()
156 .filter(|entry| entry.status != TraceStatus::Purged)
157 .filter(|entry| allow_sensitive || entry.trace.privacy != PrivacyClass::Sensitive)
158 .filter_map(|entry| {
159 let old = entry.trace.supersedes.as_deref()?;
160 let replaced = self.get(old)?.status == TraceStatus::Superseded;
161 replaced.then_some((old, entry.trace.id.as_str()))
162 })
163 .collect();
164 for result in results {
165 let id = result.trace.id.as_str();
166 let active = self
167 .get(id)
168 .is_some_and(|entry| entry.status == TraceStatus::Active);
169 if active {
170 result.conflicts = conflicting_ids(
171 &result.trace,
172 self.active_traces()
173 .filter(|t| allow_sensitive || t.privacy != PrivacyClass::Sensitive),
174 );
175 }
176 result.supersedes = result
177 .trace
178 .supersedes
179 .clone()
180 .filter(|old| successors.get(old.as_str()) == Some(&id));
181 result.superseded_by = successors.get(id).map(|new| new.to_string());
182 }
183 }
184}
185
186fn score_trace(
187 trace: &Trace,
188 query_terms: &BTreeSet<String>,
189 query_tags: &BTreeSet<String>,
190 has_cue: bool,
191 now_ms: u64,
192 minimum: f32,
193 w: &RecallWeights,
194) -> Option<RecallResult> {
195 let trace_terms = tokenize(&trace.content);
196 let lexical = if query_terms.is_empty() {
197 0.0
198 } else {
199 let blended = w.lexical_coverage * coverage(query_terms, &trace_terms)
200 + w.lexical_jaccard * jaccard(query_terms, &trace_terms);
201 if blended > 0.0 {
202 blended.max(w.match_floor)
203 } else {
204 0.0
205 }
206 };
207 let trace_tags: BTreeSet<String> = trace
208 .tags
209 .iter()
210 .map(|t| t.trim().to_lowercase())
211 .filter(|t| !t.is_empty())
212 .collect();
213 let tag = coverage(query_tags, &trace_tags);
214 let age_days = now_ms.saturating_sub(trace.created_at_ms) as f64 / 86_400_000.0;
215 let recency = (1.0 / (1.0 + age_days / w.recency_scale_days)) as f32;
216 let provenance = (trace.provenance.authority
217 * if trace.provenance.verified {
218 1.0
219 } else {
220 w.unverified_provenance_factor
221 })
222 .clamp(0.0, 1.0);
223 let prospective = match (trace.kind, trace.deadline_ms) {
224 (TraceKind::Commitment, Some(deadline)) if deadline <= now_ms => 1.0,
225 (TraceKind::Commitment, Some(deadline)) => {
226 let days = deadline.saturating_sub(now_ms) as f64 / 86_400_000.0;
227 (1.0 / (1.0 + days / w.prospective_scale_days)) as f32
228 }
229 (TraceKind::Commitment, None) => w.undated_commitment_urgency,
230 _ => 0.0,
231 };
232
233 let cue = match (query_terms.is_empty(), query_tags.is_empty()) {
234 (false, false) => w.cue_lexical * lexical + w.cue_tag * tag,
235 (false, true) => lexical,
236 (true, false) => tag,
237 (true, true) => 0.0,
238 };
239 let meta = (w.meta_recency * recency
240 + w.meta_salience * trace.salience
241 + w.meta_confidence * trace.confidence
242 + w.meta_provenance * provenance
243 + w.meta_prospective * prospective)
244 .clamp(0.0, 1.0);
245 let overdue = prospective >= 1.0;
246 let score = if has_cue {
247 if cue < minimum && !overdue {
248 return None;
249 }
250 (cue * (w.relevance_base + w.metadata_share * meta)).max(if overdue {
251 w.overdue_floor
252 } else {
253 0.0
254 })
255 } else {
256 w.listing_scale * meta
258 }
259 .clamp(0.0, 1.0);
260
261 Some(RecallResult {
262 trace_id: trace.id.clone(),
263 trace: trace.clone(),
264 score,
265 channels: RecallChannels {
266 lexical,
267 tag,
268 recency,
269 salience: trace.salience,
270 confidence: trace.confidence,
271 provenance,
272 prospective,
273 },
274 duplicates: 0,
275 conflicts: Vec::new(),
276 supersedes: None,
277 superseded_by: None,
278 })
279}
280
281fn diversify(candidates: Vec<RecallResult>, limit: usize, w: &RecallWeights) -> Vec<RecallResult> {
282 let mut selected: Vec<(RecallResult, BTreeSet<String>)> = Vec::new();
283 for mut candidate in candidates {
284 let terms = tokenize(&candidate.trace.content);
285 let normalized = normalize_content(&candidate.trace.content);
286 let mut redundancy = 0.0_f32;
287 let mut twin: Option<usize> = None;
288 for (index, (existing, existing_terms)) in selected.iter().enumerate() {
289 if claims_conflict(&candidate.trace, &existing.trace) {
292 continue;
293 }
294 let overlap = if normalize_content(&existing.trace.content) == normalized {
295 1.0
296 } else {
297 jaccard(&terms, existing_terms)
298 };
299 if overlap > redundancy {
300 redundancy = overlap;
301 twin = Some(index);
302 }
303 }
304 if redundancy >= w.redundant_at {
305 if let Some(index) = twin {
306 selected[index].0.duplicates += 1;
307 }
308 continue;
309 }
310 candidate.score *= 1.0 - w.redundancy_penalty * redundancy;
311 selected.push((candidate, terms));
312 selected.sort_by(|a, b| {
313 b.0.score
314 .partial_cmp(&a.0.score)
315 .unwrap_or(std::cmp::Ordering::Equal)
316 });
317 if selected.len() > limit {
318 selected.pop();
319 }
320 }
321 selected.into_iter().map(|(result, _)| result).collect()
322}
323
324fn normalize_content(text: &str) -> String {
325 text.split_whitespace()
326 .collect::<Vec<_>>()
327 .join(" ")
328 .to_lowercase()
329}
330
331const STOPWORDS: &[&str] = &[
332 "a", "an", "and", "are", "as", "at", "be", "been", "but", "by", "can", "did", "do", "does",
333 "for", "from", "had", "has", "have", "how", "i", "if", "in", "into", "is", "it", "its", "me",
334 "my", "no", "not", "of", "on", "or", "our", "so", "than", "that", "the", "their", "them",
335 "then", "there", "these", "they", "this", "to", "was", "we", "were", "what", "when", "where",
336 "which", "who", "why", "will", "with", "would", "you", "your",
337];
338
339fn is_cjk(c: char) -> bool {
340 matches!(c as u32,
341 0x1100..=0x11FF | 0x3040..=0x30FF | 0x3130..=0x318F | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F | 0x20000..=0x2FA1F) }
351
352fn stem(token: &str) -> String {
356 if !token.is_ascii() || token.len() <= 4 {
357 return token.to_string();
358 }
359 let mut word = token;
360 if let Some(base) = word.strip_suffix('s') {
361 if base.len() >= 4 && !base.ends_with('s') {
362 word = base;
363 }
364 }
365 for suffix in ["ing", "ed"] {
366 if let Some(base) = word.strip_suffix(suffix) {
367 if base.len() >= 3 {
368 word = base;
369 break;
370 }
371 }
372 }
373 if word.len() > 4 {
374 if let Some(base) = word.strip_suffix('e') {
375 word = base;
376 }
377 }
378 word.to_string()
379}
380
381fn push_word(out: &mut BTreeSet<String>, word: &str) {
382 let is_number = word.chars().all(|c| c.is_ascii_digit());
383 if word.is_empty() || (!is_number && word.chars().count() < 2) || STOPWORDS.contains(&word) {
384 return;
385 }
386 out.insert(stem(word));
387}
388
389fn push_cjk_run(out: &mut BTreeSet<String>, run: &[char]) {
390 if run.len() == 1 {
393 out.insert(run[0].to_string());
394 }
395 for pair in run.windows(2) {
396 out.insert(pair.iter().collect());
397 }
398}
399
400pub(crate) fn tokenize(text: &str) -> BTreeSet<String> {
401 let lower = text.to_lowercase();
402 let mut out = BTreeSet::new();
403 for raw in lower.split(|c: char| !c.is_alphanumeric() && c != '_') {
404 if raw.is_empty() {
405 continue;
406 }
407 if !raw.chars().any(is_cjk) {
408 push_word(&mut out, raw);
409 continue;
410 }
411 let chars: Vec<char> = raw.chars().collect();
413 let mut start = 0;
414 while start < chars.len() {
415 let cjk = is_cjk(chars[start]);
416 let mut end = start + 1;
417 while end < chars.len() && is_cjk(chars[end]) == cjk {
418 end += 1;
419 }
420 if cjk {
421 push_cjk_run(&mut out, &chars[start..end]);
422 } else {
423 let word: String = chars[start..end].iter().collect();
424 push_word(&mut out, &word);
425 }
426 start = end;
427 }
428 }
429 out
430}
431
432fn coverage(query: &BTreeSet<String>, target: &BTreeSet<String>) -> f32 {
433 if query.is_empty() || target.is_empty() {
434 return 0.0;
435 }
436 query.intersection(target).count() as f32 / query.len() as f32
437}
438
439fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f32 {
440 if a.is_empty() || b.is_empty() {
441 return 0.0;
442 }
443 let intersection = a.intersection(b).count() as f32;
444 let union = a.union(b).count() as f32;
445 if union == 0.0 {
446 0.0
447 } else {
448 intersection / union
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
457 fn jaccard_is_symmetric() {
458 let a = tokenize("alpha beta gamma");
459 let b = tokenize("beta gamma delta");
460 assert_eq!(jaccard(&a, &b), jaccard(&b, &a));
461 }
462
463 #[test]
464 fn stopwords_and_suffixes_fold() {
465 let q = tokenize("why did the migrations fail");
466 assert!(q.contains("migration"));
467 assert!(!q.contains("the"));
468 assert!(!q.contains("why"));
469 assert!(tokenize("python 3").contains("3"));
470 }
471
472 #[test]
473 fn plural_and_ing_forms_agree() {
474 assert_eq!(stem("settings"), stem("setting"));
475 assert_eq!(stem("services"), stem("service"));
476 assert_eq!(stem("released"), stem("release"));
477 assert_eq!(stem("warnings"), stem("warning"));
478 }
479
480 #[test]
481 fn mixed_script_tokens_keep_latin_words() {
482 let t = tokenize("修复了API的bug");
483 assert!(t.contains("api") && t.contains("bug"));
484 assert!(t.contains("修复"));
485 }
486
487 #[test]
488 fn cjk_phrases_match_inside_clauses() {
489 let q = tokenize("数据库迁移");
490 let t = tokenize("数据库迁移失败,因为锁超时");
491 assert!(coverage(&q, &t) > 0.99);
492 }
493}