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)]
46#[serde(rename_all = "snake_case")]
47pub enum KnowledgeEdgeKind {
48 DependsOn,
49 RelatedTo,
50 Supports,
51 Contradicts,
52 Supersedes,
53}
54
55impl KnowledgeEdgeKind {
56 pub fn parse(input: &str) -> Option<Self> {
57 match input.trim().to_lowercase().as_str() {
58 "depends_on" | "depends" => Some(Self::DependsOn),
59 "related_to" | "related" => Some(Self::RelatedTo),
60 "supports" | "support" => Some(Self::Supports),
61 "contradicts" | "contradict" => Some(Self::Contradicts),
62 "supersedes" | "supersede" => Some(Self::Supersedes),
63 _ => None,
64 }
65 }
66
67 pub fn as_str(&self) -> &'static str {
68 match self {
69 KnowledgeEdgeKind::DependsOn => "depends_on",
70 KnowledgeEdgeKind::RelatedTo => "related_to",
71 KnowledgeEdgeKind::Supports => "supports",
72 KnowledgeEdgeKind::Contradicts => "contradicts",
73 KnowledgeEdgeKind::Supersedes => "supersedes",
74 }
75 }
76}
77
78fn default_strength() -> f64 {
79 0.5
80}
81fn default_decay_rate() -> f64 {
82 0.02
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct KnowledgeEdge {
87 pub from: KnowledgeNodeRef,
88 pub to: KnowledgeNodeRef,
89 pub kind: KnowledgeEdgeKind,
90 pub created_at: DateTime<Utc>,
91 #[serde(default)]
92 pub last_seen: Option<DateTime<Utc>>,
93 #[serde(default)]
94 pub count: u32,
95 pub source_session: String,
96 #[serde(default = "default_strength")]
97 pub strength: f64,
98 #[serde(default = "default_decay_rate")]
99 pub decay_rate: f64,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(default)]
104pub struct KnowledgeRelationGraph {
105 pub project_hash: String,
106 pub edges: Vec<KnowledgeEdge>,
107 pub updated_at: DateTime<Utc>,
108 #[serde(skip)]
111 pub(crate) edge_positions: BTreeMap<String, Vec<usize>>,
112 #[serde(skip)]
116 foreign_edges: Vec<serde_json::Value>,
117 #[serde(skip)]
120 unreadable: bool,
121}
122
123impl Default for KnowledgeRelationGraph {
124 fn default() -> Self {
125 Self {
126 project_hash: String::new(),
127 edges: Vec::new(),
128 updated_at: Utc::now(),
129 edge_positions: BTreeMap::new(),
130 foreign_edges: Vec::new(),
131 unreadable: false,
132 }
133 }
134}
135
136impl KnowledgeRelationGraph {
137 pub fn new(project_hash: &str) -> Self {
138 Self {
139 project_hash: project_hash.to_string(),
140 edges: Vec::new(),
141 updated_at: Utc::now(),
142 edge_positions: BTreeMap::new(),
143 foreign_edges: Vec::new(),
144 unreadable: false,
145 }
146 }
147
148 pub fn path(project_hash: &str) -> Result<PathBuf, String> {
149 let dir = crate::core::data_dir::lean_ctx_data_dir()?
150 .join("knowledge")
151 .join(project_hash);
152 Ok(dir.join("relations.json"))
153 }
154
155 pub fn load(project_hash: &str) -> Option<Self> {
156 Self::load_checked(project_hash).ok().flatten()
157 }
158
159 fn load_checked(project_hash: &str) -> Result<Option<Self>, String> {
162 let path = Self::path(project_hash)?;
163 let content = match std::fs::read_to_string(&path) {
164 Ok(content) => content,
165 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
166 Err(e) => return Err(format!("{}: {e}", path.display())),
167 };
168 let mut g = Self::from_json(&content)
169 .ok_or_else(|| format!("{} does not parse", path.display()))?;
170 if g.project_hash.trim().is_empty() {
171 g.project_hash = project_hash.to_string();
172 }
173 g.rebuild_index();
174 Ok(Some(g))
175 }
176
177 fn from_json(content: &str) -> Option<Self> {
180 let mut doc: serde_json::Value = serde_json::from_str(content).ok()?;
181 let raw = doc.as_object_mut()?.remove("edges");
182 let mut g: Self = serde_json::from_value(doc).ok()?;
183 match raw {
184 None | Some(serde_json::Value::Null) => {}
185 Some(serde_json::Value::Array(raw)) => {
186 for edge in raw {
187 match serde_json::from_value::<KnowledgeEdge>(edge.clone()) {
188 Ok(edge) => g.edges.push(edge),
189 Err(_) => g.foreign_edges.push(edge),
190 }
191 }
192 }
193 Some(_) => return None,
194 }
195 Some(g)
196 }
197
198 pub fn load_or_create(project_hash: &str) -> Self {
199 match Self::load_checked(project_hash) {
200 Ok(Some(g)) => g,
201 Ok(None) => Self::new(project_hash),
202 Err(error) => {
203 tracing::warn!("[knowledge] relations not loaded, kept as they are: {error}");
204 let mut g = Self::new(project_hash);
205 g.unreadable = true;
206 g
207 }
208 }
209 }
210
211 pub fn mutate_locked<T>(
218 project_root: &str,
219 project_hash: &str,
220 change: impl FnOnce(&mut Self) -> Option<T>,
221 ) -> Result<Option<T>, String> {
222 crate::core::knowledge::ProjectKnowledge::with_project_lock_checked(project_root, || {
223 let mut graph = Self::load_or_create(project_hash);
224 match change(&mut graph) {
225 Some(output) => graph.save().map(|()| Some(output)),
226 None => Ok(None),
227 }
228 })?
229 }
230
231 pub fn save(&mut self) -> Result<(), String> {
232 if self.unreadable {
233 return Err(
234 "relations.json could not be read; it is not overwritten (fix or remove it)"
235 .to_string(),
236 );
237 }
238 let path = Self::path(&self.project_hash)?;
239 if let Some(dir) = path.parent() {
240 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
241 }
242
243 self.updated_at = Utc::now();
244 self.edges.sort_by(|a, b| {
245 a.from
246 .category
247 .cmp(&b.from.category)
248 .then_with(|| a.from.key.cmp(&b.from.key))
249 .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
250 .then_with(|| a.to.category.cmp(&b.to.category))
251 .then_with(|| a.to.key.cmp(&b.to.key))
252 .then_with(|| b.count.cmp(&a.count))
253 .then_with(|| b.last_seen.cmp(&a.last_seen))
254 .then_with(|| b.created_at.cmp(&a.created_at))
255 });
256 self.rebuild_index();
257
258 let json = if self.foreign_edges.is_empty() {
259 serde_json::to_string_pretty(self)
260 } else {
261 serde_json::to_value(&*self).and_then(|mut doc| {
263 if let Some(serde_json::Value::Array(edges)) = doc.get_mut("edges") {
264 edges.extend(self.foreign_edges.iter().cloned());
265 }
266 serde_json::to_string_pretty(&doc)
267 })
268 }
269 .map_err(|e| e.to_string())?;
270 crate::core::atomic_fs::try_atomic_write(&path, json.as_bytes(), None)
273 .map_err(|e| e.to_string())
274 }
275
276 pub fn upsert_edge(
277 &mut self,
278 from: KnowledgeNodeRef,
279 to: KnowledgeNodeRef,
280 kind: KnowledgeEdgeKind,
281 session_id: &str,
282 ) -> bool {
283 let now = Utc::now();
284 if let Some(e) = self
285 .edges
286 .iter_mut()
287 .find(|e| e.from == from && e.to == to && e.kind == kind)
288 {
289 e.count = e.count.saturating_add(1).max(1);
290 e.last_seen = Some(now);
291 e.source_session = session_id.to_string();
292 e.strength = (e.strength + 0.1 * (1.0 - e.strength)).min(1.0);
293 self.updated_at = now;
294 return false;
295 }
296
297 self.edges.push(KnowledgeEdge {
298 from,
299 to,
300 kind,
301 created_at: now,
302 last_seen: Some(now),
303 count: 1,
304 source_session: session_id.to_string(),
305 strength: default_strength(),
306 decay_rate: default_decay_rate(),
307 });
308 self.rebuild_index();
309 self.updated_at = now;
310 true
311 }
312
313 pub fn remove_edge(
314 &mut self,
315 from: &KnowledgeNodeRef,
316 to: &KnowledgeNodeRef,
317 kind: Option<KnowledgeEdgeKind>,
318 ) -> usize {
319 let before = self.edges.len();
320 self.edges.retain(|e| {
321 if &e.from != from || &e.to != to {
322 return true;
323 }
324 if let Some(k) = kind {
325 e.kind != k
326 } else {
327 false
328 }
329 });
330 let removed = before.saturating_sub(self.edges.len());
331 if removed > 0 {
332 self.rebuild_index();
333 }
334 removed
335 }
336
337 pub fn enforce_cap(&mut self, max_edges: usize) -> bool {
338 if max_edges == 0 || self.edges.len() <= max_edges {
339 return false;
340 }
341
342 self.edges.sort_by(|a, b| {
343 b.count
344 .cmp(&a.count)
345 .then_with(|| b.last_seen.cmp(&a.last_seen))
346 .then_with(|| b.created_at.cmp(&a.created_at))
347 .then_with(|| a.from.category.cmp(&b.from.category))
348 .then_with(|| a.from.key.cmp(&b.from.key))
349 .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
350 .then_with(|| a.to.category.cmp(&b.to.category))
351 .then_with(|| a.to.key.cmp(&b.to.key))
352 });
353
354 self.edges.truncate(max_edges);
355 self.rebuild_index();
356 true
357 }
358
359 pub fn strengthen_edge(
361 &mut self,
362 from: &KnowledgeNodeRef,
363 to: &KnowledgeNodeRef,
364 amount: f64,
365 ) -> bool {
366 if let Some(e) = self
367 .edges
368 .iter_mut()
369 .find(|e| &e.from == from && &e.to == to)
370 {
371 e.strength = (e.strength + amount * (1.0 - e.strength)).min(1.0);
372 e.last_seen = Some(Utc::now());
373 e.count = e.count.saturating_add(1);
374 return true;
375 }
376 if let Some(e) = self
377 .edges
378 .iter_mut()
379 .find(|e| &e.from == to && &e.to == from)
380 {
381 e.strength = (e.strength + amount * (1.0 - e.strength)).min(1.0);
382 e.last_seen = Some(Utc::now());
383 e.count = e.count.saturating_add(1);
384 return true;
385 }
386 false
387 }
388
389 pub fn decay_all_edges(&mut self, days_elapsed: f64) {
391 for e in &mut self.edges {
392 e.strength *= (1.0 - e.decay_rate).powf(days_elapsed);
393 e.strength = e.strength.max(0.0);
394 }
395 }
396
397 pub fn prune_weak_edges(&mut self, threshold: f64) -> usize {
399 let before = self.edges.len();
400 self.edges.retain(|e| e.strength >= threshold);
401 let removed = before - self.edges.len();
402 if removed > 0 {
403 self.rebuild_index();
404 }
405 removed
406 }
407
408 pub(crate) fn rebuild_index(&mut self) {
410 self.edge_positions.clear();
411 for (position, edge) in self.edges.iter().enumerate() {
412 self.edge_positions
413 .entry(edge.from.id())
414 .or_default()
415 .push(position);
416 if edge.to != edge.from {
417 self.edge_positions
418 .entry(edge.to.id())
419 .or_default()
420 .push(position);
421 }
422 }
423 }
424
425 pub fn incident_edges(&self, node: &KnowledgeNodeRef) -> Vec<&KnowledgeEdge> {
427 self.edge_positions
428 .get(&node.id())
429 .into_iter()
430 .flatten()
431 .filter_map(|&position| self.edges.get(position))
432 .collect()
433 }
434}
435
436pub fn parse_node_ref(input: &str) -> Option<KnowledgeNodeRef> {
437 let s = input.trim();
438 if s.is_empty() {
439 return None;
440 }
441
442 if let Some((cat, key)) = s.split_once('/') {
443 let cat = cat.trim();
444 let key = key.trim();
445 if !cat.is_empty() && !key.is_empty() {
446 return Some(KnowledgeNodeRef::new(cat, key));
447 }
448 }
449 if let Some((cat, key)) = s.split_once(':') {
450 let cat = cat.trim();
451 let key = key.trim();
452 if !cat.is_empty() && !key.is_empty() {
453 return Some(KnowledgeNodeRef::new(cat, key));
454 }
455 }
456
457 None
458}
459
460pub fn format_mermaid(edges: &[KnowledgeEdge]) -> String {
461 if edges.is_empty() {
462 return "graph TD\n %% no relations".to_string();
463 }
464
465 fn id_for(n: &KnowledgeNodeRef) -> String {
466 let mut out = String::from("K_");
467 for ch in n.id().chars() {
468 if ch.is_ascii_alphanumeric() {
469 out.push(ch);
470 } else {
471 out.push('_');
472 }
473 }
474 out
475 }
476
477 let mut lines = Vec::new();
478 lines.push("graph TD".to_string());
479 for e in edges {
480 let from = id_for(&e.from);
481 let to = id_for(&e.to);
482 let from_label = e.from.id();
483 let to_label = e.to.id();
484 lines.push(format!(
485 " {from}[\"{from_label}\"] -->|{}| {to}[\"{to_label}\"]",
486 e.kind.as_str()
487 ));
488 }
489 lines.join("\n")
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
499 fn concurrent_locked_writers_keep_every_edge() {
500 let _data = crate::core::data_dir::isolated_data_dir();
501 let project = tempfile::tempdir().unwrap();
502 let root = project.path().to_string_lossy().into_owned();
503 let hash = crate::core::project_hash::hash_project_root(&root);
504 const WRITERS: usize = 6;
507 std::thread::scope(|s| {
508 for i in 0..WRITERS {
509 let (root, hash) = (&root, &hash);
510 s.spawn(move || {
511 KnowledgeRelationGraph::mutate_locked(root, hash, |graph| {
512 graph.upsert_edge(
513 KnowledgeNodeRef::new("a", &format!("{i}")),
514 KnowledgeNodeRef::new("b", "target"),
515 KnowledgeEdgeKind::RelatedTo,
516 "s1",
517 );
518 Some(())
519 })
520 .expect("locked write");
521 });
522 }
523 });
524 let graph = KnowledgeRelationGraph::load(&hash).expect("saved graph");
525 assert_eq!(
526 graph.edges.len(),
527 WRITERS,
528 "an edge was lost to a concurrent write"
529 );
530
531 let untouched = KnowledgeRelationGraph::mutate_locked(&root, &hash, |_| None::<()>);
533 assert_eq!(untouched, Ok(None));
534 }
535
536 #[test]
537 fn strengthen_edge_saturating() {
538 let mut graph = KnowledgeRelationGraph::new("test");
539 let from = KnowledgeNodeRef::new("a", "1");
540 let to = KnowledgeNodeRef::new("b", "2");
541 graph.upsert_edge(from.clone(), to.clone(), KnowledgeEdgeKind::RelatedTo, "s1");
542
543 let initial = graph.edges[0].strength;
544 assert!((initial - 0.5).abs() < 0.01);
545
546 graph.strengthen_edge(&from, &to, 0.3);
547 assert!(graph.edges[0].strength > initial);
548 assert!(graph.edges[0].strength <= 1.0);
549
550 for _ in 0..100 {
551 graph.strengthen_edge(&from, &to, 0.5);
552 }
553 assert!(graph.edges[0].strength <= 1.0);
554 assert!(graph.edges[0].strength > 0.99);
555 }
556
557 #[test]
558 fn decay_reduces_strength() {
559 let mut graph = KnowledgeRelationGraph::new("test");
560 let from = KnowledgeNodeRef::new("a", "1");
561 let to = KnowledgeNodeRef::new("b", "2");
562 graph.upsert_edge(from, to, KnowledgeEdgeKind::RelatedTo, "s1");
563
564 let initial = graph.edges[0].strength;
565 graph.decay_all_edges(10.0);
566 assert!(graph.edges[0].strength < initial);
567 assert!(graph.edges[0].strength > 0.0);
568 }
569
570 #[test]
571 fn prune_weak_edges_removes_below_threshold() {
572 let mut graph = KnowledgeRelationGraph::new("test");
573 graph.upsert_edge(
574 KnowledgeNodeRef::new("a", "1"),
575 KnowledgeNodeRef::new("b", "2"),
576 KnowledgeEdgeKind::RelatedTo,
577 "s1",
578 );
579 graph.upsert_edge(
580 KnowledgeNodeRef::new("c", "3"),
581 KnowledgeNodeRef::new("d", "4"),
582 KnowledgeEdgeKind::RelatedTo,
583 "s2",
584 );
585
586 graph.edges[1].strength = 0.01;
587
588 let removed = graph.prune_weak_edges(0.05);
589 assert_eq!(removed, 1);
590 assert_eq!(graph.edges.len(), 1);
591 }
592
593 #[test]
594 fn backward_compatible_edge_deserialization() {
595 let json = r#"{
596 "from": {"category": "a", "key": "1"},
597 "to": {"category": "b", "key": "2"},
598 "kind": "related_to",
599 "created_at": "2024-01-01T00:00:00Z",
600 "count": 1,
601 "source_session": "s1"
602 }"#;
603 let edge: KnowledgeEdge = serde_json::from_str(json).unwrap();
604 assert!((edge.strength - 0.5).abs() < 0.01);
605 assert!((edge.decay_rate - 0.02).abs() < 0.001);
606 }
607
608 #[test]
613 fn unknown_kinds_survive_a_write() {
614 let _isolated = crate::core::data_dir::isolated_data_dir();
615 let project = tempfile::tempdir().unwrap();
616 let root = project.path().to_string_lossy().into_owned();
617 let hash = crate::core::project_hash::hash_project_root(&root);
618 let future = serde_json::json!({
619 "from": {"category": "a", "key": "1"},
620 "to": {"category": "c", "key": "3"},
621 "kind": "answers",
622 "created_at": "2026-11-01T00:00:00Z",
623 "count": 2,
624 "source_session": "newer-build"
625 });
626 let doc = serde_json::json!({
627 "project_hash": hash,
628 "edges": [
629 {"from": {"category": "a", "key": "1"}, "to": {"category": "b", "key": "2"},
630 "kind": "related_to", "created_at": "2024-01-01T00:00:00Z",
631 "count": 1, "source_session": "s1"},
632 future
633 ],
634 "updated_at": "2024-01-01T00:00:00Z"
635 });
636 let path = KnowledgeRelationGraph::path(&hash).unwrap();
637 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
638 std::fs::write(&path, doc.to_string()).unwrap();
639
640 KnowledgeRelationGraph::mutate_locked(&root, &hash, |g| {
641 assert_eq!(g.edges.len(), 1, "the old-shape edge is read");
642 g.upsert_edge(
643 KnowledgeNodeRef::new("a", "1"),
644 KnowledgeNodeRef::new("d", "4"),
645 KnowledgeEdgeKind::DependsOn,
646 "s2",
647 );
648 Some(())
649 })
650 .expect("write");
651 let edges_on_disk = || -> Vec<serde_json::Value> {
652 let saved: serde_json::Value =
653 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
654 saved["edges"].as_array().unwrap().clone()
655 };
656 let first = edges_on_disk();
657 assert_eq!(first.len(), 3);
658 assert_eq!(first.last(), Some(&future), "kept exactly as written");
659
660 let mut again = KnowledgeRelationGraph::load(&hash).unwrap();
661 again.save().unwrap();
662 assert_eq!(edges_on_disk(), first, "same graph, same edges on disk");
663 }
664
665 #[test]
667 fn an_unreadable_file_is_not_overwritten() {
668 let _isolated = crate::core::data_dir::isolated_data_dir();
669 let project = tempfile::tempdir().unwrap();
670 let root = project.path().to_string_lossy().into_owned();
671 let hash = crate::core::project_hash::hash_project_root(&root);
672 let path = KnowledgeRelationGraph::path(&hash).unwrap();
673 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
674 std::fs::write(&path, "{\"edges\": [ torn").unwrap();
675
676 let written = KnowledgeRelationGraph::mutate_locked(&root, &hash, |g| {
677 g.upsert_edge(
678 KnowledgeNodeRef::new("a", "1"),
679 KnowledgeNodeRef::new("b", "2"),
680 KnowledgeEdgeKind::DependsOn,
681 "s1",
682 );
683 Some(())
684 });
685 assert!(written.is_err(), "{written:?}");
686 assert_eq!(
687 std::fs::read_to_string(&path).unwrap(),
688 "{\"edges\": [ torn"
689 );
690 }
691
692 #[test]
693 fn load_rebuilds_ephemeral_index_without_changing_json() {
694 let _isolated = crate::core::data_dir::isolated_data_dir();
695 let project_hash = "knowledge-relations-index";
696 let from = KnowledgeNodeRef::new("architecture", "storage");
697 let to = KnowledgeNodeRef::new("dependency", "sqlite");
698 let mut graph = KnowledgeRelationGraph::new(project_hash);
699 graph.upsert_edge(
700 from.clone(),
701 to,
702 KnowledgeEdgeKind::DependsOn,
703 "test-session",
704 );
705 graph.save().unwrap();
706
707 let loaded = KnowledgeRelationGraph::load(project_hash).expect("saved graph should load");
708 assert_eq!(loaded.edge_positions.get(&from.id()).map(Vec::len), Some(1));
709 assert_eq!(loaded.incident_edges(&from).len(), 1);
710
711 let json = serde_json::to_string(&loaded).unwrap();
712 assert!(!json.contains("\"edge_positions\""));
713 }
714
715 #[test]
716 fn strengthen_edge_bidirectional() {
717 let mut graph = KnowledgeRelationGraph::new("test");
718 let from = KnowledgeNodeRef::new("a", "1");
719 let to = KnowledgeNodeRef::new("b", "2");
720 graph.upsert_edge(from.clone(), to.clone(), KnowledgeEdgeKind::RelatedTo, "s1");
721
722 let found = graph.strengthen_edge(&to, &from, 0.2);
723 assert!(found);
724 assert!(graph.edges[0].strength > 0.5);
725 }
726}