Skip to main content

uqa_graph/
index.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Specialized graph indexes for accelerating traversal and RPQ
8//! evaluation (Section 6.4, Paper 2).
9//!
10//! `LabelIndex` exposes label cardinality and label-to-vertex sets on
11//! top of what the underlying [`GraphStore`] already tracks.
12//! `PathIndex` pre-computes the `(start, end)` reachability set for a
13//! list of label sequences so the RPQ operator can short-circuit when
14//! the input expression is a pure label-concatenation.
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use uqa_core::{EdgeId, Value, VertexId};
19
20use crate::store::{GraphStore, GraphStoreError, GraphStoreResult};
21
22#[derive(Debug, Clone, Default)]
23pub struct LabelIndex {
24    label_to_edges: BTreeMap<String, Vec<EdgeId>>,
25    label_to_vertices: BTreeMap<String, BTreeSet<VertexId>>,
26}
27
28impl LabelIndex {
29    pub fn build<G: GraphStore>(store: &G, graph: &str) -> GraphStoreResult<Self> {
30        let mut idx = Self::default();
31        for vid in store.vertex_ids_in_graph(graph)? {
32            for eid in store.out_edge_ids(vid, graph)? {
33                let edge = store.get_edge(eid).ok_or_else(|| {
34                    GraphStoreError::CorruptGraph(format!("missing indexed edge {eid}"))
35                })?;
36                idx.label_to_edges
37                    .entry(edge.label.clone())
38                    .or_default()
39                    .push(eid);
40                let vset = idx.label_to_vertices.entry(edge.label.clone()).or_default();
41                vset.insert(edge.source_id);
42                vset.insert(edge.target_id);
43            }
44        }
45        for edges in idx.label_to_edges.values_mut() {
46            edges.sort_unstable();
47            edges.dedup();
48        }
49        Ok(idx)
50    }
51
52    pub fn edges_by_label(&self, label: &str) -> &[EdgeId] {
53        self.label_to_edges.get(label).map_or(&[], Vec::as_slice)
54    }
55
56    pub fn vertices_by_label(&self, label: &str) -> Option<&BTreeSet<VertexId>> {
57        self.label_to_vertices.get(label)
58    }
59
60    pub fn labels(&self) -> Vec<String> {
61        self.label_to_edges.keys().cloned().collect()
62    }
63
64    pub fn label_count(&self, label: &str) -> usize {
65        self.label_to_edges.get(label).map_or(0, Vec::len)
66    }
67}
68
69/// Immutable equality index over selected vertex properties in one named graph.
70///
71/// Missing properties are not indexed, while an explicitly stored
72/// [`Value::Null`] remains queryable. Value equality follows [`Value`]'s total
73/// ordering, including its cross-numeric equality rules, so indexed lookup and
74/// relational equality agree.
75#[derive(Debug, Clone, Default)]
76pub struct VertexPropertyIndex {
77    property_values: BTreeMap<String, BTreeMap<Value, BTreeSet<VertexId>>>,
78}
79
80impl VertexPropertyIndex {
81    pub fn build<G: GraphStore>(
82        store: &G,
83        graph: &str,
84        properties: &[&str],
85    ) -> GraphStoreResult<Self> {
86        let property_names: BTreeSet<String> = properties
87            .iter()
88            .map(|property| (*property).to_owned())
89            .collect();
90        let mut property_values: BTreeMap<String, BTreeMap<Value, BTreeSet<VertexId>>> =
91            property_names
92                .iter()
93                .map(|property| (property.clone(), BTreeMap::new()))
94                .collect();
95
96        for vertex_id in store.vertex_ids_in_graph(graph)? {
97            let vertex = store.get_vertex(vertex_id).ok_or_else(|| {
98                GraphStoreError::CorruptGraph(format!(
99                    "graph {graph:?} references missing indexed vertex {vertex_id}"
100                ))
101            })?;
102            for property in &property_names {
103                let Some(value) = vertex.properties.get(property) else {
104                    continue;
105                };
106                property_values
107                    .get_mut(property)
108                    .expect("requested property was initialized")
109                    .entry(value.clone())
110                    .or_default()
111                    .insert(vertex_id);
112            }
113        }
114
115        Ok(Self { property_values })
116    }
117
118    pub fn has_property(&self, property: &str) -> bool {
119        self.property_values.contains_key(property)
120    }
121
122    pub fn lookup_eq(&self, property: &str, value: &Value) -> Option<&BTreeSet<VertexId>> {
123        self.property_values.get(property)?.get(value)
124    }
125
126    pub fn property_names(&self) -> impl Iterator<Item = &str> {
127        self.property_values.keys().map(String::as_str)
128    }
129}
130
131/// Pre-indexed reachable `(start, end)` pairs for fixed label
132/// sequences. Lookup is keyed by the slash-joined sequence so the RPQ
133/// operator can lift a `Label / Label / ...` expression into a direct
134/// hit without running NFA simulation.
135#[derive(Debug, Clone, Default)]
136pub struct PathIndex {
137    path_pairs: BTreeMap<String, BTreeSet<(VertexId, VertexId)>>,
138}
139
140impl PathIndex {
141    pub fn build<G: GraphStore>(
142        store: &G,
143        graph: &str,
144        label_sequences: &[Vec<String>],
145    ) -> GraphStoreResult<Self> {
146        let mut idx = Self::default();
147        for seq in label_sequences {
148            let key = seq.join("/");
149            let mut pairs: BTreeSet<(VertexId, VertexId)> = BTreeSet::new();
150            for start in store.vertex_ids_in_graph(graph)? {
151                let ends = follow_path(store, graph, start, seq)?;
152                for end in ends {
153                    pairs.insert((start, end));
154                }
155            }
156            idx.path_pairs.insert(key, pairs);
157        }
158        Ok(idx)
159    }
160
161    pub fn lookup(&self, label_sequence: &[String]) -> Option<&BTreeSet<(VertexId, VertexId)>> {
162        let key = label_sequence.join("/");
163        self.path_pairs.get(&key)
164    }
165
166    pub fn has_path(&self, label_sequence: &[String]) -> bool {
167        let key = label_sequence.join("/");
168        self.path_pairs.contains_key(&key)
169    }
170
171    pub fn indexed_paths(&self) -> Vec<String> {
172        self.path_pairs.keys().cloned().collect()
173    }
174}
175
176fn follow_path<G: GraphStore>(
177    store: &G,
178    graph: &str,
179    start: VertexId,
180    labels: &[String],
181) -> GraphStoreResult<BTreeSet<VertexId>> {
182    let mut current: BTreeSet<VertexId> = BTreeSet::from([start]);
183    for label in labels {
184        let mut next_set: BTreeSet<VertexId> = BTreeSet::new();
185        for vid in &current {
186            for eid in store.out_edge_ids(*vid, graph)? {
187                let edge = store.get_edge(eid).ok_or_else(|| {
188                    GraphStoreError::CorruptGraph(format!("missing path-index edge {eid}"))
189                })?;
190                if &edge.label == label {
191                    next_set.insert(edge.target_id);
192                }
193            }
194        }
195        current = next_set;
196        if current.is_empty() {
197            break;
198        }
199    }
200    Ok(current)
201}
202
203#[cfg(test)]
204mod tests {
205    use uqa_core::{Value, Vertex};
206
207    use super::VertexPropertyIndex;
208    use crate::{GraphStore, MemoryGraphStore};
209
210    #[test]
211    fn vertex_property_index_tracks_selected_values_and_missing_fields() {
212        let mut store = MemoryGraphStore::new();
213        store.create_graph("g");
214        let mut first = Vertex::new(1, "node");
215        first.properties.insert("val".into(), Value::Int(7));
216        first.properties.insert("nullable".into(), Value::Null);
217        store.add_vertex(first, "g").unwrap();
218        let mut second = Vertex::new(2, "node");
219        second.properties.insert("val".into(), Value::Float(7.0));
220        store.add_vertex(second, "g").unwrap();
221
222        let index =
223            VertexPropertyIndex::build(&store, "g", &["val", "nullable", "missing"]).unwrap();
224
225        assert!(index.has_property("val"));
226        assert!(index.has_property("missing"));
227        assert_eq!(
228            index.lookup_eq("val", &Value::Int(7)).unwrap(),
229            &std::collections::BTreeSet::from([1, 2])
230        );
231        assert_eq!(
232            index.lookup_eq("nullable", &Value::Null).unwrap(),
233            &std::collections::BTreeSet::from([1])
234        );
235        assert!(index.lookup_eq("missing", &Value::Null).is_none());
236        assert!(index.lookup_eq("not-indexed", &Value::Int(7)).is_none());
237    }
238
239    #[test]
240    fn vertex_property_index_rejects_unknown_graph() {
241        let store = MemoryGraphStore::new();
242        let error = VertexPropertyIndex::build(&store, "missing", &["val"]).unwrap_err();
243        assert!(error.to_string().contains("missing"));
244    }
245}