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
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn add_forward_and_reverse_edges() {
95        let mut adj = RefAdjacency::new();
96        adj.add(0, "siteRef", "site-1");
97        adj.add(0, "equipRef", "equip-1");
98
99        let targets = adj.targets_from(0, None);
100        assert_eq!(targets.len(), 2);
101        assert!(targets.contains(&"site-1".to_string()));
102        assert!(targets.contains(&"equip-1".to_string()));
103
104        let sources = adj.sources_to("site-1", None);
105        assert_eq!(sources, vec![0]);
106    }
107
108    #[test]
109    fn remove_entity_edges() {
110        let mut adj = RefAdjacency::new();
111        adj.add(0, "siteRef", "site-1");
112        adj.add(1, "siteRef", "site-1");
113
114        adj.remove(0);
115
116        assert!(adj.targets_from(0, None).is_empty());
117        // Entity 1 should still reference site-1.
118        let sources = adj.sources_to("site-1", None);
119        assert_eq!(sources, vec![1]);
120    }
121
122    #[test]
123    fn targets_from_with_type_filter() {
124        let mut adj = RefAdjacency::new();
125        adj.add(0, "siteRef", "site-1");
126        adj.add(0, "equipRef", "equip-1");
127
128        let site_targets = adj.targets_from(0, Some("siteRef"));
129        assert_eq!(site_targets, vec!["site-1".to_string()]);
130
131        let equip_targets = adj.targets_from(0, Some("equipRef"));
132        assert_eq!(equip_targets, vec!["equip-1".to_string()]);
133
134        let none_targets = adj.targets_from(0, Some("spaceRef"));
135        assert!(none_targets.is_empty());
136    }
137
138    #[test]
139    fn targets_from_without_type_filter() {
140        let mut adj = RefAdjacency::new();
141        adj.add(0, "siteRef", "site-1");
142        adj.add(0, "equipRef", "equip-1");
143
144        let all = adj.targets_from(0, None);
145        assert_eq!(all.len(), 2);
146    }
147
148    #[test]
149    fn sources_to_with_type_filter() {
150        let mut adj = RefAdjacency::new();
151        adj.add(0, "siteRef", "site-1");
152        adj.add(1, "equipRef", "site-1");
153
154        let site_sources = adj.sources_to("site-1", Some("siteRef"));
155        assert_eq!(site_sources, vec![0]);
156
157        let equip_sources = adj.sources_to("site-1", Some("equipRef"));
158        assert_eq!(equip_sources, vec![1]);
159    }
160
161    #[test]
162    fn sources_to_without_type_filter() {
163        let mut adj = RefAdjacency::new();
164        adj.add(0, "siteRef", "site-1");
165        adj.add(1, "equipRef", "site-1");
166
167        let all = adj.sources_to("site-1", None);
168        assert_eq!(all.len(), 2);
169    }
170
171    #[test]
172    fn targets_from_nonexistent_entity() {
173        let adj = RefAdjacency::new();
174        assert!(adj.targets_from(999, None).is_empty());
175    }
176
177    #[test]
178    fn sources_to_nonexistent_target() {
179        let adj = RefAdjacency::new();
180        assert!(adj.sources_to("nonexistent", None).is_empty());
181    }
182
183    #[test]
184    fn remove_nonexistent_entity_is_noop() {
185        let mut adj = RefAdjacency::new();
186        // Should not panic.
187        adj.remove(999);
188    }
189
190    #[test]
191    fn remove_cleans_up_reverse_entry() {
192        let mut adj = RefAdjacency::new();
193        adj.add(0, "siteRef", "site-1");
194
195        adj.remove(0);
196
197        // The reverse entry for site-1 should be gone entirely.
198        assert!(adj.sources_to("site-1", None).is_empty());
199    }
200}