1use super::types::KnowledgeFact;
14use crate::core::memory_consolidation::token_jaccard;
15
16const MAX_CLUSTER_SIZE: usize = 6;
18const JOIN_THRESHOLD: f64 = 0.3;
22
23#[derive(Debug)]
24pub struct FactCluster<'a> {
25 pub topic: String,
26 pub facts: Vec<&'a KnowledgeFact>,
27}
28
29fn fact_text(f: &KnowledgeFact) -> String {
30 format!("{} {}", f.key, f.value)
31}
32
33fn path_tokens(s: &str) -> Vec<String> {
36 s.split_whitespace()
37 .filter(|t| t.contains('/') || t.contains(".rs") || t.contains(".ts") || t.contains(".py"))
38 .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric() && c != '/' && c != '.'))
39 .filter(|t| !t.is_empty())
40 .map(str::to_lowercase)
41 .collect()
42}
43
44fn path_overlap(a: &str, b: &str) -> f64 {
45 let pa: std::collections::HashSet<String> = path_tokens(a).into_iter().collect();
46 let pb: std::collections::HashSet<String> = path_tokens(b).into_iter().collect();
47 if pa.is_empty() || pb.is_empty() {
48 return 0.0;
49 }
50 let inter = pa.intersection(&pb).count() as f64;
51 let union = pa.union(&pb).count() as f64;
52 inter / union
53}
54
55fn fact_similarity(a: &KnowledgeFact, b: &KnowledgeFact) -> f64 {
57 let ta = fact_text(a);
58 let tb = fact_text(b);
59 let lexical = token_jaccard(&ta, &tb);
60 let same_cat = if a.category == b.category { 1.0 } else { 0.0 };
61 let paths = path_overlap(&ta, &tb);
62 0.5 * lexical + 0.3 * same_cat + 0.2 * paths
63}
64
65fn cluster_affinity(f: &KnowledgeFact, cluster: &FactCluster<'_>) -> f64 {
67 if cluster.facts.is_empty() {
68 return 0.0;
69 }
70 cluster
71 .facts
72 .iter()
73 .map(|m| fact_similarity(f, m))
74 .sum::<f64>()
75 / cluster.facts.len() as f64
76}
77
78fn derive_topic(facts: &[&KnowledgeFact]) -> String {
82 use std::collections::HashMap;
83
84 let mut cat_counts: HashMap<&str, usize> = HashMap::new();
85 for f in facts {
86 *cat_counts.entry(f.category.as_str()).or_insert(0) += 1;
87 }
88 let (dominant_cat, cat_n) = cat_counts
89 .iter()
90 .max_by_key(|(name, n)| (**n, std::cmp::Reverse(*name)))
91 .map_or(("facts", 0), |(name, n)| (*name, *n));
92
93 if cat_n * 2 > facts.len() {
94 return dominant_cat.to_string();
95 }
96
97 let mut path_counts: HashMap<String, usize> = HashMap::new();
98 for f in facts {
99 for t in path_tokens(&fact_text(f)) {
100 *path_counts.entry(t).or_insert(0) += 1;
101 }
102 }
103 path_counts
104 .into_iter()
105 .filter(|(_, n)| *n >= 2)
106 .max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
107 .map_or_else(|| dominant_cat.to_string(), |(t, _)| t)
108}
109
110pub fn cluster_facts<'a>(facts: &[&'a KnowledgeFact]) -> Vec<FactCluster<'a>> {
114 let mut clusters: Vec<FactCluster<'a>> = Vec::new();
115
116 for f in facts {
117 let best = clusters
118 .iter_mut()
119 .filter(|c| c.facts.len() < MAX_CLUSTER_SIZE)
120 .map(|c| {
121 let affinity = cluster_affinity(f, c);
122 (affinity, c)
123 })
124 .max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
125
126 match best {
127 Some((affinity, cluster)) if affinity >= JOIN_THRESHOLD => cluster.facts.push(f),
128 _ => clusters.push(FactCluster {
129 topic: String::new(),
130 facts: vec![f],
131 }),
132 }
133 }
134
135 for c in &mut clusters {
136 c.topic = derive_topic(&c.facts);
137 }
138 clusters
139}
140
141pub fn render_chunked(clusters: &[FactCluster<'_>]) -> String {
146 let mut out = String::from("FACTS:\n");
147 for c in clusters {
148 let items: Vec<String> = c
149 .facts
150 .iter()
151 .map(|f| {
152 let key = crate::core::sanitize::neutralize_metadata(&f.key);
153 let val = crate::core::sanitize::neutralize_metadata(&f.value);
154 if f.category == c.topic {
155 format!("{key}={val}")
156 } else {
157 let cat = crate::core::sanitize::neutralize_metadata(&f.category);
158 format!("{cat}/{key}={val}")
159 }
160 })
161 .collect();
162 let topic = crate::core::sanitize::neutralize_metadata(&c.topic);
163 out.push_str(&format!("[{topic}] {}\n", items.join("|")));
164 }
165 out.trim_end().to_string()
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use chrono::Utc;
172
173 fn fact(cat: &str, key: &str, val: &str) -> KnowledgeFact {
174 serde_json::from_value(serde_json::json!({
176 "category": cat,
177 "key": key,
178 "value": val,
179 "source_session": "test",
180 "confidence": 0.9,
181 "created_at": Utc::now(),
182 "last_confirmed": Utc::now(),
183 }))
184 .expect("valid fact json")
185 }
186
187 fn three_topic_facts() -> Vec<KnowledgeFact> {
188 vec![
189 fact(
190 "billing-stripe",
191 "webhook",
192 "parses cancel_at from stripe payloads",
193 ),
194 fact(
195 "billing-stripe",
196 "emails",
197 "welcome email sent on subscription start",
198 ),
199 fact(
200 "billing-stripe",
201 "purge",
202 "account purge runs in one transaction",
203 ),
204 fact(
205 "billing-stripe",
206 "portal",
207 "cancellation reasons enabled in portal",
208 ),
209 fact(
210 "billing-stripe",
211 "entitlements",
212 "internal key header required",
213 ),
214 fact(
215 "billing-stripe",
216 "metadata",
217 "update format is metadata bracket key",
218 ),
219 fact(
220 "dashboard-ui",
221 "heatmap",
222 "renders bounce counters per file",
223 ),
224 fact("dashboard-ui", "charts", "cumulative actions over 90 days"),
225 fact("dashboard-ui", "auth", "token required in sessionStorage"),
226 fact("dashboard-ui", "port", "dashboard serves on 7421 with flag"),
227 fact(
228 "dashboard-ui",
229 "pressure",
230 "context pressure table lists files",
231 ),
232 fact("dashboard-ui", "roi", "savings ledger feeds roi panel"),
233 fact(
234 "infrastructure",
235 "deploy",
236 "rsync to pounce-server then script",
237 ),
238 fact(
239 "infrastructure",
240 "docker",
241 "billing uses separate database container",
242 ),
243 fact(
244 "infrastructure",
245 "launchagent",
246 "keepalive respawns the proxy",
247 ),
248 fact("infrastructure", "postgres", "cloud user is leanctx_cloud"),
249 fact(
250 "infrastructure",
251 "traefik",
252 "routes by host rule to services",
253 ),
254 fact("infrastructure", "smtp", "zeptomail sends lifecycle emails"),
255 ]
256 }
257
258 #[test]
259 fn three_topics_form_three_clusters() {
260 let facts = three_topic_facts();
261 let refs: Vec<&KnowledgeFact> = facts.iter().collect();
262 let clusters = cluster_facts(&refs);
263 assert_eq!(clusters.len(), 3, "one cluster per topic: {clusters:#?}");
264 for c in &clusters {
265 assert!(c.facts.len() <= MAX_CLUSTER_SIZE);
266 let cats: std::collections::HashSet<&str> =
267 c.facts.iter().map(|f| f.category.as_str()).collect();
268 assert_eq!(cats.len(), 1, "category-pure clusters for this fixture");
269 }
270 }
271
272 #[test]
273 fn cluster_never_exceeds_theta_capacity() {
274 let facts: Vec<KnowledgeFact> = (0..20)
275 .map(|i| {
276 fact(
277 "arch",
278 &format!("component_{i}"),
279 "shares the same words entirely",
280 )
281 })
282 .collect();
283 let refs: Vec<&KnowledgeFact> = facts.iter().collect();
284 let clusters = cluster_facts(&refs);
285 for c in &clusters {
286 assert!(c.facts.len() <= MAX_CLUSTER_SIZE, "got {}", c.facts.len());
287 }
288 assert!(clusters.len() >= 20 / MAX_CLUSTER_SIZE);
289 }
290
291 #[test]
292 fn chunked_rendering_saves_tokens_vs_flat() {
293 use crate::core::tokens::count_tokens;
294 let facts = three_topic_facts();
295 let refs: Vec<&KnowledgeFact> = facts.iter().collect();
296
297 let flat: Vec<String> = refs
298 .iter()
299 .map(|f| format!("{}/{}={}", f.category, f.key, f.value))
300 .collect();
301 let flat_block = format!("FACTS:{}", flat.join("|"));
302
303 let clusters = cluster_facts(&refs);
304 let chunked_block = render_chunked(&clusters);
305
306 let flat_tok = count_tokens(&flat_block);
307 let chunk_tok = count_tokens(&chunked_block);
308 assert!(
309 (chunk_tok as f64) <= (flat_tok as f64) * 0.9,
310 "chunked must save >=10% tokens: flat={flat_tok} chunked={chunk_tok}"
311 );
312 }
313
314 #[test]
315 fn singletons_stay_readable() {
316 let f1 = fact("misc", "lone", "completely unrelated standalone fact");
317 let refs = vec![&f1];
318 let clusters = cluster_facts(&refs);
319 assert_eq!(clusters.len(), 1);
320 let rendered = render_chunked(&clusters);
321 assert!(rendered.contains("[misc] lone="));
322 }
323
324 #[test]
325 fn deterministic_across_runs() {
326 let facts = three_topic_facts();
327 let refs: Vec<&KnowledgeFact> = facts.iter().collect();
328 let a = render_chunked(&cluster_facts(&refs));
329 let b = render_chunked(&cluster_facts(&refs));
330 assert_eq!(a, b);
331 }
332}