1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use std::path::PathBuf;
5
6pub const EDGES_PER_FACT: usize = 8;
11pub const MIN_EDGE_BOUND: usize = 1_600;
14
15pub 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 #[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 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 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 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 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 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 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 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 #[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 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}