1use crate::binding::{binding_payload_bytes, Binding};
16use crate::{QueryMemoryAccount, QueryMemoryLease};
17use hawdb_core::Result;
18use hawdb_plan_cypher::GraphExpansionBudget;
19use hawdb_storage::NodeId;
20use std::collections::BTreeSet;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum GraphExpansionTruncationReason {
24 CandidateLimit,
25 PayloadByteLimit,
26}
27
28impl GraphExpansionTruncationReason {
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::CandidateLimit => "candidate_limit",
32 Self::PayloadByteLimit => "payload_byte_limit",
33 }
34 }
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct GraphExpansionExecutionReport {
39 pub seed_count: usize,
40 pub expanded_node_count: usize,
41 pub expanded_edge_count: usize,
42 pub relation_types: Vec<String>,
43 pub min_hops: usize,
44 pub max_hops: usize,
45 pub reranked_seed_count: usize,
46 pub candidate_limit: usize,
47 pub payload_byte_limit: usize,
48 pub payload_bytes_used: usize,
49 pub returned_count: usize,
50 pub truncation_reason: Option<GraphExpansionTruncationReason>,
51}
52
53impl GraphExpansionExecutionReport {
54 pub fn truncated(&self) -> bool {
55 self.truncation_reason.is_some()
56 }
57}
58
59#[doc(hidden)]
60pub struct GraphExpansionExecutionState {
61 budget: Option<GraphExpansionBudget>,
62 seed_count: usize,
63 expanded_nodes: BTreeSet<NodeId>,
64 expanded_nodes_lease: Option<QueryMemoryLease>,
65 expanded_edge_count: usize,
66 reranked_seed_count: usize,
67 payload_bytes_used: usize,
68 returned_count: usize,
69 pub truncation_reason: Option<GraphExpansionTruncationReason>,
70}
71
72impl GraphExpansionExecutionState {
73 pub fn new(
74 budget: Option<GraphExpansionBudget>,
75 seed_count: usize,
76 reranked_seed_count: usize,
77 ) -> Self {
78 Self {
79 budget,
80 seed_count,
81 expanded_nodes: BTreeSet::new(),
82 expanded_nodes_lease: None,
83 expanded_edge_count: 0,
84 reranked_seed_count,
85 payload_bytes_used: 0,
86 returned_count: 0,
87 truncation_reason: None,
88 }
89 }
90
91 pub fn with_memory_account(
92 budget: Option<GraphExpansionBudget>,
93 seed_count: usize,
94 reranked_seed_count: usize,
95 memory_account: &QueryMemoryAccount,
96 ) -> Result<Self> {
97 Ok(Self {
98 expanded_nodes_lease: Some(memory_account.reserve(0)?),
99 ..Self::new(budget, seed_count, reranked_seed_count)
100 })
101 }
102
103 pub fn try_push(
104 &mut self,
105 output: &mut Vec<Binding>,
106 candidate: Binding,
107 target_id: Option<NodeId>,
108 hop: usize,
109 ) -> Result<bool> {
110 if !self.try_admit(&candidate, target_id, hop)? {
111 return Ok(false);
112 }
113 output.push(candidate);
114 Ok(true)
115 }
116
117 pub fn try_admit(
118 &mut self,
119 candidate: &Binding,
120 target_id: Option<NodeId>,
121 hop: usize,
122 ) -> Result<bool> {
123 let Some(budget) = self.budget else {
124 self.returned_count = self.returned_count.saturating_add(1);
125 return Ok(true);
126 };
127 if self.returned_count >= budget.candidate_limit {
128 self.truncation_reason = Some(GraphExpansionTruncationReason::CandidateLimit);
129 return Ok(false);
130 }
131 let candidate_bytes = binding_payload_bytes(candidate);
132 if self.payload_bytes_used.saturating_add(candidate_bytes) > budget.payload_byte_limit {
133 self.truncation_reason = Some(GraphExpansionTruncationReason::PayloadByteLimit);
134 return Ok(false);
135 }
136 if let Some(target_id) = target_id
137 && !self.expanded_nodes.contains(&target_id)
138 {
139 const EXPANDED_NODE_MEMORY_BYTES: usize =
140 std::mem::size_of::<NodeId>() + 3 * std::mem::size_of::<usize>();
141 if let Some(lease) = self.expanded_nodes_lease.as_mut() {
142 lease.grow(EXPANDED_NODE_MEMORY_BYTES)?;
143 }
144 if !self.expanded_nodes.insert(target_id)
145 && let Some(lease) = self.expanded_nodes_lease.as_mut()
146 {
147 lease.shrink(EXPANDED_NODE_MEMORY_BYTES);
148 }
149 }
150 self.payload_bytes_used = self.payload_bytes_used.saturating_add(candidate_bytes);
151 self.returned_count = self.returned_count.saturating_add(1);
152 self.expanded_edge_count = self.expanded_edge_count.saturating_add(hop);
153 Ok(true)
154 }
155
156 pub fn record_seed(&mut self) {
157 self.seed_count = self.seed_count.saturating_add(1);
158 }
159
160 pub fn set_reranked_seed_count(&mut self, reranked_seed_count: usize) {
161 self.reranked_seed_count = reranked_seed_count;
162 }
163
164 pub fn returned_count(&self) -> usize {
165 self.returned_count
166 }
167
168 pub fn report(
169 &self,
170 rel_type: &str,
171 min_hops: usize,
172 max_hops: usize,
173 returned_count: usize,
174 ) -> Option<GraphExpansionExecutionReport> {
175 let budget = self.budget?;
176 Some(GraphExpansionExecutionReport {
177 seed_count: self.seed_count,
178 expanded_node_count: self.expanded_nodes.len(),
179 expanded_edge_count: self.expanded_edge_count,
180 relation_types: if rel_type.is_empty() {
181 Vec::new()
182 } else {
183 vec![rel_type.to_string()]
184 },
185 min_hops,
186 max_hops,
187 reranked_seed_count: self.reranked_seed_count,
188 candidate_limit: budget.candidate_limit,
189 payload_byte_limit: budget.payload_byte_limit,
190 payload_bytes_used: self.payload_bytes_used,
191 returned_count,
192 truncation_reason: self.truncation_reason,
193 })
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn truncation_reason_codes_are_stable() {
203 assert_eq!(
204 GraphExpansionTruncationReason::CandidateLimit.as_str(),
205 "candidate_limit"
206 );
207 assert_eq!(
208 GraphExpansionTruncationReason::PayloadByteLimit.as_str(),
209 "payload_byte_limit"
210 );
211 }
212
213 #[test]
214 fn reranked_seed_count_can_be_recorded_after_streaming_input() {
215 let mut state = GraphExpansionExecutionState::new(
216 Some(GraphExpansionBudget {
217 candidate_limit: 8,
218 payload_byte_limit: 1_024,
219 }),
220 0,
221 0,
222 );
223
224 state.set_reranked_seed_count(3);
225
226 let report = state.report("related", 1, 1, 0).expect("budgeted report");
227 assert_eq!(report.reranked_seed_count, 3);
228 }
229
230 #[test]
231 fn expansion_state_enforces_candidate_and_payload_budgets_before_push() {
232 let binding = Binding {
233 values: std::collections::BTreeMap::from([(
234 "value".to_string(),
235 hawdb_core::Value::String("payload".to_string()),
236 )]),
237 nodes: std::collections::BTreeMap::new(),
238 relationships: std::collections::BTreeMap::new(),
239 };
240 let mut state = GraphExpansionExecutionState::new(
241 Some(GraphExpansionBudget {
242 candidate_limit: 1,
243 payload_byte_limit: usize::MAX,
244 }),
245 1,
246 1,
247 );
248 let mut output = Vec::new();
249
250 assert!(state
251 .try_push(&mut output, binding.clone(), None, 1)
252 .unwrap());
253 assert!(!state.try_push(&mut output, binding, None, 1).unwrap());
254 assert_eq!(
255 state.truncation_reason,
256 Some(GraphExpansionTruncationReason::CandidateLimit)
257 );
258
259 let binding = Binding {
260 values: std::collections::BTreeMap::from([(
261 "value".to_string(),
262 hawdb_core::Value::String("payload".to_string()),
263 )]),
264 nodes: std::collections::BTreeMap::new(),
265 relationships: std::collections::BTreeMap::new(),
266 };
267 let mut state = GraphExpansionExecutionState::new(
268 Some(GraphExpansionBudget {
269 candidate_limit: 2,
270 payload_byte_limit: binding_payload_bytes(&binding).saturating_sub(1),
271 }),
272 1,
273 1,
274 );
275 let mut output = Vec::new();
276
277 assert!(!state.try_push(&mut output, binding, None, 1).unwrap());
278 assert!(output.is_empty());
279 assert_eq!(
280 state.truncation_reason,
281 Some(GraphExpansionTruncationReason::PayloadByteLimit)
282 );
283 }
284
285 #[test]
286 fn expansion_node_set_uses_and_releases_the_query_root() {
287 let ledger = crate::QueryMemoryLedger::new(std::num::NonZeroUsize::new(64).unwrap());
288 let account = ledger.account(
289 crate::QueryMemoryClass::BlockingState,
290 "test expansion",
291 std::num::NonZeroUsize::new(64).unwrap(),
292 );
293 let mut state = GraphExpansionExecutionState::with_memory_account(
294 Some(GraphExpansionBudget {
295 candidate_limit: 2,
296 payload_byte_limit: usize::MAX,
297 }),
298 1,
299 0,
300 &account,
301 )
302 .unwrap();
303 let binding = Binding::values(std::collections::BTreeMap::new());
304
305 assert!(state.try_admit(&binding, Some(NodeId(7)), 1).unwrap());
306 assert!(ledger.snapshot().used_bytes > 0);
307 assert!(state.try_admit(&binding, Some(NodeId(7)), 1).unwrap());
308 assert_eq!(state.expanded_nodes.len(), 1);
309
310 drop(state);
311 assert_eq!(ledger.snapshot().used_bytes, 0);
312 }
313}