Skip to main content

uqa_graph/operators/
weighted_path.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! Bounded weighted regular-path execution.
8
9use super::{
10    build_nfa, simplify, subset_construction, value_as_f64, BTreeMap, Dfa, DfaState, EdgeId,
11    GraphPayload, GraphPostingList, GraphStore, GraphStoreError, GraphStoreResult,
12    PathWeightPredicate, Payload, PostingEntry, PostingList, RegularPathExpr, Value, VecDeque,
13    VertexId, DEFAULT_GRAPH_SCORE,
14};
15
16/// A bounded regular-path walk whose accumulated numeric edge weight must
17/// satisfy a caller-provided predicate.
18///
19/// Unlike reachability-only RPQ evaluation, weighted execution cannot collapse
20/// all visits to the same `(vertex, DFA state)`: two walks can reach that
21/// configuration with different accumulated weights. `max_hops` therefore
22/// makes the walk domain explicit and finite. When several accepted walks end
23/// at the same vertex, the result retains the greatest accumulated weight and
24/// its concrete vertex / edge path.
25pub struct WeightedPathQuery<'a> {
26    pub path: RegularPathExpr,
27    pub graph: &'a str,
28    pub start_vertex: Option<VertexId>,
29    pub weight_property: &'a str,
30    pub default_edge_weight: f64,
31    pub max_hops: usize,
32    pub predicate: PathWeightPredicate,
33    pub score: f64,
34}
35
36impl<'a> WeightedPathQuery<'a> {
37    pub fn new(
38        path: RegularPathExpr,
39        graph: &'a str,
40        weight_property: &'a str,
41        predicate: PathWeightPredicate,
42    ) -> Self {
43        Self {
44            path,
45            graph,
46            start_vertex: None,
47            weight_property,
48            default_edge_weight: 1.0,
49            max_hops: 16,
50            predicate,
51            score: DEFAULT_GRAPH_SCORE,
52        }
53    }
54
55    pub fn from_vertex(mut self, start: VertexId) -> Self {
56        self.start_vertex = Some(start);
57        self
58    }
59
60    pub fn execute<G: GraphStore>(&self, store: &G) -> GraphStoreResult<GraphPostingList> {
61        if !self.default_edge_weight.is_finite() {
62            return Err(GraphStoreError::InvalidMutation(format!(
63                "default edge weight must be finite, got {}",
64                self.default_edge_weight
65            )));
66        }
67        if !self.score.is_finite() {
68            return Err(GraphStoreError::InvalidMutation(format!(
69                "weighted path score must be finite, got {}",
70                self.score
71            )));
72        }
73        let simplified = simplify(&self.path)
74            .map_err(|error| GraphStoreError::InvalidQuery(error.to_string()))?;
75        let nfa = build_nfa(&simplified)
76            .map_err(|error| GraphStoreError::InvalidQuery(error.to_string()))?;
77        let dfa = subset_construction(&nfa)
78            .map_err(|error| GraphStoreError::InvalidQuery(error.to_string()))?;
79        let starts: Vec<VertexId> = match self.start_vertex {
80            Some(vertex) => {
81                store.require_vertex_in_graph(vertex, self.graph)?;
82                vec![vertex]
83            }
84            None => store.vertex_ids_in_graph(self.graph)?.into_iter().collect(),
85        };
86        let mut accepted = BTreeMap::<VertexId, WeightedPathMatch>::new();
87        for start in starts {
88            self.simulate_from(store, start, &dfa, &mut accepted)?;
89        }
90
91        let mut entries = Vec::with_capacity(accepted.len());
92        let mut graph_payloads = BTreeMap::new();
93        for (end, path_match) in accepted {
94            let mut fields = BTreeMap::new();
95            fields.insert("_path_weight".to_string(), Value::Float(path_match.weight));
96            entries.push(PostingEntry::new(
97                end,
98                Payload {
99                    score: self.score,
100                    fields,
101                    ..Default::default()
102                },
103            ));
104            graph_payloads.insert(
105                end,
106                GraphPayload {
107                    subgraph_vertices: path_match.vertices,
108                    subgraph_edges: path_match.edges,
109                    graph_name: self.graph.to_string(),
110                    score_override: Some(self.score),
111                },
112            );
113        }
114        GraphPostingList::try_from_parts(
115            PostingList::from_sorted_unchecked(entries),
116            graph_payloads,
117        )
118        .map_err(Into::into)
119    }
120
121    fn simulate_from<G: GraphStore>(
122        &self,
123        store: &G,
124        start: VertexId,
125        dfa: &Dfa,
126        accepted: &mut BTreeMap<VertexId, WeightedPathMatch>,
127    ) -> GraphStoreResult<()> {
128        let mut queue = VecDeque::from([WeightedWalk {
129            vertex: start,
130            state: dfa.start.clone(),
131            hops: 0,
132            weight: 0.0,
133            vertices: vec![start],
134            edges: Vec::new(),
135        }]);
136        if dfa.accepts.contains(&dfa.start) && (self.predicate)(0.0) {
137            record_weighted_match(accepted, start, 0.0, vec![start], Vec::new());
138        }
139
140        while let Some(walk) = queue.pop_front() {
141            if walk.hops >= self.max_hops {
142                continue;
143            }
144            let Some(transitions) = dfa.transitions.get(&walk.state) else {
145                continue;
146            };
147            for edge_id in store.out_edge_ids(walk.vertex, self.graph)? {
148                let edge = store.get_edge(edge_id).ok_or_else(|| {
149                    GraphStoreError::CorruptGraph(format!("missing weighted-path edge {edge_id}"))
150                })?;
151                let Some(next_state) = transitions.get(&edge.label) else {
152                    continue;
153                };
154                let edge_weight = match edge.properties.get(self.weight_property) {
155                    Some(value) => value_as_f64(value)?.ok_or_else(|| {
156                        GraphStoreError::InvalidMutation(format!(
157                            "edge {edge_id} weight property {:?} is not numeric",
158                            self.weight_property
159                        ))
160                    })?,
161                    None => self.default_edge_weight,
162                };
163                let weight = walk.weight + edge_weight;
164                if !weight.is_finite() {
165                    return Err(GraphStoreError::InvalidMutation(format!(
166                        "weighted path accumulation is not finite at edge {edge_id}"
167                    )));
168                }
169                let mut vertices = walk.vertices.clone();
170                vertices.push(edge.target_id);
171                let mut edges = walk.edges.clone();
172                edges.push(edge_id);
173                if dfa.accepts.contains(next_state) && (self.predicate)(weight) {
174                    record_weighted_match(
175                        accepted,
176                        edge.target_id,
177                        weight,
178                        vertices.clone(),
179                        edges.clone(),
180                    );
181                }
182                queue.push_back(WeightedWalk {
183                    vertex: edge.target_id,
184                    state: next_state.clone(),
185                    hops: walk.hops.checked_add(1).ok_or_else(|| {
186                        GraphStoreError::CorruptGraph("weighted path hop count overflow".into())
187                    })?,
188                    weight,
189                    vertices,
190                    edges,
191                });
192            }
193        }
194        Ok(())
195    }
196}
197
198struct WeightedWalk {
199    vertex: VertexId,
200    state: DfaState,
201    hops: usize,
202    weight: f64,
203    vertices: Vec<VertexId>,
204    edges: Vec<EdgeId>,
205}
206
207struct WeightedPathMatch {
208    weight: f64,
209    vertices: Vec<VertexId>,
210    edges: Vec<EdgeId>,
211}
212
213fn record_weighted_match(
214    accepted: &mut BTreeMap<VertexId, WeightedPathMatch>,
215    endpoint: VertexId,
216    weight: f64,
217    vertices: Vec<VertexId>,
218    edges: Vec<EdgeId>,
219) {
220    let replace = accepted
221        .get(&endpoint)
222        .is_none_or(|current| weight.total_cmp(&current.weight).is_gt());
223    if replace {
224        accepted.insert(
225            endpoint,
226            WeightedPathMatch {
227                weight,
228                vertices,
229                edges,
230            },
231        );
232    }
233}