reddb_server/storage/graph/viewport.rs
1//! Graph viewport contract — issue #744.
2//!
3//! Red UI's graph explorer asks one question on every panel render:
4//! "given a collection, a center or a small filter set, a bounded
5//! traversal depth and a hard cap on node count, what nodes and edges
6//! should I show?". Before this slice the UI answered that by reaching
7//! into the runtime's graph helpers directly and stitching together a
8//! shape that included `RuntimeGraphVisit` / `RuntimeGraphEdge`. That
9//! coupling broke twice: once when neighborhood output added the
10//! `depth` field, and again when the frontend started chunking
11//! `id IN (rid_1, rid_2, …)` requests by hand to work around a lookup
12//! path that silently returned zero rows for large RID lists.
13//!
14//! This module is the stable contract that replaces both workarounds:
15//!
16//! * [`ViewportRequest`] — the request shape Red UI sends. A
17//! collection name plus a [`ViewportSelector`] (center node, RID
18//! list, or label/type filter), an optional traversal `depth`, and
19//! a hard `limit` on returned nodes. The RID-list variant carries
20//! *all* requested ids verbatim; the contract guarantees no silent
21//! chunking or input truncation. Output truncation is reported
22//! explicitly via [`TruncationMeta`].
23//!
24//! * [`Viewport`] — the response. Normalized [`ViewportNode`] +
25//! [`ViewportEdge`] lists with stable ordering, plus
26//! [`TruncationMeta`] that tells the UI exactly *why* the response
27//! was capped (node-limit hit, depth-limit hit, both, or neither).
28//!
29//! * [`Viewport::from_visits`] — the pure builder runtime wiring will
30//! call. It is deliberately pure so this module can be unit-tested
31//! end-to-end without spinning up a graph store, and so the
32//! truncation rule lives in exactly one place.
33//!
34//! Independence from internal storage modules is the load-bearing
35//! property. We re-declare `ViewportDirection` and the node / edge
36//! shapes here (rather than re-exporting them from `runtime` or
37//! `engine::graph_store`) so a future internal rename does not force
38//! a Red UI release. The pattern matches `storage::vector::introspection`
39//! (issue #743) and `storage::queue::presence` (issue #742).
40
41use std::collections::HashSet;
42
43/// Traversal direction for a viewport request. Stable wire-style
44/// strings via [`ViewportDirection::as_str`].
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum ViewportDirection {
47 /// Follow outgoing edges only.
48 Outgoing,
49 /// Follow incoming edges only.
50 Incoming,
51 /// Follow edges in both directions.
52 #[default]
53 Both,
54}
55
56impl ViewportDirection {
57 pub fn as_str(self) -> &'static str {
58 match self {
59 ViewportDirection::Outgoing => "outgoing",
60 ViewportDirection::Incoming => "incoming",
61 ViewportDirection::Both => "both",
62 }
63 }
64}
65
66/// How Red UI picks the starting set of nodes for a viewport. Exactly
67/// one variant per request.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ViewportSelector {
70 /// Expand outwards from a single named node id.
71 Center(String),
72 /// Seed the viewport with a specific RID list. Carries *all*
73 /// requested ids verbatim — the contract guarantees no silent
74 /// chunking on the way in, even when the list is large. This is
75 /// the replacement for the frontend's old RID-IN chunking
76 /// workaround.
77 Rids(Vec<String>),
78 /// Seed the viewport with every node whose label / type matches
79 /// the filter. Empty string = match-all (UI uses this for the
80 /// initial "show me the whole collection, capped" load).
81 LabelEquals(String),
82}
83
84impl ViewportSelector {
85 /// Number of explicit seed ids the selector carries. For
86 /// `LabelEquals` and `Center` this is the obvious 1 / 0; for
87 /// `Rids` it is the full input cardinality (the contract never
88 /// drops ids on the way in).
89 pub fn seed_count(&self) -> usize {
90 match self {
91 ViewportSelector::Center(_) => 1,
92 ViewportSelector::Rids(ids) => ids.len(),
93 ViewportSelector::LabelEquals(_) => 0,
94 }
95 }
96}
97
98/// A viewport request. The runtime call that will translate this into
99/// a populated [`Viewport`] is wired in a follow-up slice; the shape
100/// is the load-bearing contract here.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ViewportRequest {
103 /// Graph collection name.
104 pub collection: String,
105 /// How to pick the starting node set.
106 pub selector: ViewportSelector,
107 /// How many hops out from the seed set to expand. `0` means "just
108 /// the seeds, no expansion".
109 pub depth: u32,
110 /// Optional edge-label allow-list. Empty = all labels.
111 pub edge_labels: Vec<String>,
112 /// Traversal direction.
113 pub direction: ViewportDirection,
114 /// Hard cap on returned nodes. The builder enforces this and
115 /// reports the cap via [`TruncationMeta::node_limit_hit`].
116 pub node_limit: u32,
117}
118
119impl ViewportRequest {
120 /// Convenience: minimal request seeded from a single center node
121 /// with default direction (`Both`).
122 pub fn center(collection: impl Into<String>, node: impl Into<String>) -> Self {
123 Self {
124 collection: collection.into(),
125 selector: ViewportSelector::Center(node.into()),
126 depth: 1,
127 edge_labels: Vec::new(),
128 direction: ViewportDirection::Both,
129 node_limit: 256,
130 }
131 }
132}
133
134/// One node in a viewport response. Properties and weight are
135/// optional because not every graph collection stores them.
136#[derive(Debug, Clone, PartialEq)]
137pub struct ViewportNode {
138 pub id: String,
139 pub label: String,
140 pub node_type: String,
141 /// JSON-shaped property bag as a string (the canonical contract
142 /// wire form). The runtime fills this from the stored property
143 /// page; `"{}"` if the node has no properties.
144 pub properties: String,
145 /// Hop distance from the seed set. `0` for seeds themselves.
146 pub depth: u32,
147}
148
149/// One edge in a viewport response. Always points from `source` to
150/// `target` in storage order; UIs that render undirected views can
151/// collapse pairs themselves.
152#[derive(Debug, Clone, PartialEq)]
153pub struct ViewportEdge {
154 pub source: String,
155 pub target: String,
156 pub edge_type: String,
157 /// `None` when the edge has no stored weight (the typical case
158 /// for unweighted graphs); the UI must not invent a default.
159 pub weight: Option<f32>,
160 /// Same JSON-shaped property bag convention as [`ViewportNode`].
161 pub properties: String,
162}
163
164/// Why a viewport response was capped. Each flag is independent: a
165/// single response can hit both the node limit and the depth limit.
166#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
167pub struct TruncationMeta {
168 /// True when the response was cut short because [`ViewportRequest::node_limit`]
169 /// would have been exceeded.
170 pub node_limit_hit: bool,
171 /// True when the traversal stopped because [`ViewportRequest::depth`]
172 /// was reached and the frontier was non-empty (i.e. more nodes
173 /// existed beyond the depth cap).
174 pub depth_limit_hit: bool,
175 /// Number of additional nodes the traversal saw but did not
176 /// return because the node limit was reached. `0` when
177 /// `node_limit_hit` is false.
178 pub dropped_node_count: u32,
179}
180
181impl TruncationMeta {
182 /// `true` when nothing was dropped — neither limit hit.
183 pub fn is_complete(self) -> bool {
184 !self.node_limit_hit && !self.depth_limit_hit
185 }
186}
187
188/// A populated viewport response. Returned by the (future) runtime
189/// call and consumed directly by Red UI.
190#[derive(Debug, Clone, PartialEq)]
191pub struct Viewport {
192 pub collection: String,
193 pub seed_count: u32,
194 pub nodes: Vec<ViewportNode>,
195 pub edges: Vec<ViewportEdge>,
196 pub truncation: TruncationMeta,
197}
198
199/// One input visit row for [`Viewport::from_visits`] — a node the
200/// traversal reached, in traversal order, with its depth and (for the
201/// frontier) whether its outbound expansion was cut by the depth cap.
202#[derive(Debug, Clone, PartialEq)]
203pub struct ViewportVisitInput {
204 pub node: ViewportNode,
205 /// True when this visit sat on the depth frontier and the
206 /// traversal would have expanded further if `depth` had been
207 /// larger. The builder folds this into
208 /// [`TruncationMeta::depth_limit_hit`].
209 pub frontier_truncated: bool,
210}
211
212impl Viewport {
213 /// Pure builder: given an ordered visit list and the edge list
214 /// the traversal collected, apply [`ViewportRequest::node_limit`]
215 /// and produce the contract response.
216 ///
217 /// The builder is intentionally pure so the truncation rule lives
218 /// here once and is testable without a graph store. Runtime
219 /// wiring (a follow-up slice) will call this after running the
220 /// real traversal.
221 ///
222 /// Rules:
223 ///
224 /// 1. Visits are kept in input order. Callers (the traversal)
225 /// are responsible for stable ordering — typically BFS by
226 /// depth, then by node id.
227 /// 2. The first `node_limit` visits are returned; the remainder
228 /// are reported via [`TruncationMeta::dropped_node_count`] and
229 /// [`TruncationMeta::node_limit_hit`].
230 /// 3. Edges are filtered down to those whose `source` *and*
231 /// `target` both survived the node cap. An edge to a dropped
232 /// node would be a dangling reference in the UI.
233 /// 4. `depth_limit_hit` is the OR of every surviving visit's
234 /// `frontier_truncated`. A visit dropped by the node cap does
235 /// not contribute (the UI will surface the node cap instead;
236 /// re-asking with a larger node limit is the correct fix).
237 pub fn from_visits(
238 request: &ViewportRequest,
239 visits: Vec<ViewportVisitInput>,
240 edges: Vec<ViewportEdge>,
241 ) -> Self {
242 let node_limit = request.node_limit as usize;
243 let total = visits.len();
244
245 let kept_count = total.min(node_limit);
246 let dropped = total.saturating_sub(kept_count);
247
248 let mut kept_ids: HashSet<String> = HashSet::with_capacity(kept_count);
249 let mut nodes: Vec<ViewportNode> = Vec::with_capacity(kept_count);
250 let mut depth_limit_hit = false;
251
252 for visit in visits.into_iter().take(kept_count) {
253 kept_ids.insert(visit.node.id.clone());
254 if visit.frontier_truncated {
255 depth_limit_hit = true;
256 }
257 nodes.push(visit.node);
258 }
259
260 let edges: Vec<ViewportEdge> = edges
261 .into_iter()
262 .filter(|e| kept_ids.contains(&e.source) && kept_ids.contains(&e.target))
263 .collect();
264
265 let truncation = TruncationMeta {
266 node_limit_hit: dropped > 0,
267 depth_limit_hit,
268 dropped_node_count: u32::try_from(dropped).unwrap_or(u32::MAX),
269 };
270
271 Viewport {
272 collection: request.collection.clone(),
273 seed_count: u32::try_from(request.selector.seed_count()).unwrap_or(u32::MAX),
274 nodes,
275 edges,
276 truncation,
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn node(id: &str, depth: u32) -> ViewportNode {
286 ViewportNode {
287 id: id.into(),
288 label: format!("L:{id}"),
289 node_type: "Person".into(),
290 properties: "{}".into(),
291 depth,
292 }
293 }
294
295 fn visit(id: &str, depth: u32, frontier_truncated: bool) -> ViewportVisitInput {
296 ViewportVisitInput {
297 node: node(id, depth),
298 frontier_truncated,
299 }
300 }
301
302 fn edge(source: &str, target: &str) -> ViewportEdge {
303 ViewportEdge {
304 source: source.into(),
305 target: target.into(),
306 edge_type: "KNOWS".into(),
307 weight: None,
308 properties: "{}".into(),
309 }
310 }
311
312 /// Acceptance: "Red UI can request a bounded subgraph by
313 /// collection, center or filter, depth, and limit". A
314 /// well-formed center-seeded request round-trips through the
315 /// builder with every field intact and reports no truncation
316 /// when the response fits.
317 #[test]
318 fn center_request_round_trips_through_builder() {
319 let req = ViewportRequest::center("friends", "alice");
320 assert_eq!(req.collection, "friends");
321 assert_eq!(req.selector, ViewportSelector::Center("alice".into()));
322 assert_eq!(req.depth, 1);
323 assert_eq!(req.direction, ViewportDirection::Both);
324 assert_eq!(req.node_limit, 256);
325
326 let visits = vec![
327 visit("alice", 0, false),
328 visit("bob", 1, false),
329 visit("carol", 1, false),
330 ];
331 let edges = vec![edge("alice", "bob"), edge("alice", "carol")];
332 let v = Viewport::from_visits(&req, visits, edges);
333
334 assert_eq!(v.collection, "friends");
335 assert_eq!(v.seed_count, 1);
336 assert_eq!(v.nodes.len(), 3);
337 assert_eq!(v.edges.len(), 2);
338 assert!(v.truncation.is_complete());
339 assert_eq!(v.truncation.dropped_node_count, 0);
340 }
341
342 /// Acceptance: "The response returns normalized nodes and edges
343 /// with ids, labels or types, properties, weights where known,
344 /// and truncation metadata." Property bags and weights round-trip
345 /// faithfully (None weight stays None — UI must not invent a
346 /// default).
347 #[test]
348 fn nodes_and_edges_preserve_properties_and_weights() {
349 let req = ViewportRequest::center("g", "n1");
350 let mut typed = node("n1", 0);
351 typed.properties = r#"{"name":"alice","age":30}"#.into();
352 typed.node_type = "Customer".into();
353 let visits = vec![ViewportVisitInput {
354 node: typed,
355 frontier_truncated: false,
356 }];
357 let weighted = ViewportEdge {
358 source: "n1".into(),
359 target: "n1".into(),
360 edge_type: "SELF".into(),
361 weight: Some(2.5),
362 properties: r#"{"k":"v"}"#.into(),
363 };
364 let v = Viewport::from_visits(&req, visits, vec![weighted.clone()]);
365 assert_eq!(v.nodes[0].node_type, "Customer");
366 assert_eq!(v.nodes[0].properties, r#"{"name":"alice","age":30}"#);
367 assert_eq!(v.edges[0].weight, Some(2.5));
368 assert_eq!(v.edges[0].properties, r#"{"k":"v"}"#);
369 }
370
371 /// Acceptance: "Tests cover graph visibility and limit/truncation
372 /// behavior." Node-limit truncation reports `node_limit_hit`,
373 /// `dropped_node_count`, and prunes edges to surviving nodes so
374 /// the UI never gets a dangling reference.
375 #[test]
376 fn node_limit_truncation_prunes_dangling_edges() {
377 let mut req = ViewportRequest::center("g", "a");
378 req.node_limit = 2;
379 let visits = vec![
380 visit("a", 0, false),
381 visit("b", 1, false),
382 visit("c", 1, false),
383 visit("d", 1, false),
384 ];
385 let edges = vec![
386 edge("a", "b"), // both kept
387 edge("a", "c"), // c dropped → drop edge
388 edge("b", "d"), // d dropped → drop edge
389 ];
390 let v = Viewport::from_visits(&req, visits, edges);
391
392 assert_eq!(v.nodes.len(), 2);
393 assert_eq!(
394 v.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
395 vec!["a", "b"]
396 );
397 assert!(v.truncation.node_limit_hit);
398 assert_eq!(v.truncation.dropped_node_count, 2);
399 // Only the edge whose endpoints both survived remains.
400 assert_eq!(v.edges.len(), 1);
401 assert_eq!(v.edges[0].target, "b");
402 }
403
404 /// Acceptance: "Tests cover graph visibility and limit/truncation
405 /// behavior." Depth-limit truncation is a separate flag from the
406 /// node-limit cap, and the two compose independently.
407 #[test]
408 fn depth_limit_flag_set_when_frontier_truncated() {
409 let req = ViewportRequest::center("g", "a");
410 // No node-limit pressure (default 256 ≫ 2), but the visit at
411 // depth 1 was on the frontier and the traversal stopped.
412 let visits = vec![visit("a", 0, false), visit("b", 1, true)];
413 let v = Viewport::from_visits(&req, visits, vec![]);
414 assert!(!v.truncation.node_limit_hit);
415 assert!(v.truncation.depth_limit_hit);
416 assert!(!v.truncation.is_complete());
417 }
418
419 #[test]
420 fn complete_response_reports_no_truncation() {
421 let req = ViewportRequest::center("g", "a");
422 let v = Viewport::from_visits(&req, vec![visit("a", 0, false)], vec![]);
423 assert!(v.truncation.is_complete());
424 assert_eq!(v.truncation.dropped_node_count, 0);
425 }
426
427 /// Acceptance: "A regression test covers RID-list lookup
428 /// behavior so larger RID IN queries do not silently return zero
429 /// rows."
430 ///
431 /// The Red UI workaround this contract replaces was: when the
432 /// frontend wanted to load many nodes by id at once, the old
433 /// path silently returned zero rows past a certain list size,
434 /// so the UI broke the call into hand-rolled chunks. The
435 /// `ViewportSelector::Rids` contract pins the opposite
436 /// behavior: every requested id is carried into the request
437 /// verbatim, the seed count exposed to the UI matches the input,
438 /// and the builder happily emits every visit it is handed. No
439 /// silent input truncation, no zero-row drop — the only way to
440 /// lose nodes is the explicit `node_limit`, which always reports
441 /// itself.
442 #[test]
443 fn large_rid_list_lookup_does_not_silently_drop_rows() {
444 // A "large" list — well past the historical chunking
445 // threshold the frontend used. Keep this round so the test
446 // intent is obvious; the contract has no magic number.
447 const N: usize = 1_024;
448 let ids: Vec<String> = (0..N).map(|i| format!("rid-{i:04}")).collect();
449
450 let req = ViewportRequest {
451 collection: "people".into(),
452 selector: ViewportSelector::Rids(ids.clone()),
453 depth: 0,
454 edge_labels: vec![],
455 direction: ViewportDirection::Both,
456 node_limit: u32::try_from(N).unwrap(),
457 };
458
459 // The selector keeps every input id — no silent chunking,
460 // no zero-row drop. This is the load-bearing pin.
461 assert_eq!(req.selector.seed_count(), N);
462 if let ViewportSelector::Rids(ref kept) = req.selector {
463 assert_eq!(kept.len(), N);
464 assert_eq!(kept[0], "rid-0000");
465 assert_eq!(kept[N - 1], format!("rid-{:04}", N - 1));
466 } else {
467 panic!("selector lost variant identity");
468 }
469
470 // Builder round-trip with one visit per requested id.
471 let visits: Vec<ViewportVisitInput> =
472 ids.iter().map(|id| visit(id.as_str(), 0, false)).collect();
473 let v = Viewport::from_visits(&req, visits, vec![]);
474
475 assert_eq!(v.seed_count, u32::try_from(N).unwrap());
476 assert_eq!(v.nodes.len(), N);
477 assert!(
478 v.truncation.is_complete(),
479 "large RID-list lookup must not report truncation when every id round-trips"
480 );
481 assert_eq!(v.truncation.dropped_node_count, 0);
482 }
483
484 /// Companion to the regression test above: when an RID-list
485 /// request *does* exceed the node limit, the response truncates
486 /// loudly via `TruncationMeta` rather than silently. The whole
487 /// point of the contract is that the UI can tell the difference.
488 #[test]
489 fn rid_list_truncation_is_explicit_not_silent() {
490 let ids: Vec<String> = (0..10).map(|i| format!("rid-{i}")).collect();
491 let req = ViewportRequest {
492 collection: "people".into(),
493 selector: ViewportSelector::Rids(ids.clone()),
494 depth: 0,
495 edge_labels: vec![],
496 direction: ViewportDirection::Both,
497 node_limit: 4,
498 };
499 let visits: Vec<ViewportVisitInput> =
500 ids.iter().map(|id| visit(id.as_str(), 0, false)).collect();
501 let v = Viewport::from_visits(&req, visits, vec![]);
502 assert_eq!(v.nodes.len(), 4);
503 assert!(v.truncation.node_limit_hit);
504 assert_eq!(v.truncation.dropped_node_count, 6);
505 // Seeds metadata still reflects the request's full input —
506 // the UI sees "I asked for 10, I got 4, 6 were dropped".
507 assert_eq!(v.seed_count, 10);
508 }
509
510 /// Direction tags are part of the wire contract — pin them.
511 #[test]
512 fn direction_strings_are_stable() {
513 assert_eq!(ViewportDirection::Outgoing.as_str(), "outgoing");
514 assert_eq!(ViewportDirection::Incoming.as_str(), "incoming");
515 assert_eq!(ViewportDirection::Both.as_str(), "both");
516 }
517
518 #[test]
519 fn label_equals_selector_carries_zero_seed_ids() {
520 let req = ViewportRequest {
521 collection: "g".into(),
522 selector: ViewportSelector::LabelEquals("Person".into()),
523 depth: 1,
524 edge_labels: vec![],
525 direction: ViewportDirection::Outgoing,
526 node_limit: 16,
527 };
528 assert_eq!(req.selector.seed_count(), 0);
529 }
530}