use crate::{Aggregate, Edge, Graph, NeighborResult, Node, NodeId};
#[derive(Debug, Default)]
pub struct PreviewLayer {
generation: u64,
anchor: Option<NodeId>,
nodes: Vec<Node>,
edges: Vec<Edge>,
aggregates: Vec<Aggregate>,
}
impl PreviewLayer {
pub fn new() -> Self { Self::default() }
pub fn begin(&mut self, anchor: NodeId) -> u64 {
self.generation += 1;
self.anchor = Some(anchor);
self.nodes.clear();
self.edges.clear();
self.aggregates.clear();
self.generation
}
pub fn anchor(&self) -> Option<&NodeId> { self.anchor.as_ref() }
pub fn nodes(&self) -> &[Node] { &self.nodes }
pub fn edges(&self) -> &[Edge] { &self.edges }
pub fn aggregates(&self) -> &[Aggregate] { &self.aggregates }
pub fn node_count(&self) -> usize { self.nodes.len() }
pub fn is_empty(&self) -> bool {
self.nodes.is_empty() && self.edges.is_empty() && self.aggregates.is_empty()
}
pub fn has_committable(&self) -> bool {
!self.nodes.is_empty() || !self.edges.is_empty()
}
pub fn contains(&self, id: &str) -> bool { self.nodes.iter().any(|n| n.id == id) }
pub fn fill(&mut self, gen: u64, res: &NeighborResult, committed: &Graph) -> bool {
let Some(anchor) = self.anchor.clone() else { return false };
if gen != self.generation {
return false;
}
let mut seen = std::collections::HashSet::new();
let nodes: Vec<Node> = res
.nodes
.iter()
.filter(|n| committed.node(&n.id).is_none())
.filter(|n| seen.insert(n.id.clone()))
.cloned()
.collect();
let retained: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
let known = |id: &str| committed.node(id).is_some() || retained.contains(id);
self.edges = res
.edges
.iter()
.filter(|e| committed.edge(&e.id).is_none())
.filter(|e| known(&e.source) && known(&e.target))
.cloned()
.collect();
self.nodes = nodes;
self.aggregates = res.aggregates.iter().cloned().map(|mut a| {
a.parent = anchor.clone();
a
}).collect();
true
}
pub fn promote(&mut self, id: &str, committed: &Graph) -> Option<(Node, Vec<Edge>)> {
let idx = self.nodes.iter().position(|n| n.id == id)?;
let node = self.nodes.remove(idx);
let mut taken = Vec::new();
self.edges.retain(|e| {
if e.source != id && e.target != id {
return true;
}
let other = if e.source == id { &e.target } else { &e.source };
if other == id || committed.node(other).is_some() {
taken.push(e.clone());
false
} else {
true
}
});
Some((node, taken))
}
pub fn take_all(&mut self) -> (Vec<Node>, Vec<Edge>) {
self.generation += 1;
(std::mem::take(&mut self.nodes), std::mem::take(&mut self.edges))
}
pub fn clear(&mut self) {
self.generation += 1;
self.anchor = None;
self.nodes.clear();
self.edges.clear();
self.aggregates.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Aggregate, Edge, Graph, GroupBy, NeighborResult, Node, QueryParams};
fn committed(node_ids: &[&str], edges: &[(&str, &str, &str)]) -> Graph {
let mut g = Graph::default();
for id in node_ids { g.add_node(Node::new(*id)); }
for (id, s, t) in edges { g.add_edge(Edge::new(*id, *s, *t)); }
g
}
fn result(nodes: &[&str], edges: &[(&str, &str, &str)]) -> NeighborResult {
NeighborResult {
nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
edges: edges.iter().map(|(id, s, t)| Edge::new(*id, *s, *t)).collect(),
aggregates: vec![],
next: None,
pending: false,
}
}
fn result_with_agg(nodes: &[&str], aggs: &[(&str, &str, u64)]) -> NeighborResult {
NeighborResult {
nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
edges: vec![],
aggregates: aggs.iter().map(|(id, value, count)| Aggregate {
id: (*id).into(),
parent: String::new(),
group_by: GroupBy::Label,
value: (*value).into(),
relationships: vec![],
count: *count,
query: QueryParams { limit: 8, cursor: None },
}).collect(),
next: None,
pending: false,
}
}
#[test]
fn begin_bumps_the_generation_and_clears_prior_contents() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let g1 = p.begin("a".into());
assert!(p.fill(g1, &result(&["b"], &[]), &g));
assert_eq!(p.node_count(), 1);
let g2 = p.begin("b".into());
assert!(g2 > g1, "each preview gets a fresh generation");
assert!(p.is_empty(), "beginning a new preview clears the old contents");
assert_eq!(p.anchor(), Some(&"b".to_string()));
}
#[test]
fn fill_with_a_stale_generation_is_rejected_and_changes_nothing() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let stale = p.begin("a".into());
p.begin("b".into());
assert!(!p.fill(stale, &result(&["x", "y"], &[]), &g), "stale fill must be refused");
assert!(p.is_empty(), "a refused fill must not leak nodes into the layer");
}
#[test]
fn fill_drops_nodes_already_committed() {
let g = committed(&["a", "known"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["known", "fresh"], &[]), &g);
let ids: Vec<&str> = p.nodes().iter().map(|n| n.id.as_str()).collect();
assert_eq!(ids, vec!["fresh"]);
}
#[test]
fn fill_keeps_a_new_edge_between_two_committed_nodes() {
let g = committed(&["a", "b"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
assert_eq!(p.edges().len(), 1);
assert!(!p.is_empty(), "edge-only content still counts as a preview");
}
#[test]
fn fill_drops_edges_already_committed() {
let g = committed(&["a", "b"], &[("e1", "a", "b")]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
assert!(p.edges().is_empty());
}
#[test]
fn promote_takes_the_node_and_only_safely_committable_edges() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b"), ("bc", "b", "c")]), &g);
let (node, edges) = p.promote("b", &g).expect("b is provisional");
assert_eq!(node.id, "b");
let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["ab"]);
assert!(!p.contains("b"), "a promoted node leaves the preview");
assert!(p.contains("c"), "its sibling stays behind");
assert_eq!(p.edges().len(), 1, "b–c stays until c is committed too");
}
#[test]
fn fill_drops_an_edge_into_an_unknown_node() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[("bz", "b", "z")]), &g);
assert!(p.edges().is_empty(), "an edge to an unknown node must not survive fill");
}
#[test]
fn fill_on_a_fresh_layer_is_rejected() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
assert!(!p.fill(0, &result(&["b"], &[]), &g));
assert!(p.is_empty());
}
#[test]
fn fill_dedupes_duplicate_node_ids() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b", "b"], &[]), &g);
assert_eq!(p.node_count(), 1, "duplicate ids collapse to one entry");
}
#[test]
fn promote_of_an_unknown_or_already_promoted_id_is_none() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[]), &g);
assert!(p.promote("nope", &g).is_none());
assert!(p.promote("b", &g).is_some());
assert!(p.promote("b", &g).is_none(), "promoting twice is not possible");
}
#[test]
fn take_all_drains_the_layer() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b")]), &g);
let (nodes, edges) = p.take_all();
assert_eq!(nodes.len(), 2);
assert_eq!(edges.len(), 1);
assert!(p.is_empty(), "take_all leaves nothing behind");
assert!(p.take_all().0.is_empty(), "draining twice yields nothing");
}
#[test]
fn take_all_retains_aggregates_and_anchor_while_clearing_committables() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
let (nodes, edges) = p.take_all();
assert_eq!(nodes.len(), 1, "the committable node was drained");
assert!(edges.is_empty());
assert!(p.nodes().is_empty(), "committable nodes are gone from the layer");
assert!(p.edges().is_empty(), "committable edges are gone from the layer");
assert_eq!(p.aggregates().len(), 1, "the aggregate offer survives the commit");
assert_eq!(p.anchor(), Some(&"a".to_string()), "the anchor survives too");
assert!(!p.has_committable(), "nothing left to commit");
assert!(!p.is_empty(), "the surviving aggregate still counts as something to report");
}
#[test]
fn take_all_still_retires_the_generation() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
p.take_all();
assert!(!p.fill(gen, &result(&["c"], &[]), &g), "a late arrival must still be blocked");
}
#[test]
fn clear_after_take_all_drops_the_retained_aggregates() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
p.take_all();
p.clear();
assert!(p.aggregates().is_empty());
assert_eq!(p.anchor(), None);
}
#[test]
fn fill_after_take_all_is_rejected() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[]), &g);
p.take_all();
assert!(!p.fill(gen, &result(&["c"], &[]), &g));
assert!(p.is_empty(), "no aggregates were ever filled, so nothing is retained here");
assert_eq!(
p.anchor(),
Some(&"a".to_string()),
"the anchor survives a commit — it only dies with the next begin/supersede/clear"
);
}
#[test]
fn fill_after_promote_is_still_accepted() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[]), &g);
p.promote("b", &g);
assert!(p.fill(gen, &result(&["c"], &[]), &g));
assert!(p.contains("c"));
}
#[test]
fn promote_takes_a_self_loop_on_the_promoted_node() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[("loop", "b", "b")]), &g);
let (_, edges) = p.promote("b", &g).expect("b is provisional");
let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["loop"], "a self-loop travels with its node");
}
#[test]
fn promote_leaves_non_incident_edges_untouched() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b", "c", "d"], &[("cd", "c", "d")]), &g);
let (_, taken) = p.promote("b", &g).expect("b is provisional");
assert!(taken.is_empty(), "b has no incident edges");
assert_eq!(p.edges().len(), 1, "c-d is untouched by promoting b");
}
#[test]
fn clear_retires_the_generation_so_a_late_arrival_cannot_refill_it() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.clear();
assert!(!p.fill(gen, &result(&["b"], &[]), &g));
assert!(p.is_empty());
assert_eq!(p.anchor(), None);
}
#[test]
fn contains_reports_membership_of_the_provisional_set() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[]), &g);
assert!(p.contains("b"));
assert!(!p.contains("a"), "the anchor is committed, not provisional");
}
#[test]
fn fill_retains_aggregates_and_stamps_their_parent() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
assert_eq!(p.aggregates().len(), 1);
assert_eq!(p.aggregates()[0].parent, "a", "stamped from the anchor");
assert_eq!(p.aggregates()[0].count, 32);
}
#[test]
fn fill_overwrites_a_wire_supplied_parent_with_the_anchor() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
let res = NeighborResult {
nodes: vec![],
edges: vec![],
aggregates: vec![Aggregate {
id: "agg:Sorcery".into(),
parent: "somewhere-else".into(),
group_by: GroupBy::Label,
value: "Sorcery".into(),
relationships: vec![],
count: 32,
query: QueryParams { limit: 8, cursor: None },
}],
next: None,
pending: false,
};
p.fill(gen, &res, &g);
assert_eq!(p.aggregates()[0].parent, "a", "the anchor overwrites whatever the wire sent");
}
#[test]
fn fill_keeps_aggregates_whole_even_if_their_id_collides_with_a_committed_node() {
let g = committed(&["a", "agg:Sorcery"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
assert_eq!(p.aggregates().len(), 1, "kept whole, not filtered like a node");
}
#[test]
fn an_aggregate_only_neighborhood_is_not_empty() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
assert_eq!(p.node_count(), 0, "no individual nodes");
assert!(!p.is_empty(), "but the layer is NOT empty — there is something to report");
}
#[test]
fn begin_and_clear_drop_aggregates_but_take_all_does_not() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
assert_eq!(p.aggregates().len(), 1);
p.begin("b".into());
assert!(p.aggregates().is_empty(), "a new preview starts clean");
let gen2 = p.begin("a".into());
p.fill(gen2, &result_with_agg(&["c"], &[("agg:X", "X", 3)]), &g);
p.take_all();
assert_eq!(
p.aggregates().len(),
1,
"committing the ghosts retains the aggregate offers — they die with the preview, not the commit"
);
let gen3 = p.begin("a".into());
p.fill(gen3, &result_with_agg(&["d"], &[("agg:X", "X", 3)]), &g);
p.clear();
assert!(p.aggregates().is_empty(), "discarding drops them");
}
#[test]
fn has_committable_distinguishes_reportable_aggregates_from_committable_content() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
assert!(!p.is_empty(), "an aggregate is reportable");
assert!(!p.has_committable(), "but an aggregate is never committed into the Graph");
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&["b"], &[]), &g);
assert!(!p.is_empty());
assert!(p.has_committable(), "a provisional node is committable");
let g2 = committed(&["a", "b"], &[]);
let mut p = PreviewLayer::new();
let gen = p.begin("a".into());
p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g2);
assert!(!p.is_empty());
assert!(p.has_committable(), "a provisional edge is committable");
}
#[test]
fn a_stale_fill_does_not_leak_aggregates() {
let g = committed(&["a"], &[]);
let mut p = PreviewLayer::new();
let stale = p.begin("a".into());
p.begin("b".into());
assert!(!p.fill(stale, &result_with_agg(&[], &[("agg:X", "X", 9)]), &g));
assert!(p.aggregates().is_empty());
}
}