Skip to main content

lean_ctx/core/knowledge/
ranking.rs

1use chrono::Utc;
2
3use super::types::{JudgedPair, KnowledgeFact, KnowledgeIndex};
4
5pub(super) fn confidence_stars(confidence: f32) -> &'static str {
6    if confidence >= 0.95 {
7        "★★★★★"
8    } else if confidence >= 0.85 {
9        "★★★★"
10    } else if confidence >= 0.7 {
11        "★★★"
12    } else if confidence >= 0.5 {
13        "★★"
14    } else {
15        "★"
16    }
17}
18
19/// Word-set Jaccard similarity of two texts, case-insensitive.
20pub fn string_similarity(a: &str, b: &str) -> f32 {
21    let a_lower = a.to_lowercase();
22    let b_lower = b.to_lowercase();
23    let a_words: std::collections::HashSet<&str> = a_lower.split_whitespace().collect();
24    let b_words: std::collections::HashSet<&str> = b_lower.split_whitespace().collect();
25
26    if a_words.is_empty() && b_words.is_empty() {
27        return 1.0;
28    }
29
30    let intersection = a_words.intersection(&b_words).count();
31    let union = a_words.union(&b_words).count();
32
33    if union == 0 {
34        return 0.0;
35    }
36
37    intersection as f32 / union as f32
38}
39
40pub fn sort_fact_for_output(a: &KnowledgeFact, b: &KnowledgeFact) -> std::cmp::Ordering {
41    // Pure salience ordering for display/grouping. The observation tier (#802) lives
42    // in the *selection* layer (`recall_for_output`, `semantic_recall`,
43    // `recall_by_category_for_output`) which has query context to keep a summary above
44    // incidental matches yet below an exact key hit; the display then preserves that
45    // order. Keeping this comparator tier-free avoids side effects on
46    // wakeup/summary/aaak, where facts are already grouped by category.
47    salience_score(b)
48        .cmp(&salience_score(a))
49        .then_with(|| {
50            b.quality_score()
51                .partial_cmp(&a.quality_score())
52                .unwrap_or(std::cmp::Ordering::Equal)
53        })
54        .then_with(|| {
55            b.confidence
56                .partial_cmp(&a.confidence)
57                .unwrap_or(std::cmp::Ordering::Equal)
58        })
59        .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
60        .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
61        .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
62        .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
63        .then_with(|| a.category.cmp(&b.category))
64        .then_with(|| a.key.cmp(&b.key))
65        .then_with(|| a.value.cmp(&b.value))
66}
67
68/// Salience-based ranking for fact output ordering.
69///
70/// Unlike `quality_score()` (which is a stable, intrinsic measure of fact
71/// reliability based on confidence, confirmations, and feedback), salience
72/// combines category priority, quality, recency, and retrieval frequency
73/// into a single sort key for _display_ ordering. Salience is volatile and
74/// changes on every access; quality_score is deterministic and stable.
75fn salience_score(f: &KnowledgeFact) -> u32 {
76    let cat = f.category.to_lowercase();
77    let base: u32 = match cat.as_str() {
78        "decision" => 70,
79        "gotcha" => 75,
80        "architecture" | "arch" => 60,
81        "security" => 65,
82        "testing" | "tests" | "deployment" | "deploy" => 55,
83        "conventions" | "convention" => 45,
84        "finding" => 40,
85        _ => 30,
86    };
87
88    let quality_bonus = (f.quality_score() * 60.0) as u32;
89
90    let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
91        let days = Utc::now().signed_duration_since(t).num_days();
92        if days <= 7 {
93            10u32
94        } else if days <= 30 {
95            5u32
96        } else {
97            0u32
98        }
99    });
100
101    let archetype_bonus = f.archetype.salience_bonus();
102
103    let fidelity_bonus = f
104        .fidelity
105        .as_ref()
106        .map_or(0u32, |fi| (fi.structural * 10.0) as u32);
107
108    base + quality_bonus + recency_bonus + archetype_bonus + fidelity_bonus
109}
110
111pub(super) fn hash_project_root(root: &str) -> String {
112    crate::core::project_hash::hash_project_root(root)
113}
114
115pub(super) fn tokenize_lower(s: &str) -> impl Iterator<Item = String> + '_ {
116    s.to_lowercase()
117        .split(|c: char| c.is_whitespace() || c == '-' || c == '_' || c == '/' || c == '.')
118        .filter(|t| !t.is_empty())
119        .map(String::from)
120        .collect::<Vec<_>>()
121        .into_iter()
122}
123
124pub(super) fn build_knowledge_index(facts: &[KnowledgeFact]) -> KnowledgeIndex {
125    let mut index = KnowledgeIndex::default();
126    for (i, f) in facts.iter().enumerate() {
127        for token in tokenize_lower(&f.category) {
128            index.token_positions.entry(token).or_default().push(i);
129        }
130        for token in tokenize_lower(&f.key) {
131            index.token_positions.entry(token).or_default().push(i);
132        }
133        for token in tokenize_lower(&f.value) {
134            index.token_positions.entry(token).or_default().push(i);
135        }
136        for token in tokenize_lower(&f.source_session) {
137            index
138                .session_token_positions
139                .entry(token)
140                .or_default()
141                .push(i);
142        }
143        index
144            .category_positions
145            .entry(f.category.clone())
146            .or_default()
147            .push(i);
148    }
149    for indices in index.token_positions.values_mut() {
150        indices.sort_unstable();
151        indices.dedup();
152    }
153    for indices in index.session_token_positions.values_mut() {
154        indices.sort_unstable();
155        indices.dedup();
156    }
157    index
158}
159
160#[derive(Debug, Clone)]
161pub struct SimilarFact {
162    pub category: String,
163    pub key: String,
164    pub value_preview: String,
165    pub similarity: f32,
166}
167
168pub fn find_cross_key_similar(
169    new_category: &str,
170    new_key: &str,
171    new_value: &str,
172    all_facts: &[KnowledgeFact],
173    judged_pairs: &[JudgedPair],
174    limit: usize,
175) -> Vec<SimilarFact> {
176    let composite_key = format!("{new_category}/{new_key}");
177    let mut results: Vec<SimilarFact> = Vec::new();
178
179    for f in all_facts {
180        if !f.is_current() {
181            continue;
182        }
183        let other_key = format!("{}/{}", f.category, f.key);
184        if other_key == composite_key {
185            continue;
186        }
187
188        let already_judged = judged_pairs.iter().any(|jp| {
189            (jp.key_a == composite_key && jp.key_b == other_key)
190                || (jp.key_a == other_key && jp.key_b == composite_key)
191        });
192        if already_judged {
193            continue;
194        }
195
196        let sim = string_similarity(new_value, &f.value);
197        if sim > 0.35 {
198            // Char-boundary safe: a byte slice panics inside a multi-byte char.
199            let preview = if f.value.chars().count() > 60 {
200                format!("{}...", f.value.chars().take(57).collect::<String>())
201            } else {
202                f.value.clone()
203            };
204            results.push(SimilarFact {
205                category: f.category.clone(),
206                key: f.key.clone(),
207                value_preview: preview,
208                similarity: sim,
209            });
210        }
211    }
212
213    results.sort_by(|a, b| {
214        b.similarity
215            .partial_cmp(&a.similarity)
216            .unwrap_or(std::cmp::Ordering::Equal)
217    });
218    results.truncate(limit);
219    results
220}
221
222/// The version id a successor's `supersedes` names.
223pub fn fact_version_id_v1(f: &KnowledgeFact) -> String {
224    use md5::{Digest, Md5};
225    let mut hasher = Md5::new();
226    hasher.update(f.category.as_bytes());
227    hasher.update(b"\n");
228    hasher.update(f.key.as_bytes());
229    hasher.update(b"\n");
230    hasher.update(f.value.as_bytes());
231    hasher.update(b"\n");
232    hasher.update(f.source_session.as_bytes());
233    hasher.update(b"\n");
234    hasher.update(f.created_at.to_rfc3339().as_bytes());
235    crate::core::agent_identity::hex_encode(&hasher.finalize())
236}