use std::collections::HashSet;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ViewportDirection {
Outgoing,
Incoming,
#[default]
Both,
}
impl ViewportDirection {
pub fn as_str(self) -> &'static str {
match self {
ViewportDirection::Outgoing => "outgoing",
ViewportDirection::Incoming => "incoming",
ViewportDirection::Both => "both",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViewportSelector {
Center(String),
Rids(Vec<String>),
LabelEquals(String),
}
impl ViewportSelector {
pub fn seed_count(&self) -> usize {
match self {
ViewportSelector::Center(_) => 1,
ViewportSelector::Rids(ids) => ids.len(),
ViewportSelector::LabelEquals(_) => 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewportRequest {
pub collection: String,
pub selector: ViewportSelector,
pub depth: u32,
pub edge_labels: Vec<String>,
pub direction: ViewportDirection,
pub node_limit: u32,
}
impl ViewportRequest {
pub fn center(collection: impl Into<String>, node: impl Into<String>) -> Self {
Self {
collection: collection.into(),
selector: ViewportSelector::Center(node.into()),
depth: 1,
edge_labels: Vec::new(),
direction: ViewportDirection::Both,
node_limit: 256,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewportNode {
pub id: String,
pub label: String,
pub node_type: String,
pub properties: String,
pub depth: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewportEdge {
pub source: String,
pub target: String,
pub edge_type: String,
pub weight: Option<f32>,
pub properties: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TruncationMeta {
pub node_limit_hit: bool,
pub depth_limit_hit: bool,
pub dropped_node_count: u32,
}
impl TruncationMeta {
pub fn is_complete(self) -> bool {
!self.node_limit_hit && !self.depth_limit_hit
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Viewport {
pub collection: String,
pub seed_count: u32,
pub nodes: Vec<ViewportNode>,
pub edges: Vec<ViewportEdge>,
pub truncation: TruncationMeta,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewportVisitInput {
pub node: ViewportNode,
pub frontier_truncated: bool,
}
impl Viewport {
pub fn from_visits(
request: &ViewportRequest,
visits: Vec<ViewportVisitInput>,
edges: Vec<ViewportEdge>,
) -> Self {
let node_limit = request.node_limit as usize;
let total = visits.len();
let kept_count = total.min(node_limit);
let dropped = total.saturating_sub(kept_count);
let mut kept_ids: HashSet<String> = HashSet::with_capacity(kept_count);
let mut nodes: Vec<ViewportNode> = Vec::with_capacity(kept_count);
let mut depth_limit_hit = false;
for visit in visits.into_iter().take(kept_count) {
kept_ids.insert(visit.node.id.clone());
if visit.frontier_truncated {
depth_limit_hit = true;
}
nodes.push(visit.node);
}
let edges: Vec<ViewportEdge> = edges
.into_iter()
.filter(|e| kept_ids.contains(&e.source) && kept_ids.contains(&e.target))
.collect();
let truncation = TruncationMeta {
node_limit_hit: dropped > 0,
depth_limit_hit,
dropped_node_count: u32::try_from(dropped).unwrap_or(u32::MAX),
};
Viewport {
collection: request.collection.clone(),
seed_count: u32::try_from(request.selector.seed_count()).unwrap_or(u32::MAX),
nodes,
edges,
truncation,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(id: &str, depth: u32) -> ViewportNode {
ViewportNode {
id: id.into(),
label: format!("L:{id}"),
node_type: "Person".into(),
properties: "{}".into(),
depth,
}
}
fn visit(id: &str, depth: u32, frontier_truncated: bool) -> ViewportVisitInput {
ViewportVisitInput {
node: node(id, depth),
frontier_truncated,
}
}
fn edge(source: &str, target: &str) -> ViewportEdge {
ViewportEdge {
source: source.into(),
target: target.into(),
edge_type: "KNOWS".into(),
weight: None,
properties: "{}".into(),
}
}
#[test]
fn center_request_round_trips_through_builder() {
let req = ViewportRequest::center("friends", "alice");
assert_eq!(req.collection, "friends");
assert_eq!(req.selector, ViewportSelector::Center("alice".into()));
assert_eq!(req.depth, 1);
assert_eq!(req.direction, ViewportDirection::Both);
assert_eq!(req.node_limit, 256);
let visits = vec![
visit("alice", 0, false),
visit("bob", 1, false),
visit("carol", 1, false),
];
let edges = vec![edge("alice", "bob"), edge("alice", "carol")];
let v = Viewport::from_visits(&req, visits, edges);
assert_eq!(v.collection, "friends");
assert_eq!(v.seed_count, 1);
assert_eq!(v.nodes.len(), 3);
assert_eq!(v.edges.len(), 2);
assert!(v.truncation.is_complete());
assert_eq!(v.truncation.dropped_node_count, 0);
}
#[test]
fn nodes_and_edges_preserve_properties_and_weights() {
let req = ViewportRequest::center("g", "n1");
let mut typed = node("n1", 0);
typed.properties = r#"{"name":"alice","age":30}"#.into();
typed.node_type = "Customer".into();
let visits = vec![ViewportVisitInput {
node: typed,
frontier_truncated: false,
}];
let weighted = ViewportEdge {
source: "n1".into(),
target: "n1".into(),
edge_type: "SELF".into(),
weight: Some(2.5),
properties: r#"{"k":"v"}"#.into(),
};
let v = Viewport::from_visits(&req, visits, vec![weighted.clone()]);
assert_eq!(v.nodes[0].node_type, "Customer");
assert_eq!(v.nodes[0].properties, r#"{"name":"alice","age":30}"#);
assert_eq!(v.edges[0].weight, Some(2.5));
assert_eq!(v.edges[0].properties, r#"{"k":"v"}"#);
}
#[test]
fn node_limit_truncation_prunes_dangling_edges() {
let mut req = ViewportRequest::center("g", "a");
req.node_limit = 2;
let visits = vec![
visit("a", 0, false),
visit("b", 1, false),
visit("c", 1, false),
visit("d", 1, false),
];
let edges = vec![
edge("a", "b"), edge("a", "c"), edge("b", "d"), ];
let v = Viewport::from_visits(&req, visits, edges);
assert_eq!(v.nodes.len(), 2);
assert_eq!(
v.nodes.iter().map(|n| n.id.as_str()).collect::<Vec<_>>(),
vec!["a", "b"]
);
assert!(v.truncation.node_limit_hit);
assert_eq!(v.truncation.dropped_node_count, 2);
assert_eq!(v.edges.len(), 1);
assert_eq!(v.edges[0].target, "b");
}
#[test]
fn depth_limit_flag_set_when_frontier_truncated() {
let req = ViewportRequest::center("g", "a");
let visits = vec![visit("a", 0, false), visit("b", 1, true)];
let v = Viewport::from_visits(&req, visits, vec![]);
assert!(!v.truncation.node_limit_hit);
assert!(v.truncation.depth_limit_hit);
assert!(!v.truncation.is_complete());
}
#[test]
fn complete_response_reports_no_truncation() {
let req = ViewportRequest::center("g", "a");
let v = Viewport::from_visits(&req, vec![visit("a", 0, false)], vec![]);
assert!(v.truncation.is_complete());
assert_eq!(v.truncation.dropped_node_count, 0);
}
#[test]
fn large_rid_list_lookup_does_not_silently_drop_rows() {
const N: usize = 1_024;
let ids: Vec<String> = (0..N).map(|i| format!("rid-{i:04}")).collect();
let req = ViewportRequest {
collection: "people".into(),
selector: ViewportSelector::Rids(ids.clone()),
depth: 0,
edge_labels: vec![],
direction: ViewportDirection::Both,
node_limit: u32::try_from(N).unwrap(),
};
assert_eq!(req.selector.seed_count(), N);
if let ViewportSelector::Rids(ref kept) = req.selector {
assert_eq!(kept.len(), N);
assert_eq!(kept[0], "rid-0000");
assert_eq!(kept[N - 1], format!("rid-{:04}", N - 1));
} else {
panic!("selector lost variant identity");
}
let visits: Vec<ViewportVisitInput> =
ids.iter().map(|id| visit(id.as_str(), 0, false)).collect();
let v = Viewport::from_visits(&req, visits, vec![]);
assert_eq!(v.seed_count, u32::try_from(N).unwrap());
assert_eq!(v.nodes.len(), N);
assert!(
v.truncation.is_complete(),
"large RID-list lookup must not report truncation when every id round-trips"
);
assert_eq!(v.truncation.dropped_node_count, 0);
}
#[test]
fn rid_list_truncation_is_explicit_not_silent() {
let ids: Vec<String> = (0..10).map(|i| format!("rid-{i}")).collect();
let req = ViewportRequest {
collection: "people".into(),
selector: ViewportSelector::Rids(ids.clone()),
depth: 0,
edge_labels: vec![],
direction: ViewportDirection::Both,
node_limit: 4,
};
let visits: Vec<ViewportVisitInput> =
ids.iter().map(|id| visit(id.as_str(), 0, false)).collect();
let v = Viewport::from_visits(&req, visits, vec![]);
assert_eq!(v.nodes.len(), 4);
assert!(v.truncation.node_limit_hit);
assert_eq!(v.truncation.dropped_node_count, 6);
assert_eq!(v.seed_count, 10);
}
#[test]
fn direction_strings_are_stable() {
assert_eq!(ViewportDirection::Outgoing.as_str(), "outgoing");
assert_eq!(ViewportDirection::Incoming.as_str(), "incoming");
assert_eq!(ViewportDirection::Both.as_str(), "both");
}
#[test]
fn label_equals_selector_carries_zero_seed_ids() {
let req = ViewportRequest {
collection: "g".into(),
selector: ViewportSelector::LabelEquals("Person".into()),
depth: 1,
edge_labels: vec![],
direction: ViewportDirection::Outgoing,
node_limit: 16,
};
assert_eq!(req.selector.seed_count(), 0);
}
}