Skip to main content

uqa_graph/
temporal.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Temporal filtering and traversal (Section 10, Paper 2).
8//!
9//! Edges may carry `valid_from` / `valid_to` properties (numeric
10//! seconds-since-epoch by convention). [`TemporalFilter`] accepts an
11//! edge whose validity interval covers a query timestamp or overlaps a
12//! query range. [`TemporalTraverse`] is `Traverse` with the filter
13//! applied to each edge before it is followed.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use uqa_core::{DocId, EdgeId, Payload, PostingEntry, PostingList, Value, VertexId};
18
19use crate::operators::DEFAULT_GRAPH_SCORE;
20use crate::pattern::GraphPattern;
21use crate::posting_list::{GraphPayload, GraphPostingList};
22use crate::store::{GraphStore, GraphStoreError, GraphStoreResult};
23
24/// Time-aware edge filter. `Timestamp(t)` accepts an edge if
25/// `valid_from <= t <= valid_to`; `Range(a, b)` accepts an edge whose
26/// validity interval overlaps `[a, b]`. An edge with neither
27/// `valid_from` nor `valid_to` is always accepted.
28#[derive(Debug, Clone, Copy)]
29pub enum TemporalFilter {
30    /// Accept everything.
31    Any,
32    /// Accept edges valid at exactly this timestamp.
33    Timestamp(f64),
34    /// Accept edges whose validity interval overlaps the closed range.
35    Range(f64, f64),
36    /// Accept edges that are valid at `timestamp` and whose validity
37    /// interval overlaps the closed range. Keeping the conjunction in
38    /// the physical filter preserves an IR that supplies both bounds.
39    TimestampAndRange(f64, f64, f64),
40}
41
42impl TemporalFilter {
43    pub fn is_valid(&self, properties: &BTreeMap<String, Value>) -> GraphStoreResult<bool> {
44        self.validate()?;
45        let valid_from = numeric(properties.get("valid_from"), "valid_from")?;
46        let valid_to = numeric(properties.get("valid_to"), "valid_to")?;
47        if valid_from.is_none() && valid_to.is_none() {
48            return Ok(true);
49        }
50        let vf = valid_from.unwrap_or(f64::NEG_INFINITY);
51        let vt = valid_to.unwrap_or(f64::INFINITY);
52        Ok(match *self {
53            TemporalFilter::Any => true,
54            TemporalFilter::Timestamp(t) => vf <= t && t <= vt,
55            TemporalFilter::Range(start, end) => vf <= end && vt >= start,
56            TemporalFilter::TimestampAndRange(t, start, end) => {
57                vf <= t && t <= vt && vf <= end && vt >= start
58            }
59        })
60    }
61
62    fn validate(&self) -> GraphStoreResult<()> {
63        let invalid = match *self {
64            TemporalFilter::Any => false,
65            TemporalFilter::Timestamp(timestamp) => !timestamp.is_finite(),
66            TemporalFilter::Range(start, end) => {
67                !start.is_finite() || !end.is_finite() || start > end
68            }
69            TemporalFilter::TimestampAndRange(timestamp, start, end) => {
70                !timestamp.is_finite() || !start.is_finite() || !end.is_finite() || start > end
71            }
72        };
73        if invalid {
74            Err(GraphStoreError::InvalidMutation(format!(
75                "invalid temporal filter {self:?}"
76            )))
77        } else {
78            Ok(())
79        }
80    }
81}
82
83fn numeric(v: Option<&Value>, property: &str) -> GraphStoreResult<Option<f64>> {
84    match v {
85        None => Ok(None),
86        Some(Value::Int(n)) if n.unsigned_abs() <= (1_u64 << 53) => Ok(Some(*n as f64)),
87        Some(Value::Float(value)) if value.is_finite() => Ok(Some(*value)),
88        Some(Value::Decimal(value)) => value
89            .to_f64()
90            .filter(|converted| converted.is_finite())
91            .map(Some)
92            .ok_or_else(|| {
93                GraphStoreError::InvalidMutation(format!(
94                    "temporal property {property:?} is not representable as finite f64"
95                ))
96            }),
97        Some(Value::Int(value)) => Err(GraphStoreError::InvalidMutation(format!(
98            "temporal property {property:?} integer {value} is not exactly representable as f64"
99        ))),
100        Some(value) => Err(GraphStoreError::InvalidMutation(format!(
101            "temporal property {property:?} must be finite numeric, got {value:?}"
102        ))),
103    }
104}
105
106/// BFS traversal that applies a [`TemporalFilter`] to every candidate
107/// edge before it is followed. Mirrors the structure of
108/// [`crate::Traverse`] but with the filter step inlined.
109pub struct TemporalTraverse<'a> {
110    pub start_vertex: VertexId,
111    pub graph: &'a str,
112    pub label: Option<&'a str>,
113    pub max_hops: u32,
114    pub filter: TemporalFilter,
115    pub score: f64,
116}
117
118impl<'a> TemporalTraverse<'a> {
119    pub fn new(start: VertexId, graph: &'a str) -> Self {
120        Self {
121            start_vertex: start,
122            graph,
123            label: None,
124            max_hops: 1,
125            filter: TemporalFilter::Any,
126            score: DEFAULT_GRAPH_SCORE,
127        }
128    }
129
130    pub fn label(mut self, label: &'a str) -> Self {
131        self.label = Some(label);
132        self
133    }
134
135    pub fn max_hops(mut self, hops: u32) -> Self {
136        self.max_hops = hops;
137        self
138    }
139
140    pub fn filter(mut self, filter: TemporalFilter) -> Self {
141        self.filter = filter;
142        self
143    }
144
145    pub fn execute<G: GraphStore>(&self, store: &G) -> GraphStoreResult<GraphPostingList> {
146        self.filter.validate()?;
147        validate_score(self.score)?;
148        store.require_vertex_in_graph(self.start_vertex, self.graph)?;
149        let mut visited: BTreeSet<VertexId> = BTreeSet::new();
150        let mut frontier: BTreeSet<VertexId> = BTreeSet::new();
151        frontier.insert(self.start_vertex);
152        let mut all_edges: BTreeSet<EdgeId> = BTreeSet::new();
153
154        for _ in 0..self.max_hops {
155            let mut next_frontier: BTreeSet<VertexId> = BTreeSet::new();
156            for v in &frontier {
157                for eid in store.out_edge_ids(*v, self.graph)? {
158                    let edge = store.get_edge(eid).ok_or_else(|| {
159                        GraphStoreError::CorruptGraph(format!(
160                            "temporal traversal references missing edge {eid}"
161                        ))
162                    })?;
163                    if let Some(want) = self.label {
164                        if edge.label != want {
165                            continue;
166                        }
167                    }
168                    if !self.filter.is_valid(&edge.properties)? {
169                        continue;
170                    }
171                    let neighbor = edge.target_id;
172                    if !visited.contains(&neighbor) && !frontier.contains(&neighbor) {
173                        next_frontier.insert(neighbor);
174                    }
175                    all_edges.insert(eid);
176                }
177            }
178            visited.append(&mut frontier.clone());
179            frontier = next_frontier;
180            if frontier.is_empty() {
181                break;
182            }
183        }
184        visited.append(&mut frontier);
185
186        let visited_vec: Vec<VertexId> = visited.iter().copied().collect();
187        let edges_vec: Vec<EdgeId> = all_edges.iter().copied().collect();
188        let mut entries: Vec<PostingEntry> = Vec::with_capacity(visited_vec.len());
189        let mut graph_payloads: BTreeMap<DocId, GraphPayload> = BTreeMap::new();
190        for vid in &visited_vec {
191            entries.push(PostingEntry::new(*vid, Payload::with_score(self.score)));
192            graph_payloads.insert(
193                *vid,
194                GraphPayload {
195                    subgraph_vertices: visited_vec.clone(),
196                    subgraph_edges: edges_vec.clone(),
197                    graph_name: self.graph.to_string(),
198                    score_override: Some(self.score),
199                },
200            );
201        }
202        GraphPostingList::try_from_parts(
203            PostingList::from_sorted_unchecked(entries),
204            graph_payloads,
205        )
206        .map_err(Into::into)
207    }
208}
209
210/// Temporal-aware pattern matching (Section 10, Paper 2).
211///
212/// Same algorithm as the standard subgraph matcher but every edge
213/// candidate is filtered through a [`TemporalFilter`] before it is
214/// admitted into the assignment, so only temporally valid edges
215/// participate in pattern matching.
216pub struct TemporalPatternMatch<'a> {
217    pub pattern: GraphPattern,
218    pub graph: &'a str,
219    pub temporal_filter: TemporalFilter,
220    pub score: f64,
221}
222
223impl<'a> TemporalPatternMatch<'a> {
224    pub fn new(pattern: GraphPattern, graph: &'a str) -> Self {
225        Self {
226            pattern,
227            graph,
228            temporal_filter: TemporalFilter::Any,
229            score: DEFAULT_GRAPH_SCORE,
230        }
231    }
232
233    pub fn filter(mut self, filter: TemporalFilter) -> Self {
234        self.temporal_filter = filter;
235        self
236    }
237
238    pub fn score(mut self, score: f64) -> Self {
239        self.score = score;
240        self
241    }
242
243    pub fn execute<G: GraphStore>(&self, store: &G) -> GraphStoreResult<GraphPostingList> {
244        self.temporal_filter.validate()?;
245        validate_score(self.score)?;
246        let candidates = self.compute_candidates(store)?;
247
248        // Group edges by both source and target variable so the
249        // backtracking validator can quickly find every edge that
250        // touches a newly-assigned variable.
251        let mut var_edges: BTreeMap<String, Vec<usize>> = BTreeMap::new();
252        for (i, ep) in self.pattern.edge_patterns.iter().enumerate() {
253            var_edges.entry(ep.source_var.clone()).or_default().push(i);
254            var_edges.entry(ep.target_var.clone()).or_default().push(i);
255        }
256
257        let mut unassigned: BTreeSet<String> = self
258            .pattern
259            .vertex_patterns
260            .iter()
261            .map(|vp| vp.variable.clone())
262            .collect();
263        let mut assignment: BTreeMap<String, VertexId> = BTreeMap::new();
264        let mut assigned_values: BTreeSet<VertexId> = BTreeSet::new();
265        let mut matches: Vec<BTreeMap<String, VertexId>> = Vec::new();
266
267        self.backtrack(
268            store,
269            &candidates,
270            &var_edges,
271            &mut unassigned,
272            &mut assignment,
273            &mut assigned_values,
274            &mut matches,
275        )?;
276
277        let mut entries: Vec<PostingEntry> = Vec::with_capacity(matches.len());
278        let mut graph_payloads: BTreeMap<DocId, GraphPayload> = BTreeMap::new();
279        for (i, assn) in matches.iter().enumerate() {
280            let doc_id = u64::try_from(i)
281                .ok()
282                .and_then(|value| value.checked_add(1))
283                .ok_or_else(|| {
284                    GraphStoreError::IdExhausted(
285                        "temporal match result id counter overflow".to_string(),
286                    )
287                })?;
288            let mut fields: BTreeMap<String, Value> = BTreeMap::new();
289            for (k, v) in assn {
290                fields.insert(k.clone(), graph_id_value(*v));
291            }
292            entries.push(PostingEntry::new(
293                doc_id,
294                Payload {
295                    score: self.score,
296                    fields,
297                    ..Default::default()
298                },
299            ));
300            let match_vertices: Vec<VertexId> = assn.values().copied().collect();
301            let match_edges = self.collect_match_edges(store, assn)?;
302            graph_payloads.insert(
303                doc_id,
304                GraphPayload {
305                    subgraph_vertices: match_vertices,
306                    subgraph_edges: match_edges,
307                    graph_name: self.graph.to_string(),
308                    score_override: Some(self.score),
309                },
310            );
311        }
312
313        GraphPostingList::try_from_parts(
314            PostingList::from_sorted_unchecked(entries),
315            graph_payloads,
316        )
317        .map_err(Into::into)
318    }
319
320    fn compute_candidates<G: GraphStore>(
321        &self,
322        store: &G,
323    ) -> GraphStoreResult<BTreeMap<String, Vec<VertexId>>> {
324        let mut out: BTreeMap<String, Vec<VertexId>> = BTreeMap::new();
325        let vids = store.vertex_ids_in_graph(self.graph)?;
326        for vp in &self.pattern.vertex_patterns {
327            let mut candidates = Vec::new();
328            for vid in &vids {
329                let vertex = store.get_vertex(*vid).ok_or_else(|| {
330                    GraphStoreError::CorruptGraph(format!(
331                        "graph {:?} references missing vertex {vid}",
332                        self.graph
333                    ))
334                })?;
335                if vp
336                    .constraints
337                    .iter()
338                    .all(|constraint| constraint.matches(vertex))
339                {
340                    candidates.push(*vid);
341                }
342            }
343            out.insert(vp.variable.clone(), candidates);
344        }
345        Ok(out)
346    }
347
348    #[expect(
349        clippy::too_many_arguments,
350        reason = "keeps graph scope inputs aligned"
351    )]
352    fn backtrack<G: GraphStore>(
353        &self,
354        store: &G,
355        candidates: &BTreeMap<String, Vec<VertexId>>,
356        var_edges: &BTreeMap<String, Vec<usize>>,
357        unassigned: &mut BTreeSet<String>,
358        assignment: &mut BTreeMap<String, VertexId>,
359        assigned_values: &mut BTreeSet<VertexId>,
360        matches: &mut Vec<BTreeMap<String, VertexId>>,
361    ) -> GraphStoreResult<()> {
362        if unassigned.is_empty() {
363            matches.push(assignment.clone());
364            return Ok(());
365        }
366        // Pick the variable with the fewest candidates first (the
367        // minimum-remaining-values heuristic).
368        let pick: String = unassigned
369            .iter()
370            .min_by_key(|v| candidates.get(*v).map_or(usize::MAX, Vec::len))
371            .cloned()
372            .ok_or_else(|| {
373                GraphStoreError::CorruptGraph(
374                    "temporal matcher has no variable to assign".to_string(),
375                )
376            })?;
377
378        let cands: Vec<VertexId> = candidates.get(&pick).cloned().ok_or_else(|| {
379            GraphStoreError::CorruptGraph(format!(
380                "temporal matcher has no candidates entry for variable {pick:?}"
381            ))
382        })?;
383        unassigned.remove(&pick);
384
385        for vid in cands {
386            if assigned_values.contains(&vid) {
387                continue;
388            }
389            assignment.insert(pick.clone(), vid);
390            assigned_values.insert(vid);
391
392            if self.validate_edges_for(store, &pick, var_edges, assignment)? {
393                self.backtrack(
394                    store,
395                    candidates,
396                    var_edges,
397                    unassigned,
398                    assignment,
399                    assigned_values,
400                    matches,
401                )?;
402            }
403
404            assignment.remove(&pick);
405            assigned_values.remove(&vid);
406        }
407
408        unassigned.insert(pick);
409        Ok(())
410    }
411
412    fn validate_edges_for<G: GraphStore>(
413        &self,
414        store: &G,
415        var: &str,
416        var_edges: &BTreeMap<String, Vec<usize>>,
417        assignment: &BTreeMap<String, VertexId>,
418    ) -> GraphStoreResult<bool> {
419        let Some(edges) = var_edges.get(var) else {
420            return Ok(true);
421        };
422        for &ei in edges {
423            let ep = &self.pattern.edge_patterns[ei];
424            let (Some(&src_id), Some(&tgt_id)) = (
425                assignment.get(&ep.source_var),
426                assignment.get(&ep.target_var),
427            ) else {
428                continue;
429            };
430            let mut found = false;
431            for eid in store.out_edge_ids(src_id, self.graph)? {
432                let edge = store.get_edge(eid).ok_or_else(|| {
433                    GraphStoreError::CorruptGraph(format!(
434                        "temporal matcher references missing edge {eid}"
435                    ))
436                })?;
437                if edge.target_id != tgt_id {
438                    continue;
439                }
440                if let Some(label) = &ep.label {
441                    if edge.label != *label {
442                        continue;
443                    }
444                }
445                if !ep.constraints.iter().all(|c| c.matches(edge)) {
446                    continue;
447                }
448                if !self.temporal_filter.is_valid(&edge.properties)? {
449                    continue;
450                }
451                found = true;
452                break;
453            }
454            if !found {
455                return Ok(false);
456            }
457        }
458        Ok(true)
459    }
460
461    fn collect_match_edges<G: GraphStore>(
462        &self,
463        store: &G,
464        assignment: &BTreeMap<String, VertexId>,
465    ) -> GraphStoreResult<Vec<EdgeId>> {
466        let mut edge_ids: BTreeSet<EdgeId> = BTreeSet::new();
467        for ep in &self.pattern.edge_patterns {
468            let (Some(&src_id), Some(&tgt_id)) = (
469                assignment.get(&ep.source_var),
470                assignment.get(&ep.target_var),
471            ) else {
472                continue;
473            };
474            for eid in store.out_edge_ids(src_id, self.graph)? {
475                let edge = store.get_edge(eid).ok_or_else(|| {
476                    GraphStoreError::CorruptGraph(format!(
477                        "temporal match result references missing edge {eid}"
478                    ))
479                })?;
480                if edge.target_id == tgt_id
481                    && (ep.label.as_deref().is_none_or(|l| edge.label == l))
482                    && self.temporal_filter.is_valid(&edge.properties)?
483                {
484                    edge_ids.insert(eid);
485                    break;
486                }
487            }
488        }
489        Ok(edge_ids.into_iter().collect())
490    }
491}
492
493fn validate_score(score: f64) -> GraphStoreResult<()> {
494    if score.is_finite() {
495        Ok(())
496    } else {
497        Err(GraphStoreError::InvalidMutation(
498            "temporal graph score must be finite".to_string(),
499        ))
500    }
501}
502
503fn graph_id_value(id: u64) -> Value {
504    i64::try_from(id).map_or_else(|_| Value::Bytes(id.to_be_bytes().to_vec()), Value::Int)
505}