Skip to main content

oxios_kernel/memory/
graph.rs

1//! PageRank-based importance scoring for memory entries.
2//!
3//! Models memory entries as nodes in a graph where edges represent
4//! co-access (entries accessed in the same session are linked).
5//! PageRank iteration propagates importance through the graph.
6
7use std::collections::HashMap;
8
9/// A memory link graph for computing PageRank-style importance.
10///
11/// Nodes are memory entry IDs (mapped to u64 indices internally).
12/// Edges represent co-access relationships.
13#[derive(Debug, Clone, Default)]
14pub struct MemoryGraph {
15    /// Adjacency list: node -> outgoing edges (neighbors).
16    edges: HashMap<u64, Vec<u64>>,
17    /// Reverse mapping: node -> incoming edges.
18    incoming: HashMap<u64, Vec<u64>>,
19    /// Number of nodes.
20    node_count: usize,
21}
22
23impl MemoryGraph {
24    /// Create an empty graph.
25    pub fn new() -> Self {
26        Self::default()
27    }
28
29    /// Add a directed edge from `from` to `to`.
30    ///
31    /// If the edge already exists, this is a no-op.
32    pub fn add_edge(&mut self, from: u64, to: u64) {
33        if from == to {
34            return; // no self-loops
35        }
36        self.edges.entry(from).or_default();
37        self.edges.entry(to).or_default();
38        self.incoming.entry(from).or_default();
39        self.incoming.entry(to).or_default();
40
41        let neighbors = self.edges.get_mut(&from).unwrap();
42        if !neighbors.contains(&to) {
43            neighbors.push(to);
44            self.incoming.get_mut(&to).unwrap().push(from);
45        }
46
47        self.node_count = self.edges.len();
48    }
49
50    /// Add a bidirectional link between two nodes (co-access).
51    pub fn link(&mut self, a: u64, b: u64) {
52        self.add_edge(a, b);
53        self.add_edge(b, a);
54    }
55
56    /// Get the number of nodes in the graph.
57    pub fn node_count(&self) -> usize {
58        self.node_count
59    }
60
61    /// Get outgoing neighbors of a node.
62    pub fn neighbors(&self, node: u64) -> &[u64] {
63        self.edges.get(&node).map(|v| v.as_slice()).unwrap_or(&[])
64    }
65
66    /// Compute PageRank scores for all nodes.
67    ///
68    /// Uses the standard iterative algorithm with damping factor.
69    ///
70    /// # Arguments
71    /// * `damping` — Damping factor (typically 0.85).
72    /// * `iterations` — Number of iterations (typically 20-50).
73    /// * `initial_scores` — Optional initial scores (e.g., base importance).
74    ///   If provided, the initial PageRank is seeded with these values.
75    ///
76    /// # Returns
77    /// A map of node ID -> PageRank score.
78    pub fn pagerank(
79        &self,
80        damping: f64,
81        iterations: usize,
82        initial_scores: Option<&HashMap<u64, f64>>,
83    ) -> HashMap<u64, f64> {
84        if self.node_count == 0 {
85            return HashMap::new();
86        }
87
88        let n = self.node_count as f64;
89        let base = 1.0 / n;
90
91        // Initialize scores
92        let mut scores: HashMap<u64, f64> = self
93            .edges
94            .keys()
95            .map(|&k| {
96                let init = initial_scores
97                    .and_then(|m| m.get(&k))
98                    .copied()
99                    .unwrap_or(base);
100                (k, init)
101            })
102            .collect();
103
104        // Compute out-degree for each node
105        let out_degree: HashMap<u64, usize> =
106            self.edges.iter().map(|(&k, v)| (k, v.len())).collect();
107
108        // Iterative PageRank
109        for _ in 0..iterations {
110            let mut new_scores = HashMap::with_capacity(self.node_count);
111
112            let sink_sum: f64 = scores
113                .iter()
114                .filter(|(&k, _)| out_degree.get(&k).copied().unwrap_or(0) == 0)
115                .map(|(_, &s)| s)
116                .sum();
117
118            for &node in self.edges.keys() {
119                // Sum of incoming PageRank contributions
120                let incoming_sum: f64 = self
121                    .incoming
122                    .get(&node)
123                    .map(|neighbors| {
124                        neighbors
125                            .iter()
126                            .map(|&src| {
127                                let src_out = out_degree.get(&src).copied().unwrap_or(1) as f64;
128                                scores.get(&src).copied().unwrap_or(0.0) / src_out
129                            })
130                            .sum()
131                    })
132                    .unwrap_or(0.0);
133
134                let rank = (1.0 - damping) / n + damping * (incoming_sum + sink_sum / n);
135                new_scores.insert(node, rank);
136            }
137
138            scores = new_scores;
139        }
140
141        scores
142    }
143
144    /// Compute co-access graph from session access patterns.
145    ///
146    /// Given a list of sessions where each session contains a list of
147    /// memory IDs that were accessed together, build a graph where
148    /// co-accessed memories are linked.
149    pub fn from_co_access(sessions: &[Vec<u64>]) -> Self {
150        let mut graph = Self::new();
151        for session in sessions {
152            // Link all pairs of co-accessed memories
153            for i in 0..session.len() {
154                for j in (i + 1)..session.len() {
155                    graph.link(session[i], session[j]);
156                }
157            }
158        }
159        graph
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Tests
165// ---------------------------------------------------------------------------
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_empty_graph() {
173        let graph = MemoryGraph::new();
174        let scores = graph.pagerank(0.85, 20, None);
175        assert!(scores.is_empty());
176    }
177
178    #[test]
179    fn test_single_node() {
180        let mut graph = MemoryGraph::new();
181        graph.add_edge(1, 1); // self-loop ignored
182        let scores = graph.pagerank(0.85, 20, None);
183        assert!(scores.is_empty() || scores.values().all(|&v| v > 0.0));
184    }
185
186    #[test]
187    fn test_two_nodes() {
188        let mut graph = MemoryGraph::new();
189        graph.link(1, 2);
190
191        let scores = graph.pagerank(0.85, 50, None);
192        assert_eq!(scores.len(), 2);
193
194        // Both nodes should have similar scores (symmetric graph)
195        let s1 = scores.get(&1).unwrap();
196        let s2 = scores.get(&2).unwrap();
197        assert!(
198            (s1 - s2).abs() < 0.01,
199            "Symmetric graph should have equal scores"
200        );
201    }
202
203    #[test]
204    fn test_hub_authority() {
205        // Node 1 links to 2, 3, 4
206        // Node 2, 3, 4 link back to 1
207        // Node 1 is a hub
208        let mut graph = MemoryGraph::new();
209        graph.add_edge(1, 2);
210        graph.add_edge(1, 3);
211        graph.add_edge(1, 4);
212        graph.add_edge(2, 1);
213        graph.add_edge(3, 1);
214        graph.add_edge(4, 1);
215
216        let scores = graph.pagerank(0.85, 50, None);
217        let s1 = scores.get(&1).unwrap();
218
219        // Node 1 should have higher score than any single leaf
220        for &node in &[2u64, 3, 4] {
221            let sn = scores.get(&node).unwrap();
222            assert!(*s1 >= *sn, "Hub node should have >= score than leaf");
223        }
224    }
225
226    #[test]
227    fn test_from_co_access() {
228        let sessions = vec![
229            vec![1, 2, 3], // session 1: memories 1, 2, 3 co-accessed
230            vec![2, 4],    // session 2: memories 2, 4 co-accessed
231        ];
232
233        let graph = MemoryGraph::from_co_access(&sessions);
234        assert_eq!(graph.node_count(), 4);
235
236        // Node 2 is in both sessions, should have highest PageRank
237        let scores = graph.pagerank(0.85, 50, None);
238        let s2 = scores.get(&2).unwrap();
239        for &node in &[1u64, 3, 4] {
240            let sn = scores.get(&node).unwrap();
241            assert!(*s2 >= *sn, "Node 2 should have highest score");
242        }
243    }
244
245    #[test]
246    fn test_initial_scores_influence() {
247        // Asymmetric graph: 1 -> 2 (one-way)
248        let mut graph = MemoryGraph::new();
249        graph.add_edge(1, 2);
250
251        // Give node 1 a much higher initial score
252        let initial = HashMap::from([(1u64, 10.0), (2u64, 0.1)]);
253        let scores = graph.pagerank(0.85, 5, Some(&initial));
254
255        // Node 1 should maintain higher score due to being the only hub
256        let s1 = scores.get(&1).unwrap();
257        let s2 = scores.get(&2).unwrap();
258        // PageRank should propagate some to node 2, but 1 started higher
259        assert!(*s1 > 0.0, "Node 1 should have positive score");
260        assert!(*s2 > 0.0, "Node 2 should have positive score");
261    }
262}