Skip to main content

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}