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/// The kinds this store writes. Every released reader of `relations.json`
42/// deserializes the kind strictly, so a new kind is added only once the
43/// oldest supported reader parses it; kinds a newer writer stored are kept
44/// as written (see [`KnowledgeRelationGraph::load`]).
45#[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    /// Ephemeral incident-edge lookup rebuilt after JSON load and mutations.
109    /// Skipping it preserves the existing relations.json format.
110    #[serde(skip)]
111    pub(crate) edge_positions: BTreeMap<String, Vec<usize>>,
112    /// Edges this build cannot read (a kind or shape a newer writer stored),
113    /// kept as written and saved back unchanged, so no reader drops what it
114    /// does not understand.
115    #[serde(skip)]
116    foreign_edges: Vec<serde_json::Value>,
117    /// The file exists but does not parse: [`Self::save`] refuses, so a
118    /// writer never replaces relations it could not read with an empty set.
119    #[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    /// `Ok(None)` when there is no file, `Err` when it exists but cannot be
160    /// read or parsed.
161    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    /// Parse a stored graph edge by edge: an edge this build cannot read is
178    /// kept in [`Self::foreign_edges`] instead of failing the whole file.
179    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    /// Load, change and save the graph under the project's knowledge lock,
212    /// the in-process and cross-process lock fact edits and the cognition loop
213    /// hold, so a concurrent writer (another process, the dashboard, the
214    /// cognition loop) never loses an edge. `change` returns `None` when it
215    /// changed nothing; the graph is then not written. Must not be called while
216    /// that lock is held.
217    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            // Foreign edges follow the readable ones, in the order they were read.
262            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        // `load` treats an unparsable file as absent; a torn write must not
271        // turn into an empty graph that the next save persists.
272        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    /// Hebbian strengthening: saturating formula so strength approaches but never exceeds 1.0
360    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    /// Time-based exponential decay on all edge strengths
390    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    /// Remove edges whose strength has fallen below `threshold`
398    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    /// Rebuilds the in-memory incident-edge index from persisted edge positions.
409    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    /// Returns only the edges incident to `node`, using the load-time index.
426    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    /// Parallel writers each add their own edge; under the knowledge lock no
497    /// read-modify-write drops another writer's edge.
498    #[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        // Six writers race as well as sixteen did, and stay inside the lock's
505        // 10 s wait on a loaded CI host (sixteen serialized fsyncs did not).
506        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        // A change that changes nothing writes nothing.
532        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    /// A file holding an old-shape edge and an edge of a kind this build does
609    /// not know survives load → change → save: the old edge is read, the
610    /// unknown one is written back as it was, and a second save of the
611    /// same graph writes the same edges.
612    #[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    /// A file that does not parse is never replaced by an empty graph.
666    #[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}