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 indexmap::IndexMap;
6use parking_lot::Mutex;
7use rayon::prelude::*;
8
9use crate::data::{HCol, HDict, HGrid};
10use crate::filter::{FilterNode, matches_with_ns, parse_filter};
11use crate::kinds::{HRef, Kind};
12use crate::ontology::{DefNamespace, ValidationIssue};
13
14use super::adjacency::RefAdjacency;
15use super::bitmap::TagBitmapIndex;
16use super::changelog::{ChangelogGap, DiffOp, GraphDiff};
17use super::columnar::ColumnarStore;
18use super::csr::CsrAdjacency;
19use super::query_planner;
20use super::value_index::ValueIndex;
21
22/// Errors returned by EntityGraph operations.
23#[derive(Debug, thiserror::Error)]
24pub enum GraphError {
25    #[error("entity missing 'id' tag")]
26    MissingId,
27    #[error("entity id must be a Ref")]
28    InvalidId,
29    #[error("entity already exists: {0}")]
30    DuplicateRef(String),
31    #[error("entity not found: {0}")]
32    NotFound(String),
33    #[error("filter error: {0}")]
34    Filter(String),
35}
36
37/// Core entity graph with bitmap tag indexing and bidirectional ref adjacency.
38pub struct EntityGraph {
39    /// ref_val -> entity dict
40    entities: HashMap<String, HDict>,
41    /// ref_val -> internal numeric id (for bitmap indexing)
42    id_map: HashMap<String, usize>,
43    /// internal numeric id -> ref_val
44    reverse_id: HashMap<usize, String>,
45    /// Next internal id to assign.
46    next_id: usize,
47    /// Tag bitmap index for fast has/missing queries.
48    tag_index: TagBitmapIndex,
49    /// Bidirectional ref adjacency for graph traversal.
50    adjacency: RefAdjacency,
51    /// Optional ontology namespace for spec-aware operations.
52    namespace: Option<DefNamespace>,
53    /// Monotonic version counter, incremented on every mutation.
54    version: u64,
55    /// Ordered list of mutations.
56    changelog: std::collections::VecDeque<GraphDiff>,
57    /// Maximum number of changelog entries retained.
58    changelog_capacity: usize,
59    /// Lowest version still present in the changelog (0 = no evictions yet).
60    floor_version: u64,
61    /// LRU query cache: (filter, version) → matching ref_vals.
62    /// Uses Mutex for interior mutability since read_all takes &self.
63    query_cache: Mutex<QueryCache>,
64    /// Parsed filter AST cache: filter_string → AST (version-independent).
65    ast_cache: Mutex<HashMap<String, FilterNode>>,
66    /// Optional B-Tree value indexes for comparison-based filter acceleration.
67    value_index: ValueIndex,
68    /// CSR snapshot of adjacency for cache-friendly traversal.
69    /// Rebuilt lazily via `rebuild_csr()`. `None` until first build.
70    csr: Option<CsrAdjacency>,
71    /// Version at which the CSR was last rebuilt (for staleness detection).
72    csr_version: u64,
73    /// Columnar storage for cache-friendly single-tag scans.
74    columnar: ColumnarStore,
75}
76
77/// Fixed-capacity LRU cache for filter query results using IndexMap for O(1) ops.
78struct QueryCache {
79    /// (filter, version) → matching ref_vals. Most-recently-used at the back.
80    entries: IndexMap<(String, u64), Vec<String>>,
81    capacity: usize,
82}
83
84impl QueryCache {
85    fn new(capacity: usize) -> Self {
86        Self {
87            entries: IndexMap::with_capacity(capacity),
88            capacity,
89        }
90    }
91
92    fn get(&mut self, filter: &str, version: u64) -> Option<&[String]> {
93        // Move to back (most recently used) on access.
94        let key = (filter.to_string(), version);
95        let idx = self.entries.get_index_of(&key)?;
96        self.entries.move_index(idx, self.entries.len() - 1);
97        self.entries.get(&key).map(|v| v.as_slice())
98    }
99
100    fn insert(&mut self, filter: String, version: u64, ref_vals: Vec<String>) {
101        if self.entries.len() >= self.capacity {
102            // First try purging stale entries from older versions.
103            self.purge_stale(version);
104        }
105        if self.entries.len() >= self.capacity {
106            // Still at capacity — evict least recently used (front).
107            self.entries.shift_remove_index(0);
108        }
109        self.entries.insert((filter, version), ref_vals);
110    }
111
112    /// Remove all entries whose version is older than `min_version`.
113    fn purge_stale(&mut self, min_version: u64) {
114        self.entries
115            .retain(|(_filter, version), _| *version >= min_version);
116    }
117}
118
119const QUERY_CACHE_CAPACITY: usize = 256;
120
121/// Common Haystack fields to auto-index for O(log N) value lookups.
122const AUTO_INDEX_FIELDS: &[&str] = &[
123    "siteRef", "equipRef", "dis", "curVal", "area", "geoCity", "kind", "unit",
124];
125
126impl EntityGraph {
127    /// Create an empty entity graph with standard Haystack fields auto-indexed
128    /// and default changelog capacity (50,000).
129    pub fn new() -> Self {
130        Self::with_changelog_capacity(super::changelog::DEFAULT_CHANGELOG_CAPACITY)
131    }
132
133    /// Create an empty entity graph with a custom changelog capacity.
134    pub fn with_changelog_capacity(capacity: usize) -> Self {
135        let capacity = capacity.max(1); // Ensure at least 1 entry
136        let mut value_index = ValueIndex::new();
137        for field in AUTO_INDEX_FIELDS {
138            value_index.index_field(field);
139        }
140        Self {
141            entities: HashMap::new(),
142            id_map: HashMap::new(),
143            reverse_id: HashMap::new(),
144            next_id: 0,
145            tag_index: TagBitmapIndex::new(),
146            adjacency: RefAdjacency::new(),
147            namespace: None,
148            version: 0,
149            changelog: std::collections::VecDeque::new(),
150            changelog_capacity: capacity,
151            floor_version: 0,
152            query_cache: Mutex::new(QueryCache::new(QUERY_CACHE_CAPACITY)),
153            ast_cache: Mutex::new(HashMap::new()),
154            value_index,
155            csr: None,
156            csr_version: 0,
157            columnar: ColumnarStore::new(),
158        }
159    }
160
161    /// Create an entity graph with an ontology namespace.
162    pub fn with_namespace(ns: DefNamespace) -> Self {
163        Self {
164            namespace: Some(ns),
165            ..Self::new()
166        }
167    }
168
169    // ── Value Indexes ──
170
171    /// Register a field for B-Tree value indexing. Enables O(log N) range
172    /// queries (e.g. `temp > 72`) for this field. Must be called before
173    /// entities are added, or followed by `rebuild_value_index` for existing data.
174    pub fn index_field(&mut self, field: &str) {
175        self.value_index.index_field(field);
176    }
177
178    /// Rebuild the value index for all indexed fields from the current entities.
179    pub fn rebuild_value_index(&mut self) {
180        self.value_index.clear();
181        for (ref_val, entity) in &self.entities {
182            if let Some(&eid) = self.id_map.get(ref_val.as_str()) {
183                for (name, val) in entity.iter() {
184                    if self.value_index.has_index(name) {
185                        self.value_index.add(eid, name, val);
186                    }
187                }
188            }
189        }
190    }
191
192    /// Returns a reference to the value index (for use by the query planner).
193    pub fn value_index(&self) -> &ValueIndex {
194        &self.value_index
195    }
196
197    // ── Columnar Storage ──
198
199    /// Register a tag for columnar storage. Enables cache-friendly sequential
200    /// scans for this tag. Must be called before entities are added, or followed
201    /// by `rebuild_columnar` for existing data.
202    pub fn track_column(&mut self, tag: &str) {
203        self.columnar.track_tag(tag);
204    }
205
206    /// Rebuild all tracked columnar data from current entities.
207    pub fn rebuild_columnar(&mut self) {
208        self.columnar.clear();
209        self.columnar.ensure_capacity(self.next_id);
210        for (ref_val, entity) in &self.entities {
211            if let Some(&eid) = self.id_map.get(ref_val.as_str()) {
212                for (name, val) in entity.iter() {
213                    if self.columnar.is_tracked(name) {
214                        self.columnar.set(eid, name, val);
215                    }
216                }
217            }
218        }
219    }
220
221    /// Returns a reference to the columnar store for direct column scans.
222    pub fn columnar(&self) -> &ColumnarStore {
223        &self.columnar
224    }
225
226    // ── CRUD ──
227
228    /// Add an entity to the graph.
229    ///
230    /// The entity must have an `id` tag that is a `Ref`. Returns the ref
231    /// value string on success.
232    pub fn add(&mut self, entity: HDict) -> Result<String, GraphError> {
233        let ref_val = extract_ref_val(&entity)?;
234
235        if self.entities.contains_key(&ref_val) {
236            return Err(GraphError::DuplicateRef(ref_val));
237        }
238
239        let eid = self.next_id;
240        self.next_id = self.next_id.checked_add(1).ok_or(GraphError::InvalidId)?;
241
242        self.id_map.insert(ref_val.clone(), eid);
243        self.reverse_id.insert(eid, ref_val.clone());
244
245        // Index before inserting (borrows entity immutably, self mutably).
246        self.index_tags(eid, &entity);
247        self.index_refs(eid, &entity);
248
249        // Clone for the changelog, then move the entity into the map.
250        let entity_for_log = entity.clone();
251        self.entities.insert(ref_val.clone(), entity);
252
253        self.version += 1;
254        self.csr = None; // Invalidate CSR snapshot on mutation.
255        self.push_changelog(GraphDiff {
256            version: self.version,
257            timestamp: 0,
258            op: DiffOp::Add,
259            ref_val: ref_val.clone(),
260            old: None,
261            new: Some(entity_for_log),
262            changed_tags: None,
263            previous_tags: None,
264        });
265
266        Ok(ref_val)
267    }
268
269    /// Get a reference to an entity by ref value.
270    pub fn get(&self, ref_val: &str) -> Option<&HDict> {
271        self.entities.get(ref_val)
272    }
273
274    /// Update an existing entity by merging `changes` into it.
275    ///
276    /// Tags in `changes` overwrite existing tags; `Kind::Remove` tags are
277    /// deleted. The `id` tag cannot be changed.
278    pub fn update(&mut self, ref_val: &str, changes: HDict) -> Result<(), GraphError> {
279        let eid = *self
280            .id_map
281            .get(ref_val)
282            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
283
284        // Remove the old entity from the map (avoids an extra clone).
285        let mut old_entity = self
286            .entities
287            .remove(ref_val)
288            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
289
290        // Remove old indexing.
291        self.remove_indexing(eid, &old_entity);
292
293        // Compute delta: capture only the tags being changed.
294        let mut prev_tags = HDict::new();
295        let mut changed = HDict::new();
296        for (key, new_val) in changes.iter() {
297            if let Some(old_val) = old_entity.get(key) {
298                prev_tags.set(key, old_val.clone());
299            }
300            changed.set(key, new_val.clone());
301        }
302
303        // Merge in-place.
304        old_entity.merge(&changes);
305
306        // Re-index before re-inserting (entity is a local value, no borrow conflict).
307        self.index_tags(eid, &old_entity);
308        self.index_refs(eid, &old_entity);
309
310        self.entities.insert(ref_val.to_string(), old_entity);
311
312        self.version += 1;
313        self.csr = None; // Invalidate CSR snapshot on mutation.
314        self.push_changelog(GraphDiff {
315            version: self.version,
316            timestamp: 0,
317            op: DiffOp::Update,
318            ref_val: ref_val.to_string(),
319            old: None,
320            new: None,
321            changed_tags: Some(changed),
322            previous_tags: Some(prev_tags),
323        });
324
325        Ok(())
326    }
327
328    /// Remove an entity from the graph. Returns the removed entity.
329    pub fn remove(&mut self, ref_val: &str) -> Result<HDict, GraphError> {
330        let eid = self
331            .id_map
332            .remove(ref_val)
333            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
334
335        self.reverse_id.remove(&eid);
336
337        let entity = self
338            .entities
339            .remove(ref_val)
340            .ok_or_else(|| GraphError::NotFound(ref_val.to_string()))?;
341
342        self.remove_indexing(eid, &entity);
343
344        self.version += 1;
345        self.csr = None; // Invalidate CSR snapshot on mutation.
346        self.push_changelog(GraphDiff {
347            version: self.version,
348            timestamp: 0,
349            op: DiffOp::Remove,
350            ref_val: ref_val.to_string(),
351            old: Some(entity.clone()),
352            new: None,
353            changed_tags: None,
354            previous_tags: None,
355        });
356
357        Ok(entity)
358    }
359
360    // ── Query ──
361
362    /// Run a filter expression and return matching entities as a grid.
363    pub fn read(&self, filter_expr: &str, limit: usize) -> Result<HGrid, GraphError> {
364        let results = self.read_all(filter_expr, limit)?;
365
366        if results.is_empty() {
367            return Ok(HGrid::new());
368        }
369
370        // Collect all unique column names.
371        let mut seen: std::collections::HashSet<String> =
372            std::collections::HashSet::with_capacity(results.len().min(64));
373        for entity in &results {
374            for name in entity.tag_names() {
375                seen.insert(name.to_string());
376            }
377        }
378        let mut col_set: Vec<String> = seen.into_iter().collect();
379        col_set.sort();
380        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
381        let rows: Vec<HDict> = results.into_iter().cloned().collect();
382
383        Ok(HGrid::from_parts(HDict::new(), cols, rows))
384    }
385
386    /// Run a filter expression and return matching entities as references.
387    pub fn read_all(&self, filter_expr: &str, limit: usize) -> Result<Vec<&HDict>, GraphError> {
388        let effective_limit = if limit == 0 { usize::MAX } else { limit };
389
390        // Check query cache (version-keyed, so mutations auto-invalidate).
391        {
392            let mut cache = self.query_cache.lock();
393            if let Some(cached_refs) = cache.get(filter_expr, self.version) {
394                let mut results = Vec::new();
395                for rv in cached_refs {
396                    if results.len() >= effective_limit {
397                        break;
398                    }
399                    if let Some(entity) = self.entities.get(rv) {
400                        results.push(entity);
401                    }
402                }
403                return Ok(results);
404            }
405        }
406
407        // Use cached AST or parse and cache it (ASTs are version-independent).
408        let ast = {
409            let mut ast_cache = self.ast_cache.lock();
410            if let Some(cached) = ast_cache.get(filter_expr) {
411                cached.clone()
412            } else {
413                let parsed =
414                    parse_filter(filter_expr).map_err(|e| GraphError::Filter(e.to_string()))?;
415                ast_cache.insert(filter_expr.to_string(), parsed.clone());
416                parsed
417            }
418        };
419
420        // Phase 1: bitmap acceleration (with value index + adjacency).
421        let max_id = self.next_id;
422        let candidates = query_planner::bitmap_candidates_with_values(
423            &ast,
424            &self.tag_index,
425            &self.value_index,
426            &self.adjacency,
427            max_id,
428        );
429
430        // Phase 2: full filter evaluation.
431        let resolver = |r: &HRef| -> Option<&HDict> { self.entities.get(&r.val) };
432        let ns = self.namespace.as_ref();
433
434        // Use parallel evaluation only for large unlimited queries where rayon
435        // overhead is worthwhile. Bounded queries always use sequential + early exit.
436        const PARALLEL_THRESHOLD: usize = 500;
437        let use_parallel = effective_limit == usize::MAX;
438
439        let mut results: Vec<&HDict>;
440
441        if let Some(ref bitmap) = candidates {
442            let candidate_ids: Vec<usize> = TagBitmapIndex::iter_set_bits(bitmap).collect();
443
444            if candidate_ids.len() >= PARALLEL_THRESHOLD && use_parallel {
445                results = candidate_ids
446                    .par_iter()
447                    .filter_map(|&eid| {
448                        let ref_val = self.reverse_id.get(&eid)?;
449                        let entity = self.entities.get(ref_val)?;
450                        if matches_with_ns(&ast, entity, Some(&resolver), ns) {
451                            Some(entity)
452                        } else {
453                            None
454                        }
455                    })
456                    .collect();
457            } else {
458                results = Vec::new();
459                for eid in TagBitmapIndex::iter_set_bits(bitmap) {
460                    if results.len() >= effective_limit {
461                        break;
462                    }
463                    if let Some(ref_val) = self.reverse_id.get(&eid)
464                        && let Some(entity) = self.entities.get(ref_val)
465                        && matches_with_ns(&ast, entity, Some(&resolver), ns)
466                    {
467                        results.push(entity);
468                    }
469                }
470            }
471        } else {
472            let entity_count = self.entities.len();
473
474            if entity_count >= PARALLEL_THRESHOLD && use_parallel {
475                results = self
476                    .entities
477                    .par_iter()
478                    .filter_map(|(_, entity)| {
479                        if matches_with_ns(&ast, entity, Some(&resolver), ns) {
480                            Some(entity)
481                        } else {
482                            None
483                        }
484                    })
485                    .collect();
486            } else {
487                results = Vec::new();
488                for entity in self.entities.values() {
489                    if results.len() >= effective_limit {
490                        break;
491                    }
492                    if matches_with_ns(&ast, entity, Some(&resolver), ns) {
493                        results.push(entity);
494                    }
495                }
496            }
497        }
498
499        if results.len() > effective_limit {
500            results.truncate(effective_limit);
501        }
502
503        // Populate cache with result ref_vals (only for unlimited queries to
504        // avoid caching partial results that depend on limit).
505        if limit == 0 {
506            let ref_vals: Vec<String> = results
507                .iter()
508                .filter_map(|e| {
509                    e.get("id").and_then(|k| match k {
510                        Kind::Ref(r) => Some(r.val.clone()),
511                        _ => None,
512                    })
513                })
514                .collect();
515            let mut cache = self.query_cache.lock();
516            cache.insert(filter_expr.to_string(), self.version, ref_vals);
517        }
518
519        Ok(results)
520    }
521
522    // ── Ref traversal ──
523
524    /// Get ref values that the given entity points to.
525    pub fn refs_from(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
526        match self.id_map.get(ref_val) {
527            Some(&eid) => {
528                if let Some(csr) = &self.csr {
529                    csr.targets_from(eid, ref_type)
530                } else {
531                    self.adjacency.targets_from(eid, ref_type)
532                }
533            }
534            None => Vec::new(),
535        }
536    }
537
538    /// Get ref values of entities that point to the given entity.
539    pub fn refs_to(&self, ref_val: &str, ref_type: Option<&str>) -> Vec<String> {
540        if let Some(csr) = &self.csr {
541            csr.sources_to(ref_val, ref_type)
542                .iter()
543                .filter_map(|eid| self.reverse_id.get(eid).cloned())
544                .collect()
545        } else {
546            self.adjacency
547                .sources_to(ref_val, ref_type)
548                .iter()
549                .filter_map(|eid| self.reverse_id.get(eid).cloned())
550                .collect()
551        }
552    }
553
554    /// Rebuild the CSR snapshot from the current adjacency.
555    /// Should be called after a batch of mutations (e.g., import, sync).
556    pub fn rebuild_csr(&mut self) {
557        let max_id = if self.next_id > 0 { self.next_id } else { 0 };
558        self.csr = Some(CsrAdjacency::from_ref_adjacency(&self.adjacency, max_id));
559        self.csr_version = self.version;
560    }
561
562    /// Returns true if the CSR snapshot is stale (version mismatch).
563    pub fn csr_is_stale(&self) -> bool {
564        match &self.csr {
565            Some(_) => self.csr_version != self.version,
566            None => true,
567        }
568    }
569
570    // ── Graph traversal ──
571
572    /// Return all edges in the graph as `(source_ref, ref_tag, target_ref)` tuples.
573    pub fn all_edges(&self) -> Vec<(String, String, String)> {
574        let mut edges = Vec::new();
575        for (&eid, ref_val) in &self.reverse_id {
576            if let Some(fwd) = self.adjacency.forward_raw().get(&eid) {
577                for (ref_tag, target) in fwd {
578                    edges.push((ref_val.clone(), ref_tag.clone(), target.clone()));
579                }
580            }
581        }
582        edges
583    }
584
585    /// BFS neighborhood: return entities and edges within `hops` of `ref_val`.
586    ///
587    /// `ref_types` optionally restricts which ref tags are traversed.
588    /// Returns `(entities, edges)` where edges are `(source, ref_tag, target)`.
589    pub fn neighbors(
590        &self,
591        ref_val: &str,
592        hops: usize,
593        ref_types: Option<&[&str]>,
594    ) -> (Vec<&HDict>, Vec<(String, String, String)>) {
595        use std::collections::{HashSet, VecDeque};
596
597        let start_eid = match self.id_map.get(ref_val) {
598            Some(&eid) => eid,
599            None => return (Vec::new(), Vec::new()),
600        };
601
602        let mut visited: HashSet<usize> = HashSet::new();
603        let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
604        let mut result_entities: Vec<&HDict> = Vec::with_capacity(16);
605        let mut result_edges: Vec<(String, String, String)> = Vec::with_capacity(16);
606
607        visited.insert(start_eid);
608        queue.push_back((start_eid, 0));
609
610        if let Some(entity) = self.entities.get(ref_val) {
611            result_entities.push(entity);
612        }
613
614        while let Some((current_eid, depth)) = queue.pop_front() {
615            if depth >= hops {
616                continue;
617            }
618            let current_ref = match self.reverse_id.get(&current_eid) {
619                Some(s) => s.as_str(),
620                None => continue,
621            };
622
623            // Traverse forward edges
624            if let Some(fwd) = self.adjacency.forward_raw().get(&current_eid) {
625                for (ref_tag, target) in fwd {
626                    if let Some(types) = ref_types
627                        && !types.iter().any(|t| t == ref_tag)
628                    {
629                        continue;
630                    }
631                    result_edges.push((current_ref.to_string(), ref_tag.clone(), target.clone()));
632                    if let Some(&target_eid) = self.id_map.get(target.as_str())
633                        && visited.insert(target_eid)
634                    {
635                        if let Some(entity) = self.entities.get(target.as_str()) {
636                            result_entities.push(entity);
637                        }
638                        queue.push_back((target_eid, depth + 1));
639                    }
640                }
641            }
642            // Traverse reverse edges
643            if let Some(rev) = self.adjacency.reverse_raw().get(current_ref) {
644                for (ref_tag, source_eid) in rev {
645                    if let Some(types) = ref_types
646                        && !types.iter().any(|t| t == ref_tag)
647                    {
648                        continue;
649                    }
650                    if let Some(source_ref) = self.reverse_id.get(source_eid) {
651                        result_edges.push((
652                            source_ref.clone(),
653                            ref_tag.clone(),
654                            current_ref.to_string(),
655                        ));
656                        if visited.insert(*source_eid) {
657                            if let Some(entity) = self.entities.get(source_ref.as_str()) {
658                                result_entities.push(entity);
659                            }
660                            queue.push_back((*source_eid, depth + 1));
661                        }
662                    }
663                }
664            }
665        }
666
667        result_entities.sort_by(|a, b| {
668            let a_id = a.id().map(|r| r.val.as_str()).unwrap_or("");
669            let b_id = b.id().map(|r| r.val.as_str()).unwrap_or("");
670            a_id.cmp(b_id)
671        });
672        result_edges.sort();
673        // Deduplicate edges (reverse traversal can produce duplicates)
674        result_edges.dedup();
675
676        (result_entities, result_edges)
677    }
678
679    /// BFS shortest path from `from` to `to`. Returns ordered ref_vals, or
680    /// empty vec if no path exists.
681    pub fn shortest_path(&self, from: &str, to: &str) -> Vec<String> {
682        use std::collections::{HashMap as StdHashMap, VecDeque};
683
684        if from == to {
685            return vec![from.to_string()];
686        }
687        let from_eid = match self.id_map.get(from) {
688            Some(&eid) => eid,
689            None => return Vec::new(),
690        };
691        let to_eid = match self.id_map.get(to) {
692            Some(&eid) => eid,
693            None => return Vec::new(),
694        };
695
696        // parent map: child_eid -> parent_eid (usize::MAX = root sentinel)
697        let mut parents: StdHashMap<usize, usize> = StdHashMap::new();
698        let mut queue: VecDeque<usize> = VecDeque::new();
699        parents.insert(from_eid, usize::MAX);
700        queue.push_back(from_eid);
701
702        while let Some(current_eid) = queue.pop_front() {
703            let current_ref = match self.reverse_id.get(&current_eid) {
704                Some(s) => s.as_str(),
705                None => continue,
706            };
707
708            // Forward edges
709            if let Some(fwd) = self.adjacency.forward_raw().get(&current_eid) {
710                for (_, target) in fwd {
711                    if let Some(&target_eid) = self.id_map.get(target.as_str())
712                        && let std::collections::hash_map::Entry::Vacant(e) =
713                            parents.entry(target_eid)
714                    {
715                        e.insert(current_eid);
716                        if target_eid == to_eid {
717                            return Self::reconstruct_path_usize(
718                                &parents,
719                                to_eid,
720                                &self.reverse_id,
721                            );
722                        }
723                        queue.push_back(target_eid);
724                    }
725                }
726            }
727            // Reverse edges
728            if let Some(rev) = self.adjacency.reverse_raw().get(current_ref) {
729                for (_, source_eid) in rev {
730                    if !parents.contains_key(source_eid) {
731                        parents.insert(*source_eid, current_eid);
732                        if *source_eid == to_eid {
733                            return Self::reconstruct_path_usize(
734                                &parents,
735                                to_eid,
736                                &self.reverse_id,
737                            );
738                        }
739                        queue.push_back(*source_eid);
740                    }
741                }
742            }
743        }
744
745        Vec::new() // No path found
746    }
747
748    /// Reconstruct path from usize-based BFS parent map.
749    fn reconstruct_path_usize(
750        parents: &std::collections::HashMap<usize, usize>,
751        to_eid: usize,
752        reverse_id: &HashMap<usize, String>,
753    ) -> Vec<String> {
754        let mut path_eids = vec![to_eid];
755        let mut current = to_eid;
756        while let Some(&parent) = parents.get(&current) {
757            if parent == usize::MAX {
758                break;
759            }
760            path_eids.push(parent);
761            current = parent;
762        }
763        path_eids.reverse();
764        path_eids
765            .iter()
766            .filter_map(|eid| reverse_id.get(eid).cloned())
767            .collect()
768    }
769
770    /// Return the subtree rooted at `root` up to `max_depth` levels.
771    ///
772    /// Walks reverse refs (children referencing parent). Returns entities
773    /// paired with their depth from root. Root is depth 0.
774    pub fn subtree(&self, root: &str, max_depth: usize) -> Vec<(&HDict, usize)> {
775        use std::collections::{HashSet, VecDeque};
776
777        let mut visited: HashSet<String> = HashSet::new();
778        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
779        let mut results: Vec<(&HDict, usize)> = Vec::new();
780
781        visited.insert(root.to_string());
782        queue.push_back((root.to_string(), 0));
783
784        if let Some(entity) = self.entities.get(root) {
785            results.push((entity, 0));
786        } else {
787            return Vec::new();
788        }
789
790        while let Some((current, depth)) = queue.pop_front() {
791            if depth >= max_depth {
792                continue;
793            }
794            // Children = entities that reference current (reverse refs)
795            let child_refs = self.refs_to(&current, None);
796            for child_ref in child_refs {
797                if visited.insert(child_ref.clone())
798                    && let Some(entity) = self.entities.get(&child_ref)
799                {
800                    results.push((entity, depth + 1));
801                    queue.push_back((child_ref, depth + 1));
802                }
803            }
804        }
805
806        results
807    }
808
809    // ── Haystack Hierarchy Helpers ──
810
811    /// Walk a chain of ref tags starting from an entity.
812    ///
813    /// For example, `ref_chain("point-1", &["equipRef", "siteRef"])` follows
814    /// `point-1` → its `equipRef` → that entity's `siteRef`, returning the
815    /// ordered path of resolved entities (excluding the starting entity).
816    pub fn ref_chain(&self, ref_val: &str, ref_tags: &[&str]) -> Vec<&HDict> {
817        let mut result = Vec::with_capacity(ref_tags.len());
818        let mut current = ref_val.to_string();
819        for tag in ref_tags {
820            let entity = match self.entities.get(&current) {
821                Some(e) => e,
822                None => break,
823            };
824            match entity.get(tag) {
825                Some(Kind::Ref(r)) => {
826                    current = r.val.clone();
827                    if let Some(target) = self.entities.get(&current) {
828                        result.push(target);
829                    } else {
830                        break;
831                    }
832                }
833                _ => break,
834            }
835        }
836        result
837    }
838
839    /// Resolve the site for any entity by walking `equipRef` → `siteRef`.
840    ///
841    /// If the entity itself has a `site` marker, returns it directly.
842    /// Otherwise walks the standard Haystack ref chain.
843    pub fn site_for(&self, ref_val: &str) -> Option<&HDict> {
844        let entity = self.entities.get(ref_val)?;
845        // If the entity is itself a site, return it.
846        if entity.has("site") {
847            return Some(entity);
848        }
849        // Check direct siteRef.
850        if let Some(Kind::Ref(r)) = entity.get("siteRef") {
851            return self.entities.get(&r.val);
852        }
853        // Walk equipRef → siteRef.
854        if let Some(Kind::Ref(r)) = entity.get("equipRef")
855            && let Some(equip) = self.entities.get(&r.val)
856            && let Some(Kind::Ref(sr)) = equip.get("siteRef")
857        {
858            return self.entities.get(&sr.val);
859        }
860        None
861    }
862
863    /// All direct children: entities with any ref tag pointing to this entity.
864    pub fn children(&self, ref_val: &str) -> Vec<&HDict> {
865        self.refs_to(ref_val, None)
866            .iter()
867            .filter_map(|r| self.entities.get(r))
868            .collect()
869    }
870
871    /// All points for an equip — children with the `point` marker.
872    ///
873    /// Optionally filter further with a filter expression.
874    pub fn equip_points(&self, equip_ref: &str, filter: Option<&str>) -> Vec<&HDict> {
875        let points: Vec<&HDict> = self
876            .children(equip_ref)
877            .into_iter()
878            .filter(|e| e.has("point"))
879            .collect();
880        match filter {
881            Some(expr) => {
882                let ast = match crate::filter::parse_filter(expr) {
883                    Ok(ast) => ast,
884                    Err(_) => return points,
885                };
886                points
887                    .into_iter()
888                    .filter(|e| crate::filter::matches(&ast, e, None))
889                    .collect()
890            }
891            None => points,
892        }
893    }
894
895    // ── Spec-aware ──
896
897    /// Find all entities that structurally fit a spec/type name.
898    ///
899    /// Requires a namespace to be set. Returns empty if no namespace.
900    pub fn entities_fitting(&self, spec_name: &str) -> Vec<&HDict> {
901        match &self.namespace {
902            Some(ns) => self
903                .entities
904                .values()
905                .filter(|e| ns.fits(e, spec_name))
906                .collect(),
907            None => Vec::new(),
908        }
909    }
910
911    /// Validate all entities against the namespace and check for dangling refs.
912    ///
913    /// Returns empty if no namespace is set and no dangling refs exist.
914    pub fn validate(&self) -> Vec<ValidationIssue> {
915        let mut issues: Vec<ValidationIssue> = match &self.namespace {
916            Some(ns) => self
917                .entities
918                .values()
919                .flat_map(|e| ns.validate_entity(e))
920                .collect(),
921            None => Vec::new(),
922        };
923
924        // Check for dangling refs: Ref values (except `id`) that point to
925        // entities not present in the graph.
926        for entity in self.entities.values() {
927            let entity_ref = entity.id().map(|r| r.val.as_str());
928            for (name, val) in entity.iter() {
929                if name == "id" {
930                    continue;
931                }
932                if let Kind::Ref(r) = val
933                    && !self.entities.contains_key(&r.val)
934                {
935                    issues.push(ValidationIssue {
936                        entity: entity_ref.map(|s| s.to_string()),
937                        issue_type: "dangling_ref".to_string(),
938                        detail: format!(
939                            "tag '{}' references '{}' which does not exist in the graph",
940                            name, r.val
941                        ),
942                    });
943                }
944            }
945        }
946
947        issues
948    }
949
950    // ── Serialization ──
951
952    /// Convert matching entities to a grid.
953    ///
954    /// If `filter_expr` is empty, exports all entities.
955    /// Otherwise, delegates to `read`.
956    pub fn to_grid(&self, filter_expr: &str) -> Result<HGrid, GraphError> {
957        if filter_expr.is_empty() {
958            let entities: Vec<&HDict> = self.entities.values().collect();
959            return Ok(Self::entities_to_grid(&entities));
960        }
961        self.read(filter_expr, 0)
962    }
963
964    /// Build a grid from a slice of entity references.
965    fn entities_to_grid(entities: &[&HDict]) -> HGrid {
966        if entities.is_empty() {
967            return HGrid::new();
968        }
969
970        let mut col_set: Vec<String> = Vec::new();
971        let mut seen = std::collections::HashSet::new();
972        for entity in entities {
973            for name in entity.tag_names() {
974                if seen.insert(name.to_string()) {
975                    col_set.push(name.to_string());
976                }
977            }
978        }
979        col_set.sort();
980        let cols: Vec<HCol> = col_set.iter().map(|n| HCol::new(n.as_str())).collect();
981        let rows: Vec<HDict> = entities.iter().map(|e| (*e).clone()).collect();
982
983        HGrid::from_parts(HDict::new(), cols, rows)
984    }
985
986    /// Build an EntityGraph from a grid.
987    ///
988    /// Rows without a valid `id` Ref tag are silently skipped.
989    pub fn from_grid(grid: &HGrid, namespace: Option<DefNamespace>) -> Result<Self, GraphError> {
990        let mut graph = match namespace {
991            Some(ns) => Self::with_namespace(ns),
992            None => Self::new(),
993        };
994        for row in &grid.rows {
995            if row.id().is_some() {
996                graph.add(row.clone())?;
997            }
998        }
999        // Bulk import: build CSR once after all adds.
1000        graph.rebuild_csr();
1001        Ok(graph)
1002    }
1003
1004    // ── Change tracking ──
1005
1006    /// Get changelog entries since a given version.
1007    ///
1008    /// Returns `Err(ChangelogGap)` if the requested version has been evicted
1009    /// from the changelog, signalling the subscriber must do a full resync.
1010    pub fn changes_since(&self, version: u64) -> Result<Vec<&GraphDiff>, ChangelogGap> {
1011        let target = version + 1;
1012        // If the floor has advanced past the requested version, the subscriber
1013        // has fallen behind and missed entries.
1014        if self.floor_version > 0 && version < self.floor_version {
1015            return Err(ChangelogGap {
1016                subscriber_version: version,
1017                floor_version: self.floor_version,
1018            });
1019        }
1020        // VecDeque may not be contiguous, so collect matching entries.
1021        Ok(self
1022            .changelog
1023            .iter()
1024            .filter(|d| d.version >= target)
1025            .collect())
1026    }
1027
1028    /// The lowest version still retained in the changelog.
1029    ///
1030    /// Returns 0 if no entries have been evicted.
1031    pub fn floor_version(&self) -> u64 {
1032        self.floor_version
1033    }
1034
1035    /// The configured changelog capacity.
1036    pub fn changelog_capacity(&self) -> usize {
1037        self.changelog_capacity
1038    }
1039
1040    /// Current graph version (monotonically increasing).
1041    pub fn version(&self) -> u64 {
1042        self.version
1043    }
1044
1045    // ── Container ──
1046
1047    /// Number of entities in the graph.
1048    pub fn len(&self) -> usize {
1049        self.entities.len()
1050    }
1051
1052    /// Returns `true` if the graph has no entities.
1053    pub fn is_empty(&self) -> bool {
1054        self.entities.is_empty()
1055    }
1056
1057    /// Returns `true` if an entity with the given ref value exists.
1058    pub fn contains(&self, ref_val: &str) -> bool {
1059        self.entities.contains_key(ref_val)
1060    }
1061
1062    /// Returns references to all entities in the graph.
1063    pub fn all(&self) -> Vec<&HDict> {
1064        self.entities.values().collect()
1065    }
1066
1067    // ── Internal indexing ──
1068
1069    /// Add tag bitmap entries for an entity.
1070    fn index_tags(&mut self, entity_id: usize, entity: &HDict) {
1071        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
1072        self.tag_index.add(entity_id, &tags);
1073
1074        // Update value indexes for any indexed fields present on this entity.
1075        for (name, val) in entity.iter() {
1076            if self.value_index.has_index(name) {
1077                self.value_index.add(entity_id, name, val);
1078            }
1079            // Update columnar store for tracked tags.
1080            if self.columnar.is_tracked(name) {
1081                self.columnar.set(entity_id, name, val);
1082            }
1083        }
1084    }
1085
1086    /// Add ref adjacency entries for an entity.
1087    fn index_refs(&mut self, entity_id: usize, entity: &HDict) {
1088        for (name, val) in entity.iter() {
1089            if let Kind::Ref(r) = val {
1090                // Skip the "id" tag — it is the entity's own identity,
1091                // not a reference edge.
1092                if name != "id" {
1093                    self.adjacency.add(entity_id, name, &r.val);
1094                }
1095            }
1096        }
1097    }
1098
1099    /// Remove all index entries for an entity.
1100    fn remove_indexing(&mut self, entity_id: usize, entity: &HDict) {
1101        let tags: Vec<String> = entity.tag_names().map(|s| s.to_string()).collect();
1102        self.tag_index.remove(entity_id, &tags);
1103        self.adjacency.remove(entity_id);
1104
1105        // Remove from value indexes.
1106        for (name, val) in entity.iter() {
1107            if self.value_index.has_index(name) {
1108                self.value_index.remove(entity_id, name, val);
1109            }
1110        }
1111
1112        // Clear columnar data for this entity.
1113        self.columnar.clear_entity(entity_id);
1114    }
1115
1116    /// Build a full hierarchy subtree as a structured tree.
1117    /// `root` is the entity ref, `max_depth` limits recursion (0 = root only).
1118    pub fn hierarchy_tree(&self, root: &str, max_depth: usize) -> Option<HierarchyNode> {
1119        let entity = self.entities.get(root)?.clone();
1120        Some(self.build_subtree(root, &entity, 0, max_depth))
1121    }
1122
1123    fn build_subtree(
1124        &self,
1125        ref_val: &str,
1126        entity: &HDict,
1127        depth: usize,
1128        max_depth: usize,
1129    ) -> HierarchyNode {
1130        let children = if depth < max_depth {
1131            self.children(ref_val)
1132                .into_iter()
1133                .filter_map(|child| {
1134                    let child_id = child.id()?.val.clone();
1135                    Some(self.build_subtree(&child_id, child, depth + 1, max_depth))
1136                })
1137                .collect()
1138        } else {
1139            Vec::new()
1140        };
1141        HierarchyNode {
1142            entity: entity.clone(),
1143            children,
1144            depth,
1145        }
1146    }
1147
1148    /// Determine the most specific entity type from its markers.
1149    ///
1150    /// Returns the most specific marker tag that identifies the entity type.
1151    /// E.g., an entity with `equip` + `ahu` markers returns `"ahu"` (most specific).
1152    pub fn classify(&self, ref_val: &str) -> Option<String> {
1153        let entity = self.entities.get(ref_val)?;
1154        classify_entity(entity)
1155    }
1156
1157    /// Append a diff to the changelog, capping at the configured capacity.
1158    fn push_changelog(&mut self, mut diff: GraphDiff) {
1159        diff.timestamp = GraphDiff::now_nanos();
1160        self.changelog.push_back(diff);
1161        while self.changelog.len() > self.changelog_capacity {
1162            if let Some(evicted) = self.changelog.pop_front() {
1163                self.floor_version = evicted.version;
1164            }
1165        }
1166    }
1167}
1168
1169impl Default for EntityGraph {
1170    fn default() -> Self {
1171        Self::new()
1172    }
1173}
1174
1175/// A node in a hierarchy tree produced by [`EntityGraph::hierarchy_tree`].
1176#[derive(Debug, Clone)]
1177pub struct HierarchyNode {
1178    pub entity: HDict,
1179    pub children: Vec<HierarchyNode>,
1180    pub depth: usize,
1181}
1182
1183/// Extract the ref value string from an entity's `id` tag.
1184fn extract_ref_val(entity: &HDict) -> Result<String, GraphError> {
1185    match entity.get("id") {
1186        Some(Kind::Ref(r)) => Ok(r.val.clone()),
1187        Some(_) => Err(GraphError::InvalidId),
1188        None => Err(GraphError::MissingId),
1189    }
1190}
1191
1192/// Priority-ordered list of marker tags from most specific to least specific.
1193/// The first match wins.
1194const CLASSIFY_PRIORITY: &[&str] = &[
1195    // Point subtypes
1196    "sensor", "cmd", "sp", // Equipment subtypes
1197    "ahu", "vav", "boiler", "chiller", "meter", // Base categories
1198    "point", "equip", // Space types
1199    "room", "floor", "zone", "space", // Site
1200    "site",  // Other well-known
1201    "weather", "device", "network",
1202];
1203
1204/// Classify an entity by returning the most specific recognized marker tag.
1205fn classify_entity(entity: &HDict) -> Option<String> {
1206    for &tag in CLASSIFY_PRIORITY {
1207        if entity.has(tag) {
1208            return Some(tag.to_string());
1209        }
1210    }
1211    None
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216    use super::*;
1217    use crate::kinds::Number;
1218
1219    fn make_site(id: &str) -> HDict {
1220        let mut d = HDict::new();
1221        d.set("id", Kind::Ref(HRef::from_val(id)));
1222        d.set("site", Kind::Marker);
1223        d.set("dis", Kind::Str(format!("Site {id}")));
1224        d.set(
1225            "area",
1226            Kind::Number(Number::new(4500.0, Some("ft\u{00b2}".into()))),
1227        );
1228        d
1229    }
1230
1231    fn make_equip(id: &str, site_ref: &str) -> HDict {
1232        let mut d = HDict::new();
1233        d.set("id", Kind::Ref(HRef::from_val(id)));
1234        d.set("equip", Kind::Marker);
1235        d.set("dis", Kind::Str(format!("Equip {id}")));
1236        d.set("siteRef", Kind::Ref(HRef::from_val(site_ref)));
1237        d
1238    }
1239
1240    fn make_point(id: &str, equip_ref: &str) -> HDict {
1241        let mut d = HDict::new();
1242        d.set("id", Kind::Ref(HRef::from_val(id)));
1243        d.set("point", Kind::Marker);
1244        d.set("sensor", Kind::Marker);
1245        d.set("temp", Kind::Marker);
1246        d.set("dis", Kind::Str(format!("Point {id}")));
1247        d.set("equipRef", Kind::Ref(HRef::from_val(equip_ref)));
1248        d.set(
1249            "curVal",
1250            Kind::Number(Number::new(72.5, Some("\u{00b0}F".into()))),
1251        );
1252        d
1253    }
1254
1255    // ── Add tests ──
1256
1257    #[test]
1258    fn add_entity_with_valid_id() {
1259        let mut g = EntityGraph::new();
1260        let result = g.add(make_site("site-1"));
1261        assert!(result.is_ok());
1262        assert_eq!(result.unwrap(), "site-1");
1263        assert_eq!(g.len(), 1);
1264    }
1265
1266    #[test]
1267    fn add_entity_missing_id_fails() {
1268        let mut g = EntityGraph::new();
1269        let entity = HDict::new();
1270        let err = g.add(entity).unwrap_err();
1271        assert!(matches!(err, GraphError::MissingId));
1272    }
1273
1274    #[test]
1275    fn add_entity_non_ref_id_fails() {
1276        let mut g = EntityGraph::new();
1277        let mut entity = HDict::new();
1278        entity.set("id", Kind::Str("not-a-ref".into()));
1279        let err = g.add(entity).unwrap_err();
1280        assert!(matches!(err, GraphError::InvalidId));
1281    }
1282
1283    #[test]
1284    fn add_duplicate_ref_fails() {
1285        let mut g = EntityGraph::new();
1286        g.add(make_site("site-1")).unwrap();
1287        let err = g.add(make_site("site-1")).unwrap_err();
1288        assert!(matches!(err, GraphError::DuplicateRef(_)));
1289    }
1290
1291    // ── Get tests ──
1292
1293    #[test]
1294    fn get_existing_entity() {
1295        let mut g = EntityGraph::new();
1296        g.add(make_site("site-1")).unwrap();
1297        let entity = g.get("site-1").unwrap();
1298        assert!(entity.has("site"));
1299        assert_eq!(entity.get("dis"), Some(&Kind::Str("Site site-1".into())));
1300    }
1301
1302    #[test]
1303    fn get_missing_entity_returns_none() {
1304        let g = EntityGraph::new();
1305        assert!(g.get("nonexistent").is_none());
1306    }
1307
1308    // ── Update tests ──
1309
1310    #[test]
1311    fn update_merges_changes() {
1312        let mut g = EntityGraph::new();
1313        g.add(make_site("site-1")).unwrap();
1314
1315        let mut changes = HDict::new();
1316        changes.set("dis", Kind::Str("Updated Site".into()));
1317        changes.set("geoCity", Kind::Str("Richmond".into()));
1318        g.update("site-1", changes).unwrap();
1319
1320        let entity = g.get("site-1").unwrap();
1321        assert_eq!(entity.get("dis"), Some(&Kind::Str("Updated Site".into())));
1322        assert_eq!(entity.get("geoCity"), Some(&Kind::Str("Richmond".into())));
1323        assert!(entity.has("site")); // unchanged
1324    }
1325
1326    #[test]
1327    fn update_missing_entity_fails() {
1328        let mut g = EntityGraph::new();
1329        let err = g.update("nonexistent", HDict::new()).unwrap_err();
1330        assert!(matches!(err, GraphError::NotFound(_)));
1331    }
1332
1333    // ── Remove tests ──
1334
1335    #[test]
1336    fn remove_entity() {
1337        let mut g = EntityGraph::new();
1338        g.add(make_site("site-1")).unwrap();
1339        let removed = g.remove("site-1").unwrap();
1340        assert!(removed.has("site"));
1341        assert!(g.get("site-1").is_none());
1342        assert_eq!(g.len(), 0);
1343    }
1344
1345    #[test]
1346    fn remove_missing_entity_fails() {
1347        let mut g = EntityGraph::new();
1348        let err = g.remove("nonexistent").unwrap_err();
1349        assert!(matches!(err, GraphError::NotFound(_)));
1350    }
1351
1352    // ── Version / changelog tests ──
1353
1354    #[test]
1355    fn version_increments_on_mutations() {
1356        let mut g = EntityGraph::new();
1357        assert_eq!(g.version(), 0);
1358
1359        g.add(make_site("site-1")).unwrap();
1360        assert_eq!(g.version(), 1);
1361
1362        g.update("site-1", HDict::new()).unwrap();
1363        assert_eq!(g.version(), 2);
1364
1365        g.remove("site-1").unwrap();
1366        assert_eq!(g.version(), 3);
1367    }
1368
1369    #[test]
1370    fn changelog_records_add_update_remove() {
1371        let mut g = EntityGraph::new();
1372        g.add(make_site("site-1")).unwrap();
1373        g.update("site-1", HDict::new()).unwrap();
1374        g.remove("site-1").unwrap();
1375
1376        let changes = g.changes_since(0).unwrap();
1377        assert_eq!(changes.len(), 3);
1378
1379        // Add: has new, no old, no deltas.
1380        assert_eq!(changes[0].op, DiffOp::Add);
1381        assert_eq!(changes[0].ref_val, "site-1");
1382        assert!(changes[0].old.is_none());
1383        assert!(changes[0].new.is_some());
1384        assert!(changes[0].changed_tags.is_none());
1385
1386        // Update: has deltas, no old/new.
1387        assert_eq!(changes[1].op, DiffOp::Update);
1388        assert!(changes[1].old.is_none());
1389        assert!(changes[1].new.is_none());
1390        assert!(changes[1].changed_tags.is_some());
1391        assert!(changes[1].previous_tags.is_some());
1392
1393        // Remove: has old, no new, no deltas.
1394        assert_eq!(changes[2].op, DiffOp::Remove);
1395        assert!(changes[2].old.is_some());
1396        assert!(changes[2].new.is_none());
1397        assert!(changes[2].changed_tags.is_none());
1398    }
1399
1400    #[test]
1401    fn changes_since_returns_subset() {
1402        let mut g = EntityGraph::new();
1403        g.add(make_site("site-1")).unwrap(); // v1
1404        g.add(make_site("site-2")).unwrap(); // v2
1405        g.add(make_site("site-3")).unwrap(); // v3
1406
1407        let since_v2 = g.changes_since(2).unwrap();
1408        assert_eq!(since_v2.len(), 1);
1409        assert_eq!(since_v2[0].ref_val, "site-3");
1410    }
1411
1412    #[test]
1413    fn configurable_changelog_capacity() {
1414        let mut g = EntityGraph::with_changelog_capacity(3);
1415        assert_eq!(g.changelog_capacity(), 3);
1416
1417        // Add 5 entities — first 2 should be evicted from changelog.
1418        for i in 0..5 {
1419            g.add(make_site(&format!("site-{i}"))).unwrap();
1420        }
1421
1422        assert_eq!(g.version(), 5);
1423        assert_eq!(g.floor_version(), 2); // v1 and v2 evicted
1424
1425        // Can still get changes from v2 onwards.
1426        let changes = g.changes_since(2).unwrap();
1427        assert_eq!(changes.len(), 3); // v3, v4, v5
1428
1429        // Requesting from v1 (evicted) should return ChangelogGap.
1430        let gap = g.changes_since(1).unwrap_err();
1431        assert_eq!(gap.subscriber_version, 1);
1432        assert_eq!(gap.floor_version, 2);
1433    }
1434
1435    #[test]
1436    fn changelog_gap_on_version_zero_after_eviction() {
1437        let mut g = EntityGraph::with_changelog_capacity(2);
1438        for i in 0..4 {
1439            g.add(make_site(&format!("site-{i}"))).unwrap();
1440        }
1441
1442        // Requesting since v0 after evictions should return gap.
1443        let gap = g.changes_since(0).unwrap_err();
1444        assert_eq!(gap.subscriber_version, 0);
1445        assert!(gap.floor_version > 0);
1446    }
1447
1448    #[test]
1449    fn no_gap_when_capacity_sufficient() {
1450        let mut g = EntityGraph::with_changelog_capacity(100);
1451        for i in 0..50 {
1452            g.add(make_site(&format!("site-{i}"))).unwrap();
1453        }
1454        assert_eq!(g.floor_version(), 0);
1455        let changes = g.changes_since(0).unwrap();
1456        assert_eq!(changes.len(), 50);
1457    }
1458
1459    #[test]
1460    fn changelog_entries_have_timestamps() {
1461        let mut g = EntityGraph::new();
1462        g.add(make_site("site-1")).unwrap();
1463        g.update("site-1", HDict::new()).unwrap();
1464        g.remove("site-1").unwrap();
1465
1466        let changes = g.changes_since(0).unwrap();
1467        for diff in &changes {
1468            assert!(diff.timestamp > 0, "timestamp should be positive");
1469        }
1470        // Timestamps should be non-decreasing.
1471        for pair in changes.windows(2) {
1472            assert!(pair[1].timestamp >= pair[0].timestamp);
1473        }
1474    }
1475
1476    #[test]
1477    fn update_diff_carries_delta_tags() {
1478        let mut g = EntityGraph::new();
1479        let mut site = HDict::new();
1480        site.set("id", Kind::Ref(HRef::from_val("site-1")));
1481        site.set("site", Kind::Marker);
1482        site.set("dis", Kind::Str("Original".into()));
1483        site.set("area", Kind::Number(Number::unitless(1000.0)));
1484        g.add(site).unwrap();
1485
1486        let mut changes = HDict::new();
1487        changes.set("dis", Kind::Str("Updated".into()));
1488        g.update("site-1", changes).unwrap();
1489
1490        let diffs = g.changes_since(1).unwrap(); // skip the Add
1491        assert_eq!(diffs.len(), 1);
1492        let diff = &diffs[0];
1493        assert_eq!(diff.op, DiffOp::Update);
1494
1495        // old/new should be None for Update (delta only).
1496        assert!(diff.old.is_none());
1497        assert!(diff.new.is_none());
1498
1499        // changed_tags has the new value.
1500        let ct = diff.changed_tags.as_ref().unwrap();
1501        assert_eq!(ct.get("dis"), Some(&Kind::Str("Updated".into())));
1502        assert!(ct.get("area").is_none()); // unchanged tag not included
1503
1504        // previous_tags has the old value.
1505        let pt = diff.previous_tags.as_ref().unwrap();
1506        assert_eq!(pt.get("dis"), Some(&Kind::Str("Original".into())));
1507    }
1508
1509    // ── Container tests ──
1510
1511    #[test]
1512    fn contains_check() {
1513        let mut g = EntityGraph::new();
1514        g.add(make_site("site-1")).unwrap();
1515        assert!(g.contains("site-1"));
1516        assert!(!g.contains("site-2"));
1517    }
1518
1519    #[test]
1520    fn len_and_is_empty() {
1521        let mut g = EntityGraph::new();
1522        assert!(g.is_empty());
1523        assert_eq!(g.len(), 0);
1524
1525        g.add(make_site("site-1")).unwrap();
1526        assert!(!g.is_empty());
1527        assert_eq!(g.len(), 1);
1528    }
1529
1530    // ── Query tests ──
1531
1532    #[test]
1533    fn read_with_simple_has_filter() {
1534        let mut g = EntityGraph::new();
1535        g.add(make_site("site-1")).unwrap();
1536        g.add(make_equip("equip-1", "site-1")).unwrap();
1537
1538        let results = g.read_all("site", 0).unwrap();
1539        assert_eq!(results.len(), 1);
1540        assert!(results[0].has("site"));
1541    }
1542
1543    #[test]
1544    fn read_with_comparison_filter() {
1545        let mut g = EntityGraph::new();
1546        g.add(make_point("pt-1", "equip-1")).unwrap();
1547
1548        let results = g.read_all("curVal > 70\u{00b0}F", 0).unwrap();
1549        assert_eq!(results.len(), 1);
1550    }
1551
1552    #[test]
1553    fn read_with_and_filter() {
1554        let mut g = EntityGraph::new();
1555        g.add(make_point("pt-1", "equip-1")).unwrap();
1556        g.add(make_equip("equip-1", "site-1")).unwrap();
1557
1558        let results = g.read_all("point and sensor", 0).unwrap();
1559        assert_eq!(results.len(), 1);
1560    }
1561
1562    #[test]
1563    fn read_with_or_filter() {
1564        let mut g = EntityGraph::new();
1565        g.add(make_site("site-1")).unwrap();
1566        g.add(make_equip("equip-1", "site-1")).unwrap();
1567
1568        let results = g.read_all("site or equip", 0).unwrap();
1569        assert_eq!(results.len(), 2);
1570    }
1571
1572    #[test]
1573    fn read_limit_parameter_works() {
1574        let mut g = EntityGraph::new();
1575        g.add(make_site("site-1")).unwrap();
1576        g.add(make_site("site-2")).unwrap();
1577        g.add(make_site("site-3")).unwrap();
1578
1579        let results = g.read_all("site", 2).unwrap();
1580        assert_eq!(results.len(), 2);
1581    }
1582
1583    #[test]
1584    fn read_returns_grid() {
1585        let mut g = EntityGraph::new();
1586        g.add(make_site("site-1")).unwrap();
1587        g.add(make_site("site-2")).unwrap();
1588
1589        let grid = g.read("site", 0).unwrap();
1590        assert_eq!(grid.len(), 2);
1591        assert!(grid.col("site").is_some());
1592        assert!(grid.col("id").is_some());
1593    }
1594
1595    #[test]
1596    fn read_invalid_filter() {
1597        let g = EntityGraph::new();
1598        let err = g.read("!!!", 0).unwrap_err();
1599        assert!(matches!(err, GraphError::Filter(_)));
1600    }
1601
1602    #[test]
1603    fn query_cache_returns_same_results() {
1604        let mut g = EntityGraph::new();
1605        g.add(make_site("site-1")).unwrap();
1606        g.add(make_equip("equip-1", "site-1")).unwrap();
1607        g.add(make_point("pt-1", "equip-1")).unwrap();
1608
1609        // First call populates cache
1610        let results1 = g.read_all("site", 0).unwrap();
1611        assert_eq!(results1.len(), 1);
1612
1613        // Second call should hit cache and return same results
1614        let results2 = g.read_all("site", 0).unwrap();
1615        assert_eq!(results2.len(), 1);
1616        assert_eq!(results1[0].get("id"), results2[0].get("id"));
1617    }
1618
1619    #[test]
1620    fn query_cache_invalidated_by_mutation() {
1621        let mut g = EntityGraph::new();
1622        g.add(make_site("site-1")).unwrap();
1623
1624        let results = g.read_all("site", 0).unwrap();
1625        assert_eq!(results.len(), 1);
1626
1627        // Add another site — cache should be invalidated by version bump
1628        g.add(make_site("site-2")).unwrap();
1629
1630        let results = g.read_all("site", 0).unwrap();
1631        assert_eq!(results.len(), 2);
1632    }
1633
1634    // ── Ref traversal tests ──
1635
1636    #[test]
1637    fn refs_from_returns_targets() {
1638        let mut g = EntityGraph::new();
1639        g.add(make_site("site-1")).unwrap();
1640        g.add(make_equip("equip-1", "site-1")).unwrap();
1641
1642        let targets = g.refs_from("equip-1", None);
1643        assert_eq!(targets, vec!["site-1".to_string()]);
1644    }
1645
1646    #[test]
1647    fn refs_to_returns_sources() {
1648        let mut g = EntityGraph::new();
1649        g.add(make_site("site-1")).unwrap();
1650        g.add(make_equip("equip-1", "site-1")).unwrap();
1651        g.add(make_equip("equip-2", "site-1")).unwrap();
1652
1653        let mut sources = g.refs_to("site-1", None);
1654        sources.sort();
1655        assert_eq!(sources.len(), 2);
1656    }
1657
1658    #[test]
1659    fn type_filtered_ref_queries() {
1660        let mut g = EntityGraph::new();
1661        g.add(make_site("site-1")).unwrap();
1662        g.add(make_equip("equip-1", "site-1")).unwrap();
1663
1664        let targets = g.refs_from("equip-1", Some("siteRef"));
1665        assert_eq!(targets, vec!["site-1".to_string()]);
1666
1667        let targets = g.refs_from("equip-1", Some("equipRef"));
1668        assert!(targets.is_empty());
1669    }
1670
1671    #[test]
1672    fn refs_from_nonexistent_entity() {
1673        let g = EntityGraph::new();
1674        assert!(g.refs_from("nonexistent", None).is_empty());
1675    }
1676
1677    #[test]
1678    fn refs_to_nonexistent_entity() {
1679        let g = EntityGraph::new();
1680        assert!(g.refs_to("nonexistent", None).is_empty());
1681    }
1682
1683    // ── Serialization tests ──
1684
1685    #[test]
1686    fn from_grid_round_trip() {
1687        let mut g = EntityGraph::new();
1688        g.add(make_site("site-1")).unwrap();
1689        g.add(make_equip("equip-1", "site-1")).unwrap();
1690
1691        let grid = g.to_grid("site or equip").unwrap();
1692        assert_eq!(grid.len(), 2);
1693
1694        let g2 = EntityGraph::from_grid(&grid, None).unwrap();
1695        assert_eq!(g2.len(), 2);
1696        assert!(g2.contains("site-1"));
1697        assert!(g2.contains("equip-1"));
1698    }
1699
1700    #[test]
1701    fn to_grid_empty_result() {
1702        let g = EntityGraph::new();
1703        let grid = g.to_grid("site").unwrap();
1704        assert!(grid.is_empty());
1705    }
1706
1707    // ── Update re-indexes correctly ──
1708
1709    #[test]
1710    fn update_reindexes_tags() {
1711        let mut g = EntityGraph::new();
1712        g.add(make_site("site-1")).unwrap();
1713
1714        // Should find the site with "site" filter.
1715        assert_eq!(g.read_all("site", 0).unwrap().len(), 1);
1716
1717        // Remove the "site" marker via update.
1718        let mut changes = HDict::new();
1719        changes.set("site", Kind::Remove);
1720        g.update("site-1", changes).unwrap();
1721
1722        // Should no longer match "site" filter.
1723        assert_eq!(g.read_all("site", 0).unwrap().len(), 0);
1724    }
1725
1726    #[test]
1727    fn update_reindexes_refs() {
1728        let mut g = EntityGraph::new();
1729        g.add(make_site("site-1")).unwrap();
1730        g.add(make_site("site-2")).unwrap();
1731        g.add(make_equip("equip-1", "site-1")).unwrap();
1732
1733        // Initially equip-1 points to site-1.
1734        assert_eq!(g.refs_from("equip-1", None), vec!["site-1".to_string()]);
1735
1736        // Move equip-1 to site-2.
1737        let mut changes = HDict::new();
1738        changes.set("siteRef", Kind::Ref(HRef::from_val("site-2")));
1739        g.update("equip-1", changes).unwrap();
1740
1741        assert_eq!(g.refs_from("equip-1", None), vec!["site-2".to_string()]);
1742        assert!(g.refs_to("site-1", None).is_empty());
1743    }
1744
1745    // ── Dangling ref validation ──
1746
1747    #[test]
1748    fn validate_detects_dangling_refs() {
1749        let mut g = EntityGraph::new();
1750        g.add(make_site("site-1")).unwrap();
1751        // equip-1 has siteRef pointing to "site-1" (exists) — no issue
1752        g.add(make_equip("equip-1", "site-1")).unwrap();
1753        // equip-2 has siteRef pointing to "site-999" (does not exist) — dangling
1754        g.add(make_equip("equip-2", "site-999")).unwrap();
1755
1756        let issues = g.validate();
1757        assert!(!issues.is_empty());
1758
1759        let dangling: Vec<_> = issues
1760            .iter()
1761            .filter(|i| i.issue_type == "dangling_ref")
1762            .collect();
1763        assert_eq!(dangling.len(), 1);
1764        assert_eq!(dangling[0].entity.as_deref(), Some("equip-2"));
1765        assert!(dangling[0].detail.contains("site-999"));
1766        assert!(dangling[0].detail.contains("siteRef"));
1767    }
1768
1769    // ── Empty filter exports all ──
1770
1771    #[test]
1772    fn to_grid_empty_filter_exports_all() {
1773        let mut g = EntityGraph::new();
1774        g.add(make_site("site-1")).unwrap();
1775        g.add(make_equip("equip-1", "site-1")).unwrap();
1776        g.add(make_point("pt-1", "equip-1")).unwrap();
1777
1778        let grid = g.to_grid("").unwrap();
1779        assert_eq!(grid.len(), 3);
1780        assert!(grid.col("id").is_some());
1781    }
1782
1783    // ── from_grid skips rows without id ──
1784
1785    #[test]
1786    fn changelog_bounded_to_max_size() {
1787        // Use a small capacity to test bounding without 50K iterations.
1788        let mut graph = EntityGraph::with_changelog_capacity(100);
1789        for i in 0..200 {
1790            let mut d = HDict::new();
1791            d.set("id", Kind::Ref(HRef::from_val(format!("e{i}"))));
1792            d.set("dis", Kind::Str(format!("Entity {i}")));
1793            graph.add(d).unwrap();
1794        }
1795        // Changelog should be capped at capacity.
1796        // Requesting since floor_version should succeed.
1797        let floor = graph.floor_version();
1798        assert!(floor > 0);
1799        let changes = graph.changes_since(floor).unwrap();
1800        assert!(changes.len() <= 100);
1801        // Latest changes should still be present.
1802        assert!(graph.changes_since(199).unwrap().len() <= 1);
1803        // Old versions should return gap.
1804        assert!(graph.changes_since(0).is_err());
1805    }
1806
1807    #[test]
1808    fn from_grid_skips_rows_without_id() {
1809        let cols = vec![HCol::new("id"), HCol::new("dis"), HCol::new("site")];
1810
1811        let mut row_with_id = HDict::new();
1812        row_with_id.set("id", Kind::Ref(HRef::from_val("site-1")));
1813        row_with_id.set("site", Kind::Marker);
1814        row_with_id.set("dis", Kind::Str("Has ID".into()));
1815
1816        // Row with string id (not a Ref) — should be skipped.
1817        let mut row_bad_id = HDict::new();
1818        row_bad_id.set("id", Kind::Str("not-a-ref".into()));
1819        row_bad_id.set("dis", Kind::Str("Bad ID".into()));
1820
1821        // Row with no id at all — should be skipped.
1822        let mut row_no_id = HDict::new();
1823        row_no_id.set("dis", Kind::Str("No ID".into()));
1824
1825        let grid = HGrid::from_parts(HDict::new(), cols, vec![row_with_id, row_bad_id, row_no_id]);
1826        let g = EntityGraph::from_grid(&grid, None).unwrap();
1827
1828        assert_eq!(g.len(), 1);
1829        assert!(g.contains("site-1"));
1830    }
1831
1832    // ── Graph traversal method tests ──
1833
1834    fn build_hierarchy_graph() -> EntityGraph {
1835        let mut g = EntityGraph::new();
1836        g.add(make_site("s1")).unwrap();
1837        g.add(make_site("s2")).unwrap();
1838        g.add(make_equip("e1", "s1")).unwrap();
1839        g.add(make_equip("e2", "s1")).unwrap();
1840        g.add(make_equip("e3", "s2")).unwrap();
1841        g.add(make_point("p1", "e1")).unwrap();
1842        g.add(make_point("p2", "e1")).unwrap();
1843        g.add(make_point("p3", "e2")).unwrap();
1844        g
1845    }
1846
1847    #[test]
1848    fn all_edges_returns_all_ref_relationships() {
1849        let g = build_hierarchy_graph();
1850        let edges = g.all_edges();
1851        // e1->s1 (siteRef), e2->s1, e3->s2, p1->e1 (equipRef), p2->e1, p3->e2
1852        assert_eq!(edges.len(), 6);
1853        assert!(
1854            edges
1855                .iter()
1856                .any(|(s, t, d)| s == "e1" && t == "siteRef" && d == "s1")
1857        );
1858        assert!(
1859            edges
1860                .iter()
1861                .any(|(s, t, d)| s == "p1" && t == "equipRef" && d == "e1")
1862        );
1863    }
1864
1865    #[test]
1866    fn all_edges_empty_graph() {
1867        let g = EntityGraph::new();
1868        assert!(g.all_edges().is_empty());
1869    }
1870
1871    #[test]
1872    fn neighbors_one_hop() {
1873        let g = build_hierarchy_graph();
1874        let (entities, edges) = g.neighbors("e1", 1, None);
1875        // e1 + s1 (forward via siteRef) + p1, p2 (reverse from equipRef)
1876        let ids: Vec<String> = entities
1877            .iter()
1878            .filter_map(|e| e.id().map(|r| r.val.clone()))
1879            .collect();
1880        assert!(ids.contains(&"e1".to_string()));
1881        assert!(ids.contains(&"s1".to_string()));
1882        assert!(ids.contains(&"p1".to_string()));
1883        assert!(ids.contains(&"p2".to_string()));
1884        assert!(!edges.is_empty());
1885    }
1886
1887    #[test]
1888    fn neighbors_with_ref_type_filter() {
1889        let g = build_hierarchy_graph();
1890        let (entities, edges) = g.neighbors("e1", 1, Some(&["siteRef"]));
1891        // Only forward siteRef edge: e1->s1
1892        let ids: Vec<String> = entities
1893            .iter()
1894            .filter_map(|e| e.id().map(|r| r.val.clone()))
1895            .collect();
1896        assert!(ids.contains(&"e1".to_string()));
1897        assert!(ids.contains(&"s1".to_string()));
1898        // Should not include p1/p2 (those connect via equipRef)
1899        assert!(!ids.contains(&"p1".to_string()));
1900        // Edges should only contain siteRef
1901        assert!(edges.iter().all(|(_, tag, _)| tag == "siteRef"));
1902    }
1903
1904    #[test]
1905    fn neighbors_zero_hops() {
1906        let g = build_hierarchy_graph();
1907        let (entities, edges) = g.neighbors("e1", 0, None);
1908        assert_eq!(entities.len(), 1);
1909        assert!(edges.is_empty());
1910    }
1911
1912    #[test]
1913    fn neighbors_nonexistent_entity() {
1914        let g = build_hierarchy_graph();
1915        let (entities, _) = g.neighbors("nonexistent", 1, None);
1916        assert!(entities.is_empty());
1917    }
1918
1919    #[test]
1920    fn shortest_path_direct() {
1921        let g = build_hierarchy_graph();
1922        let path = g.shortest_path("e1", "s1");
1923        assert_eq!(path, vec!["e1".to_string(), "s1".to_string()]);
1924    }
1925
1926    #[test]
1927    fn shortest_path_two_hops() {
1928        let g = build_hierarchy_graph();
1929        let path = g.shortest_path("p1", "s1");
1930        // p1 -> e1 -> s1
1931        assert_eq!(path.len(), 3);
1932        assert_eq!(path[0], "p1");
1933        assert_eq!(path[2], "s1");
1934    }
1935
1936    #[test]
1937    fn shortest_path_same_node() {
1938        let g = build_hierarchy_graph();
1939        let path = g.shortest_path("s1", "s1");
1940        assert_eq!(path, vec!["s1".to_string()]);
1941    }
1942
1943    #[test]
1944    fn shortest_path_no_connection() {
1945        // s1 and s2 are not connected to each other directly
1946        let g = build_hierarchy_graph();
1947        let path = g.shortest_path("s1", "s2");
1948        // They are disconnected (no edges between them)
1949        assert!(path.is_empty());
1950    }
1951
1952    #[test]
1953    fn shortest_path_nonexistent() {
1954        let g = build_hierarchy_graph();
1955        let path = g.shortest_path("s1", "nonexistent");
1956        assert!(path.is_empty());
1957    }
1958
1959    #[test]
1960    fn subtree_from_site() {
1961        let g = build_hierarchy_graph();
1962        let tree = g.subtree("s1", 10);
1963        // s1 (depth 0), e1 (1), e2 (1), p1 (2), p2 (2), p3 (2)
1964        assert_eq!(tree.len(), 6);
1965        // Root is at depth 0
1966        assert_eq!(tree[0].0.id().unwrap().val, "s1");
1967        assert_eq!(tree[0].1, 0);
1968        // Equips at depth 1
1969        let depth_1: Vec<_> = tree.iter().filter(|(_, d)| *d == 1).collect();
1970        assert_eq!(depth_1.len(), 2);
1971        // Points at depth 2
1972        let depth_2: Vec<_> = tree.iter().filter(|(_, d)| *d == 2).collect();
1973        assert_eq!(depth_2.len(), 3);
1974    }
1975
1976    #[test]
1977    fn subtree_max_depth_1() {
1978        let g = build_hierarchy_graph();
1979        let tree = g.subtree("s1", 1);
1980        // s1 (depth 0), e1 (1), e2 (1) — no points (depth 2)
1981        assert_eq!(tree.len(), 3);
1982    }
1983
1984    #[test]
1985    fn subtree_nonexistent_root() {
1986        let g = build_hierarchy_graph();
1987        let tree = g.subtree("nonexistent", 10);
1988        assert!(tree.is_empty());
1989    }
1990
1991    #[test]
1992    fn subtree_leaf_node() {
1993        let g = build_hierarchy_graph();
1994        let tree = g.subtree("p1", 10);
1995        // p1 has no children referencing it
1996        assert_eq!(tree.len(), 1);
1997        assert_eq!(tree[0].0.id().unwrap().val, "p1");
1998    }
1999
2000    // ── Haystack Hierarchy Helper tests ──
2001
2002    #[test]
2003    fn ref_chain_walks_equip_to_site() {
2004        let g = build_hierarchy_graph();
2005        // p1 → equipRef=e1 → siteRef=s1
2006        let chain = g.ref_chain("p1", &["equipRef", "siteRef"]);
2007        assert_eq!(chain.len(), 2);
2008        assert_eq!(chain[0].id().unwrap().val, "e1");
2009        assert_eq!(chain[1].id().unwrap().val, "s1");
2010    }
2011
2012    #[test]
2013    fn ref_chain_stops_on_missing_tag() {
2014        let g = build_hierarchy_graph();
2015        // e1 has siteRef but no spaceRef — should return just the site.
2016        let chain = g.ref_chain("e1", &["siteRef", "spaceRef"]);
2017        assert_eq!(chain.len(), 1);
2018        assert_eq!(chain[0].id().unwrap().val, "s1");
2019    }
2020
2021    #[test]
2022    fn ref_chain_empty_for_nonexistent() {
2023        let g = build_hierarchy_graph();
2024        let chain = g.ref_chain("nonexistent", &["equipRef"]);
2025        assert!(chain.is_empty());
2026    }
2027
2028    #[test]
2029    fn site_for_returns_site_itself() {
2030        let g = build_hierarchy_graph();
2031        let site = g.site_for("s1").unwrap();
2032        assert_eq!(site.id().unwrap().val, "s1");
2033    }
2034
2035    #[test]
2036    fn site_for_walks_from_point() {
2037        let g = build_hierarchy_graph();
2038        // p1 → equipRef=e1 → siteRef=s1
2039        let site = g.site_for("p1").unwrap();
2040        assert_eq!(site.id().unwrap().val, "s1");
2041    }
2042
2043    #[test]
2044    fn site_for_walks_from_equip() {
2045        let g = build_hierarchy_graph();
2046        let site = g.site_for("e1").unwrap();
2047        assert_eq!(site.id().unwrap().val, "s1");
2048    }
2049
2050    #[test]
2051    fn children_returns_direct_refs() {
2052        let g = build_hierarchy_graph();
2053        let kids = g.children("s1");
2054        // e1 and e2 reference s1 via siteRef.
2055        let ids: Vec<&str> = kids.iter().map(|e| e.id().unwrap().val.as_str()).collect();
2056        assert!(ids.contains(&"e1"));
2057        assert!(ids.contains(&"e2"));
2058    }
2059
2060    #[test]
2061    fn equip_points_returns_points_only() {
2062        let g = build_hierarchy_graph();
2063        let points = g.equip_points("e1", None);
2064        assert_eq!(points.len(), 2); // p1, p2
2065        for p in &points {
2066            assert!(p.has("point"));
2067        }
2068    }
2069
2070    #[test]
2071    fn equip_points_with_filter() {
2072        let mut g = build_hierarchy_graph();
2073        // Existing points already have temp marker. Add one with flow instead.
2074        let mut pf = HDict::new();
2075        pf.set("id", Kind::Ref(HRef::from_val("pf")));
2076        pf.set("point", Kind::Marker);
2077        pf.set("flow", Kind::Marker);
2078        pf.set("equipRef", Kind::Ref(HRef::from_val("e1")));
2079        g.add(pf).unwrap();
2080
2081        let temp_points = g.equip_points("e1", Some("temp"));
2082        // Only p1 and p2 have temp (the existing ones).
2083        assert_eq!(temp_points.len(), 2);
2084        assert!(temp_points.iter().all(|p| p.has("temp")));
2085    }
2086
2087    // ── Hierarchy tree tests ──
2088
2089    #[test]
2090    fn hierarchy_tree_from_site() {
2091        let g = build_hierarchy_graph();
2092        let tree = g.hierarchy_tree("s1", 10).unwrap();
2093        assert_eq!(tree.depth, 0);
2094        assert_eq!(tree.entity.id().unwrap().val, "s1");
2095        // s1 has children e1, e2
2096        assert_eq!(tree.children.len(), 2);
2097        let child_ids: Vec<String> = tree
2098            .children
2099            .iter()
2100            .map(|c| c.entity.id().unwrap().val.clone())
2101            .collect();
2102        assert!(child_ids.contains(&"e1".to_string()));
2103        assert!(child_ids.contains(&"e2".to_string()));
2104        // e1 has children p1, p2
2105        let e1_node = tree
2106            .children
2107            .iter()
2108            .find(|c| c.entity.id().unwrap().val == "e1")
2109            .unwrap();
2110        assert_eq!(e1_node.children.len(), 2);
2111        let point_ids: Vec<String> = e1_node
2112            .children
2113            .iter()
2114            .map(|c| c.entity.id().unwrap().val.clone())
2115            .collect();
2116        assert!(point_ids.contains(&"p1".to_string()));
2117        assert!(point_ids.contains(&"p2".to_string()));
2118    }
2119
2120    #[test]
2121    fn hierarchy_tree_max_depth() {
2122        let g = build_hierarchy_graph();
2123        // depth 0 = root only
2124        let tree = g.hierarchy_tree("s1", 0).unwrap();
2125        assert!(tree.children.is_empty());
2126        // depth 1 = root + direct children
2127        let tree = g.hierarchy_tree("s1", 1).unwrap();
2128        assert_eq!(tree.children.len(), 2);
2129        assert!(tree.children.iter().all(|c| c.children.is_empty()));
2130    }
2131
2132    #[test]
2133    fn hierarchy_tree_missing_root() {
2134        let g = build_hierarchy_graph();
2135        assert!(g.hierarchy_tree("nonexistent", 10).is_none());
2136    }
2137
2138    // ── Classify tests ──
2139
2140    #[test]
2141    fn classify_site() {
2142        let g = build_hierarchy_graph();
2143        assert_eq!(g.classify("s1").unwrap(), "site");
2144    }
2145
2146    #[test]
2147    fn classify_equip() {
2148        let mut g = EntityGraph::new();
2149        let mut d = HDict::new();
2150        d.set("id", Kind::Ref(HRef::from_val("ahu-1")));
2151        d.set("equip", Kind::Marker);
2152        d.set("ahu", Kind::Marker);
2153        g.add(d).unwrap();
2154        assert_eq!(g.classify("ahu-1").unwrap(), "ahu");
2155    }
2156
2157    #[test]
2158    fn classify_point() {
2159        let g = build_hierarchy_graph();
2160        // Points have point + sensor + temp markers; sensor is most specific.
2161        assert_eq!(g.classify("p1").unwrap(), "sensor");
2162    }
2163
2164    #[test]
2165    fn classify_unknown() {
2166        let mut g = EntityGraph::new();
2167        let mut d = HDict::new();
2168        d.set("id", Kind::Ref(HRef::from_val("x1")));
2169        d.set("custom", Kind::Marker);
2170        g.add(d).unwrap();
2171        assert!(g.classify("x1").is_none());
2172    }
2173}