Skip to main content

haystack_core/graph/
adjacency.rs

1// Ref adjacency — bidirectional edge tracking for entity references.
2
3use smallvec::SmallVec;
4use std::collections::HashMap;
5
6/// Bidirectional ref-edge tracking.
7///
8/// Maintains forward edges (entity -> targets) and reverse edges
9/// (target -> sources) for efficient traversal in both directions.
10pub struct RefAdjacency {
11    /// entity_id -> [(ref_tag, target_ref_val)]
12    forward: HashMap<usize, SmallVec<[(String, String); 4]>>,
13    /// target_ref_val -> [(ref_tag, source_entity_id)]
14    reverse: HashMap<String, SmallVec<[(String, usize); 4]>>,
15}
16
17impl RefAdjacency {
18    /// Create an empty adjacency index.
19    pub fn new() -> Self {
20        Self {
21            forward: HashMap::new(),
22            reverse: HashMap::new(),
23        }
24    }
25
26    /// Record a ref edge from `entity_id` via `ref_tag` to `target_ref_val`.
27    pub fn add(&mut self, entity_id: usize, ref_tag: &str, target_ref_val: &str) {
28        self.forward
29            .entry(entity_id)
30            .or_default()
31            .push((ref_tag.to_string(), target_ref_val.to_string()));
32        self.reverse
33            .entry(target_ref_val.to_string())
34            .or_default()
35            .push((ref_tag.to_string(), entity_id));
36    }
37
38    /// Remove all edges originating from `entity_id`.
39    ///
40    /// Cleans up both forward and reverse indices.
41    pub fn remove(&mut self, entity_id: usize) {
42        if let Some(edges) = self.forward.remove(&entity_id) {
43            for (ref_tag, target) in edges {
44                if let Some(rev) = self.reverse.get_mut(&target) {
45                    rev.retain(|(rt, sid)| !(rt == &ref_tag && *sid == entity_id));
46                    if rev.is_empty() {
47                        self.reverse.remove(&target);
48                    }
49                }
50            }
51        }
52    }
53
54    /// Get target ref values from an entity, optionally filtered by ref type.
55    ///
56    /// If `ref_type` is `None`, returns all targets. Otherwise only targets
57    /// reachable via the specified ref tag (e.g. "siteRef").
58    pub fn targets_from(&self, entity_id: usize, ref_type: Option<&str>) -> Vec<String> {
59        match self.forward.get(&entity_id) {
60            Some(edges) => edges
61                .iter()
62                .filter(|(rt, _)| ref_type.is_none_or(|t| rt == t))
63                .map(|(_, target)| target.clone())
64                .collect(),
65            None => Vec::new(),
66        }
67    }
68
69    /// Get source entity ids that reference `target_ref_val`, optionally
70    /// filtered by ref type.
71    pub fn sources_to(&self, target_ref_val: &str, ref_type: Option<&str>) -> Vec<usize> {
72        match self.reverse.get(target_ref_val) {
73            Some(edges) => edges
74                .iter()
75                .filter(|(rt, _)| ref_type.is_none_or(|t| rt == t))
76                .map(|(_, sid)| *sid)
77                .collect(),
78            None => Vec::new(),
79        }
80    }
81}
82
83impl Default for RefAdjacency {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89impl RefAdjacency {
90    /// Read-only access to the forward edge map (for CSR construction).
91    pub fn forward_raw(&self) -> &HashMap<usize, SmallVec<[(String, String); 4]>> {
92        &self.forward
93    }
94
95    /// Read-only access to the reverse edge map (for CSR construction).
96    pub fn reverse_raw(&self) -> &HashMap<String, SmallVec<[(String, usize); 4]>> {
97        &self.reverse
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn add_forward_and_reverse_edges() {
107        let mut adj = RefAdjacency::new();
108        adj.add(0, "siteRef", "site-1");
109        adj.add(0, "equipRef", "equip-1");
110
111        let targets = adj.targets_from(0, None);
112        assert_eq!(targets.len(), 2);
113        assert!(targets.contains(&"site-1".to_string()));
114        assert!(targets.contains(&"equip-1".to_string()));
115
116        let sources = adj.sources_to("site-1", None);
117        assert_eq!(sources, vec![0]);
118    }
119
120    #[test]
121    fn remove_entity_edges() {
122        let mut adj = RefAdjacency::new();
123        adj.add(0, "siteRef", "site-1");
124        adj.add(1, "siteRef", "site-1");
125
126        adj.remove(0);
127
128        assert!(adj.targets_from(0, None).is_empty());
129        // Entity 1 should still reference site-1.
130        let sources = adj.sources_to("site-1", None);
131        assert_eq!(sources, vec![1]);
132    }
133
134    #[test]
135    fn targets_from_with_type_filter() {
136        let mut adj = RefAdjacency::new();
137        adj.add(0, "siteRef", "site-1");
138        adj.add(0, "equipRef", "equip-1");
139
140        let site_targets = adj.targets_from(0, Some("siteRef"));
141        assert_eq!(site_targets, vec!["site-1".to_string()]);
142
143        let equip_targets = adj.targets_from(0, Some("equipRef"));
144        assert_eq!(equip_targets, vec!["equip-1".to_string()]);
145
146        let none_targets = adj.targets_from(0, Some("spaceRef"));
147        assert!(none_targets.is_empty());
148    }
149
150    #[test]
151    fn targets_from_without_type_filter() {
152        let mut adj = RefAdjacency::new();
153        adj.add(0, "siteRef", "site-1");
154        adj.add(0, "equipRef", "equip-1");
155
156        let all = adj.targets_from(0, None);
157        assert_eq!(all.len(), 2);
158    }
159
160    #[test]
161    fn sources_to_with_type_filter() {
162        let mut adj = RefAdjacency::new();
163        adj.add(0, "siteRef", "site-1");
164        adj.add(1, "equipRef", "site-1");
165
166        let site_sources = adj.sources_to("site-1", Some("siteRef"));
167        assert_eq!(site_sources, vec![0]);
168
169        let equip_sources = adj.sources_to("site-1", Some("equipRef"));
170        assert_eq!(equip_sources, vec![1]);
171    }
172
173    #[test]
174    fn sources_to_without_type_filter() {
175        let mut adj = RefAdjacency::new();
176        adj.add(0, "siteRef", "site-1");
177        adj.add(1, "equipRef", "site-1");
178
179        let all = adj.sources_to("site-1", None);
180        assert_eq!(all.len(), 2);
181    }
182
183    #[test]
184    fn targets_from_nonexistent_entity() {
185        let adj = RefAdjacency::new();
186        assert!(adj.targets_from(999, None).is_empty());
187    }
188
189    #[test]
190    fn sources_to_nonexistent_target() {
191        let adj = RefAdjacency::new();
192        assert!(adj.sources_to("nonexistent", None).is_empty());
193    }
194
195    #[test]
196    fn remove_nonexistent_entity_is_noop() {
197        let mut adj = RefAdjacency::new();
198        // Should not panic.
199        adj.remove(999);
200    }
201
202    #[test]
203    fn remove_cleans_up_reverse_entry() {
204        let mut adj = RefAdjacency::new();
205        adj.add(0, "siteRef", "site-1");
206
207        adj.remove(0);
208
209        // The reverse entry for site-1 should be gone entirely.
210        assert!(adj.sources_to("site-1", None).is_empty());
211    }
212}