Skip to main content

selene_graph/
graph.rs

1//! Immutable graph snapshot and read accessors.
2
3use std::borrow::Cow;
4use std::ops::RangeBounds;
5use std::sync::Arc;
6
7use imbl::HashMap;
8use roaring::RoaringBitmap;
9use rustc_hash::FxHashMap;
10use serde::{Deserialize, Serialize};
11use smallvec::SmallVec;
12
13use selene_core::{
14    DbString, EdgeId, GraphId, HnswIndexConfig, IvfIndexConfig, LabelSet, NodeId, PropertyMap,
15    Value,
16};
17
18use crate::adjacency::AdjacencyEntry;
19use crate::composite_typed_index::CompositeTypedIndex;
20use crate::graph_types::GraphTypeDef;
21use crate::id_map::{EngineIdMap, engine_id_map};
22use crate::store::{EdgeStore, NodeStore, RowIndex};
23use crate::text_index::{TextIndex, TextIndexMemoryUsage, TextIndexStats};
24use crate::typed_index::{TypedIndex, TypedIndexKind};
25use crate::vector_index::{VectorIndex, VectorIndexKind, VectorIndexMemoryUsage};
26
27/// Registered built-in property-index metadata.
28#[derive(Clone, Debug)]
29pub struct PropertyIndexEntry {
30    /// Index data for the `(label, property)` registration.
31    pub index: Arc<TypedIndex>,
32    /// Optional explicit catalog name. `None` means the name is derived at render time.
33    pub name: Option<DbString>,
34}
35
36impl PropertyIndexEntry {
37    /// Construct an index entry from the built index and optional explicit name.
38    #[must_use]
39    pub fn new(index: TypedIndex, name: Option<DbString>) -> Self {
40        Self {
41            index: Arc::new(index),
42            name,
43        }
44    }
45
46    /// Return the registered index kind.
47    #[must_use]
48    pub fn kind(&self) -> TypedIndexKind {
49        self.index.kind()
50    }
51}
52
53/// Registered built-in composite-property index metadata.
54#[derive(Clone, Debug)]
55pub struct CompositePropertyIndexEntry {
56    /// Index data for the `(label, properties...)` registration.
57    pub index: Arc<CompositeTypedIndex>,
58    /// Indexed properties in declaration order.
59    pub declared_properties: SmallVec<[DbString; 4]>,
60    /// Optional explicit catalog name. `None` means the name is derived at render time.
61    pub name: Option<DbString>,
62}
63
64impl CompositePropertyIndexEntry {
65    /// Construct a composite index entry.
66    #[must_use]
67    pub fn new(
68        index: CompositeTypedIndex,
69        declared_properties: SmallVec<[DbString; 4]>,
70        name: Option<DbString>,
71    ) -> Self {
72        Self {
73            index: Arc::new(index),
74            declared_properties,
75            name,
76        }
77    }
78
79    /// Return the registered component kinds in declaration order.
80    #[must_use]
81    pub fn kinds(&self) -> SmallVec<[TypedIndexKind; 4]> {
82        self.index.kinds().iter().copied().collect()
83    }
84}
85
86/// Registered built-in vector-index metadata.
87#[derive(Clone, Debug)]
88pub struct VectorIndexEntry {
89    /// Index data for the `(label, property)` registration.
90    pub index: Arc<VectorIndex>,
91    /// Optional explicit catalog name. `None` means the name is derived at render time.
92    pub name: Option<DbString>,
93}
94
95impl VectorIndexEntry {
96    /// Construct a vector index entry from the built index and optional name.
97    #[must_use]
98    pub fn new(index: VectorIndex, name: Option<DbString>) -> Self {
99        Self {
100            index: Arc::new(index),
101            name,
102        }
103    }
104
105    /// Return the registered vector index kind.
106    #[must_use]
107    pub fn kind(&self) -> VectorIndexKind {
108        self.index.kind()
109    }
110
111    /// Return the registered vector dimensionality.
112    #[must_use]
113    pub fn dimension(&self) -> u32 {
114        self.index.dimension()
115    }
116
117    /// Return the registered HNSW construction config, if this is an HNSW index.
118    #[must_use]
119    pub fn hnsw_config(&self) -> Option<HnswIndexConfig> {
120        self.index.hnsw_config()
121    }
122
123    /// Return the registered IVF construction config, if this is a configured IVF index.
124    #[must_use]
125    pub fn ivf_config(&self) -> Option<IvfIndexConfig> {
126        self.index.ivf_config()
127    }
128
129    /// Return an estimated memory usage snapshot for this vector index.
130    #[must_use]
131    pub fn memory_usage(&self) -> VectorIndexMemoryUsage {
132        self.index.memory_usage()
133    }
134}
135
136/// Registered built-in text-index metadata.
137#[derive(Clone, Debug)]
138pub struct TextIndexEntry {
139    /// Index data for the `(label, property)` registration.
140    pub index: Arc<TextIndex>,
141    /// Optional explicit catalog name. `None` means the name is derived at render time.
142    pub name: Option<DbString>,
143}
144
145impl TextIndexEntry {
146    /// Construct a text index entry from the built index and optional name.
147    #[must_use]
148    pub fn new(index: TextIndex, name: Option<DbString>) -> Self {
149        Self {
150            index: Arc::new(index),
151            name,
152        }
153    }
154
155    /// Return aggregate index counters.
156    #[must_use]
157    pub fn stats(&self) -> TextIndexStats {
158        self.index.stats()
159    }
160
161    /// Return an estimated memory usage snapshot for this text index.
162    #[must_use]
163    pub fn memory_usage(&self) -> TextIndexMemoryUsage {
164        self.index.memory_usage()
165    }
166}
167
168/// Owned row returned when iterating composite property-index registrations.
169pub type CompositePropertyIndexEntryRow = (
170    DbString,
171    SmallVec<[DbString; 4]>,
172    SmallVec<[TypedIndexKind; 4]>,
173    Option<DbString>,
174);
175
176/// Owned row returned when iterating vector-index registrations.
177pub type VectorIndexEntryRow = (
178    DbString,
179    DbString,
180    VectorIndexKind,
181    u32,
182    Option<HnswIndexConfig>,
183    Option<IvfIndexConfig>,
184    Option<DbString>,
185);
186
187/// Owned row returned when iterating text-index registrations.
188pub type TextIndexEntryRow = (
189    DbString,
190    DbString,
191    TextIndexStats,
192    TextIndexMemoryUsage,
193    Option<DbString>,
194);
195
196/// Snapshot metadata.
197#[derive(
198    Clone,
199    Debug,
200    Deserialize,
201    PartialEq,
202    rkyv::Archive,
203    rkyv::Deserialize,
204    rkyv::Serialize,
205    Serialize,
206)]
207pub struct GraphMeta {
208    /// Graph identifier.
209    pub graph_id: GraphId,
210    /// Published generation counter.
211    pub generation: u64,
212    /// Next node ID to allocate.
213    pub next_node_id: u64,
214    /// Next edge ID to allocate.
215    pub next_edge_id: u64,
216    /// Bound closed graph type. `None` means GG01/open graph.
217    pub bound_type: Option<Arc<GraphTypeDef>>,
218}
219
220/// Immutable graph snapshot.
221#[derive(Clone, Debug)]
222pub struct SeleneGraph {
223    /// Snapshot metadata.
224    pub meta: GraphMeta,
225    /// Node storage.
226    pub node_store: NodeStore,
227    /// Edge storage.
228    pub edge_store: EdgeStore,
229    /// Outgoing adjacency keyed by source node.
230    pub adjacency_out: EngineIdMap<NodeId, AdjacencyEntry>,
231    /// Incoming adjacency keyed by target node.
232    pub adjacency_in: EngineIdMap<NodeId, AdjacencyEntry>,
233    /// Bitmap of node rows carrying each label.
234    pub idx_label: HashMap<DbString, RoaringBitmap>,
235    /// Bitmap of edge rows carrying each edge label.
236    pub idx_edge_label: HashMap<DbString, RoaringBitmap>,
237    /// Per-`(label, property)` node value indexes. See spec 03 section 5.2.
238    pub property_index: FxHashMap<(DbString, DbString), PropertyIndexEntry>,
239    /// Per-`(label, properties...)` node composite value indexes.
240    pub composite_property_index:
241        FxHashMap<(DbString, SmallVec<[DbString; 4]>), CompositePropertyIndexEntry>,
242    /// Per-`(label, property)` node vector indexes.
243    pub vector_index: FxHashMap<(DbString, DbString), VectorIndexEntry>,
244    /// Per-`(label, property)` node BM25 text indexes.
245    pub text_index: FxHashMap<(DbString, DbString), TextIndexEntry>,
246    /// External `NodeId -> RowIndex` lookup (the inverse of
247    /// [`NodeStore::row_to_id`]). Replaces the `id.get() - 1` arithmetic so the
248    /// external id can stay stable while the row is remapped by compaction
249    /// (D22 / BRIEF-Item-4a). `imbl` for cheap copy-on-write snapshot clones.
250    pub node_id_to_row: EngineIdMap<NodeId, RowIndex>,
251    /// External `EdgeId -> RowIndex` lookup (inverse of [`EdgeStore::row_to_id`]).
252    pub edge_id_to_row: EngineIdMap<EdgeId, RowIndex>,
253}
254
255impl SeleneGraph {
256    /// Construct an empty graph snapshot.
257    #[must_use]
258    pub fn new(graph_id: GraphId) -> Self {
259        Self {
260            meta: GraphMeta {
261                graph_id,
262                generation: 0,
263                next_node_id: 1,
264                next_edge_id: 1,
265                bound_type: None,
266            },
267            node_store: NodeStore::new(),
268            edge_store: EdgeStore::new(),
269            adjacency_out: engine_id_map(),
270            adjacency_in: engine_id_map(),
271            idx_label: HashMap::new(),
272            idx_edge_label: HashMap::new(),
273            property_index: FxHashMap::default(),
274            composite_property_index: FxHashMap::default(),
275            vector_index: FxHashMap::default(),
276            text_index: FxHashMap::default(),
277            node_id_to_row: engine_id_map(),
278            edge_id_to_row: engine_id_map(),
279        }
280    }
281
282    /// Return this graph snapshot's stable graph ID.
283    #[must_use]
284    pub const fn graph_id(&self) -> GraphId {
285        self.meta.graph_id
286    }
287
288    /// Number of alive nodes.
289    #[must_use]
290    pub fn node_count(&self) -> usize {
291        self.node_store.alive.len() as usize
292    }
293
294    /// Bitmap of alive node *row indices*.
295    ///
296    /// Returned bitmap is row-indexed (matching `nodes_with_label`), not
297    /// `NodeId`-indexed; consumers convert a row to its external `NodeId` via
298    /// [`Self::node_id_for_row`] (never by `row + 1` arithmetic — the external id
299    /// is stable while compaction renumbers the row). Used by `selene-algorithms`
300    /// to seed the "all alive nodes" baseline of a `GraphProjection`.
301    #[must_use]
302    pub fn live_nodes(&self) -> &RoaringBitmap {
303        // B1: alive is Arc-shared COW state; expose the bitmap, not the Arc,
304        // so the crate boundary (selene-algorithms) is unchanged.
305        &self.node_store.alive
306    }
307
308    /// Number of alive edges.
309    #[must_use]
310    pub fn edge_count(&self) -> usize {
311        self.edge_store.alive.len() as usize
312    }
313
314    /// Return current row-space pressure for compaction planning.
315    ///
316    /// This is a cheap read over store lengths and liveness bitmaps; it does not
317    /// rebuild indexes or allocate a dense graph.
318    #[must_use]
319    pub fn compaction_stats(&self) -> crate::compaction::CompactionStats {
320        crate::compaction::CompactionStats::from_graph(self)
321    }
322
323    /// Bitmap of alive edge *row indices*.
324    ///
325    /// The edge-side sibling of [`Self::live_nodes`]. The returned bitmap is
326    /// row-indexed (matching `edges_with_label`), not `EdgeId`-indexed; consumers
327    /// convert a row to its external `EdgeId` via [`Self::edge_id_for_row`] (never
328    /// by `row + 1` arithmetic). Covers every alive edge regardless of label —
329    /// used by the `DROP GRAPH` factory-reset (BRIEF-152) to enumerate every live
330    /// edge, including untyped/arbitrary-label ones that a per-type truncate would
331    /// miss.
332    #[must_use]
333    pub fn live_edges(&self) -> &RoaringBitmap {
334        // B1: see `live_nodes` — deref the COW Arc at the boundary.
335        &self.edge_store.alive
336    }
337
338    /// Map an external [`NodeId`] to its internal [`RowIndex`].
339    ///
340    /// Returns `None` for a never-committed (aborted-tx hole) id. A deleted id
341    /// still resolves — to its now-dead row — so liveness, not existence,
342    /// distinguishes it (the row's `alive` bit is clear). This is the map-backed
343    /// replacement for the old `id - 1` arithmetic; the external id stays stable
344    /// while BRIEF-Item-4b compaction renumbers the row.
345    #[must_use]
346    pub fn row_for_node_id(&self, id: NodeId) -> Option<RowIndex> {
347        self.node_id_to_row.get(&id).copied()
348    }
349
350    /// Map an external [`EdgeId`] to its internal [`RowIndex`]; see
351    /// [`Self::row_for_node_id`].
352    #[must_use]
353    pub fn row_for_edge_id(&self, id: EdgeId) -> Option<RowIndex> {
354        self.edge_id_to_row.get(&id).copied()
355    }
356
357    /// Recover the external [`NodeId`] bound to a materialized [`RowIndex`].
358    ///
359    /// Reads the `row_to_id` column (the persistence-stable per-row id), never
360    /// synthesizing `row + 1`. Returns `None` past the column end or for a
361    /// never-committed hole row (which holds [`NodeId::TOMBSTONE`]).
362    #[must_use]
363    pub fn node_id_for_row(&self, row: RowIndex) -> Option<NodeId> {
364        self.node_store
365            .row_to_id
366            .get(row.get() as usize)
367            .copied()
368            .filter(|id| *id != NodeId::TOMBSTONE)
369    }
370
371    /// Recover the external [`EdgeId`] bound to a materialized [`RowIndex`]; see
372    /// [`Self::node_id_for_row`].
373    #[must_use]
374    pub fn edge_id_for_row(&self, row: RowIndex) -> Option<EdgeId> {
375        self.edge_store
376            .row_to_id
377            .get(row.get() as usize)
378            .copied()
379            .filter(|id| *id != EdgeId::TOMBSTONE)
380    }
381
382    /// Return true when `id` names an alive node.
383    #[must_use]
384    pub fn is_node_alive(&self, id: NodeId) -> bool {
385        self.live_node_row(id).is_some()
386    }
387
388    /// Return true when `id` names an alive edge.
389    #[must_use]
390    pub fn is_edge_alive(&self, id: EdgeId) -> bool {
391        self.live_edge_row(id).is_some()
392    }
393
394    /// Return node labels for an alive node.
395    #[must_use]
396    pub fn node_labels(&self, id: NodeId) -> Option<&LabelSet> {
397        self.live_node_row(id)
398            .and_then(|row| self.node_store.labels.get(row))
399    }
400
401    /// Return node properties for an alive node.
402    #[must_use]
403    pub fn node_properties(&self, id: NodeId) -> Option<&PropertyMap> {
404        self.live_node_row(id)
405            .and_then(|row| self.node_store.properties.get(row))
406    }
407
408    /// Return edge label for an alive edge.
409    #[must_use]
410    pub fn edge_label(&self, id: EdgeId) -> Option<&DbString> {
411        self.live_edge_row(id)
412            .and_then(|row| self.edge_store.label.get(row))
413    }
414
415    /// Return edge endpoints for an alive edge.
416    #[must_use]
417    pub fn edge_endpoints(&self, id: EdgeId) -> Option<(NodeId, NodeId)> {
418        self.live_edge_row(id).and_then(|row| {
419            Some((
420                *self.edge_store.source.get(row)?,
421                *self.edge_store.target.get(row)?,
422            ))
423        })
424    }
425
426    /// Return edge properties for an alive edge.
427    #[must_use]
428    pub fn edge_properties(&self, id: EdgeId) -> Option<&PropertyMap> {
429        self.live_edge_row(id)
430            .and_then(|row| self.edge_store.properties.get(row))
431    }
432
433    /// Return outgoing adjacency for `source`.
434    #[must_use]
435    pub fn outgoing_edges(&self, source: NodeId) -> Option<&AdjacencyEntry> {
436        self.adjacency_out.get(&source)
437    }
438
439    /// Return incoming adjacency for `target`.
440    #[must_use]
441    pub fn incoming_edges(&self, target: NodeId) -> Option<&AdjacencyEntry> {
442        self.adjacency_in.get(&target)
443    }
444
445    /// Return true when an alive node has at least one incident edge.
446    #[must_use]
447    pub fn node_has_incident_edges(&self, id: NodeId) -> bool {
448        self.outgoing_edges(id)
449            .is_some_and(|entry| !entry.is_empty())
450            || self
451                .incoming_edges(id)
452                .is_some_and(|entry| !entry.is_empty())
453    }
454
455    /// Return the bitmap of node rows carrying `label`.
456    #[must_use]
457    pub fn nodes_with_label(&self, label: &DbString) -> Option<&RoaringBitmap> {
458        self.idx_label.get(label)
459    }
460
461    /// Return the bitmap of edge rows carrying `label`.
462    #[must_use]
463    pub fn edges_with_label(&self, label: &DbString) -> Option<&RoaringBitmap> {
464        self.idx_edge_label.get(label)
465    }
466
467    /// Number of distinct node labels currently indexed.
468    #[must_use]
469    pub fn label_count(&self) -> usize {
470        self.idx_label.len()
471    }
472
473    /// Number of distinct edge labels currently indexed.
474    #[must_use]
475    pub fn edge_label_count(&self) -> usize {
476        self.idx_edge_label.len()
477    }
478
479    /// Return a clone of the registered `(label, property)` index.
480    #[must_use]
481    pub fn property_index_for(
482        &self,
483        label: &DbString,
484        property: &DbString,
485    ) -> Option<Arc<TypedIndex>> {
486        self.property_index
487            .get(&(label.clone(), property.clone()))
488            .map(|entry| Arc::clone(&entry.index))
489    }
490
491    /// Return a clone of the registered composite index.
492    #[must_use]
493    pub fn composite_property_index_for(
494        &self,
495        label: &DbString,
496        properties: &[DbString],
497    ) -> Option<Arc<CompositeTypedIndex>> {
498        self.composite_property_index_entry_for(label, properties)
499            .map(|entry| Arc::clone(&entry.index))
500    }
501
502    /// Return composite index metadata for a property set.
503    #[must_use]
504    pub fn composite_property_index_entry_for(
505        &self,
506        label: &DbString,
507        properties: &[DbString],
508    ) -> Option<&CompositePropertyIndexEntry> {
509        let key = composite_property_key(properties);
510        self.composite_property_index.get(&(label.clone(), key))
511    }
512
513    /// Return a clone of the registered vector index.
514    #[must_use]
515    pub fn vector_index_for(
516        &self,
517        label: &DbString,
518        property: &DbString,
519    ) -> Option<Arc<VectorIndex>> {
520        self.vector_index
521            .get(&(label.clone(), property.clone()))
522            .map(|entry| Arc::clone(&entry.index))
523    }
524
525    /// Return a clone of the registered text index.
526    #[must_use]
527    pub fn text_index_for(&self, label: &DbString, property: &DbString) -> Option<Arc<TextIndex>> {
528        self.text_index
529            .get(&(label.clone(), property.clone()))
530            .map(|entry| Arc::clone(&entry.index))
531    }
532
533    /// Number of distinct `(label, property)` indexes currently registered.
534    #[must_use]
535    pub fn property_index_count(&self) -> usize {
536        self.property_index.len()
537    }
538
539    /// Number of distinct `(label, properties...)` indexes currently registered.
540    #[must_use]
541    pub fn composite_property_index_count(&self) -> usize {
542        self.composite_property_index.len()
543    }
544
545    /// Number of distinct `(label, property)` vector indexes currently registered.
546    #[must_use]
547    pub fn vector_index_count(&self) -> usize {
548        self.vector_index.len()
549    }
550
551    /// Number of distinct `(label, property)` text indexes currently registered.
552    #[must_use]
553    pub fn text_index_count(&self) -> usize {
554        self.text_index.len()
555    }
556
557    /// Iterate built-in property indexes as owned `(label, property, kind)` tuples.
558    ///
559    /// This covers only SeleneGraph's built-in property indexes.
560    /// Extension-provider index state is surfaced through that provider's own
561    /// procedures.
562    pub fn iter_property_indexes(
563        &self,
564    ) -> impl Iterator<Item = (DbString, DbString, TypedIndexKind)> + '_ {
565        self.property_index
566            .iter()
567            .map(|((label, property), entry)| (label.clone(), property.clone(), entry.kind()))
568    }
569
570    /// Iterate built-in property indexes with optional explicit catalog names.
571    pub fn iter_property_index_entries(
572        &self,
573    ) -> impl Iterator<Item = (DbString, DbString, TypedIndexKind, Option<DbString>)> + '_ {
574        self.property_index
575            .iter()
576            .map(|((label, property), entry)| {
577                (
578                    label.clone(),
579                    property.clone(),
580                    entry.kind(),
581                    entry.name.clone(),
582                )
583            })
584    }
585
586    /// Iterate built-in composite property indexes with optional explicit catalog names.
587    pub fn iter_composite_property_index_entries(
588        &self,
589    ) -> impl Iterator<Item = CompositePropertyIndexEntryRow> + '_ {
590        self.composite_property_index
591            .iter()
592            .map(|((label, _), entry)| {
593                (
594                    label.clone(),
595                    entry.declared_properties.clone(),
596                    entry.kinds(),
597                    entry.name.clone(),
598                )
599            })
600    }
601
602    /// Iterate built-in vector indexes with optional explicit catalog names.
603    pub fn iter_vector_index_entries(&self) -> impl Iterator<Item = VectorIndexEntryRow> + '_ {
604        self.vector_index.iter().map(|((label, property), entry)| {
605            (
606                label.clone(),
607                property.clone(),
608                entry.kind(),
609                entry.dimension(),
610                entry.hnsw_config(),
611                entry.ivf_config(),
612                entry.name.clone(),
613            )
614        })
615    }
616
617    /// Iterate built-in text indexes with optional explicit catalog names.
618    pub fn iter_text_index_entries(&self) -> impl Iterator<Item = TextIndexEntryRow> + '_ {
619        self.text_index.iter().map(|((label, property), entry)| {
620            (
621                label.clone(),
622                property.clone(),
623                entry.stats(),
624                entry.memory_usage(),
625                entry.name.clone(),
626            )
627        })
628    }
629
630    /// Return rows matching `value` under a registered property index.
631    ///
632    /// `None` means no index is registered for `(label, property)` or the
633    /// supplied value cannot be used with that index kind. `Some(empty)` means
634    /// the index exists but no row matches. A kind-mismatched probe returns
635    /// `None` so the caller drops to a linear scan; open-graph kind drift
636    /// remains discoverable via cross-variant `value_compare`.
637    #[must_use]
638    pub fn nodes_with_property_eq(
639        &self,
640        label: &DbString,
641        property: &DbString,
642        value: &Value,
643    ) -> Option<Cow<'_, RoaringBitmap>> {
644        self.property_index
645            .get(&(label.clone(), property.clone()))
646            .and_then(|entry| entry.index.lookup_eq(value))
647    }
648
649    /// Return rows matching `range` under a registered property index.
650    ///
651    /// `None` means no index is registered or the supplied bounds do not match
652    /// the index kind. `Some(empty)` means the index exists but the range
653    /// matches no rows.
654    #[must_use]
655    pub fn nodes_with_property_range<R>(
656        &self,
657        label: &DbString,
658        property: &DbString,
659        range: R,
660    ) -> Option<RoaringBitmap>
661    where
662        R: RangeBounds<Value>,
663    {
664        self.property_index
665            .get(&(label.clone(), property.clone()))
666            .and_then(|entry| entry.index.lookup_range(range))
667    }
668
669    /// Return rows whose string property key starts with `prefix`.
670    ///
671    /// `None` means no index is registered or the registered index is not a
672    /// string index.
673    #[must_use]
674    pub fn nodes_with_property_prefix(
675        &self,
676        label: &DbString,
677        property: &DbString,
678        prefix: &str,
679    ) -> Option<RoaringBitmap> {
680        self.property_index
681            .get(&(label.clone(), property.clone()))
682            .and_then(|entry| entry.index.lookup_prefix(prefix))
683    }
684
685    fn live_node_row(&self, id: NodeId) -> Option<usize> {
686        let row = self.row_for_node_id(id)?.get();
687        ((row as usize) < self.node_store.len() && self.node_store.is_alive(row))
688            .then_some(row as usize)
689    }
690
691    fn live_edge_row(&self, id: EdgeId) -> Option<usize> {
692        let row = self.row_for_edge_id(id)?.get();
693        ((row as usize) < self.edge_store.len() && self.edge_store.is_alive(row))
694            .then_some(row as usize)
695    }
696}
697
698pub(crate) fn composite_property_key(properties: &[DbString]) -> SmallVec<[DbString; 4]> {
699    let mut key: SmallVec<[DbString; 4]> = properties.iter().cloned().collect();
700    key.sort();
701    key
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use selene_core::db_string;
708
709    #[test]
710    fn new_graph_is_empty() {
711        let graph = SeleneGraph::new(GraphId::new(1));
712        assert_eq!(graph.node_count(), 0);
713        assert_eq!(graph.edge_count(), 0);
714        assert_eq!(graph.label_count(), 0);
715        assert_eq!(graph.edge_label_count(), 0);
716        assert_eq!(graph.property_index_count(), 0);
717        assert_eq!(graph.composite_property_index_count(), 0);
718        assert_eq!(graph.vector_index_count(), 0);
719        assert_eq!(graph.text_index_count(), 0);
720        assert!(graph.idx_label.is_empty());
721        assert!(graph.idx_edge_label.is_empty());
722        assert!(graph.property_index.is_empty());
723        assert!(graph.composite_property_index.is_empty());
724        assert!(graph.vector_index.is_empty());
725        assert!(graph.text_index.is_empty());
726        assert_eq!(graph.meta.generation, 0);
727        assert_eq!(graph.meta.next_node_id, 1);
728        assert_eq!(graph.meta.next_edge_id, 1);
729    }
730
731    #[test]
732    fn read_accessors_return_none_for_unknown_ids() {
733        let graph = SeleneGraph::new(GraphId::new(1));
734        assert_eq!(graph.node_labels(NodeId::new(1)), None);
735        assert_eq!(graph.edge_label(EdgeId::new(1)), None);
736        assert_eq!(
737            graph.nodes_with_label(&db_string("graph.missing").unwrap()),
738            None
739        );
740        assert!(!graph.is_node_alive(NodeId::TOMBSTONE));
741    }
742
743    #[test]
744    fn node_labels_returns_some_for_alive_node() {
745        let mut graph = SeleneGraph::new(GraphId::new(1));
746        let label = db_string("graph.node").unwrap();
747        graph
748            .node_store
749            .labels
750            .push(LabelSet::single(label.clone()));
751        graph.node_store.properties.push(PropertyMap::new());
752        // BRIEF-Item-4a: a manually built row must also bind id <-> row, the way
753        // create_node / rebuild_id_maps do — reads now resolve through the map.
754        graph.node_store.row_to_id.push(NodeId::new(1));
755        graph
756            .node_id_to_row
757            .insert(NodeId::new(1), RowIndex::new(0));
758        graph.node_store.alive_mut().insert(0);
759        assert_eq!(
760            graph
761                .node_labels(NodeId::new(1))
762                .unwrap()
763                .iter()
764                .cloned()
765                .collect::<Vec<_>>(),
766            vec![label]
767        );
768    }
769
770    #[test]
771    fn label_count_reports_distinct_labels_only() {
772        let mut graph = SeleneGraph::new(GraphId::new(1));
773        let label = db_string("graph.same").unwrap();
774        let mut bitmap = RoaringBitmap::new();
775        bitmap.insert(0);
776        bitmap.insert(1);
777        graph.idx_label.insert(label.clone(), bitmap);
778        assert_eq!(graph.label_count(), 1);
779        assert!(graph.nodes_with_label(&label).unwrap().contains(0));
780        assert!(graph.nodes_with_label(&label).unwrap().contains(1));
781    }
782}