Skip to main content

lean_ctx/core/
knowledge_relations.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6/// Average relations per current fact the graph may hold before its weakest
7/// edges are pruned. Knowledge has no fact limit; this bounds edge *density*
8/// (an edge-producing loop cannot grow the graph faster than the facts it
9/// links), so the bound grows with the store instead of capping it.
10pub const EDGES_PER_FACT: usize = 8;
11/// Floor of [`edge_bound`], so a young store keeps room for relations (the
12/// bound the old default of 200 facts gave).
13pub const MIN_EDGE_BOUND: usize = 1_600;
14
15/// The relation-edge bound for a store with `current_facts` current facts.
16pub fn edge_bound(current_facts: usize) -> usize {
17    current_facts
18        .saturating_mul(EDGES_PER_FACT)
19        .max(MIN_EDGE_BOUND)
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
23pub struct KnowledgeNodeRef {
24    pub category: String,
25    pub key: String,
26}
27
28impl KnowledgeNodeRef {
29    pub fn new(category: &str, key: &str) -> Self {
30        Self {
31            category: category.trim().to_string(),
32            key: key.trim().to_string(),
33        }
34    }
35
36    pub fn id(&self) -> String {
37        format!("{}/{}", self.category, self.key)
38    }
39}
40
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
42#[serde(rename_all = "snake_case")]
43pub enum KnowledgeEdgeKind {
44    DependsOn,
45    RelatedTo,
46    Supports,
47    Contradicts,
48    Supersedes,
49}
50
51impl KnowledgeEdgeKind {
52    pub fn parse(input: &str) -> Option<Self> {
53        match input.trim().to_lowercase().as_str() {
54            "depends_on" | "depends" => Some(Self::DependsOn),
55            "related_to" | "related" => Some(Self::RelatedTo),
56            "supports" | "support" => Some(Self::Supports),
57            "contradicts" | "contradict" => Some(Self::Contradicts),
58            "supersedes" | "supersede" => Some(Self::Supersedes),
59            _ => None,
60        }
61    }
62
63    pub fn as_str(&self) -> &'static str {
64        match self {
65            KnowledgeEdgeKind::DependsOn => "depends_on",
66            KnowledgeEdgeKind::RelatedTo => "related_to",
67            KnowledgeEdgeKind::Supports => "supports",
68            KnowledgeEdgeKind::Contradicts => "contradicts",
69            KnowledgeEdgeKind::Supersedes => "supersedes",
70        }
71    }
72}
73
74fn default_strength() -> f64 {
75    0.5
76}
77fn default_decay_rate() -> f64 {
78    0.02
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct KnowledgeEdge {
83    pub from: KnowledgeNodeRef,
84    pub to: KnowledgeNodeRef,
85    pub kind: KnowledgeEdgeKind,
86    pub created_at: DateTime<Utc>,
87    #[serde(default)]
88    pub last_seen: Option<DateTime<Utc>>,
89    #[serde(default)]
90    pub count: u32,
91    pub source_session: String,
92    #[serde(default = "default_strength")]
93    pub strength: f64,
94    #[serde(default = "default_decay_rate")]
95    pub decay_rate: f64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(default)]
100pub struct KnowledgeRelationGraph {
101    pub project_hash: String,
102    pub edges: Vec<KnowledgeEdge>,
103    pub updated_at: DateTime<Utc>,
104    /// Ephemeral incident-edge lookup rebuilt after JSON load and mutations.
105    /// Skipping it preserves the existing relations.json format.
106    #[serde(skip)]
107    pub(crate) edge_positions: BTreeMap<String, Vec<usize>>,
108}
109
110impl Default for KnowledgeRelationGraph {
111    fn default() -> Self {
112        Self {
113            project_hash: String::new(),
114            edges: Vec::new(),
115            updated_at: Utc::now(),
116            edge_positions: BTreeMap::new(),
117        }
118    }
119}
120
121impl KnowledgeRelationGraph {
122    pub fn new(project_hash: &str) -> Self {
123        Self {
124            project_hash: project_hash.to_string(),
125            edges: Vec::new(),
126            updated_at: Utc::now(),
127            edge_positions: BTreeMap::new(),
128        }
129    }
130
131    pub fn path(project_hash: &str) -> Result<PathBuf, String> {
132        let dir = crate::core::data_dir::lean_ctx_data_dir()?
133            .join("knowledge")
134            .join(project_hash);
135        Ok(dir.join("relations.json"))
136    }
137
138    pub fn load(project_hash: &str) -> Option<Self> {
139        let path = Self::path(project_hash).ok()?;
140        let content = std::fs::read_to_string(&path).ok()?;
141        let mut g = serde_json::from_str::<Self>(&content).ok()?;
142        if g.project_hash.trim().is_empty() {
143            g.project_hash = project_hash.to_string();
144        }
145        g.rebuild_index();
146        Some(g)
147    }
148
149    pub fn load_or_create(project_hash: &str) -> Self {
150        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
151    }
152
153    /// Load, change and save the graph under the project's knowledge lock,
154    /// the in-process and cross-process lock fact edits and the cognition loop
155    /// hold, so a concurrent writer (another process, the dashboard, the
156    /// cognition loop) never loses an edge. `change` returns `None` when it
157    /// changed nothing; the graph is then not written. Must not be called while
158    /// that lock is held.
159    pub fn mutate_locked<T>(
160        project_root: &str,
161        project_hash: &str,
162        change: impl FnOnce(&mut Self) -> Option<T>,
163    ) -> Result<Option<T>, String> {
164        crate::core::knowledge::ProjectKnowledge::with_project_lock_checked(project_root, || {
165            let mut graph = Self::load_or_create(project_hash);
166            match change(&mut graph) {
167                Some(output) => graph.save().map(|()| Some(output)),
168                None => Ok(None),
169            }
170        })?
171    }
172
173    pub fn save(&mut self) -> Result<(), String> {
174        let path = Self::path(&self.project_hash)?;
175        if let Some(dir) = path.parent() {
176            std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
177        }
178
179        self.updated_at = Utc::now();
180        self.edges.sort_by(|a, b| {
181            a.from
182                .category
183                .cmp(&b.from.category)
184                .then_with(|| a.from.key.cmp(&b.from.key))
185                .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
186                .then_with(|| a.to.category.cmp(&b.to.category))
187                .then_with(|| a.to.key.cmp(&b.to.key))
188                .then_with(|| b.count.cmp(&a.count))
189                .then_with(|| b.last_seen.cmp(&a.last_seen))
190                .then_with(|| b.created_at.cmp(&a.created_at))
191        });
192        self.rebuild_index();
193
194        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
195        // `load` treats an unparsable file as absent; a torn write must not
196        // turn into an empty graph that the next save persists.
197        crate::core::atomic_fs::try_atomic_write(&path, json.as_bytes(), None)
198            .map_err(|e| e.to_string())
199    }
200
201    pub fn upsert_edge(
202        &mut self,
203        from: KnowledgeNodeRef,
204        to: KnowledgeNodeRef,
205        kind: KnowledgeEdgeKind,
206        session_id: &str,
207    ) -> bool {
208        let now = Utc::now();
209        if let Some(e) = self
210            .edges
211            .iter_mut()
212            .find(|e| e.from == from && e.to == to && e.kind == kind)
213        {
214            e.count = e.count.saturating_add(1).max(1);
215            e.last_seen = Some(now);
216            e.source_session = session_id.to_string();
217            e.strength = (e.strength + 0.1 * (1.0 - e.strength)).min(1.0);
218            self.updated_at = now;
219            return false;
220        }
221
222        self.edges.push(KnowledgeEdge {
223            from,
224            to,
225            kind,
226            created_at: now,
227            last_seen: Some(now),
228            count: 1,
229            source_session: session_id.to_string(),
230            strength: default_strength(),
231            decay_rate: default_decay_rate(),
232        });
233        self.rebuild_index();
234        self.updated_at = now;
235        true
236    }
237
238    pub fn remove_edge(
239        &mut self,
240        from: &KnowledgeNodeRef,
241        to: &KnowledgeNodeRef,
242        kind: Option<KnowledgeEdgeKind>,
243    ) -> usize {
244        let before = self.edges.len();
245        self.edges.retain(|e| {
246            if &e.from != from || &e.to != to {
247                return true;
248            }
249            if let Some(k) = kind {
250                e.kind != k
251            } else {
252                false
253            }
254        });
255        let removed = before.saturating_sub(self.edges.len());
256        if removed > 0 {
257            self.rebuild_index();
258        }
259        removed
260    }
261
262    pub fn enforce_cap(&mut self, max_edges: usize) -> bool {
263        if max_edges == 0 || self.edges.len() <= max_edges {
264            return false;
265        }
266
267        self.edges.sort_by(|a, b| {
268            b.count
269                .cmp(&a.count)
270                .then_with(|| b.last_seen.cmp(&a.last_seen))
271                .then_with(|| b.created_at.cmp(&a.created_at))
272                .then_with(|| a.from.category.cmp(&b.from.category))
273                .then_with(|| a.from.key.cmp(&b.from.key))
274                .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
275                .then_with(|| a.to.category.cmp(&b.to.category))
276                .then_with(|| a.to.key.cmp(&b.to.key))
277        });
278
279        self.edges.truncate(max_edges);
280        self.rebuild_index();
281        true
282    }
283
284    /// Hebbian strengthening: saturating formula so strength approaches but never exceeds 1.0
285    pub fn strengthen_edge(
286        &mut self,
287        from: &KnowledgeNodeRef,
288        to: &KnowledgeNodeRef,
289        amount: f64,
290    ) -> bool {
291        if let Some(e) = self
292            .edges
293            .iter_mut()
294            .find(|e| &e.from == from && &e.to == to)
295        {
296            e.strength = (e.strength + amount * (1.0 - e.strength)).min(1.0);
297            e.last_seen = Some(Utc::now());
298            e.count = e.count.saturating_add(1);
299            return true;
300        }
301        if let Some(e) = self
302            .edges
303            .iter_mut()
304            .find(|e| &e.from == to && &e.to == from)
305        {
306            e.strength = (e.strength + amount * (1.0 - e.strength)).min(1.0);
307            e.last_seen = Some(Utc::now());
308            e.count = e.count.saturating_add(1);
309            return true;
310        }
311        false
312    }
313
314    /// Time-based exponential decay on all edge strengths
315    pub fn decay_all_edges(&mut self, days_elapsed: f64) {
316        for e in &mut self.edges {
317            e.strength *= (1.0 - e.decay_rate).powf(days_elapsed);
318            e.strength = e.strength.max(0.0);
319        }
320    }
321
322    /// Remove edges whose strength has fallen below `threshold`
323    pub fn prune_weak_edges(&mut self, threshold: f64) -> usize {
324        let before = self.edges.len();
325        self.edges.retain(|e| e.strength >= threshold);
326        let removed = before - self.edges.len();
327        if removed > 0 {
328            self.rebuild_index();
329        }
330        removed
331    }
332
333    /// Rebuilds the in-memory incident-edge index from persisted edge positions.
334    pub(crate) fn rebuild_index(&mut self) {
335        self.edge_positions.clear();
336        for (position, edge) in self.edges.iter().enumerate() {
337            self.edge_positions
338                .entry(edge.from.id())
339                .or_default()
340                .push(position);
341            if edge.to != edge.from {
342                self.edge_positions
343                    .entry(edge.to.id())
344                    .or_default()
345                    .push(position);
346            }
347        }
348    }
349
350    /// Returns only the edges incident to `node`, using the load-time index.
351    pub fn incident_edges(&self, node: &KnowledgeNodeRef) -> Vec<&KnowledgeEdge> {
352        self.edge_positions
353            .get(&node.id())
354            .into_iter()
355            .flatten()
356            .filter_map(|&position| self.edges.get(position))
357            .collect()
358    }
359}
360
361pub fn parse_node_ref(input: &str) -> Option<KnowledgeNodeRef> {
362    let s = input.trim();
363    if s.is_empty() {
364        return None;
365    }
366
367    if let Some((cat, key)) = s.split_once('/') {
368        let cat = cat.trim();
369        let key = key.trim();
370        if !cat.is_empty() && !key.is_empty() {
371            return Some(KnowledgeNodeRef::new(cat, key));
372        }
373    }
374    if let Some((cat, key)) = s.split_once(':') {
375        let cat = cat.trim();
376        let key = key.trim();
377        if !cat.is_empty() && !key.is_empty() {
378            return Some(KnowledgeNodeRef::new(cat, key));
379        }
380    }
381
382    None
383}
384
385pub fn format_mermaid(edges: &[KnowledgeEdge]) -> String {
386    if edges.is_empty() {
387        return "graph TD\n  %% no relations".to_string();
388    }
389
390    fn id_for(n: &KnowledgeNodeRef) -> String {
391        let mut out = String::from("K_");
392        for ch in n.id().chars() {
393            if ch.is_ascii_alphanumeric() {
394                out.push(ch);
395            } else {
396                out.push('_');
397            }
398        }
399        out
400    }
401
402    let mut lines = Vec::new();
403    lines.push("graph TD".to_string());
404    for e in edges {
405        let from = id_for(&e.from);
406        let to = id_for(&e.to);
407        let from_label = e.from.id();
408        let to_label = e.to.id();
409        lines.push(format!(
410            "  {from}[\"{from_label}\"] -->|{}| {to}[\"{to_label}\"]",
411            e.kind.as_str()
412        ));
413    }
414    lines.join("\n")
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    /// Parallel writers each add their own edge; under the knowledge lock no
422    /// read-modify-write drops another writer's edge.
423    #[test]
424    fn concurrent_locked_writers_keep_every_edge() {
425        let _data = crate::core::data_dir::isolated_data_dir();
426        let project = tempfile::tempdir().unwrap();
427        let root = project.path().to_string_lossy().into_owned();
428        let hash = crate::core::project_hash::hash_project_root(&root);
429        std::thread::scope(|s| {
430            for i in 0..16 {
431                let (root, hash) = (&root, &hash);
432                s.spawn(move || {
433                    KnowledgeRelationGraph::mutate_locked(root, hash, |graph| {
434                        graph.upsert_edge(
435                            KnowledgeNodeRef::new("a", &format!("{i}")),
436                            KnowledgeNodeRef::new("b", "target"),
437                            KnowledgeEdgeKind::RelatedTo,
438                            "s1",
439                        );
440                        Some(())
441                    })
442                    .expect("locked write");
443                });
444            }
445        });
446        let graph = KnowledgeRelationGraph::load(&hash).expect("saved graph");
447        assert_eq!(
448            graph.edges.len(),
449            16,
450            "an edge was lost to a concurrent write"
451        );
452
453        // A change that changes nothing writes nothing.
454        let untouched = KnowledgeRelationGraph::mutate_locked(&root, &hash, |_| None::<()>);
455        assert_eq!(untouched, Ok(None));
456    }
457
458    #[test]
459    fn strengthen_edge_saturating() {
460        let mut graph = KnowledgeRelationGraph::new("test");
461        let from = KnowledgeNodeRef::new("a", "1");
462        let to = KnowledgeNodeRef::new("b", "2");
463        graph.upsert_edge(from.clone(), to.clone(), KnowledgeEdgeKind::RelatedTo, "s1");
464
465        let initial = graph.edges[0].strength;
466        assert!((initial - 0.5).abs() < 0.01);
467
468        graph.strengthen_edge(&from, &to, 0.3);
469        assert!(graph.edges[0].strength > initial);
470        assert!(graph.edges[0].strength <= 1.0);
471
472        for _ in 0..100 {
473            graph.strengthen_edge(&from, &to, 0.5);
474        }
475        assert!(graph.edges[0].strength <= 1.0);
476        assert!(graph.edges[0].strength > 0.99);
477    }
478
479    #[test]
480    fn decay_reduces_strength() {
481        let mut graph = KnowledgeRelationGraph::new("test");
482        let from = KnowledgeNodeRef::new("a", "1");
483        let to = KnowledgeNodeRef::new("b", "2");
484        graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "s1");
485
486        let initial = graph.edges[0].strength;
487        graph.decay_all_edges(10.0);
488        assert!(graph.edges[0].strength < initial);
489        assert!(graph.edges[0].strength > 0.0);
490    }
491
492    #[test]
493    fn prune_weak_edges_removes_below_threshold() {
494        let mut graph = KnowledgeRelationGraph::new("test");
495        graph.upsert_edge(
496            KnowledgeNodeRef::new("a", "1"),
497            KnowledgeNodeRef::new("b", "2"),
498            KnowledgeEdgeKind::RelatedTo,
499            "s1",
500        );
501        graph.upsert_edge(
502            KnowledgeNodeRef::new("c", "3"),
503            KnowledgeNodeRef::new("d", "4"),
504            KnowledgeEdgeKind::RelatedTo,
505            "s2",
506        );
507
508        graph.edges[1].strength = 0.01;
509
510        let removed = graph.prune_weak_edges(0.05);
511        assert_eq!(removed, 1);
512        assert_eq!(graph.edges.len(), 1);
513    }
514
515    #[test]
516    fn backward_compatible_edge_deserialization() {
517        let json = r#"{
518            "from": {"category": "a", "key": "1"},
519            "to": {"category": "b", "key": "2"},
520            "kind": "related_to",
521            "created_at": "2024-01-01T00:00:00Z",
522            "count": 1,
523            "source_session": "s1"
524        }"#;
525        let edge: KnowledgeEdge = serde_json::from_str(json).unwrap();
526        assert!((edge.strength - 0.5).abs() < 0.01);
527        assert!((edge.decay_rate - 0.02).abs() < 0.001);
528    }
529
530    #[test]
531    fn load_rebuilds_ephemeral_index_without_changing_json() {
532        let _isolated = crate::core::data_dir::isolated_data_dir();
533        let project_hash = "knowledge-relations-index";
534        let from = KnowledgeNodeRef::new("architecture", "storage");
535        let to = KnowledgeNodeRef::new("dependency", "sqlite");
536        let mut graph = KnowledgeRelationGraph::new(project_hash);
537        graph.upsert_edge(
538            from.clone(),
539            to,
540            KnowledgeEdgeKind::DependsOn,
541            "test-session",
542        );
543        graph.save().unwrap();
544
545        let loaded = KnowledgeRelationGraph::load(project_hash).expect("saved graph should load");
546        assert_eq!(loaded.edge_positions.get(&from.id()).map(Vec::len), Some(1));
547        assert_eq!(loaded.incident_edges(&from).len(), 1);
548
549        let json = serde_json::to_string(&loaded).unwrap();
550        assert!(!json.contains("\"edge_positions\""));
551    }
552
553    #[test]
554    fn strengthen_edge_bidirectional() {
555        let mut graph = KnowledgeRelationGraph::new("test");
556        let from = KnowledgeNodeRef::new("a", "1");
557        let to = KnowledgeNodeRef::new("b", "2");
558        graph.upsert_edge(from.clone(), to.clone(), KnowledgeEdgeKind::RelatedTo, "s1");
559
560        let found = graph.strengthen_edge(&to, &from, 0.2);
561        assert!(found);
562        assert!(graph.edges[0].strength > 0.5);
563    }
564}