Skip to main content

hikmah_kernel/
recall.rs

1//! Deterministic contextual recall.
2//!
3//! Scoring (3.1.0): a trace must match the query's cues (terms or tags) to be recalled at all.
4//! Relevance then sets the score and metadata can only scale it:
5//!
6//! `score = cue × (relevance_base + metadata_share × meta)` (defaults 0.55 and 0.45)
7//!
8//! where `cue ∈ [0,1]` comes from query-term coverage and Jaccard overlap (plus tag coverage), and
9//! `meta ∈ [0,1]` blends recency, salience, confidence, provenance, and commitment urgency.
10//! `minimum_recall_score` is applied to `cue` (relevance), not to the final score, and any shared
11//! content term lifts `cue` to at least `match_floor`, so a long natural-language question that
12//! shares one key word with a memory still recalls it. With the default weights, self-reported
13//! salience/confidence can reorder relevant memories by less than 2× (at most
14//! `1 + metadata_share / relevance_base`); with any weights they can never make an irrelevant
15//! memory appear. Overdue commitments surface without a cue. A query made only of stopwords
16//! matches nothing. Every weight is a field of [`RecallWeights`] in the kernel policy: explicit
17//! design choices, not calibrated values.
18//!
19//! Each result carries its claim's unresolved `conflicts` (other active traces with the same
20//! normalized claim key and a different value) and its supersession links, so a correction or a
21//! competing claim is visible beside the claim it challenges. Every result also carries a
22//! top-level `trace_id` (duplicated from the nested trace) for first-class attribution.
23use 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    /// Also recall superseded traces (history), each with `superseded_by` set. Off by default:
38    /// a replaced belief must not be activated as if it were current.
39    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    /// First-class attribution: the recalled trace's id, duplicated from `trace.id`
69    /// so consumers don't have to dig into the nested trace.
70    pub trace_id: String,
71    pub trace: Trace,
72    pub score: f32,
73    pub channels: RecallChannels,
74    /// Near-identical traces folded into this result by redundancy suppression.
75    #[serde(default)]
76    pub duplicates: usize,
77    /// Other active traces whose claim has the same normalized key and a different normalized
78    /// value: an unresolved conflict. Empty for superseded results and for traces without a claim.
79    #[serde(default)]
80    pub conflicts: Vec<String>,
81    /// The trace this one replaced (a correction's predecessor), when that supersession applied.
82    #[serde(default)]
83    pub supersedes: Option<String>,
84    /// The trace that replaced this one. Only superseded traces have one, so it is set only when
85    /// the query asked for `include_superseded`.
86    #[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        // Text was given but every word was a stopword: there is nothing to match on.
101        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                    // Model predictions are not memories of the world; ask for them explicitly.
118                    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    /// Attach unresolved claim conflicts and supersession links to recall results.
150    fn annotate_links(&self, results: &mut [RecallResult], allow_sensitive: bool) {
151        // Successor of every trace whose supersession applied (old id -> new id). A purged
152        // successor, or one hidden by the privacy filter, is not shown: its id would point at
153        // something recall itself refuses to return.
154        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        // Listing mode (no query cues): order by metadata only.
257        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            // A competing claim is not redundant, however similar its wording: never fold or
290            // penalize it against the claim it contradicts.
291            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     // Hangul Jamo
342        | 0x3040..=0x30FF   // Hiragana, Katakana
343        | 0x3130..=0x318F   // Hangul compatibility Jamo
344        | 0x3400..=0x4DBF   // CJK Extension A
345        | 0x4E00..=0x9FFF   // CJK Unified Ideographs
346        | 0xAC00..=0xD7AF   // Hangul syllables
347        | 0xF900..=0xFAFF   // CJK compatibility ideographs
348        | 0xFF66..=0xFF9F   // Half-width katakana
349        | 0x20000..=0x2FA1F) // CJK Extensions B-F and compatibility supplement
350}
351
352/// Light English suffix folding applied identically to queries and traces: strip a plural `s`,
353/// then one of `ing`/`ed`, then a trailing `e`, so setting/settings, service/services, and
354/// release/released agree.
355fn 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    // Scripts written without spaces: index character bigrams (and single characters for
391    // one-character runs) so a phrase can match inside a longer clause.
392    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        // Split mixed tokens such as "修复了api的bug" into same-script runs.
412        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}