1use std::cmp::Reverse;
7use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
8
9use petgraph::Direction;
10use petgraph::stable_graph::{EdgeIndex, NodeIndex, StableDiGraph};
11use petgraph::visit::EdgeRef;
12use serde::{Deserialize, Serialize};
13
14use hirn_core::id::MemoryId;
15use hirn_core::metadata::{Metadata, MetadataValue};
16use hirn_core::timestamp::Timestamp;
17use hirn_core::types::{EdgeRelation, Layer, Namespace};
18use hirn_core::{HirnError, HirnResult};
19
20pub type EdgeId = MemoryId;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CausalEdgeData {
34 pub strength: f32,
36 pub confidence: f32,
38 pub evidence_count: u32,
40 pub confounders: Vec<String>,
42 pub provenance: Option<String>,
44 pub mechanism: Option<String>,
46 pub direction: Option<CausalDirection>,
48}
49
50impl Default for CausalEdgeData {
51 fn default() -> Self {
52 Self {
53 strength: 0.0,
54 confidence: 0.5,
55 evidence_count: 0_u32,
56 confounders: vec![],
57 provenance: None,
58 mechanism: None,
59 direction: None,
60 }
61 }
62}
63
64impl CausalEdgeData {
65 pub fn new(strength: f32, confidence: f32, evidence_count: u32) -> Self {
68 Self {
69 strength,
70 confidence,
71 evidence_count,
72 ..Default::default()
73 }
74 }
75
76 #[must_use]
78 pub fn with_mechanism(mut self, mechanism: impl Into<String>) -> Self {
79 self.mechanism = Some(mechanism.into());
80 self
81 }
82
83 #[must_use]
85 pub fn with_provenance(mut self, provenance: impl Into<String>) -> Self {
86 self.provenance = Some(provenance.into());
87 self
88 }
89
90 #[must_use]
92 pub fn with_direction(mut self, direction: CausalDirection) -> Self {
93 self.direction = Some(direction);
94 self
95 }
96
97 #[must_use]
99 pub fn with_confounders(mut self, confounders: Vec<String>) -> Self {
100 self.confounders = confounders;
101 self
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct GraphEdge {
108 pub id: EdgeId,
109 pub source: MemoryId,
110 pub target: MemoryId,
111 pub relation: EdgeRelation,
112 pub weight: f32,
113 pub co_retrieval_count: u64,
114 pub created_at: Timestamp,
115 pub updated_at: Timestamp,
116 #[serde(default)]
117 pub valid_from: Option<Timestamp>,
118 #[serde(default)]
119 pub valid_until: Option<Timestamp>,
120 pub metadata: Metadata,
121 #[serde(default)]
123 pub resolved: bool,
124 pub namespace: Namespace,
126 #[serde(default)]
129 pub causal: Option<Box<CausalEdgeData>>,
130}
131
132impl GraphEdge {
133 #[inline]
135 pub fn strength(&self) -> Option<f32> {
136 self.causal.as_ref().map(|c| c.strength)
137 }
138
139 #[inline]
141 pub fn confidence(&self) -> Option<f32> {
142 self.causal.as_ref().map(|c| c.confidence)
143 }
144
145 #[inline]
147 pub fn evidence_count(&self) -> Option<u32> {
148 self.causal.as_ref().map(|c| c.evidence_count)
149 }
150
151 #[inline]
153 pub fn confounders(&self) -> Option<&[String]> {
154 self.causal.as_ref().map(|c| c.confounders.as_slice())
155 }
156
157 #[inline]
159 pub fn provenance(&self) -> Option<&str> {
160 self.causal.as_ref().and_then(|c| c.provenance.as_deref())
161 }
162
163 #[inline]
165 pub fn mechanism(&self) -> Option<&str> {
166 self.causal.as_ref().and_then(|c| c.mechanism.as_deref())
167 }
168
169 #[inline]
171 pub fn direction(&self) -> Option<CausalDirection> {
172 self.causal.as_ref().and_then(|c| c.direction)
173 }
174
175 #[inline]
180 pub fn relevance_score(&self) -> Option<f32> {
181 self.causal
182 .as_ref()
183 .map(|c| c.strength * c.confidence * (1.0_f32 + c.evidence_count as f32).ln())
184 }
185
186 #[must_use]
192 #[inline]
193 pub fn is_valid_at(&self, as_of: Timestamp) -> bool {
194 let from_ok = self
195 .valid_from
196 .map_or(true, |vf| vf.timestamp_ms() <= as_of.timestamp_ms());
197 let until_ok = self
198 .valid_until
199 .map_or(true, |vu| vu.timestamp_ms() > as_of.timestamp_ms());
200 from_ok && until_ok
201 }
202
203 #[must_use]
207 #[inline]
208 pub fn is_currently_active(&self) -> bool {
209 let now = Timestamp::now();
210 self.is_valid_at(now)
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
216pub enum CausalDirection {
217 Forward,
218 Backward,
219 Bidirectional,
220}
221
222#[derive(Debug, Serialize, Deserialize)]
224pub struct GraphSnapshot {
225 pub nodes: Vec<GraphNodeData>,
226 pub edges: Vec<GraphEdge>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct GraphNodeData {
232 pub id: MemoryId,
233 pub layer: Layer,
234 pub importance: f32,
235 pub created_at: Timestamp,
236 #[serde(default = "Namespace::default")]
241 pub namespace: Namespace,
242 #[serde(default)]
245 pub access_count: u64,
246}
247
248#[derive(Debug, Clone)]
251struct NodeData {
252 id: MemoryId,
253 layer: Layer,
254 importance: f32,
255 created_at: Timestamp,
256 namespace: Namespace,
257 access_count: u64,
259}
260
261pub const MAX_EDGES_PER_NODE: usize = 512;
266
267pub const MAX_EDGE_METADATA_BYTES: usize = 16 * 1024;
270
271fn metadata_value_bytes(value: &MetadataValue) -> usize {
272 match value {
273 MetadataValue::Null => 0,
274 MetadataValue::Bool(_) => 1,
275 MetadataValue::Int(_) | MetadataValue::Float(_) => 8,
276 MetadataValue::String(value) => value.len(),
277 MetadataValue::List(values) => values.iter().map(metadata_value_bytes).sum(),
278 MetadataValue::Map(values) => values
279 .iter()
280 .map(|(key, value)| key.len() + metadata_value_bytes(value))
281 .sum(),
282 }
283}
284
285#[must_use]
286pub fn edge_metadata_bytes(metadata: &Metadata) -> usize {
287 metadata
288 .iter()
289 .map(|(key, value)| key.len() + metadata_value_bytes(value))
290 .sum()
291}
292
293pub fn validate_edge_metadata(metadata: &Metadata) -> HirnResult<()> {
294 let metadata_bytes = edge_metadata_bytes(metadata);
295 if metadata_bytes > MAX_EDGE_METADATA_BYTES {
296 return Err(HirnError::InvalidInput(format!(
297 "edge metadata exceeds {MAX_EDGE_METADATA_BYTES} bytes ({metadata_bytes} bytes)",
298 )));
299 }
300 Ok(())
301}
302
303pub struct PropertyGraph {
305 graph: StableDiGraph<NodeData, GraphEdge>,
306 id_to_node: HashMap<MemoryId, NodeIndex>,
307 edge_id_to_idx: HashMap<EdgeId, EdgeIndex>,
308 max_node_count: usize,
310 eviction_heap: BinaryHeap<Reverse<(u64, MemoryId)>>,
314 dirty_access_counts: HashMap<MemoryId, u64>,
317}
318
319impl Default for PropertyGraph {
320 fn default() -> Self {
321 Self::new()
322 }
323}
324
325impl PropertyGraph {
326 pub fn new() -> Self {
328 Self {
329 graph: StableDiGraph::new(),
330 id_to_node: HashMap::new(),
331 edge_id_to_idx: HashMap::new(),
332 max_node_count: 500_000,
333 eviction_heap: BinaryHeap::new(),
334 dirty_access_counts: HashMap::new(),
335 }
336 }
337
338 pub fn with_max_nodes(max_node_count: usize) -> Self {
340 Self {
341 graph: StableDiGraph::new(),
342 id_to_node: HashMap::new(),
343 edge_id_to_idx: HashMap::new(),
344 max_node_count,
345 eviction_heap: BinaryHeap::new(),
346 dirty_access_counts: HashMap::new(),
347 }
348 }
349
350 pub fn from_snapshot_with_config(snapshot: GraphSnapshot, max_node_count: usize) -> Self {
357 let effective_cap = snapshot.nodes.len().max(max_node_count);
361 let mut pg = Self::with_max_nodes(effective_cap);
362 for nd in &snapshot.nodes {
363 pg.add_node_ns(
364 nd.id,
365 nd.layer,
366 nd.importance,
367 nd.created_at,
368 nd.namespace.clone(),
369 );
370 }
371 for edge in snapshot.edges {
372 if !pg.id_to_node.contains_key(&edge.source) {
374 pg.add_node(edge.source, Layer::Episodic, 0.5, edge.created_at);
375 }
376 if !pg.id_to_node.contains_key(&edge.target) {
377 pg.add_node(edge.target, Layer::Episodic, 0.5, edge.created_at);
378 }
379 let src = pg.id_to_node[&edge.source];
380 let tgt = pg.id_to_node[&edge.target];
381 let eidx = pg.graph.add_edge(src, tgt, edge);
382 let eid = pg.graph[eidx].id;
383 pg.edge_id_to_idx.insert(eid, eidx);
384 }
385 pg
386 }
387
388 pub fn snapshot(&self) -> GraphSnapshot {
390 let nodes = self
391 .id_to_node
392 .keys()
393 .map(|&id| {
394 let idx = self.id_to_node[&id];
395 let nd = &self.graph[idx];
396 GraphNodeData {
397 id: nd.id,
398 layer: nd.layer,
399 importance: nd.importance,
400 created_at: nd.created_at,
401 namespace: nd.namespace.clone(),
402 access_count: nd.access_count,
403 }
404 })
405 .collect();
406 let edges = self
407 .graph
408 .edge_indices()
409 .map(|eidx| self.graph[eidx].clone())
410 .collect();
411 GraphSnapshot { nodes, edges }
412 }
413
414 pub fn add_node(
418 &mut self,
419 id: MemoryId,
420 layer: Layer,
421 importance: f32,
422 created_at: Timestamp,
423 ) -> bool {
424 self.add_node_ns(id, layer, importance, created_at, Namespace::default())
425 }
426
427 pub fn add_node_ns(
431 &mut self,
432 id: MemoryId,
433 layer: Layer,
434 importance: f32,
435 created_at: Timestamp,
436 namespace: Namespace,
437 ) -> bool {
438 if self.id_to_node.contains_key(&id) {
439 return false;
440 }
441
442 let node_count = self.id_to_node.len();
444 if node_count >= self.max_node_count {
445 let mut evict_id = None;
449 while let Some(Reverse((heap_count, candidate))) = self.eviction_heap.pop() {
450 if candidate == id {
451 continue; }
453 match self.id_to_node.get(&candidate) {
454 None => continue, Some(&idx) => {
456 if self.graph[idx].access_count != heap_count {
457 continue; }
459 evict_id = Some(candidate);
460 break;
461 }
462 }
463 }
464 if let Some(evict_id) = evict_id {
465 tracing::debug!(
466 evicted = %evict_id,
467 access_count = self.graph[self.id_to_node[&evict_id]].access_count,
468 "evicting least-accessed node from hot tier (max_node_count reached)"
469 );
470 self.remove_node(evict_id);
471 } else {
472 tracing::error!(
473 nodes = node_count,
474 max = self.max_node_count,
475 "property graph reached max_node_count, cannot evict"
476 );
477 return false;
478 }
479 }
480
481 if node_count > 0 && node_count.is_multiple_of(100_000) {
483 tracing::warn!(
484 nodes = node_count,
485 "property graph node count high, consider consolidation or archival"
486 );
487 }
488 let idx = self.graph.add_node(NodeData {
489 id,
490 layer,
491 importance,
492 created_at,
493 namespace,
494 access_count: 0,
495 });
496 self.id_to_node.insert(id, idx);
497 self.eviction_heap.push(Reverse((0, id)));
499 true
500 }
501
502 pub fn remove_node(&mut self, id: MemoryId) -> bool {
504 if let Some(idx) = self.id_to_node.remove(&id) {
505 let edge_indices: Vec<EdgeIndex> = self
507 .graph
508 .edges_directed(idx, Direction::Outgoing)
509 .chain(self.graph.edges_directed(idx, Direction::Incoming))
510 .map(|e| e.id())
511 .collect();
512 for eidx in edge_indices {
513 if let Some(edge) = self.graph.edge_weight(eidx) {
514 self.edge_id_to_idx.remove(&edge.id);
515 }
516 }
517 self.graph.remove_node(idx);
518 true
520 } else {
521 false
522 }
523 }
524
525 pub fn node_edge_ids(&self, id: MemoryId) -> Vec<EdgeId> {
527 let Some(&idx) = self.id_to_node.get(&id) else {
528 return Vec::new();
529 };
530 self.graph
531 .edges_directed(idx, Direction::Outgoing)
532 .chain(self.graph.edges_directed(idx, Direction::Incoming))
533 .filter_map(|e| self.graph.edge_weight(e.id()).map(|w| w.id))
534 .collect()
535 }
536
537 pub fn edge_by_id(&self, edge_id: EdgeId) -> Option<&GraphEdge> {
539 let eidx = self.edge_id_to_idx.get(&edge_id)?;
540 self.graph.edge_weight(*eidx)
541 }
542
543 pub fn has_node(&self, id: MemoryId) -> bool {
545 self.id_to_node.contains_key(&id)
546 }
547
548 pub fn record_access(&mut self, id: MemoryId) {
550 if let Some(&idx) = self.id_to_node.get(&id) {
551 self.graph[idx].access_count += 1;
552 self.eviction_heap
554 .push(Reverse((self.graph[idx].access_count, id)));
555 *self.dirty_access_counts.entry(id).or_insert(0) =
557 self.graph[idx].access_count;
558 }
559 }
560
561 pub fn drain_dirty_access_counts(&mut self) -> Vec<(MemoryId, u64)> {
566 self.dirty_access_counts.drain().collect()
567 }
568
569 pub fn access_count(&self, id: MemoryId) -> u64 {
571 self.id_to_node
572 .get(&id)
573 .map(|&idx| self.graph[idx].access_count)
574 .unwrap_or(0)
575 }
576
577 pub fn node_count(&self) -> usize {
579 self.graph.node_count()
580 }
581
582 pub fn edge_count(&self) -> usize {
589 self.graph
590 .edge_weights()
591 .filter(|e| e.is_currently_active())
592 .count()
593 }
594
595 fn connected_edge_indices(&self, node_idx: NodeIndex) -> Vec<EdgeIndex> {
596 self.graph
597 .edges_directed(node_idx, Direction::Outgoing)
598 .chain(self.graph.edges_directed(node_idx, Direction::Incoming))
599 .map(|e| e.id())
600 .collect()
601 }
602
603 fn has_relation_between(
604 &self,
605 src_idx: NodeIndex,
606 tgt_idx: NodeIndex,
607 relation: EdgeRelation,
608 ) -> bool {
609 self.graph
610 .edges_connecting(src_idx, tgt_idx)
611 .any(|edge| edge.weight().relation == relation)
612 }
613
614 fn reverse_edge_index(&self, edge: &GraphEdge) -> Option<EdgeIndex> {
615 if !edge.relation.is_bidirectional() || edge.source == edge.target {
616 return None;
617 }
618
619 let src_idx = *self.id_to_node.get(&edge.source)?;
620 let tgt_idx = *self.id_to_node.get(&edge.target)?;
621 self.graph
622 .edges_connecting(tgt_idx, src_idx)
623 .find(|candidate| {
624 let candidate = candidate.weight();
625 #[allow(clippy::suspicious_operation_groupings)]
628 {
629 candidate.relation == edge.relation
630 && candidate.source == edge.target
631 && candidate.target == edge.source
632 }
633 })
634 .map(|candidate| candidate.id())
635 }
636
637 fn remove_edge_pair_by_index(&mut self, edge_idx: EdgeIndex) {
638 let Some(edge) = self.graph.edge_weight(edge_idx).cloned() else {
639 return;
640 };
641
642 let mut removals = vec![(edge.id, edge_idx)];
643 if let Some(reverse_idx) = self.reverse_edge_index(&edge)
644 && reverse_idx != edge_idx
645 && let Some(reverse_edge) = self.graph.edge_weight(reverse_idx)
646 {
647 removals.push((reverse_edge.id, reverse_idx));
648 }
649
650 let mut seen = HashSet::with_capacity(removals.len());
651 for (edge_id, edge_idx) in removals {
652 if !seen.insert(edge_idx) {
653 continue;
654 }
655 self.edge_id_to_idx.remove(&edge_id);
656 self.graph.remove_edge(edge_idx);
657 }
658 }
659
660 fn ensure_connected_edge_capacity(&mut self, node_idx: NodeIndex, additional_edges: usize) {
661 loop {
662 let connected_edges = self.connected_edge_indices(node_idx);
663 if connected_edges.len() + additional_edges <= MAX_EDGES_PER_NODE {
664 return;
665 }
666
667 let Some(evict_eidx) = connected_edges.iter().min_by(|&&a, &&b| {
668 let wa = self
669 .graph
670 .edge_weight(a)
671 .map_or(f32::MAX, |edge| edge.weight);
672 let wb = self
673 .graph
674 .edge_weight(b)
675 .map_or(f32::MAX, |edge| edge.weight);
676 wa.partial_cmp(&wb).unwrap_or(std::cmp::Ordering::Equal)
677 }) else {
678 return;
679 };
680
681 if let Some(evicted) = self.graph.edge_weight(*evict_eidx) {
682 tracing::debug!(
683 edge_id = %evicted.id,
684 relation = ?evicted.relation,
685 weight = evicted.weight,
686 "evicting lowest-weight edge group from node (MAX_EDGES_PER_NODE reached)"
687 );
688 }
689 self.remove_edge_pair_by_index(*evict_eidx);
690 }
691 }
692
693 pub fn add_edge(
701 &mut self,
702 source: MemoryId,
703 target: MemoryId,
704 relation: EdgeRelation,
705 weight: f32,
706 metadata: Metadata,
707 ) -> HirnResult<EdgeId> {
708 self.add_edge_inner(source, target, relation, weight, metadata, None)
709 }
710
711 pub fn add_causal_edge(
719 &mut self,
720 source: MemoryId,
721 target: MemoryId,
722 relation: EdgeRelation,
723 weight: f32,
724 metadata: Metadata,
725 causal: CausalEdgeData,
726 ) -> HirnResult<EdgeId> {
727 self.add_edge_inner(
728 source,
729 target,
730 relation,
731 weight,
732 metadata,
733 Some(Box::new(causal)),
734 )
735 }
736
737 fn add_edge_inner(
738 &mut self,
739 source: MemoryId,
740 target: MemoryId,
741 relation: EdgeRelation,
742 weight: f32,
743 metadata: Metadata,
744 causal: Option<Box<CausalEdgeData>>,
745 ) -> HirnResult<EdgeId> {
746 validate_edge_metadata(&metadata)?;
747
748 let src_idx = *self
749 .id_to_node
750 .get(&source)
751 .ok_or_else(|| HirnError::NotFound(format!("graph node {source}")))?;
752 let tgt_idx = *self
753 .id_to_node
754 .get(&target)
755 .ok_or_else(|| HirnError::NotFound(format!("graph node {target}")))?;
756
757 if self.has_relation_between(src_idx, tgt_idx, relation) {
759 return Err(HirnError::AlreadyExists(format!(
760 "edge {source} -[{relation:?}]-> {target}"
761 )));
762 }
763
764 let edge_slots_per_endpoint = if relation.is_bidirectional() && source != target {
765 2
766 } else {
767 1
768 };
769 self.ensure_connected_edge_capacity(src_idx, edge_slots_per_endpoint);
770 if src_idx != tgt_idx {
771 self.ensure_connected_edge_capacity(tgt_idx, edge_slots_per_endpoint);
772 }
773
774 let now = Timestamp::now();
775 let w = weight.clamp(0.0, 1.0);
776
777 let src_ns = self.graph[src_idx].namespace;
781 let tgt_ns = self.graph[tgt_idx].namespace;
782
783 let edge = GraphEdge {
784 id: EdgeId::new(),
785 source,
786 target,
787 relation,
788 weight: w,
789 co_retrieval_count: 0,
790 created_at: now,
791 updated_at: now,
792 valid_from: None,
793 valid_until: None,
794 metadata: metadata.clone(),
795 resolved: false,
796 namespace: src_ns,
798 causal: causal.clone(),
799 };
800 let eid = edge.id;
801 let eidx = self.graph.add_edge(src_idx, tgt_idx, edge);
802 self.edge_id_to_idx.insert(eid, eidx);
803
804 if relation.is_bidirectional()
806 && source != target
807 && !self.has_relation_between(tgt_idx, src_idx, relation)
808 {
809 let rev_edge = GraphEdge {
810 id: EdgeId::new(),
811 source: target,
812 target: source,
813 relation,
814 weight: w,
815 co_retrieval_count: 0,
816 created_at: now,
817 updated_at: now,
818 valid_from: None,
819 valid_until: None,
820 metadata,
821 resolved: false,
822 namespace: tgt_ns,
824 causal,
825 };
826 let rev_eid = rev_edge.id;
827 let rev_eidx = self.graph.add_edge(tgt_idx, src_idx, rev_edge);
828 self.edge_id_to_idx.insert(rev_eid, rev_eidx);
829 }
830
831 Ok(eid)
832 }
833
834 pub fn remove_edge(&mut self, edge_id: EdgeId) -> HirnResult<()> {
836 let eidx = self
837 .edge_id_to_idx
838 .remove(&edge_id)
839 .ok_or_else(|| HirnError::NotFound(format!("edge {edge_id}")))?;
840 self.graph.remove_edge(eidx);
841 Ok(())
843 }
844
845 pub fn expire_edges_for_node(&mut self, node_id: MemoryId, retraction_ts: Timestamp) {
851 let Some(&idx) = self.id_to_node.get(&node_id) else {
852 return;
853 };
854 let edge_ids: Vec<EdgeId> = self
855 .graph
856 .edges_directed(idx, Direction::Outgoing)
857 .chain(self.graph.edges_directed(idx, Direction::Incoming))
858 .map(|e| e.weight().id)
859 .collect();
860 for eid in edge_ids {
861 if let Some(&eidx) = self.edge_id_to_idx.get(&eid) {
862 if let Some(edge) = self.graph.edge_weight_mut(eidx) {
863 if edge.valid_until.is_none() {
864 edge.valid_until = Some(retraction_ts);
865 edge.updated_at = retraction_ts;
866 }
867 }
868 }
869 }
870 }
871
872 pub fn get_edges(&self, node_id: MemoryId) -> Vec<&GraphEdge> {
877 let Some(&idx) = self.id_to_node.get(&node_id) else {
878 return Vec::new();
879 };
880 self.graph
881 .edges_directed(idx, Direction::Outgoing)
882 .chain(self.graph.edges_directed(idx, Direction::Incoming))
883 .map(|e| e.weight())
884 .filter(|e| e.is_currently_active())
885 .collect()
886 }
887
888 pub fn get_edges_at(&self, node_id: MemoryId, as_of: Timestamp) -> Vec<&GraphEdge> {
890 let Some(&idx) = self.id_to_node.get(&node_id) else {
891 return Vec::new();
892 };
893 self.graph
894 .edges_directed(idx, Direction::Outgoing)
895 .chain(self.graph.edges_directed(idx, Direction::Incoming))
896 .map(|e| e.weight())
897 .filter(|e| e.is_valid_at(as_of))
898 .collect()
899 }
900
901 pub fn edges_for_nodes(&self, ids: &[MemoryId]) -> HashMap<MemoryId, Vec<&GraphEdge>> {
906 let mut result = HashMap::with_capacity(ids.len());
907 for &id in ids {
908 let edges = self.get_edges(id);
909 if !edges.is_empty() {
910 result.insert(id, edges);
911 }
912 }
913 result
914 }
915
916 pub fn get_edges_visible(
919 &self,
920 node_id: MemoryId,
921 allowed_namespaces: &[Namespace],
922 ) -> Vec<&GraphEdge> {
923 let Some(&idx) = self.id_to_node.get(&node_id) else {
924 return Vec::new();
925 };
926 self.graph
927 .edges_directed(idx, Direction::Outgoing)
928 .chain(self.graph.edges_directed(idx, Direction::Incoming))
929 .map(|e| e.weight())
930 .filter(|e| {
931 e.is_currently_active()
932 && self
933 .node_namespace(e.source)
934 .is_some_and(|ns| allowed_namespaces.contains(ns))
935 && self
936 .node_namespace(e.target)
937 .is_some_and(|ns| allowed_namespaces.contains(ns))
938 })
939 .collect()
940 }
941
942 pub fn get_edges_of_type(&self, node_id: MemoryId, relation: EdgeRelation) -> Vec<&GraphEdge> {
944 self.get_edges(node_id)
945 .into_iter()
946 .filter(|e| e.relation == relation)
947 .collect()
948 }
949
950 pub fn get_edges_of_type_visible(
953 &self,
954 node_id: MemoryId,
955 relation: EdgeRelation,
956 allowed_namespaces: &[Namespace],
957 ) -> Vec<&GraphEdge> {
958 let Some(&idx) = self.id_to_node.get(&node_id) else {
959 return Vec::new();
960 };
961 self.graph
962 .edges_directed(idx, Direction::Outgoing)
963 .chain(self.graph.edges_directed(idx, Direction::Incoming))
964 .map(|e| e.weight())
965 .filter(|e| {
966 e.relation == relation
967 && e.is_currently_active()
968 && self
969 .node_namespace(e.source)
970 .is_some_and(|ns| allowed_namespaces.contains(ns))
971 && self
972 .node_namespace(e.target)
973 .is_some_and(|ns| allowed_namespaces.contains(ns))
974 })
975 .collect()
976 }
977
978 pub fn get_edges_between(&self, a: MemoryId, b: MemoryId) -> Vec<&GraphEdge> {
980 let (Some(&a_idx), Some(&b_idx)) = (self.id_to_node.get(&a), self.id_to_node.get(&b))
981 else {
982 return Vec::new();
983 };
984 let mut edges: Vec<&GraphEdge> = self
985 .graph
986 .edges_connecting(a_idx, b_idx)
987 .map(|e| e.weight())
988 .filter(|e| e.is_currently_active())
989 .collect();
990 edges.extend(
992 self.graph
993 .edges_connecting(b_idx, a_idx)
994 .map(|e| e.weight())
995 .filter(|e| e.is_currently_active()),
996 );
997 edges
998 }
999
1000 pub fn get_edges_between_visible(
1002 &self,
1003 a: MemoryId,
1004 b: MemoryId,
1005 allowed_namespaces: &[Namespace],
1006 ) -> Vec<&GraphEdge> {
1007 let a_ok = self
1008 .node_namespace(a)
1009 .is_some_and(|ns| allowed_namespaces.contains(ns));
1010 let b_ok = self
1011 .node_namespace(b)
1012 .is_some_and(|ns| allowed_namespaces.contains(ns));
1013 if !a_ok || !b_ok {
1014 return Vec::new();
1015 }
1016 self.get_edges_between(a, b)
1017 }
1018
1019 pub fn edge_mut(&mut self, edge_id: EdgeId) -> Option<&mut GraphEdge> {
1021 let eidx = self.edge_id_to_idx.get(&edge_id)?;
1022 self.graph.edge_weight_mut(*eidx)
1023 }
1024
1025 pub fn all_edges(&self) -> Vec<&GraphEdge> {
1030 self.graph
1031 .edge_indices()
1032 .map(|e| &self.graph[e])
1033 .filter(|e| e.is_currently_active())
1034 .collect()
1035 }
1036
1037 pub fn all_edges_including_expired(&self) -> Vec<&GraphEdge> {
1039 self.graph.edge_indices().map(|e| &self.graph[e]).collect()
1040 }
1041
1042 pub fn get_neighbors(&self, start: MemoryId, depth: usize, min_weight: f32) -> Vec<MemoryId> {
1047 self.get_neighbors_filtered(start, depth, min_weight, None)
1048 }
1049
1050 pub fn get_neighbors_filtered(
1053 &self,
1054 start: MemoryId,
1055 depth: usize,
1056 min_weight: f32,
1057 allowed_namespaces: Option<&[Namespace]>,
1058 ) -> Vec<MemoryId> {
1059 let Some(&start_idx) = self.id_to_node.get(&start) else {
1060 return Vec::new();
1061 };
1062
1063 let mut visited = HashSet::new();
1064 visited.insert(start_idx);
1065 let mut queue = VecDeque::new();
1066 queue.push_back((start_idx, 0));
1067 let mut result = Vec::new();
1068
1069 while let Some((node, d)) = queue.pop_front() {
1070 if d >= depth {
1071 continue;
1072 }
1073 for edge in self.graph.edges_directed(node, Direction::Outgoing) {
1074 if !edge.weight().is_currently_active() {
1076 continue;
1077 }
1078 if edge.weight().weight < min_weight {
1079 continue;
1080 }
1081 let neighbor = edge.target();
1082 if let Some(allowed) = allowed_namespaces {
1084 let ns = &self.graph[neighbor].namespace;
1085 if !allowed.contains(ns) {
1086 continue;
1087 }
1088 }
1089 if visited.insert(neighbor) {
1090 result.push(self.graph[neighbor].id);
1091 queue.push_back((neighbor, d + 1));
1092 }
1093 }
1094 }
1095
1096 result
1097 }
1098
1099 pub fn shortest_path(&self, source: MemoryId, target: MemoryId) -> Option<Vec<MemoryId>> {
1102 let (&src_idx, &tgt_idx) = (self.id_to_node.get(&source)?, self.id_to_node.get(&target)?);
1103 if src_idx == tgt_idx {
1104 return Some(vec![source]);
1105 }
1106
1107 let mut visited = HashSet::new();
1108 visited.insert(src_idx);
1109 let mut queue = VecDeque::new();
1110 queue.push_back(src_idx);
1111 let mut parent: HashMap<NodeIndex, NodeIndex> = HashMap::new();
1112
1113 while let Some(node) = queue.pop_front() {
1114 for neighbor in self.graph.neighbors_directed(node, Direction::Outgoing) {
1115 if visited.insert(neighbor) {
1116 parent.insert(neighbor, node);
1117 if neighbor == tgt_idx {
1118 let mut path = vec![target];
1120 let mut cur = tgt_idx;
1121 while let Some(&p) = parent.get(&cur) {
1122 path.push(self.graph[p].id);
1123 cur = p;
1124 }
1125 path.reverse();
1126 return Some(path);
1127 }
1128 queue.push_back(neighbor);
1129 }
1130 }
1131 }
1132
1133 None
1134 }
1135
1136 pub fn subgraph(&self, node_ids: &[MemoryId]) -> Vec<&GraphEdge> {
1138 let idx_set: HashSet<NodeIndex> = node_ids
1139 .iter()
1140 .filter_map(|id| self.id_to_node.get(id).copied())
1141 .collect();
1142
1143 self.graph
1144 .edge_indices()
1145 .filter_map(|eidx| {
1146 let (src, tgt) = self.graph.edge_endpoints(eidx)?;
1147 if idx_set.contains(&src) && idx_set.contains(&tgt) {
1148 Some(&self.graph[eidx])
1149 } else {
1150 None
1151 }
1152 })
1153 .collect()
1154 }
1155
1156 pub fn outgoing_weighted(&self, node_id: MemoryId) -> Vec<(MemoryId, f32, EdgeRelation)> {
1158 let Some(&idx) = self.id_to_node.get(&node_id) else {
1159 return Vec::new();
1160 };
1161 self.graph
1162 .edges_directed(idx, Direction::Outgoing)
1163 .map(|e| {
1164 let w = e.weight();
1165 (self.graph[e.target()].id, w.weight, w.relation)
1166 })
1167 .collect()
1168 }
1169
1170 pub fn outgoing_weighted_iter(
1176 &self,
1177 idx: NodeIndex,
1178 ) -> impl Iterator<Item = (NodeIndex, f32, &EdgeRelation)> {
1179 self.graph
1180 .edges_directed(idx, Direction::Outgoing)
1181 .map(|e| (e.target(), e.weight().weight, &e.weight().relation))
1182 }
1183
1184 pub fn incoming_weighted_iter(
1187 &self,
1188 idx: NodeIndex,
1189 ) -> impl Iterator<Item = (NodeIndex, f32, &EdgeRelation)> {
1190 self.graph
1191 .edges_directed(idx, Direction::Incoming)
1192 .map(|e| (e.source(), e.weight().weight, &e.weight().relation))
1193 }
1194
1195 #[must_use]
1197 pub fn node_index(&self, id: MemoryId) -> Option<NodeIndex> {
1198 self.id_to_node.get(&id).copied()
1199 }
1200
1201 #[must_use]
1203 pub fn node_id(&self, idx: NodeIndex) -> Option<MemoryId> {
1204 self.graph.node_weight(idx).map(|n| n.id)
1205 }
1206
1207 pub fn node_ids(&self) -> Vec<MemoryId> {
1209 self.id_to_node.keys().copied().collect()
1210 }
1211
1212 pub fn node_importance(&self, id: MemoryId) -> Option<f32> {
1214 self.id_to_node
1215 .get(&id)
1216 .map(|&idx| self.graph[idx].importance)
1217 }
1218
1219 pub fn set_node_importance(&mut self, id: MemoryId, importance: f32) {
1221 if let Some(&idx) = self.id_to_node.get(&id) {
1222 self.graph[idx].importance = importance;
1223 }
1224 }
1225
1226 pub fn node_layer(&self, id: MemoryId) -> Option<Layer> {
1228 self.id_to_node.get(&id).map(|&idx| self.graph[idx].layer)
1229 }
1230
1231 pub fn node_namespace(&self, id: MemoryId) -> Option<&Namespace> {
1233 self.id_to_node
1234 .get(&id)
1235 .map(|&idx| &self.graph[idx].namespace)
1236 }
1237
1238 pub fn namespaces_compatible(&self, a: MemoryId, b: MemoryId) -> bool {
1242 let Some(ns_a) = self.node_namespace(a) else {
1243 return false;
1244 };
1245 let Some(ns_b) = self.node_namespace(b) else {
1246 return false;
1247 };
1248 let shared = Namespace::shared();
1249 ns_a == ns_b || *ns_a == shared || *ns_b == shared
1250 }
1251}
1252
1253pub struct ConnectBuilder<'a> {
1257 pub graph: &'a mut PropertyGraph,
1258 pub source: MemoryId,
1259 pub target: MemoryId,
1260 pub relation: EdgeRelation,
1261 pub weight: f32,
1262 pub metadata: Metadata,
1263}
1264
1265impl ConnectBuilder<'_> {
1266 #[must_use]
1268 pub const fn relation(mut self, relation: EdgeRelation) -> Self {
1269 self.relation = relation;
1270 self
1271 }
1272
1273 #[must_use]
1275 pub const fn weight(mut self, w: f32) -> Self {
1276 self.weight = w;
1277 self
1278 }
1279
1280 #[must_use]
1282 pub fn metadata_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1283 self.metadata
1284 .insert(key.into(), MetadataValue::String(value.into()));
1285 self
1286 }
1287
1288 pub fn commit(self) -> HirnResult<EdgeId> {
1290 self.graph.add_edge(
1291 self.source,
1292 self.target,
1293 self.relation,
1294 self.weight,
1295 self.metadata,
1296 )
1297 }
1298}
1299
1300#[cfg(test)]
1303mod tests {
1304 use super::*;
1305 use hirn_core::id::MemoryId;
1306
1307 fn make_node(pg: &mut PropertyGraph) -> MemoryId {
1308 let id = MemoryId::new();
1309 pg.add_node(id, Layer::Episodic, 0.5, Timestamp::now());
1310 id
1311 }
1312
1313 #[test]
1314 fn add_node_get_empty_neighbors() {
1315 let mut pg = PropertyGraph::new();
1316 let a = make_node(&mut pg);
1317 assert!(pg.get_neighbors(a, 1, 0.0).is_empty());
1318 }
1319
1320 #[test]
1321 fn add_edge_directed() {
1322 let mut pg = PropertyGraph::new();
1323 let a = make_node(&mut pg);
1324 let b = make_node(&mut pg);
1325 pg.add_edge(a, b, EdgeRelation::Causes, 0.8, Metadata::new())
1326 .unwrap();
1327
1328 assert!(pg.get_neighbors(a, 1, 0.0).contains(&b));
1329 assert!(!pg.get_neighbors(b, 1, 0.0).contains(&a));
1331 }
1332
1333 #[test]
1334 fn add_bidirectional_edge() {
1335 let mut pg = PropertyGraph::new();
1336 let a = make_node(&mut pg);
1337 let b = make_node(&mut pg);
1338 pg.add_edge(a, b, EdgeRelation::RelatedTo, 0.5, Metadata::new())
1339 .unwrap();
1340
1341 assert!(pg.get_neighbors(a, 1, 0.0).contains(&b));
1342 assert!(pg.get_neighbors(b, 1, 0.0).contains(&a));
1343 }
1344
1345 #[test]
1346 fn contradicts_is_bidirectional() {
1347 let mut pg = PropertyGraph::new();
1348 let a = make_node(&mut pg);
1349 let b = make_node(&mut pg);
1350 pg.add_edge(a, b, EdgeRelation::Contradicts, 0.5, Metadata::new())
1351 .unwrap();
1352 assert!(pg.get_neighbors(a, 1, 0.0).contains(&b));
1353 assert!(pg.get_neighbors(b, 1, 0.0).contains(&a));
1354 }
1355
1356 #[test]
1357 fn remove_node_removes_edges() {
1358 let mut pg = PropertyGraph::new();
1359 let a = make_node(&mut pg);
1360 let b = make_node(&mut pg);
1361 let c = make_node(&mut pg);
1362 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1363 .unwrap();
1364 pg.add_edge(b, c, EdgeRelation::Causes, 0.5, Metadata::new())
1365 .unwrap();
1366
1367 assert_eq!(pg.edge_count(), 2);
1368 pg.remove_node(b);
1369 assert_eq!(pg.node_count(), 2);
1370 assert_eq!(pg.edge_count(), 0);
1371 }
1372
1373 #[test]
1374 fn remove_edge_keeps_nodes() {
1375 let mut pg = PropertyGraph::new();
1376 let a = make_node(&mut pg);
1377 let b = make_node(&mut pg);
1378 let eid = pg
1379 .add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1380 .unwrap();
1381
1382 pg.remove_edge(eid).unwrap();
1383 assert!(pg.has_node(a));
1384 assert!(pg.has_node(b));
1385 assert_eq!(pg.edge_count(), 0);
1386 }
1387
1388 #[test]
1389 fn weight_clamped() {
1390 let mut pg = PropertyGraph::new();
1391 let a = make_node(&mut pg);
1392 let b = make_node(&mut pg);
1393 pg.add_edge(a, b, EdgeRelation::Causes, 5.0, Metadata::new())
1394 .unwrap();
1395 let edges = pg.get_edges(a);
1396 #[allow(clippy::float_cmp)]
1397 {
1398 assert_eq!(edges[0].weight, 1.0);
1399 }
1400 }
1401
1402 #[test]
1403 fn default_weight() {
1404 let mut pg = PropertyGraph::new();
1405 let a = make_node(&mut pg);
1406 let b = make_node(&mut pg);
1407 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1408 .unwrap();
1409 let edges = pg.get_edges(a);
1410 assert!((edges[0].weight - 0.5).abs() < f32::EPSILON);
1411 }
1412
1413 #[test]
1414 fn edge_metadata() {
1415 let mut pg = PropertyGraph::new();
1416 let a = make_node(&mut pg);
1417 let b = make_node(&mut pg);
1418 let mut meta = Metadata::new();
1419 meta.insert("reason".into(), MetadataValue::String("test".into()));
1420 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, meta).unwrap();
1421
1422 let edges = pg.get_edges(a);
1423 assert_eq!(
1424 edges[0].metadata.get("reason"),
1425 Some(&MetadataValue::String("test".into()))
1426 );
1427 }
1428
1429 #[test]
1430 fn oversized_edge_metadata_rejected() {
1431 let mut pg = PropertyGraph::new();
1432 let a = make_node(&mut pg);
1433 let b = make_node(&mut pg);
1434 let mut meta = Metadata::new();
1435 meta.insert(
1436 "payload".into(),
1437 MetadataValue::String("x".repeat(MAX_EDGE_METADATA_BYTES + 64)),
1438 );
1439
1440 let err = pg
1441 .add_edge(a, b, EdgeRelation::Causes, 0.5, meta)
1442 .unwrap_err();
1443 assert!(matches!(err, HirnError::InvalidInput(_)));
1444 assert!(err.to_string().contains("edge metadata exceeds"));
1445 }
1446
1447 #[test]
1448 fn all_edge_types_serde() {
1449 let mut pg = PropertyGraph::new();
1450 let a = make_node(&mut pg);
1451 let b = make_node(&mut pg);
1452 for rel in [
1453 EdgeRelation::RelatedTo,
1454 EdgeRelation::Causes,
1455 EdgeRelation::CausedBy,
1456 EdgeRelation::DerivedFrom,
1457 EdgeRelation::Contradicts,
1458 EdgeRelation::Supports,
1459 EdgeRelation::TemporalNext,
1460 EdgeRelation::PartOf,
1461 EdgeRelation::InstanceOf,
1462 EdgeRelation::SimilarTo,
1463 EdgeRelation::Inhibits,
1464 EdgeRelation::ParticipatesIn,
1465 ] {
1466 let edge_ids: Vec<_> = pg.all_edges().iter().map(|e| e.id).collect();
1468 for eid in edge_ids {
1469 let _ = pg.remove_edge(eid);
1470 }
1471 pg.add_edge(a, b, rel, 0.5, Metadata::new()).unwrap();
1472 let snap = pg.snapshot();
1473 let bytes = bincode::serialize(&snap).unwrap();
1474 let back: GraphSnapshot = bincode::deserialize(&bytes).unwrap();
1475 assert_eq!(back.edges.last().unwrap().relation, rel);
1476 }
1477 }
1478
1479 #[test]
1480 fn duplicate_edge_error() {
1481 let mut pg = PropertyGraph::new();
1482 let a = make_node(&mut pg);
1483 let b = make_node(&mut pg);
1484 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1485 .unwrap();
1486 let err = pg
1487 .add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1488 .unwrap_err();
1489 assert!(matches!(err, HirnError::AlreadyExists(_)));
1490 }
1491
1492 #[test]
1493 fn persistence_round_trip() {
1494 let mut pg = PropertyGraph::new();
1495 let ids: Vec<MemoryId> = (0..100).map(|_| make_node(&mut pg)).collect();
1496
1497 let mut edge_count = 0;
1499 for i in 0..100 {
1500 let j = (i + 1) % 100;
1501 pg.add_edge(ids[i], ids[j], EdgeRelation::Causes, 0.5, Metadata::new())
1502 .unwrap();
1503 edge_count += 1;
1504 let k = (i + 50) % 100;
1505 if k != i {
1506 pg.add_edge(ids[i], ids[k], EdgeRelation::Supports, 0.3, Metadata::new())
1507 .unwrap();
1508 edge_count += 1;
1509 }
1510 }
1511
1512 let snap = pg.snapshot();
1513 let bytes = bincode::serialize(&snap).unwrap();
1514 let back: GraphSnapshot = bincode::deserialize(&bytes).unwrap();
1515 let pg2 = PropertyGraph::from_snapshot_with_config(back, 500_000);
1516
1517 assert_eq!(pg2.node_count(), 100);
1518 assert_eq!(pg2.edge_count(), edge_count);
1519 }
1520
1521 #[test]
1522 fn linear_graph_depth_traversal() {
1523 let mut pg = PropertyGraph::new();
1524 let a = make_node(&mut pg);
1525 let b = make_node(&mut pg);
1526 let c = make_node(&mut pg);
1527 let d = make_node(&mut pg);
1528 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1529 .unwrap();
1530 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1531 .unwrap();
1532 pg.add_edge(c, d, EdgeRelation::Causes, 1.0, Metadata::new())
1533 .unwrap();
1534
1535 let n1 = pg.get_neighbors(a, 1, 0.0);
1536 assert_eq!(n1, vec![b]);
1537
1538 let n2 = pg.get_neighbors(a, 2, 0.0);
1539 assert_eq!(n2.len(), 2);
1540 assert!(n2.contains(&b) && n2.contains(&c));
1541
1542 let n3 = pg.get_neighbors(a, 3, 0.0);
1543 assert_eq!(n3.len(), 3);
1544 assert!(n3.contains(&b) && n3.contains(&c) && n3.contains(&d));
1545 }
1546
1547 #[test]
1548 fn star_graph_neighbors() {
1549 let mut pg = PropertyGraph::new();
1550 let center = make_node(&mut pg);
1551 for _ in 0..10 {
1552 let s = make_node(&mut pg);
1553 pg.add_edge(center, s, EdgeRelation::Causes, 1.0, Metadata::new())
1554 .unwrap();
1555 }
1556 let neighbors = pg.get_neighbors(center, 1, 0.0);
1557 assert_eq!(neighbors.len(), 10);
1558 }
1559
1560 #[test]
1561 fn min_weight_filter() {
1562 let mut pg = PropertyGraph::new();
1563 let a = make_node(&mut pg);
1564 let b = make_node(&mut pg);
1565 let c = make_node(&mut pg);
1566 pg.add_edge(a, b, EdgeRelation::Causes, 0.3, Metadata::new())
1567 .unwrap();
1568 pg.add_edge(a, c, EdgeRelation::Causes, 0.8, Metadata::new())
1569 .unwrap();
1570
1571 let neighbors = pg.get_neighbors(a, 1, 0.5);
1572 assert_eq!(neighbors, vec![c]);
1573 }
1574
1575 #[test]
1576 fn shortest_path_diamond() {
1577 let mut pg = PropertyGraph::new();
1578 let a = make_node(&mut pg);
1579 let b = make_node(&mut pg);
1580 let c = make_node(&mut pg);
1581 let d = make_node(&mut pg);
1582 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1584 .unwrap();
1585 pg.add_edge(a, c, EdgeRelation::Causes, 1.0, Metadata::new())
1586 .unwrap();
1587 pg.add_edge(b, d, EdgeRelation::Causes, 1.0, Metadata::new())
1588 .unwrap();
1589 pg.add_edge(c, d, EdgeRelation::Causes, 1.0, Metadata::new())
1590 .unwrap();
1591
1592 let path = pg.shortest_path(a, d).unwrap();
1593 assert_eq!(path.len(), 3); assert_eq!(path[0], a);
1595 assert_eq!(*path.last().unwrap(), d);
1596 }
1597
1598 #[test]
1599 fn subgraph_preserves_internal_edges() {
1600 let mut pg = PropertyGraph::new();
1601 let a = make_node(&mut pg);
1602 let b = make_node(&mut pg);
1603 let c = make_node(&mut pg);
1604 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1605 .unwrap();
1606 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1607 .unwrap();
1608
1609 let sub = pg.subgraph(&[a, b]);
1611 assert_eq!(sub.len(), 1);
1612 assert_eq!(sub[0].source, a);
1613 assert_eq!(sub[0].target, b);
1614 }
1615
1616 #[test]
1617 fn disconnected_node_empty_neighbors() {
1618 let mut pg = PropertyGraph::new();
1619 let a = make_node(&mut pg);
1620 let _b = make_node(&mut pg);
1621 assert!(pg.get_neighbors(a, 1, 0.0).is_empty());
1622 }
1623
1624 #[test]
1625 fn cyclic_graph_terminates() {
1626 let mut pg = PropertyGraph::new();
1627 let a = make_node(&mut pg);
1628 let b = make_node(&mut pg);
1629 let c = make_node(&mut pg);
1630 pg.add_edge(a, b, EdgeRelation::Causes, 1.0, Metadata::new())
1631 .unwrap();
1632 pg.add_edge(b, c, EdgeRelation::Causes, 1.0, Metadata::new())
1633 .unwrap();
1634 pg.add_edge(c, a, EdgeRelation::Causes, 1.0, Metadata::new())
1635 .unwrap();
1636
1637 let neighbors = pg.get_neighbors(a, 10, 0.0);
1638 assert_eq!(neighbors.len(), 2); }
1640
1641 #[test]
1642 fn graph_operations_10k_nodes_50k_edges() {
1643 let mut pg = PropertyGraph::new();
1644 let ids: Vec<MemoryId> = (0..10_000).map(|_| make_node(&mut pg)).collect();
1645
1646 for i in 0..10_000 {
1648 for offset in [1, 2, 3, 7, 13] {
1649 let j = (i + offset) % 10_000;
1650 let _ = pg.add_edge(ids[i], ids[j], EdgeRelation::Causes, 0.5, Metadata::new());
1651 }
1652 }
1653
1654 assert_eq!(pg.node_count(), 10_000);
1655 assert!(pg.edge_count() >= 40_000); let neighbors = pg.get_neighbors(ids[0], 1, 0.0);
1659 assert!(!neighbors.is_empty());
1660 }
1661
1662 #[test]
1663 fn edges_of_type() {
1664 let mut pg = PropertyGraph::new();
1665 let a = make_node(&mut pg);
1666 let b = make_node(&mut pg);
1667 let c = make_node(&mut pg);
1668 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1669 .unwrap();
1670 pg.add_edge(a, c, EdgeRelation::Supports, 0.5, Metadata::new())
1671 .unwrap();
1672
1673 let causes = pg.get_edges_of_type(a, EdgeRelation::Causes);
1674 assert_eq!(causes.len(), 1);
1675 assert_eq!(causes[0].target, b);
1676 }
1677
1678 #[test]
1679 fn edges_between() {
1680 let mut pg = PropertyGraph::new();
1681 let a = make_node(&mut pg);
1682 let b = make_node(&mut pg);
1683 pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1684 .unwrap();
1685 pg.add_edge(a, b, EdgeRelation::Supports, 0.3, Metadata::new())
1686 .unwrap();
1687
1688 let edges = pg.get_edges_between(a, b);
1689 assert_eq!(edges.len(), 2);
1690 }
1691
1692 #[test]
1693 fn outgoing_weighted_iter_mixed_edges() {
1694 let mut pg = PropertyGraph::new();
1695 let a = make_node(&mut pg);
1696 let b = make_node(&mut pg);
1697 let c = make_node(&mut pg);
1698 let d = make_node(&mut pg);
1699 let e = make_node(&mut pg);
1700 pg.add_edge(a, b, EdgeRelation::Causes, 0.8, Metadata::new())
1701 .unwrap();
1702 pg.add_edge(a, c, EdgeRelation::Supports, 0.6, Metadata::new())
1703 .unwrap();
1704 pg.add_edge(a, d, EdgeRelation::RelatedTo, 0.4, Metadata::new())
1705 .unwrap();
1706 pg.add_edge(a, e, EdgeRelation::Contradicts, 0.9, Metadata::new())
1707 .unwrap();
1708
1709 let a_idx = pg.node_index(a).unwrap();
1710 let results: Vec<_> = pg.outgoing_weighted_iter(a_idx).collect();
1711 assert_eq!(results.len(), 4);
1712
1713 let b_idx = pg.node_index(b).unwrap();
1715 let c_idx = pg.node_index(c).unwrap();
1716 let d_idx = pg.node_index(d).unwrap();
1717 let e_idx = pg.node_index(e).unwrap();
1718
1719 assert!(results.iter().any(|&(t, w, r)| t == b_idx
1720 && (w - 0.8).abs() < f32::EPSILON
1721 && *r == EdgeRelation::Causes));
1722 assert!(results.iter().any(|&(t, w, r)| t == c_idx
1723 && (w - 0.6).abs() < f32::EPSILON
1724 && *r == EdgeRelation::Supports));
1725 assert!(results.iter().any(|&(t, w, r)| t == d_idx
1726 && (w - 0.4).abs() < f32::EPSILON
1727 && *r == EdgeRelation::RelatedTo));
1728 assert!(results.iter().any(|&(t, w, r)| t == e_idx
1729 && (w - 0.9).abs() < f32::EPSILON
1730 && *r == EdgeRelation::Contradicts));
1731 }
1732
1733 #[test]
1734 fn outgoing_weighted_iter_empty_node() {
1735 let mut pg = PropertyGraph::new();
1736 let a = make_node(&mut pg);
1737 let a_idx = pg.node_index(a).unwrap();
1738 let results: Vec<_> = pg.outgoing_weighted_iter(a_idx).collect();
1739 assert!(results.is_empty());
1740 }
1741
1742 #[test]
1743 fn outgoing_weighted_iter_bidirectional_both_directions() {
1744 let mut pg = PropertyGraph::new();
1745 let a = make_node(&mut pg);
1746 let b = make_node(&mut pg);
1747 pg.add_edge(a, b, EdgeRelation::RelatedTo, 0.5, Metadata::new())
1749 .unwrap();
1750
1751 let a_idx = pg.node_index(a).unwrap();
1752 let b_idx = pg.node_index(b).unwrap();
1753
1754 let a_out: Vec<_> = pg.outgoing_weighted_iter(a_idx).collect();
1756 assert_eq!(a_out.len(), 1);
1757 assert_eq!(a_out[0].0, b_idx);
1758
1759 let b_out: Vec<_> = pg.outgoing_weighted_iter(b_idx).collect();
1761 assert_eq!(b_out.len(), 1);
1762 assert_eq!(b_out[0].0, a_idx);
1763 }
1764
1765 #[test]
1766 fn node_index_and_node_id_round_trip() {
1767 let mut pg = PropertyGraph::new();
1768 let a = make_node(&mut pg);
1769 let idx = pg.node_index(a).unwrap();
1770 assert_eq!(pg.node_id(idx), Some(a));
1771 }
1772
1773 #[test]
1774 fn edges_for_nodes_batch() {
1775 let mut pg = PropertyGraph::new();
1776 let a = make_node(&mut pg);
1777 let b = make_node(&mut pg);
1778 let c = make_node(&mut pg);
1779 let d = make_node(&mut pg); pg.add_edge(a, b, EdgeRelation::Causes, 0.5, Metadata::new())
1782 .unwrap();
1783 pg.add_edge(b, c, EdgeRelation::SimilarTo, 0.7, Metadata::new())
1784 .unwrap();
1785
1786 let result = pg.edges_for_nodes(&[a, b, d]);
1787 assert_eq!(result.get(&a).map(|v| v.len()), Some(1));
1789 assert!(result.get(&b).map(|v| v.len()).unwrap_or(0) >= 2);
1791 assert!(!result.contains_key(&d));
1793 }
1794
1795 #[test]
1796 fn edges_for_nodes_empty_input() {
1797 let pg = PropertyGraph::new();
1798 let result = pg.edges_for_nodes(&[]);
1799 assert!(result.is_empty());
1800 }
1801
1802 #[test]
1805 fn node_eviction_when_max_node_count_reached() {
1806 let mut pg = PropertyGraph::with_max_nodes(3);
1808 let a = MemoryId::new();
1809 let b = MemoryId::new();
1810 let c = MemoryId::new();
1811 let d = MemoryId::new();
1812
1813 pg.add_node(a, Layer::Episodic, 0.5, Timestamp::now());
1814 pg.add_node(b, Layer::Episodic, 0.5, Timestamp::now());
1815 pg.add_node(c, Layer::Episodic, 0.5, Timestamp::now());
1816
1817 pg.record_access(b);
1819 pg.record_access(b);
1820 pg.record_access(c);
1821
1822 assert_eq!(pg.node_count(), 3);
1823
1824 let added = pg.add_node(d, Layer::Episodic, 0.5, Timestamp::now());
1826 assert!(added, "node d should have been added after eviction");
1827 assert_eq!(pg.node_count(), 3);
1828 assert!(
1829 !pg.has_node(a),
1830 "a should have been evicted (least-accessed)"
1831 );
1832 assert!(pg.has_node(b));
1833 assert!(pg.has_node(c));
1834 assert!(pg.has_node(d));
1835 }
1836
1837 #[test]
1838 fn edge_eviction_when_max_edges_per_node_reached() {
1839 let mut pg = PropertyGraph::new();
1840 let center = MemoryId::new();
1841 pg.add_node(center, Layer::Episodic, 0.5, Timestamp::now());
1842
1843 let mut targets = Vec::new();
1846 for i in 0..(MAX_EDGES_PER_NODE / 2) {
1847 let t = MemoryId::new();
1848 pg.add_node(t, Layer::Episodic, 0.5, Timestamp::now());
1849 let w = (i as f32 + 1.0) / MAX_EDGES_PER_NODE as f32;
1850 pg.add_edge(center, t, EdgeRelation::RelatedTo, w, Metadata::new())
1851 .unwrap();
1852 targets.push(t);
1853 }
1854
1855 assert_eq!(pg.get_edges(center).len(), MAX_EDGES_PER_NODE);
1856 let evicted_target = targets[0];
1857
1858 let extra = MemoryId::new();
1860 pg.add_node(extra, Layer::Episodic, 0.5, Timestamp::now());
1861 let result = pg.add_edge(
1862 center,
1863 extra,
1864 EdgeRelation::RelatedTo,
1865 0.99,
1866 Metadata::new(),
1867 );
1868 assert!(result.is_ok(), "should succeed via eviction, not error");
1869
1870 assert_eq!(pg.get_edges(center).len(), MAX_EDGES_PER_NODE);
1871 assert!(pg.get_edges_between(center, evicted_target).is_empty());
1872 assert!(pg.get_edges(evicted_target).is_empty());
1873 assert_eq!(pg.get_edges_between(center, extra).len(), 2);
1874
1875 for edge in pg
1876 .all_edges()
1877 .into_iter()
1878 .filter(|edge| edge.relation.is_bidirectional() && edge.source != edge.target)
1879 {
1880 assert!(
1881 pg.get_edges_between(edge.target, edge.source)
1882 .iter()
1883 .any(|reverse| {
1884 #[allow(clippy::suspicious_operation_groupings)]
1887 {
1888 reverse.relation == edge.relation
1889 && reverse.source == edge.target
1890 && reverse.target == edge.source
1891 }
1892 })
1893 );
1894 }
1895 }
1896
1897 #[test]
1898 fn incoming_edge_addition_respects_target_capacity() {
1899 let mut pg = PropertyGraph::new();
1900 let center = MemoryId::new();
1901 pg.add_node(center, Layer::Episodic, 0.5, Timestamp::now());
1902
1903 let mut sources = Vec::new();
1904 for i in 0..MAX_EDGES_PER_NODE {
1905 let source = MemoryId::new();
1906 pg.add_node(source, Layer::Episodic, 0.5, Timestamp::now());
1907 let w = (i as f32 + 1.0) / MAX_EDGES_PER_NODE as f32;
1908 pg.add_edge(source, center, EdgeRelation::Causes, w, Metadata::new())
1909 .unwrap();
1910 sources.push(source);
1911 }
1912
1913 assert_eq!(pg.get_edges(center).len(), MAX_EDGES_PER_NODE);
1914 let evicted_source = sources[0];
1915
1916 let extra_source = MemoryId::new();
1917 pg.add_node(extra_source, Layer::Episodic, 0.5, Timestamp::now());
1918 pg.add_edge(
1919 extra_source,
1920 center,
1921 EdgeRelation::Causes,
1922 0.99,
1923 Metadata::new(),
1924 )
1925 .unwrap();
1926
1927 assert_eq!(pg.get_edges(center).len(), MAX_EDGES_PER_NODE);
1928 assert!(pg.get_edges_between(evicted_source, center).is_empty());
1929 assert_eq!(pg.get_edges_between(extra_source, center).len(), 1);
1930 }
1931
1932 #[test]
1933 fn access_tracking_works() {
1934 let mut pg = PropertyGraph::new();
1935 let a = MemoryId::new();
1936 pg.add_node(a, Layer::Episodic, 0.5, Timestamp::now());
1937
1938 assert_eq!(pg.access_count(a), 0);
1939 pg.record_access(a);
1940 assert_eq!(pg.access_count(a), 1);
1941 pg.record_access(a);
1942 pg.record_access(a);
1943 assert_eq!(pg.access_count(a), 3);
1944 }
1945}