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