Skip to main content

lean_ctx/core/knowledge/
chunking.rs

1//! Theta-gamma chunking for wakeup facts (#543, EFF-6).
2//!
3//! Working memory holds ~4±1 items as *chunks* nested in theta cycles, not as
4//! a flat list (Lisman/Idiart). The LLM equivalent: semantically related
5//! facts grouped under one shared header prime each other and amortize the
6//! shared context (category, path prefixes) — fewer tokens, better recall.
7//!
8//! Deterministic greedy agglomerative clustering — no RNG, no model:
9//! similarity = token-Jaccard over key+value, same-category bonus, and
10//! file-path co-reference bonus. Cluster topics are derived lexically
11//! (dominant category or shared path token), never via an LLM call.
12
13use super::types::KnowledgeFact;
14use crate::core::memory_consolidation::token_jaccard;
15
16/// Theta capacity: clusters never grow beyond this many facts (7 = 4±1 max).
17const MAX_CLUSTER_SIZE: usize = 6;
18/// Minimum blended similarity to join an existing cluster. Calibrated so
19/// same-category alone qualifies (categories are lean-ctx "rooms" = topics),
20/// while cross-category joins need a strong lexical/path signal.
21const 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
33/// Path-like tokens (contain '/' or a file extension) — co-reference of the
34/// same file/module is a strong grouping signal in practice.
35fn 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
55/// Blended pairwise similarity between two facts.
56fn 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
65/// Average similarity of `f` to the members of a cluster.
66fn 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
78/// Lexical topic for a cluster: the dominant category if it covers the
79/// majority of members, otherwise the most frequent path token, otherwise
80/// the dominant category anyway (deterministic tie-break by name).
81fn 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
110/// Greedy agglomerative chunking. Input order is the salience order — the
111/// first member of each cluster is its most salient fact, and clusters are
112/// returned in the order of their founding (= salience) fact.
113pub 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
141/// Render clusters in the compact wakeup notation: one line per chunk,
142/// `[topic] key=val|key=val`. Facts whose category equals the topic drop the
143/// category prefix entirely (the header amortizes it) — that is where the
144/// token savings over the flat `cat/key=val|cat/key=val` list come from.
145pub 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        // Build via serde so all `#[serde(default)]` fields fill themselves.
175        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}