|
10 | 10 | //! extraction models, hotness decay curves, and snapshot retention are driver |
11 | 11 | //! concerns and appear in none of these signatures. |
12 | 12 |
|
| 13 | +use std::collections::BTreeSet; |
| 14 | + |
13 | 15 | use async_trait::async_trait; |
14 | 16 |
|
15 | 17 | use crate::error::MemoryError; |
| 18 | +use crate::graph::{GraphEdge, GraphNode, GraphView, GraphViewQuery}; |
16 | 19 | use crate::provider::types::{DiffReport, EntityHit, SnapshotRef}; |
17 | 20 | use crate::types::{GraphRelationRecord, MemoryKvRecord}; |
18 | 21 |
|
| 22 | +/// How many edges the default [`MemoryGraph::graph_view`] traversal scans per |
| 23 | +/// predicate when it has to resolve *inbound* edges. |
| 24 | +/// |
| 25 | +/// [`MemoryGraph::relations`] filters by subject and predicate but not by |
| 26 | +/// object, so inbound expansion has no indexed form in this contract and the |
| 27 | +/// default traversal falls back to a bounded scan. The bound exists so a graph |
| 28 | +/// larger than memory cannot be pulled into a view; hitting it sets |
| 29 | +/// [`GraphView::truncated`]. A driver whose store indexes the object column |
| 30 | +/// should override `graph_view` and skip this path entirely. |
| 31 | +pub const INBOUND_SCAN_LIMIT: usize = 4_096; |
| 32 | + |
19 | 33 | /// The entity index: who and what the stored memory is about. |
20 | 34 | #[async_trait] |
21 | 35 | pub trait MemoryEntities: Send + Sync { |
@@ -140,6 +154,238 @@ pub trait MemoryGraph: Send + Sync { |
140 | 154 | /// [`MemoryError::Invalid`] for a malformed edge, otherwise backend |
141 | 155 | /// failures. |
142 | 156 | async fn put_relation(&self, relation: GraphRelationRecord) -> Result<(), MemoryError>; |
| 157 | + |
| 158 | + /// Assemble a bounded, renderable slice of the graph. |
| 159 | + /// |
| 160 | + /// This is to [`Self::relations`] what |
| 161 | + /// [`crate::provider::MemoryTree::drill_down`] is to a raw node read: one |
| 162 | + /// call returns a node *together with its surroundings*, already joined |
| 163 | + /// into a node set and an edge set, so navigating a graph is a sequence of |
| 164 | + /// view calls rather than a client-side reassembly that every caller would |
| 165 | + /// write differently. |
| 166 | + /// |
| 167 | + /// # The default implementation |
| 168 | + /// |
| 169 | + /// Provided, not required: it breadth-first expands |
| 170 | + /// [`GraphViewQuery::seeds`] using [`Self::relations`] alone, so every |
| 171 | + /// existing driver gains a graph view without writing one, and a driver |
| 172 | + /// that advertises no [`crate::capabilities::Capability::Graph`] family |
| 173 | + /// still surfaces the same [`MemoryError::Unsupported`] its `relations` |
| 174 | + /// returns. |
| 175 | + /// |
| 176 | + /// It costs one `relations` call per node visited, including one final |
| 177 | + /// round at the outermost hop that adds no nodes and exists only to close |
| 178 | + /// edges *between* nodes already in the view — without it the outer ring |
| 179 | + /// renders as a star rather than as the graph it is. A driver with a native |
| 180 | + /// multi-hop traversal should override this and use it. |
| 181 | + /// |
| 182 | + /// Inbound expansion has no indexed form here — `relations` cannot filter |
| 183 | + /// by object — so [`crate::graph::GraphDirection::In`] and |
| 184 | + /// [`crate::graph::GraphDirection::Both`] fall back to a scan capped at |
| 185 | + /// [`INBOUND_SCAN_LIMIT`] per predicate. |
| 186 | + /// |
| 187 | + /// # Errors |
| 188 | + /// |
| 189 | + /// Whatever [`Self::relations`] returns. Bounds are never an error: a |
| 190 | + /// traversal that hits one returns the partial view with |
| 191 | + /// [`GraphView::truncated`] set. |
| 192 | + async fn graph_view(&self, query: &GraphViewQuery) -> Result<GraphView, MemoryError> { |
| 193 | + let namespace = query.namespace.as_deref(); |
| 194 | + let mut view = GraphView { |
| 195 | + namespace: query.namespace.clone(), |
| 196 | + seeds: query.seeds.clone(), |
| 197 | + ..GraphView::default() |
| 198 | + }; |
| 199 | + |
| 200 | + // Each predicate needs its own call: `relations` takes one, not a set. |
| 201 | + // An empty filter becomes the single unfiltered call rather than a |
| 202 | + // special case further down. |
| 203 | + let predicates: Vec<Option<&str>> = if query.predicates.is_empty() { |
| 204 | + vec![None] |
| 205 | + } else { |
| 206 | + query.predicates.iter().map(|p| Some(p.as_str())).collect() |
| 207 | + }; |
| 208 | + |
| 209 | + // Nodes reached but never expanded, either because a bound was hit or |
| 210 | + // because they sit one hop past the requested depth. A set, not a |
| 211 | + // counter: the same boundary node is commonly reached from several |
| 212 | + // directions, and counting it twice would overstate what is left. |
| 213 | + let mut unexpanded: BTreeSet<String> = BTreeSet::new(); |
| 214 | + |
| 215 | + // Unseeded: an overview, not a traversal. One bounded scan of the |
| 216 | + // slice, and the node set is whatever the returned edges touch. |
| 217 | + if query.seeds.is_empty() { |
| 218 | + for predicate in &predicates { |
| 219 | + // One past the ceiling: a call that asked for exactly |
| 220 | + // `max_edges` and got them cannot tell a full slice from a |
| 221 | + // truncated one. |
| 222 | + let records = self |
| 223 | + .relations( |
| 224 | + namespace, |
| 225 | + None, |
| 226 | + *predicate, |
| 227 | + query.max_edges.saturating_add(1), |
| 228 | + ) |
| 229 | + .await?; |
| 230 | + for record in records { |
| 231 | + push_view_edge(&mut view, record, &mut unexpanded, query, 0); |
| 232 | + } |
| 233 | + } |
| 234 | + view.stats.frontier_remaining = unexpanded.len(); |
| 235 | + view.recompute_stats(); |
| 236 | + return Ok(view); |
| 237 | + } |
| 238 | + |
| 239 | + // Inbound edges are resolved from one scan per predicate rather than |
| 240 | + // one per node: the scan is the expensive part, and repeating it for |
| 241 | + // every node visited would multiply it by `max_nodes`. |
| 242 | + let mut inbound: Vec<GraphRelationRecord> = Vec::new(); |
| 243 | + if query.direction.follows_in() { |
| 244 | + for predicate in &predicates { |
| 245 | + let records = self |
| 246 | + .relations(namespace, None, *predicate, INBOUND_SCAN_LIMIT) |
| 247 | + .await?; |
| 248 | + if records.len() >= INBOUND_SCAN_LIMIT { |
| 249 | + view.truncated = true; |
| 250 | + } |
| 251 | + inbound.extend(records); |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + let mut frontier: Vec<String> = Vec::new(); |
| 256 | + for seed in &query.seeds { |
| 257 | + if view.nodes.iter().any(|n| &n.id == seed) { |
| 258 | + continue; |
| 259 | + } |
| 260 | + if view.nodes.len() >= query.max_nodes { |
| 261 | + unexpanded.insert(seed.clone()); |
| 262 | + view.truncated = true; |
| 263 | + continue; |
| 264 | + } |
| 265 | + view.nodes.push(GraphNode::bare(seed.clone(), 0)); |
| 266 | + frontier.push(seed.clone()); |
| 267 | + } |
| 268 | + |
| 269 | + for hop in 0..=query.depth { |
| 270 | + if frontier.is_empty() { |
| 271 | + break; |
| 272 | + } |
| 273 | + let mut next: Vec<String> = Vec::new(); |
| 274 | + for node_id in &frontier { |
| 275 | + let mut incident: Vec<GraphRelationRecord> = Vec::new(); |
| 276 | + if query.direction.follows_out() { |
| 277 | + for predicate in &predicates { |
| 278 | + incident.extend( |
| 279 | + self.relations( |
| 280 | + namespace, |
| 281 | + Some(node_id), |
| 282 | + *predicate, |
| 283 | + query.max_edges.saturating_add(1), |
| 284 | + ) |
| 285 | + .await?, |
| 286 | + ); |
| 287 | + } |
| 288 | + } |
| 289 | + if query.direction.follows_in() { |
| 290 | + incident.extend( |
| 291 | + inbound |
| 292 | + .iter() |
| 293 | + .filter(|record| &record.object == node_id) |
| 294 | + .cloned(), |
| 295 | + ); |
| 296 | + } |
| 297 | + |
| 298 | + for record in incident { |
| 299 | + if !query.accepts_predicate(&record.predicate) { |
| 300 | + continue; |
| 301 | + } |
| 302 | + let other = if &record.subject == node_id { |
| 303 | + record.object.clone() |
| 304 | + } else { |
| 305 | + record.subject.clone() |
| 306 | + }; |
| 307 | + let known = view.nodes.iter().any(|n| n.id == other); |
| 308 | + if !known { |
| 309 | + // An edge to a node the view will not hold would |
| 310 | + // dangle, so it is dropped either way — but *why* it |
| 311 | + // was dropped matters. Reaching the requested depth is |
| 312 | + // the caller getting what they asked for; hitting the |
| 313 | + // node ceiling is not, and only the second makes the |
| 314 | + // view truncated. Conflating them would set the flag on |
| 315 | + // every finite traversal of a connected graph and leave |
| 316 | + // it saying nothing. |
| 317 | + if hop >= query.depth { |
| 318 | + unexpanded.insert(other); |
| 319 | + continue; |
| 320 | + } |
| 321 | + if view.nodes.len() >= query.max_nodes { |
| 322 | + unexpanded.insert(other); |
| 323 | + view.truncated = true; |
| 324 | + continue; |
| 325 | + } |
| 326 | + view.nodes.push(GraphNode::bare(other.clone(), hop + 1)); |
| 327 | + next.push(other); |
| 328 | + } |
| 329 | + push_view_edge(&mut view, record, &mut unexpanded, query, hop); |
| 330 | + } |
| 331 | + } |
| 332 | + frontier = next; |
| 333 | + } |
| 334 | + |
| 335 | + view.stats.frontier_remaining = unexpanded.len(); |
| 336 | + view.recompute_stats(); |
| 337 | + Ok(view) |
| 338 | + } |
| 339 | +} |
| 340 | + |
| 341 | +/// Add one relation to a view, deduplicating by triple and honouring |
| 342 | +/// [`GraphViewQuery::max_edges`]. |
| 343 | +/// |
| 344 | +/// A separate function rather than a closure so the borrow of `view` ends |
| 345 | +/// between calls, which the traversal above needs while it is also pushing |
| 346 | +/// nodes. |
| 347 | +fn push_view_edge( |
| 348 | + view: &mut GraphView, |
| 349 | + record: GraphRelationRecord, |
| 350 | + unexpanded: &mut BTreeSet<String>, |
| 351 | + query: &GraphViewQuery, |
| 352 | + depth: u32, |
| 353 | +) { |
| 354 | + if !query.accepts_predicate(&record.predicate) { |
| 355 | + return; |
| 356 | + } |
| 357 | + let triple = ( |
| 358 | + record.subject.clone(), |
| 359 | + record.predicate.clone(), |
| 360 | + record.object.clone(), |
| 361 | + ); |
| 362 | + if view |
| 363 | + .edges |
| 364 | + .iter() |
| 365 | + .any(|e| e.key() == (&triple.0, &triple.1, &triple.2)) |
| 366 | + { |
| 367 | + return; |
| 368 | + } |
| 369 | + if view.edges.len() >= query.max_edges { |
| 370 | + unexpanded.insert(triple.0); |
| 371 | + unexpanded.insert(triple.2); |
| 372 | + view.truncated = true; |
| 373 | + return; |
| 374 | + } |
| 375 | + // The unseeded overview derives its node set from the edges it found; the |
| 376 | + // seeded traversal has already placed both endpoints. |
| 377 | + for id in [triple.0.clone(), triple.2.clone()] { |
| 378 | + if view.nodes.iter().any(|n| n.id == id) { |
| 379 | + continue; |
| 380 | + } |
| 381 | + if view.nodes.len() >= query.max_nodes { |
| 382 | + unexpanded.insert(id); |
| 383 | + view.truncated = true; |
| 384 | + return; |
| 385 | + } |
| 386 | + view.nodes.push(GraphNode::bare(id, depth)); |
| 387 | + } |
| 388 | + view.edges.push(GraphEdge::from(record)); |
143 | 389 | } |
144 | 390 |
|
145 | 391 | /// Snapshot capture and change computation over synced sources. |
|
0 commit comments