Skip to main content

hirn_graph/
graph.rs

1//! Property graph store backed by `petgraph`.
2//!
3//! Nodes are memory records (identified by `MemoryId`). Edges carry typed
4//! relations, weights, co-retrieval counts, and metadata.
5
6use 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
20// ── Graph Edge ───────────────────────────────────────────────────────────
21
22/// A unique identifier for a graph edge.
23pub type EdgeId = MemoryId;
24
25/// Data carried only on causal edges — boxed to keep `GraphEdge` small for
26/// the common non-causal case.
27///
28/// Required numeric fields use concrete values rather than `Option` because a
29/// `CausalEdgeData` that exists but has no strength/confidence/count is not
30/// meaningful. Defaults are: `strength = 0.0`, `confidence = 0.5`,
31/// `evidence_count = 0`, `confounders = []`.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct CausalEdgeData {
34    /// Causal effect magnitude `[0.0, 1.0]`.
35    pub strength: f32,
36    /// Certainty in the causal claim `[0.0, 1.0]`.
37    pub confidence: f32,
38    /// Number of observations supporting this edge.
39    pub evidence_count: u32,
40    /// Known confounding variables.
41    pub confounders: Vec<String>,
42    /// Provenance description (free text or JSON reference).
43    pub provenance: Option<String>,
44    /// Described causal mechanism.
45    pub mechanism: Option<String>,
46    /// Causal direction label.
47    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    /// Construct with the three required numeric fields; all optional fields
66    /// are initialised to their defaults.
67    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    /// Builder: set the causal mechanism description.
77    #[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    /// Builder: set the provenance reference.
84    #[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    /// Builder: set the causal direction label.
91    #[must_use]
92    pub fn with_direction(mut self, direction: CausalDirection) -> Self {
93        self.direction = Some(direction);
94        self
95    }
96
97    /// Builder: set known confounding variables.
98    #[must_use]
99    pub fn with_confounders(mut self, confounders: Vec<String>) -> Self {
100        self.confounders = confounders;
101        self
102    }
103}
104
105/// A typed, weighted edge in the property graph.
106#[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    /// Whether this contradiction has been resolved by ABA reconsolidation.
122    #[serde(default)]
123    pub resolved: bool,
124    /// Namespace of the source node (inherited at edge creation).
125    pub namespace: Namespace,
126    /// Causal-specific metadata. `None` for non-causal edges, keeping the
127    /// struct small (16 bytes vs ~128 bytes with 7 inlined optionals).
128    #[serde(default)]
129    pub causal: Option<Box<CausalEdgeData>>,
130}
131
132impl GraphEdge {
133    /// Causal strength, or `None` if this is not a causal edge.
134    #[inline]
135    pub fn strength(&self) -> Option<f32> {
136        self.causal.as_ref().map(|c| c.strength)
137    }
138
139    /// Causal confidence, or `None` if this is not a causal edge.
140    #[inline]
141    pub fn confidence(&self) -> Option<f32> {
142        self.causal.as_ref().map(|c| c.confidence)
143    }
144
145    /// Evidence count, or `None` if this is not a causal edge.
146    #[inline]
147    pub fn evidence_count(&self) -> Option<u32> {
148        self.causal.as_ref().map(|c| c.evidence_count)
149    }
150
151    /// Confounders slice, or `None` if this is not a causal edge.
152    #[inline]
153    pub fn confounders(&self) -> Option<&[String]> {
154        self.causal.as_ref().map(|c| c.confounders.as_slice())
155    }
156
157    /// Provenance, or `None` if this is not a causal edge or provenance is unset.
158    #[inline]
159    pub fn provenance(&self) -> Option<&str> {
160        self.causal.as_ref().and_then(|c| c.provenance.as_deref())
161    }
162
163    /// Mechanism, or `None` if this is not a causal edge or mechanism is unset.
164    #[inline]
165    pub fn mechanism(&self) -> Option<&str> {
166        self.causal.as_ref().and_then(|c| c.mechanism.as_deref())
167    }
168
169    /// Causal direction, or `None` if this is not a causal edge or direction is unset.
170    #[inline]
171    pub fn direction(&self) -> Option<CausalDirection> {
172        self.causal.as_ref().and_then(|c| c.direction)
173    }
174
175    /// Causal relevance score: `strength × confidence × ln(1 + evidence_count)`.
176    ///
177    /// Returns `None` if this edge has no causal metadata. A higher score
178    /// indicates stronger, more certain causal evidence.
179    #[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    /// Whether this edge is valid at the given point in time.
187    ///
188    /// An edge is valid at `as_of` when:
189    /// - `valid_from` is `None` OR `valid_from <= as_of`, AND
190    /// - `valid_until` is `None` OR `valid_until > as_of`
191    #[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    /// Whether this edge is currently active (not yet expired).
204    ///
205    /// Equivalent to `is_valid_at(Timestamp::now())`.
206    #[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/// Causal direction for Rich CausalEdge.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
216pub enum CausalDirection {
217    Forward,
218    Backward,
219    Bidirectional,
220}
221
222/// Serialized form for persisting all edges.
223#[derive(Debug, Serialize, Deserialize)]
224pub struct GraphSnapshot {
225    pub nodes: Vec<GraphNodeData>,
226    pub edges: Vec<GraphEdge>,
227}
228
229/// Minimal node data for persistence.
230#[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    // NOTE: `serde(default)` only helps self-describing formats (JSON).
237    // For bincode (positional), the field must always be present in the byte stream.
238    // This attribute is harmless here because `namespace` is always serialized;
239    // it only provides a fallback for legacy JSON data missing this field.
240    #[serde(default = "Namespace::default")]
241    pub namespace: Namespace,
242    /// Hot-tier access counter, periodically flushed to the cold-tier Lance dataset.
243    /// Defaults to 0 when loading legacy snapshots that predate this field.
244    #[serde(default)]
245    pub access_count: u64,
246}
247
248// ── Node metadata stored in petgraph ────────────────────────────────────
249
250#[derive(Debug, Clone)]
251struct NodeData {
252    id: MemoryId,
253    layer: Layer,
254    importance: f32,
255    created_at: Timestamp,
256    namespace: Namespace,
257    /// Number of times this node has been accessed (for LRU eviction).
258    access_count: u64,
259}
260
261// ── Property Graph ──────────────────────────────────────────────────────
262
263/// Maximum number of edges per node (fan-out cap).
264/// Prevents graph injection attacks where a malicious agent floods a node with edges.
265pub const MAX_EDGES_PER_NODE: usize = 512;
266
267/// Maximum logical metadata payload allowed on a single edge.
268/// Enforced on insert to keep hot-tier graph memory bounded.
269pub 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
303/// In-memory property graph backed by petgraph's directed graph.
304pub struct PropertyGraph {
305    graph: StableDiGraph<NodeData, GraphEdge>,
306    id_to_node: HashMap<MemoryId, NodeIndex>,
307    edge_id_to_idx: HashMap<EdgeId, EdgeIndex>,
308    /// Hard limit on node count (default 500,000). Returns error when exceeded (F-ENG-14).
309    max_node_count: usize,
310    /// Lazy min-heap for O(log N) LRU node eviction (N-H14).
311    /// Entries are `(Reverse(access_count), MemoryId)` — smallest access_count at top.
312    /// Stale entries (where heap's count ≠ node's current count) are skipped on pop.
313    eviction_heap: BinaryHeap<Reverse<(u64, MemoryId)>>,
314    /// Tracks nodes whose `access_count` has changed since the last cold-tier flush.
315    /// Drained by `drain_dirty_access_counts()` and reset to 0 after flush.
316    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    /// Create an empty graph.
327    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    /// Create an empty graph with a custom node capacity limit.
339    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    /// Reconstruct from persisted snapshot, honouring the configured node cap.
351    ///
352    /// `max_node_count` is the cap for the restored graph (the same value used
353    /// when the graph was first created).  Snapshot data that exceeds the cap is
354    /// still loaded in full so that no data is silently dropped; the cap only
355    /// governs future eviction after restoration.
356    pub fn from_snapshot_with_config(snapshot: GraphSnapshot, max_node_count: usize) -> Self {
357        // Allow loading snapshots that are larger than the configured cap so we
358        // never silently lose data on restore, but honour the cap for subsequent
359        // eviction decisions.
360        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            // Add nodes if not present (defensive).
373            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    /// Create a snapshot for persistence.
389    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    // ── Node operations ─────────────────────────────────────────────────
415
416    /// Add a node for a memory record with the default namespace. Returns `true` if newly added.
417    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    /// Add a node with an explicit namespace. Returns `true` if newly added.
428    ///
429    /// Returns an error if the graph has reached its `max_node_count` (F-ENG-14).
430    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        // Hard limit enforcement (F-ENG-14).
443        let node_count = self.id_to_node.len();
444        if node_count >= self.max_node_count {
445            // O(log N) eviction via lazy min-heap (N-H14).
446            // Pop entries until we find a valid, non-self node whose heap
447            // access_count matches the node's current count (i.e., not stale).
448            let mut evict_id = None;
449            while let Some(Reverse((heap_count, candidate))) = self.eviction_heap.pop() {
450                if candidate == id {
451                    continue; // never evict the node we're about to insert
452                }
453                match self.id_to_node.get(&candidate) {
454                    None => continue, // already evicted; stale entry
455                    Some(&idx) => {
456                        if self.graph[idx].access_count != heap_count {
457                            continue; // stale entry; node was accessed since push
458                        }
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        // F-24: Emit warning when graph exceeds capacity thresholds.
482        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        // Seed eviction heap with access_count=0 for the new node.
498        self.eviction_heap.push(Reverse((0, id)));
499        true
500    }
501
502    /// Remove a node and all its edges. Returns the IDs of removed edges.
503    pub fn remove_node(&mut self, id: MemoryId) -> bool {
504        if let Some(idx) = self.id_to_node.remove(&id) {
505            // Remove edge ID mappings for all connected edges.
506            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            // StableDiGraph preserves indices across removals — no rebuild needed.
519            true
520        } else {
521            false
522        }
523    }
524
525    /// Get the edge IDs connected to a node (for incremental persistence cleanup).
526    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    /// Get a reference to an edge by its `EdgeId`.
538    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    /// Whether a node exists.
544    pub fn has_node(&self, id: MemoryId) -> bool {
545        self.id_to_node.contains_key(&id)
546    }
547
548    /// Record an access to a node (bumps the access counter for LRU eviction).
549    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            // Push new entry to lazy heap; stale entry at old count is skipped on eviction.
553            self.eviction_heap
554                .push(Reverse((self.graph[idx].access_count, id)));
555            // Track for periodic cold-tier flush.
556            *self.dirty_access_counts.entry(id).or_insert(0) =
557                self.graph[idx].access_count;
558        }
559    }
560
561    /// Drain the set of nodes with updated `access_count` since the last flush.
562    ///
563    /// Returns `(MemoryId, latest_count)` pairs and clears the dirty set.
564    /// Called by the cold-tier flush path in `CachedGraphStore`.
565    pub fn drain_dirty_access_counts(&mut self) -> Vec<(MemoryId, u64)> {
566        self.dirty_access_counts.drain().collect()
567    }
568
569    /// Get the access count for a node (for monitoring/testing).
570    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    /// Number of nodes.
578    pub fn node_count(&self) -> usize {
579        self.graph.node_count()
580    }
581
582    /// Number of edges.
583    /// Count of currently-active (non-expired) edges.
584    ///
585    /// Consistent with `get_edges()` which also filters by `is_currently_active()`.
586    /// Use `self.graph.edge_count()` directly when a raw physical count (including
587    /// soft-expired edges) is needed for audit or storage sizing purposes.
588    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                // `source == edge.target` and `target == edge.source` is intentional:
626                // we are looking for the reverse edge A←B given edge A→B.
627                #[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    // ── Edge operations ─────────────────────────────────────────────────
694
695    /// Add a directed edge. Returns the edge ID.
696    /// If `relation.is_bidirectional()`, also adds the reverse edge.
697    ///
698    /// Enforces a per-node fan-out cap (`MAX_EDGES_PER_NODE`) to prevent
699    /// graph injection attacks.
700    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    /// Create a causal edge with associated [`CausalEdgeData`].
712    ///
713    /// Identical to [`add_edge`] but populates `causal` on the created edge so
714    /// that strength, confidence, evidence count, and mechanism are stored
715    /// together with the graph topology.  Bidirectional relations (e.g.
716    /// `Contradicts`) automatically get a reverse edge that shares the same
717    /// causal data.
718    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        // Check for duplicate edge (same source, target, relation).
758        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        // Namespace for each edge must be inherited from its own source node
778        // (not the other endpoint) so that PolicyPushdownRule filters are
779        // correct for both directions (N-M14).
780        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            // Edge inherits source node's namespace.
797            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        // Add reverse edge for bidirectional relations.
805        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                // Reverse edge inherits TARGET node's namespace (its conceptual source).
823                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    /// Remove an edge by its ID.
835    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        // StableDiGraph preserves edge indices across removals — no rebuild needed.
842        Ok(())
843    }
844
845    /// Mark all edges incident to `node_id` as expired at `retraction_ts`.
846    ///
847    /// Used when a memory record is retracted: the hot-tier edges remain in the
848    /// graph (for audit / `AS OF` time-travel) but are filtered out of live
849    /// traversal results by `is_currently_active()`.
850    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    /// Get all edges from/to a node.
873    ///
874    /// Returns only currently-active edges (not yet expired via `valid_until`).
875    /// Use [`get_edges_at`] for time-travel queries.
876    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    /// Get all edges from/to a node that were valid at `as_of`.
889    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    /// Batch edge lookup: returns all edges for each of the given node IDs.
902    ///
903    /// O(1) per node (direct petgraph adjacency). Missing IDs are silently
904    /// skipped (no entry in the result map).
905    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    /// Get edges from/to a node, filtered by namespace visibility.
917    /// Only returns currently-active edges where BOTH endpoints are in the allowed namespaces.
918    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    /// Get edges filtered by relation type.
943    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    /// Get edges of a given relation type, filtered by namespace visibility.
951    /// Only returns currently-active edges.
952    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    /// Get edges between two specific nodes (currently-active only).
979    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        // Also include reverse direction.
991        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    /// Get edges between two nodes, only if both are in the allowed namespaces.
1001    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    /// Mutably access an edge by ID.
1020    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    /// Get all currently-active edges (immutable).
1026    ///
1027    /// Expired edges (where `valid_until` has passed) are excluded.
1028    /// Use `all_edges_including_expired()` for audit / time-travel use.
1029    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    /// Get all edges including expired ones (for audit and time-travel queries).
1038    pub fn all_edges_including_expired(&self) -> Vec<&GraphEdge> {
1039        self.graph.edge_indices().map(|e| &self.graph[e]).collect()
1040    }
1041
1042    // ── Graph queries ───────────────────────────────────────────────────
1043
1044    /// BFS traversal from `start` to `depth`, respecting `min_weight`.
1045    /// Returns the set of discovered node IDs (excluding the start node).
1046    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    /// BFS traversal with optional namespace filtering.
1051    /// If `allowed_namespaces` is `Some`, only nodes in allowed namespaces are traversed.
1052    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                // Skip expired edges during BFS traversal.
1075                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                // Namespace boundary enforcement.
1083                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    /// Shortest path (BFS) from source to target. Returns the path as a vec of
1100    /// `MemoryId`s (including source and target), or `None` if no path exists.
1101    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                        // Reconstruct path.
1119                        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    /// Extract a subgraph containing the specified nodes and all edges between them.
1137    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    /// Get outgoing neighbors with their edge weights (used by activation engine).
1157    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    /// Zero-allocation iterator over outgoing edges: `(target NodeIndex, weight, &EdgeRelation)`.
1171    ///
1172    /// Unlike [`outgoing_weighted`](Self::outgoing_weighted), this returns petgraph
1173    /// `NodeIndex` values and borrows the relation instead of copying/collecting.
1174    /// Ideal for spreading activation hot paths.
1175    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    /// Zero-copy iterator over **incoming** edges from `idx`.
1185    /// Returns `(source_idx, weight, &relation)`.
1186    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    /// Resolve a `MemoryId` to its petgraph `NodeIndex`.
1196    #[must_use]
1197    pub fn node_index(&self, id: MemoryId) -> Option<NodeIndex> {
1198        self.id_to_node.get(&id).copied()
1199    }
1200
1201    /// Resolve a petgraph `NodeIndex` back to a `MemoryId`.
1202    #[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    /// Get all node IDs.
1208    pub fn node_ids(&self) -> Vec<MemoryId> {
1209        self.id_to_node.keys().copied().collect()
1210    }
1211
1212    /// Get node importance (cached).
1213    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    /// Update node importance.
1220    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    /// Get node layer.
1227    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    /// Get node namespace (borrowed to avoid cloning in hot paths).
1232    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    /// Check whether two nodes can be connected based on namespace rules.
1239    /// Auto-edges are allowed only within the same namespace or when either
1240    /// node is in the shared namespace.
1241    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
1253// ── Connect builder ─────────────────────────────────────────────────────
1254
1255/// Builder for creating graph edges.
1256pub 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    /// Set the edge relation type.
1267    #[must_use]
1268    pub const fn relation(mut self, relation: EdgeRelation) -> Self {
1269        self.relation = relation;
1270        self
1271    }
1272
1273    /// Set the edge weight (clamped to [0.0, 1.0]).
1274    #[must_use]
1275    pub const fn weight(mut self, w: f32) -> Self {
1276        self.weight = w;
1277        self
1278    }
1279
1280    /// Add a metadata entry.
1281    #[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    /// Create the edge. Returns the edge ID.
1289    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// ── Tests ────────────────────────────────────────────────────────────────
1301
1302#[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        // Causes is directed — B should NOT see A as outgoing neighbor.
1330        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            // Remove all edges first.
1467            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        // Add 200 edges.
1498        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        // A→B→D and A→C→D
1583        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); // A → B/C → D
1594        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        // Subgraph of {A, B} — only A→B edge.
1610        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); // B and C, no infinite loop
1639    }
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        // Add ~5 edges per node.
1647        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); // Some may fail due to duplicates
1656
1657        // Verify operations work quickly (no assertion on timing, just correctness).
1658        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        // Verify all targets present.
1714        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        // RelatedTo is bidirectional — adds edges in both directions.
1748        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        // a → b outgoing.
1755        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        // b → a outgoing (reverse edge auto-created).
1760        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); // isolated
1780
1781        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        // a has 1 outgoing edge (a→b)
1788        assert_eq!(result.get(&a).map(|v| v.len()), Some(1));
1789        // b has edges from both directions (a→b incoming, b→c outgoing, c→b auto-reverse)
1790        assert!(result.get(&b).map(|v| v.len()).unwrap_or(0) >= 2);
1791        // d is isolated — not in the result map
1792        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    // ── Eviction tests (Story 3.2) ─────────────────────────────────────
1803
1804    #[test]
1805    fn node_eviction_when_max_node_count_reached() {
1806        // Graph with max_node_count = 3
1807        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        // Bump access on b and c so a is least-accessed.
1818        pg.record_access(b);
1819        pg.record_access(b);
1820        pg.record_access(c);
1821
1822        assert_eq!(pg.node_count(), 3);
1823
1824        // Adding d should evict a (least-accessed, access_count=0).
1825        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        // Each RelatedTo insert creates two physical edges touching the center.
1844        // Filling MAX_EDGES_PER_NODE / 2 pairs should saturate the center exactly.
1845        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        // Add one more bidirectional edge — should evict the lowest-weight pair.
1859        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                        // source==edge.target and target==edge.source is intentional:
1885                        // we are looking for the reverse edge A←B given edge A→B.
1886                        #[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}