Skip to main content

haystack_core/graph/
entity_graph.rs

1// EntityGraph — in-memory entity store with bitmap indexing and ref adjacency.
2
3use std::collections::HashMap;
4
5use crate::data::{HCol, HDict, HGrid};
6use crate::filter::{matches_with_ns, parse_filter};
7use crate::kinds::{HRef, Kind};
8use crate::ontology::{DefNamespace, ValidationIssue};
9
10use super::adjacency::RefAdjacency;
11use super::bitmap::TagBitmapIndex;
12use super::changelog::{DiffOp, GraphDiff};
13use super::query_planner;
14
15/// Errors returned by EntityGraph operations.
16#[derive(Debug, thiserror::Error)]
17pub enum GraphError {
18    #[error("entity missing 'id' tag")]
19    MissingId,
20    #[error("entity id must be a Ref")]
21    InvalidId,
22    #[error("entity already exists: {0}")]
23    DuplicateRef(String),
24    #[error("entity not found: {0}")]
25    NotFound(String),
26    #[error("filter error: {0}")]
27    Filter(String),
28}
29
30/// Core entity graph with bitmap tag indexing and bidirectional ref adjacency.
31pub struct EntityGraph {
32    /// ref_val -> entity dict
33    entities: HashMap<String, HDict>,
34    /// ref_val -> internal numeric id (for bitmap indexing)
35    id_map: HashMap<String, usize>,
36    /// internal numeric id -> ref_val
37    reverse_id: HashMap<usize, String>,
38    /// Next internal id to assign.
39    next_id: usize,
40    /// Tag bitmap index for fast has/missing queries.
41    tag_index: TagBitmapIndex,
42    /// Bidirectional ref adjacency for graph traversal.
43    adjacency: RefAdjacency,
44    /// Optional ontology namespace for spec-aware operations.
45    namespace: Option<DefNamespace>,
46    /// Monotonic version counter, incremented on every mutation.
47    version: u64,
48    /// Ordered list of mutations.
49    changelog: Vec<GraphDiff>,
50}
51
52const MAX_CHANGELOG: usize = 10_000;
53
54impl EntityGraph {
55    /// Create an empty entity graph.
56    pub fn new() -> Self {
57        Self {
58            entities: HashMap::new(),
59            id_map: HashMap::new(),
60            reverse_id: HashMap::new(),
61            next_id: 0,
62            tag_index: TagBitmapIndex::new(),
63            adjacency: RefAdjacency::new(),
64            namespace: None,
65            version: 0,
66            changelog: Vec::new(),
67        }
68    }
69
70    /// Create an entity graph with an ontology namespace.
71    pub fn with_namespace(ns: DefNamespace) -> Self {
72        Self {
73            namespace: Some(ns),
74            ..Self::new()
75        }
76    }
77
78    // ── CRUD ──
79
80    /// Add an entity to the graph.
81    ///
82    /// The entity must have an `id` tag that is a `Ref`. Returns the ref
83    /// value string on success.
84    pub fn add(&mut self, entity: HDict) -> Result<String, GraphError> {
85        let ref_val = extract_ref_val(&entity)?;
86
87        if self.entities.contains_key(&ref_val) {
88            return Err(GraphError::DuplicateRef(ref_val));
89        }
90
91        let eid = self.next_id;
92        self.next_id += 1;
93
94        self.id_map.insert(ref_val.clone(), eid);
95        self.reverse_id.insert(eid, ref_val.clone());
96
97        // Index before inserting (borrows entity immutably, self mutably).
98        self.index_tags(eid, &entity);
99        self.index_refs(eid, &entity);
100
101        // Clone for the changelog, then move the entity into the map.
102        let entity_for_log = entity.clone();
103        self.entities.insert(ref_val.clone(), entity);
104
105        self.version += 1;
106        self.push_changelog(GraphDiff {
107            version: self.version,
108            op: DiffOp::Add,
109            ref_val: ref_val.clone(),
110            old: None,
111            new: Some(entity_for_log),
112        });
113
114        Ok(ref_val)
115    }
116
117    /// Get a reference to an entity by ref value.
118    pub fn get(&self, ref_val: &str) -> Option<&HDict> {
119        self.entities.get(ref_val)
120    }
121
122    /// Update an existing entity by merging `changes` into it.
123    ///
124    /// Tags in `changes` overwrite existing tags; `Kind::Remove` tags are
125    /// deleted. The `id` tag cannot be changed.
126    pub fn update(&mut self, ref_val: &str, changes: HDict) -> Result<(), GraphError> {
127        let eid = *self
128            .id_map
129            .get(ref_val)
130            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
131
132        // Remove the old entity from the map (avoids an extra clone).
133        let mut old_entity = self
134            .entities
135            .remove(ref_val)
136            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
137
138        // Remove old indexing.
139        self.remove_indexing(eid, &old_entity);
140
141        // Snapshot old state for changelog, then merge in-place.
142        let old_snapshot = old_entity.clone();
143        old_entity.merge(&changes);
144
145        // Re-index before re-inserting (entity is a local value, no borrow conflict).
146        self.index_tags(eid, &old_entity);
147        self.index_refs(eid, &old_entity);
148
149        // Clone for the changelog, then move the updated entity into the map.
150        let updated_for_log = old_entity.clone();
151        self.entities.insert(ref_val.to_string(), old_entity);
152
153        self.version += 1;
154        self.push_changelog(GraphDiff {
155            version: self.version,
156            op: DiffOp::Update,
157            ref_val: ref_val.to_string(),
158            old: Some(old_snapshot),
159            new: Some(updated_for_log),
160        });
161
162        Ok(())
163    }
164
165    /// Remove an entity from the graph. Returns the removed entity.
166    pub fn remove(&mut self, ref_val: &str) -> Result<HDict, GraphError> {
167        let eid = self
168            .id_map
169            .remove(ref_val)
170            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
171
172        self.reverse_id.remove(&eid);
173
174        let entity = self
175            .entities
176            .remove(ref_val)
177            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
178
179        self.remove_indexing(eid, &entity);
180
181        self.version += 1;
182        self.push_changelog(GraphDiff {
183            version: self.version,
184            op: DiffOp::Remove,
185            ref_val: ref_val.to_string(),
186            old: Some(entity.clone()),
187            new: None,
188        });
189
190        Ok(entity)
191    }
192
193    // ── Query ──
194
195    /// Run a filter expression and return matching entities as a grid.
196    pub fn read(&self, filter_expr: &str, limit: usize) -> Result<HGrid, GraphError> {
197        let results = self.read_all(filter_expr, limit)?;
198
199        if results.is_empty() {
200            return Ok(HGrid::new());
201        }
202
203        // Collect all unique column names.
204        let mut col_set: Vec<String> = Vec::new();
205        let mut seen = std::collections::HashSet::new();
206        for entity in &results {
207            for name in entity.tag_names() {
208                if seen.insert(name.to_string()) {
209                    col_set.push(name.to_string());
210                }
211            }
212        }
213        col_set.sort();
214        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
215        let rows: Vec<HDict> = results.into_iter().cloned().collect();
216
217        Ok(HGrid::from_parts(HDict::new(), cols, rows))
218    }
219
220    /// Run a filter expression and return matching entities as references.
221    pub fn read_all(&self, filter_expr: &str, limit: usize) -> Result<Vec<&HDict>, GraphError> {
222        let ast = parse_filter(filter_expr).map_err(|e| GraphError::Filter(e.to_string()))?;
223        let effective_limit = if limit == 0 { usize::MAX } else { limit };
224
225        // Phase 1: bitmap acceleration.
226        let max_id = self.next_id;
227        let candidates = query_planner::bitmap_candidates(&ast, &self.tag_index, max_id);
228
229        // Phase 2: full filter evaluation.
230        let resolver = |r: &HRef| -> Option<HDict> { self.entities.get(&r.val).cloned() };
231        let ns = self.namespace.as_ref();
232
233        let mut results = Vec::new();
234
235        if let Some(ref bitmap) = candidates {
236            // Evaluate only candidate entities.
237            for eid in TagBitmapIndex::iter_set_bits(bitmap) {
238                if results.len() >= effective_limit {
239                    break;
240                }
241                if let Some(ref_val) = self.reverse_id.get(&eid)
242                    && let Some(entity) = self.entities.get(ref_val)
243                    && matches_with_ns(&ast, entity, Some(&resolver), ns)
244                {
245                    results.push(entity);
246                }
247            }
248        } else {
249            // No bitmap optimization possible; scan all entities.
250            for entity in self.entities.values() {
251                if results.len() >= effective_limit {
252                    break;
253                }
254                if matches_with_ns(&ast, entity, Some(&resolver), ns) {
255                    results.push(entity);
256                }
257            }
258        }
259
260        Ok(results)
261    }
262
263    // ── Ref traversal ──
264
265    /// Get ref values that the given entity points to.
266    pub fn refs_from(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
267        match self.id_map.get(ref_val) {
268            Some(&eid) => self.adjacency.targets_from(eid, ref_type),
269            None => Vec::new(),
270        }
271    }
272
273    /// Get ref values of entities that point to the given entity.
274    pub fn refs_to(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
275        self.adjacency
276            .sources_to(ref_val, ref_type)
277            .iter()
278            .filter_map(|eid| self.reverse_id.get(eid).cloned())
279            .collect()
280    }
281
282    // ── Spec-aware ──
283
284    /// Find all entities that structurally fit a spec/type name.
285    ///
286    /// Requires a namespace to be set. Returns empty if no namespace.
287    pub fn entities_fitting(&self, spec_name: &str) -> Vec<&HDict> {
288        match &self.namespace {
289            Some(ns) => self
290                .entities
291                .values()
292                .filter(|e| ns.fits(e, spec_name))
293                .collect(),
294            None => Vec::new(),
295        }
296    }
297
298    /// Validate all entities against the namespace and check for dangling refs.
299    ///
300    /// Returns empty if no namespace is set and no dangling refs exist.
301    pub fn validate(&self) -> Vec<ValidationIssue> {
302        let mut issues: Vec<ValidationIssue> = match &self.namespace {
303            Some(ns) => self
304                .entities
305                .values()
306                .flat_map(|e| ns.validate_entity(e))
307                .collect(),
308            None => Vec::new(),
309        };
310
311        // Check for dangling refs: Ref values (except `id`) that point to
312        // entities not present in the graph.
313        for entity in self.entities.values() {
314            let entity_ref = entity.id().map(|r| r.val.as_str());
315            for (name, val) in entity.iter() {
316                if name == "id" {
317                    continue;
318                }
319                if let Kind::Ref(r) = val
320                    && !self.entities.contains_key(&r.val)
321                {
322                    issues.push(ValidationIssue {
323                        entity: entity_ref.map(|s| s.to_string()),
324                        issue_type: "dangling_ref".to_string(),
325                        detail: format!(
326                            "tag '{}' references '{}' which does not exist in the graph",
327                            name, r.val
328                        ),
329                    });
330                }
331            }
332        }
333
334        issues
335    }
336
337    // ── Serialization ──
338
339    /// Convert matching entities to a grid.
340    ///
341    /// If `filter_expr` is empty, exports all entities.
342    /// Otherwise, delegates to `read`.
343    pub fn to_grid(&self, filter_expr: &str) -> Result<HGrid, GraphError> {
344        if filter_expr.is_empty() {
345            let entities: Vec<&HDict> = self.entities.values().collect();
346            return Ok(Self::entities_to_grid(&entities));
347        }
348        self.read(filter_expr, 0)
349    }
350
351    /// Build a grid from a slice of entity references.
352    fn entities_to_grid(entities: &[&HDict]) -> HGrid {
353        if entities.is_empty() {
354            return HGrid::new();
355        }
356
357        let mut col_set: Vec<String> = Vec::new();
358        let mut seen = std::collections::HashSet::new();
359        for entity in entities {
360            for name in entity.tag_names() {
361                if seen.insert(name.to_string()) {
362                    col_set.push(name.to_string());
363                }
364            }
365        }
366        col_set.sort();
367        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
368        let rows: Vec<HDict> = entities.iter().map(|e| (*e).clone()).collect();
369
370        HGrid::from_parts(HDict::new(), cols, rows)
371    }
372
373    /// Build an EntityGraph from a grid.
374    ///
375    /// Rows without a valid `id` Ref tag are silently skipped.
376    pub fn from_grid(grid: &HGrid, namespace: Option<DefNamespace>) -> Result<Self, GraphError> {
377        let mut graph = match namespace {
378            Some(ns) => Self::with_namespace(ns),
379            None => Self::new(),
380        };
381        for row in &grid.rows {
382            if row.id().is_some() {
383                graph.add(row.clone())?;
384            }
385        }
386        Ok(graph)
387    }
388
389    // ── Change tracking ──
390
391    /// Get changelog entries since a given version.
392    pub fn changes_since(&self, version: u64) -> &[GraphDiff] {
393        match self
394            .changelog
395            .binary_search_by_key(&(version + 1), |d| d.version)
396        {
397            Ok(idx) => &self.changelog[idx..],
398            Err(idx) => &self.changelog[idx..],
399        }
400    }
401
402    /// Current graph version (monotonically increasing).
403    pub fn version(&self) -> u64 {
404        self.version
405    }
406
407    // ── Container ──
408
409    /// Number of entities in the graph.
410    pub fn len(&self) -> usize {
411        self.entities.len()
412    }
413
414    /// Returns `true` if the graph has no entities.
415    pub fn is_empty(&self) -> bool {
416        self.entities.is_empty()
417    }
418
419    /// Returns `true` if an entity with the given ref value exists.
420    pub fn contains(&self, ref_val: &str) -> bool {
421        self.entities.contains_key(ref_val)
422    }
423
424    // ── Internal indexing ──
425
426    /// Add tag bitmap entries for an entity.
427    fn index_tags(&mut self, entity_id: usize, entity: &HDict) {
428        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
429        self.tag_index.add(entity_id, &tags);
430    }
431
432    /// Add ref adjacency entries for an entity.
433    fn index_refs(&mut self, entity_id: usize, entity: &HDict) {
434        for (name, val) in entity.iter() {
435            if let Kind::Ref(r) = val {
436                // Skip the "id" tag — it is the entity's own identity,
437                // not a reference edge.
438                if name != "id" {
439                    self.adjacency.add(entity_id, name, &r.val);
440                }
441            }
442        }
443    }
444
445    /// Remove all index entries for an entity.
446    fn remove_indexing(&mut self, entity_id: usize, entity: &HDict) {
447        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
448        self.tag_index.remove(entity_id, &tags);
449        self.adjacency.remove(entity_id);
450    }
451
452    /// Append a diff to the changelog, capping it at [`MAX_CHANGELOG`] entries.
453    fn push_changelog(&mut self, diff: GraphDiff) {
454        self.changelog.push(diff);
455        if self.changelog.len() > MAX_CHANGELOG {
456            self.changelog.drain(..self.changelog.len() - MAX_CHANGELOG);
457        }
458    }
459}
460
461impl Default for EntityGraph {
462    fn default() -> Self {
463        Self::new()
464    }
465}
466
467/// Extract the ref value string from an entity's `id` tag.
468fn extract_ref_val(entity: &HDict) -> Result<String, GraphError> {
469    match entity.get("id") {
470        Some(Kind::Ref(r)) => Ok(r.val.clone()),
471        Some(_) => Err(GraphError::InvalidId),
472        None => Err(GraphError::MissingId),
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::kinds::Number;
480
481    fn make_site(id: &str) -> HDict {
482        let mut d = HDict::new();
483        d.set("id", Kind::Ref(HRef::from_val(id)));
484        d.set("site", Kind::Marker);
485        d.set("dis", Kind::Str(format!("Site {id}")));
486        d.set(
487            "area",
488            Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
489        );
490        d
491    }
492
493    fn make_equip(id: &str, site_ref: &str) -> HDict {
494        let mut d = HDict::new();
495        d.set("id", Kind::Ref(HRef::from_val(id)));
496        d.set("equip", Kind::Marker);
497        d.set("dis", Kind::Str(format!("Equip {id}")));
498        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
499        d
500    }
501
502    fn make_point(id: &str, equip_ref: &str) -> HDict {
503        let mut d = HDict::new();
504        d.set("id", Kind::Ref(HRef::from_val(id)));
505        d.set("point", Kind::Marker);
506        d.set("sensor", Kind::Marker);
507        d.set("temp", Kind::Marker);
508        d.set("dis", Kind::Str(format!("Point {id}")));
509        d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
510        d.set(
511            "curVal",
512            Kind::Number(Number::new(72.5, Some("\u{00b0}F".into()))),
513        );
514        d
515    }
516
517    // ── Add tests ──
518
519    #[test]
520    fn add_entity_with_valid_id() {
521        let mut g = EntityGraph::new();
522        let result = g.add(make_site("site-1"));
523        assert!(result.is_ok());
524        assert_eq!(result.unwrap(), "site-1");
525        assert_eq!(g.len(), 1);
526    }
527
528    #[test]
529    fn add_entity_missing_id_fails() {
530        let mut g = EntityGraph::new();
531        let entity = HDict::new();
532        let err = g.add(entity).unwrap_err();
533        assert!(matches!(err, GraphError::MissingId));
534    }
535
536    #[test]
537    fn add_entity_non_ref_id_fails() {
538        let mut g = EntityGraph::new();
539        let mut entity = HDict::new();
540        entity.set("id", Kind::Str("not-a-ref".into()));
541        let err = g.add(entity).unwrap_err();
542        assert!(matches!(err, GraphError::InvalidId));
543    }
544
545    #[test]
546    fn add_duplicate_ref_fails() {
547        let mut g = EntityGraph::new();
548        g.add(make_site("site-1")).unwrap();
549        let err = g.add(make_site("site-1")).unwrap_err();
550        assert!(matches!(err, GraphError::DuplicateRef(_)));
551    }
552
553    // ── Get tests ──
554
555    #[test]
556    fn get_existing_entity() {
557        let mut g = EntityGraph::new();
558        g.add(make_site("site-1")).unwrap();
559        let entity = g.get("site-1").unwrap();
560        assert!(entity.has("site"));
561        assert_eq!(entity.get("dis"), Some(&Kind::Str("Site site-1".into())));
562    }
563
564    #[test]
565    fn get_missing_entity_returns_none() {
566        let g = EntityGraph::new();
567        assert!(g.get("nonexistent").is_none());
568    }
569
570    // ── Update tests ──
571
572    #[test]
573    fn update_merges_changes() {
574        let mut g = EntityGraph::new();
575        g.add(make_site("site-1")).unwrap();
576
577        let mut changes = HDict::new();
578        changes.set("dis", Kind::Str("Updated Site".into()));
579        changes.set("geoCity", Kind::Str("Richmond".into()));
580        g.update("site-1", changes).unwrap();
581
582        let entity = g.get("site-1").unwrap();
583        assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
584        assert_eq!(entity.get("geoCity"), Some(&Kind::Str("Richmond".into())));
585        assert!(entity.has("site")); // unchanged
586    }
587
588    #[test]
589    fn update_missing_entity_fails() {
590        let mut g = EntityGraph::new();
591        let err = g.update("nonexistent", HDict::new()).unwrap_err();
592        assert!(matches!(err, GraphError::NotFound(_)));
593    }
594
595    // ── Remove tests ──
596
597    #[test]
598    fn remove_entity() {
599        let mut g = EntityGraph::new();
600        g.add(make_site("site-1")).unwrap();
601        let removed = g.remove("site-1").unwrap();
602        assert!(removed.has("site"));
603        assert!(g.get("site-1").is_none());
604        assert_eq!(g.len(), 0);
605    }
606
607    #[test]
608    fn remove_missing_entity_fails() {
609        let mut g = EntityGraph::new();
610        let err = g.remove("nonexistent").unwrap_err();
611        assert!(matches!(err, GraphError::NotFound(_)));
612    }
613
614    // ── Version / changelog tests ──
615
616    #[test]
617    fn version_increments_on_mutations() {
618        let mut g = EntityGraph::new();
619        assert_eq!(g.version(), 0);
620
621        g.add(make_site("site-1")).unwrap();
622        assert_eq!(g.version(), 1);
623
624        g.update("site-1", HDict::new()).unwrap();
625        assert_eq!(g.version(), 2);
626
627        g.remove("site-1").unwrap();
628        assert_eq!(g.version(), 3);
629    }
630
631    #[test]
632    fn changelog_records_add_update_remove() {
633        let mut g = EntityGraph::new();
634        g.add(make_site("site-1")).unwrap();
635        g.update("site-1", HDict::new()).unwrap();
636        g.remove("site-1").unwrap();
637
638        let changes = g.changes_since(0);
639        assert_eq!(changes.len(), 3);
640        assert_eq!(changes[0].op, DiffOp::Add);
641        assert_eq!(changes[0].ref_val, "site-1");
642        assert!(changes[0].old.is_none());
643        assert!(changes[0].new.is_some());
644
645        assert_eq!(changes[1].op, DiffOp::Update);
646        assert!(changes[1].old.is_some());
647        assert!(changes[1].new.is_some());
648
649        assert_eq!(changes[2].op, DiffOp::Remove);
650        assert!(changes[2].old.is_some());
651        assert!(changes[2].new.is_none());
652    }
653
654    #[test]
655    fn changes_since_returns_subset() {
656        let mut g = EntityGraph::new();
657        g.add(make_site("site-1")).unwrap(); // v1
658        g.add(make_site("site-2")).unwrap(); // v2
659        g.add(make_site("site-3")).unwrap(); // v3
660
661        let since_v2 = g.changes_since(2);
662        assert_eq!(since_v2.len(), 1);
663        assert_eq!(since_v2[0].ref_val, "site-3");
664    }
665
666    // ── Container tests ──
667
668    #[test]
669    fn contains_check() {
670        let mut g = EntityGraph::new();
671        g.add(make_site("site-1")).unwrap();
672        assert!(g.contains("site-1"));
673        assert!(!g.contains("site-2"));
674    }
675
676    #[test]
677    fn len_and_is_empty() {
678        let mut g = EntityGraph::new();
679        assert!(g.is_empty());
680        assert_eq!(g.len(), 0);
681
682        g.add(make_site("site-1")).unwrap();
683        assert!(!g.is_empty());
684        assert_eq!(g.len(), 1);
685    }
686
687    // ── Query tests ──
688
689    #[test]
690    fn read_with_simple_has_filter() {
691        let mut g = EntityGraph::new();
692        g.add(make_site("site-1")).unwrap();
693        g.add(make_equip("equip-1", "site-1")).unwrap();
694
695        let results = g.read_all("site", 0).unwrap();
696        assert_eq!(results.len(), 1);
697        assert!(results[0].has("site"));
698    }
699
700    #[test]
701    fn read_with_comparison_filter() {
702        let mut g = EntityGraph::new();
703        g.add(make_point("pt-1", "equip-1")).unwrap();
704
705        let results = g.read_all("curVal > 70\u{00b0}F", 0).unwrap();
706        assert_eq!(results.len(), 1);
707    }
708
709    #[test]
710    fn read_with_and_filter() {
711        let mut g = EntityGraph::new();
712        g.add(make_point("pt-1", "equip-1")).unwrap();
713        g.add(make_equip("equip-1", "site-1")).unwrap();
714
715        let results = g.read_all("point and sensor", 0).unwrap();
716        assert_eq!(results.len(), 1);
717    }
718
719    #[test]
720    fn read_with_or_filter() {
721        let mut g = EntityGraph::new();
722        g.add(make_site("site-1")).unwrap();
723        g.add(make_equip("equip-1", "site-1")).unwrap();
724
725        let results = g.read_all("site or equip", 0).unwrap();
726        assert_eq!(results.len(), 2);
727    }
728
729    #[test]
730    fn read_limit_parameter_works() {
731        let mut g = EntityGraph::new();
732        g.add(make_site("site-1")).unwrap();
733        g.add(make_site("site-2")).unwrap();
734        g.add(make_site("site-3")).unwrap();
735
736        let results = g.read_all("site", 2).unwrap();
737        assert_eq!(results.len(), 2);
738    }
739
740    #[test]
741    fn read_returns_grid() {
742        let mut g = EntityGraph::new();
743        g.add(make_site("site-1")).unwrap();
744        g.add(make_site("site-2")).unwrap();
745
746        let grid = g.read("site", 0).unwrap();
747        assert_eq!(grid.len(), 2);
748        assert!(grid.col("site").is_some());
749        assert!(grid.col("id").is_some());
750    }
751
752    #[test]
753    fn read_invalid_filter() {
754        let g = EntityGraph::new();
755        let err = g.read("!!!", 0).unwrap_err();
756        assert!(matches!(err, GraphError::Filter(_)));
757    }
758
759    // ── Ref traversal tests ──
760
761    #[test]
762    fn refs_from_returns_targets() {
763        let mut g = EntityGraph::new();
764        g.add(make_site("site-1")).unwrap();
765        g.add(make_equip("equip-1", "site-1")).unwrap();
766
767        let targets = g.refs_from("equip-1", None);
768        assert_eq!(targets, vec!["site-1".to_string()]);
769    }
770
771    #[test]
772    fn refs_to_returns_sources() {
773        let mut g = EntityGraph::new();
774        g.add(make_site("site-1")).unwrap();
775        g.add(make_equip("equip-1", "site-1")).unwrap();
776        g.add(make_equip("equip-2", "site-1")).unwrap();
777
778        let mut sources = g.refs_to("site-1", None);
779        sources.sort();
780        assert_eq!(sources.len(), 2);
781    }
782
783    #[test]
784    fn type_filtered_ref_queries() {
785        let mut g = EntityGraph::new();
786        g.add(make_site("site-1")).unwrap();
787        g.add(make_equip("equip-1", "site-1")).unwrap();
788
789        let targets = g.refs_from("equip-1", Some("siteRef"));
790        assert_eq!(targets, vec!["site-1".to_string()]);
791
792        let targets = g.refs_from("equip-1", Some("equipRef"));
793        assert!(targets.is_empty());
794    }
795
796    #[test]
797    fn refs_from_nonexistent_entity() {
798        let g = EntityGraph::new();
799        assert!(g.refs_from("nonexistent", None).is_empty());
800    }
801
802    #[test]
803    fn refs_to_nonexistent_entity() {
804        let g = EntityGraph::new();
805        assert!(g.refs_to("nonexistent", None).is_empty());
806    }
807
808    // ── Serialization tests ──
809
810    #[test]
811    fn from_grid_round_trip() {
812        let mut g = EntityGraph::new();
813        g.add(make_site("site-1")).unwrap();
814        g.add(make_equip("equip-1", "site-1")).unwrap();
815
816        let grid = g.to_grid("site or equip").unwrap();
817        assert_eq!(grid.len(), 2);
818
819        let g2 = EntityGraph::from_grid(&grid, None).unwrap();
820        assert_eq!(g2.len(), 2);
821        assert!(g2.contains("site-1"));
822        assert!(g2.contains("equip-1"));
823    }
824
825    #[test]
826    fn to_grid_empty_result() {
827        let g = EntityGraph::new();
828        let grid = g.to_grid("site").unwrap();
829        assert!(grid.is_empty());
830    }
831
832    // ── Update re-indexes correctly ──
833
834    #[test]
835    fn update_reindexes_tags() {
836        let mut g = EntityGraph::new();
837        g.add(make_site("site-1")).unwrap();
838
839        // Should find the site with "site" filter.
840        assert_eq!(g.read_all("site", 0).unwrap().len(), 1);
841
842        // Remove the "site" marker via update.
843        let mut changes = HDict::new();
844        changes.set("site", Kind::Remove);
845        g.update("site-1", changes).unwrap();
846
847        // Should no longer match "site" filter.
848        assert_eq!(g.read_all("site", 0).unwrap().len(), 0);
849    }
850
851    #[test]
852    fn update_reindexes_refs() {
853        let mut g = EntityGraph::new();
854        g.add(make_site("site-1")).unwrap();
855        g.add(make_site("site-2")).unwrap();
856        g.add(make_equip("equip-1", "site-1")).unwrap();
857
858        // Initially equip-1 points to site-1.
859        assert_eq!(g.refs_from("equip-1", None), vec!["site-1".to_string()]);
860
861        // Move equip-1 to site-2.
862        let mut changes = HDict::new();
863        changes.set("siteRef", Kind::Ref(HRef::from_val("site-2")));
864        g.update("equip-1", changes).unwrap();
865
866        assert_eq!(g.refs_from("equip-1", None), vec!["site-2".to_string()]);
867        assert!(g.refs_to("site-1", None).is_empty());
868    }
869
870    // ── Dangling ref validation ──
871
872    #[test]
873    fn validate_detects_dangling_refs() {
874        let mut g = EntityGraph::new();
875        g.add(make_site("site-1")).unwrap();
876        // equip-1 has siteRef pointing to "site-1" (exists) — no issue
877        g.add(make_equip("equip-1", "site-1")).unwrap();
878        // equip-2 has siteRef pointing to "site-999" (does not exist) — dangling
879        g.add(make_equip("equip-2", "site-999")).unwrap();
880
881        let issues = g.validate();
882        assert!(!issues.is_empty());
883
884        let dangling: Vec<_> = issues
885            .iter()
886            .filter(|i| i.issue_type == "dangling_ref")
887            .collect();
888        assert_eq!(dangling.len(), 1);
889        assert_eq!(dangling[0].entity.as_deref(), Some("equip-2"));
890        assert!(dangling[0].detail.contains("site-999"));
891        assert!(dangling[0].detail.contains("siteRef"));
892    }
893
894    // ── Empty filter exports all ──
895
896    #[test]
897    fn to_grid_empty_filter_exports_all() {
898        let mut g = EntityGraph::new();
899        g.add(make_site("site-1")).unwrap();
900        g.add(make_equip("equip-1", "site-1")).unwrap();
901        g.add(make_point("pt-1", "equip-1")).unwrap();
902
903        let grid = g.to_grid("").unwrap();
904        assert_eq!(grid.len(), 3);
905        assert!(grid.col("id").is_some());
906    }
907
908    // ── from_grid skips rows without id ──
909
910    #[test]
911    fn changelog_bounded_to_max_size() {
912        let mut graph = EntityGraph::new();
913        // Add more entities than MAX_CHANGELOG
914        for i in 0..12_000 {
915            let mut d = HDict::new();
916            d.set("id", Kind::Ref(HRef::from_val(format!("e{i}"))));
917            d.set("dis", Kind::Str(format!("Entity {i}")));
918            graph.add(d).unwrap();
919        }
920        // Changelog should be capped
921        assert!(graph.changes_since(0).len() <= 10_000);
922        // Latest changes should still be present
923        assert!(graph.changes_since(11_999).len() <= 1);
924    }
925
926    #[test]
927    fn from_grid_skips_rows_without_id() {
928        let cols = vec![HCol::new("id"), HCol::new("dis"), HCol::new("site")];
929
930        let mut row_with_id = HDict::new();
931        row_with_id.set("id", Kind::Ref(HRef::from_val("site-1")));
932        row_with_id.set("site", Kind::Marker);
933        row_with_id.set("dis", Kind::Str("Has ID".into()));
934
935        // Row with string id (not a Ref) — should be skipped.
936        let mut row_bad_id = HDict::new();
937        row_bad_id.set("id", Kind::Str("not-a-ref".into()));
938        row_bad_id.set("dis", Kind::Str("Bad ID".into()));
939
940        // Row with no id at all — should be skipped.
941        let mut row_no_id = HDict::new();
942        row_no_id.set("dis", Kind::Str("No ID".into()));
943
944        let grid = HGrid::from_parts(HDict::new(), cols, vec![row_with_id, row_bad_id, row_no_id]);
945        let g = EntityGraph::from_grid(&grid, None).unwrap();
946
947        assert_eq!(g.len(), 1);
948        assert!(g.contains("site-1"));
949    }
950}