1use 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#[derive(Debug, Clone, Copy)]
29pub enum TemporalFilter {
30 Any,
32 Timestamp(f64),
34 Range(f64, f64),
36 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
106pub 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
210pub 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 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 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}