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    /// Get entity IDs that reference `target_ref_val` via `ref_tag`.
101    ///
102    /// Used by the query planner to produce bitmap candidates for ref equality
103    /// filters (e.g. `siteRef == @site-0`) without a full entity scan.
104    pub fn sources_for(&self, ref_tag: &str, target_ref_val: &str) -> Vec<usize> {
105        match self.reverse.get(target_ref_val) {
106            Some(edges) => edges
107                .iter()
108                .filter(|(rt, _)| rt == ref_tag)
109                .map(|(_, eid)| *eid)
110                .collect(),
111            None => Vec::new(),
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn add_forward_and_reverse_edges() {
122        let mut adj = RefAdjacency::new();
123        adj.add(0, "siteRef", "site-1");
124        adj.add(0, "equipRef", "equip-1");
125
126        let targets = adj.targets_from(0, None);
127        assert_eq!(targets.len(), 2);
128        assert!(targets.contains(&"site-1".to_string()));
129        assert!(targets.contains(&"equip-1".to_string()));
130
131        let sources = adj.sources_to("site-1", None);
132        assert_eq!(sources, vec![0]);
133    }
134
135    #[test]
136    fn remove_entity_edges() {
137        let mut adj = RefAdjacency::new();
138        adj.add(0, "siteRef", "site-1");
139        adj.add(1, "siteRef", "site-1");
140
141        adj.remove(0);
142
143        assert!(adj.targets_from(0, None).is_empty());
144        // Entity 1 should still reference site-1.
145        let sources = adj.sources_to("site-1", None);
146        assert_eq!(sources, vec![1]);
147    }
148
149    #[test]
150    fn targets_from_with_type_filter() {
151        let mut adj = RefAdjacency::new();
152        adj.add(0, "siteRef", "site-1");
153        adj.add(0, "equipRef", "equip-1");
154
155        let site_targets = adj.targets_from(0, Some("siteRef"));
156        assert_eq!(site_targets, vec!["site-1".to_string()]);
157
158        let equip_targets = adj.targets_from(0, Some("equipRef"));
159        assert_eq!(equip_targets, vec!["equip-1".to_string()]);
160
161        let none_targets = adj.targets_from(0, Some("spaceRef"));
162        assert!(none_targets.is_empty());
163    }
164
165    #[test]
166    fn targets_from_without_type_filter() {
167        let mut adj = RefAdjacency::new();
168        adj.add(0, "siteRef", "site-1");
169        adj.add(0, "equipRef", "equip-1");
170
171        let all = adj.targets_from(0, None);
172        assert_eq!(all.len(), 2);
173    }
174
175    #[test]
176    fn sources_to_with_type_filter() {
177        let mut adj = RefAdjacency::new();
178        adj.add(0, "siteRef", "site-1");
179        adj.add(1, "equipRef", "site-1");
180
181        let site_sources = adj.sources_to("site-1", Some("siteRef"));
182        assert_eq!(site_sources, vec![0]);
183
184        let equip_sources = adj.sources_to("site-1", Some("equipRef"));
185        assert_eq!(equip_sources, vec![1]);
186    }
187
188    #[test]
189    fn sources_to_without_type_filter() {
190        let mut adj = RefAdjacency::new();
191        adj.add(0, "siteRef", "site-1");
192        adj.add(1, "equipRef", "site-1");
193
194        let all = adj.sources_to("site-1", None);
195        assert_eq!(all.len(), 2);
196    }
197
198    #[test]
199    fn targets_from_nonexistent_entity() {
200        let adj = RefAdjacency::new();
201        assert!(adj.targets_from(999, None).is_empty());
202    }
203
204    #[test]
205    fn sources_to_nonexistent_target() {
206        let adj = RefAdjacency::new();
207        assert!(adj.sources_to("nonexistent", None).is_empty());
208    }
209
210    #[test]
211    fn remove_nonexistent_entity_is_noop() {
212        let mut adj = RefAdjacency::new();
213        // Should not panic.
214        adj.remove(999);
215    }
216
217    #[test]
218    fn remove_cleans_up_reverse_entry() {
219        let mut adj = RefAdjacency::new();
220        adj.add(0, "siteRef", "site-1");
221
222        adj.remove(0);
223
224        // The reverse entry for site-1 should be gone entirely.
225        assert!(adj.sources_to("site-1", None).is_empty());
226    }
227}