Skip to main content

graph_explorer_core/
preview.rs

1//! The provisional layer: one node's discovered neighborhood, shown to the user
2//! but not yet adopted into the committed `Graph`.
3//!
4//! Discovery is asynchronous, so a preview can be superseded before its data
5//! arrives. Every preview therefore carries a monotonic `generation`, and
6//! `fill` refuses anything but the live one — a late arrival can never
7//! repopulate a layer the user has already moved on from. This guard is
8//! independent of (and holds even without) the `AbortSignal` the host fetcher
9//! receives.
10//!
11//! Pure: no I/O, no wasm, no geometry. Positions for provisional nodes are the
12//! caller's business.
13//!
14//! The generation is deliberately write-only from the outside: there is no
15//! accessor for it. `begin` hands the caller a token; `fill` only accepts
16//! that exact token back. A getter would let a caller "refresh" the token by
17//! reading it right before calling `fill`, which launders away the guard
18//! this type exists to provide.
19
20use crate::{Aggregate, Edge, Graph, NeighborResult, Node, NodeId};
21
22#[derive(Debug, Default)]
23pub struct PreviewLayer {
24    generation: u64,
25    anchor: Option<NodeId>,
26    nodes: Vec<Node>,
27    edges: Vec<Edge>,
28    aggregates: Vec<Aggregate>,
29}
30
31impl PreviewLayer {
32    pub fn new() -> Self { Self::default() }
33
34    /// Supersede any live preview and open a new one anchored at `anchor`.
35    /// Returns the new generation, which the caller must present to `fill`.
36    pub fn begin(&mut self, anchor: NodeId) -> u64 {
37        self.generation += 1;
38        self.anchor = Some(anchor);
39        self.nodes.clear();
40        self.edges.clear();
41        self.aggregates.clear();
42        self.generation
43    }
44
45    pub fn anchor(&self) -> Option<&NodeId> { self.anchor.as_ref() }
46    pub fn nodes(&self) -> &[Node] { &self.nodes }
47    pub fn edges(&self) -> &[Edge] { &self.edges }
48    pub fn aggregates(&self) -> &[Aggregate] { &self.aggregates }
49
50    /// How many nodes are provisionally shown. Edge-only and aggregate-only
51    /// previews report 0 here but are not `is_empty`. Deliberately not named
52    /// `len`: pairing a node-only count with an `is_empty` that also considers
53    /// edges and aggregates would violate the usual
54    /// `is_empty() == (len() == 0)` assumption.
55    pub fn node_count(&self) -> usize { self.nodes.len() }
56
57    /// Whether the layer holds nothing at all. Note this considers aggregates
58    /// as well, so an aggregate-only neighborhood is NOT empty even though
59    /// `node_count()` is 0 — there is something for a host to act on.
60    pub fn is_empty(&self) -> bool {
61        self.nodes.is_empty() && self.edges.is_empty() && self.aggregates.is_empty()
62    }
63
64    /// Whether `take_all` would actually hand anything back. Distinct from
65    /// `is_empty()`, which also counts aggregates: an aggregate is reported to
66    /// a host, never committed into the `Graph`, so a commit path must ask
67    /// this instead.
68    pub fn has_committable(&self) -> bool {
69        !self.nodes.is_empty() || !self.edges.is_empty()
70    }
71
72    /// Whether `id` is among the provisional nodes. Consults nodes only —
73    /// an id that appears solely as an edge endpoint is not "contained".
74    pub fn contains(&self, id: &str) -> bool { self.nodes.iter().any(|n| n.id == id) }
75
76    /// Adopt a neighbor result, keeping only what `committed` lacks. Returns
77    /// false and changes nothing when `gen` is not the live generation, or
78    /// when the layer was never `begin`-ed (a fresh layer's generation is 0,
79    /// which must not be fillable by passing 0).
80    ///
81    /// Replaces rather than appends: a preview is one page of one neighborhood.
82    pub fn fill(&mut self, gen: u64, res: &NeighborResult, committed: &Graph) -> bool {
83        let Some(anchor) = self.anchor.clone() else { return false };
84        if gen != self.generation {
85            return false;
86        }
87        // Keep the first occurrence of each id: a provider returning a node
88        // twice must not over-count the ghost/placement fan shown to the user.
89        let mut seen = std::collections::HashSet::new();
90        let nodes: Vec<Node> = res
91            .nodes
92            .iter()
93            .filter(|n| committed.node(&n.id).is_none())
94            .filter(|n| seen.insert(n.id.clone()))
95            .cloned()
96            .collect();
97        // The node filter must run first: an edge is only safe to admit once
98        // we know which nodes are actually going to be in this preview.
99        let retained: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
100        let known = |id: &str| committed.node(id).is_some() || retained.contains(id);
101        self.edges = res
102            .edges
103            .iter()
104            // An edge whose endpoints are both committed but whose id is not is
105            // still new information, so membership is tested by edge id.
106            .filter(|e| committed.edge(&e.id).is_none())
107            // A provider can page nodes and return edges spanning into the next
108            // page; an edge with an endpoint that is neither committed nor
109            // among the nodes just kept would dangle once `take_all` commits
110            // it, which `Graph::from_json` explicitly refuses to accept.
111            .filter(|e| known(&e.source) && known(&e.target))
112            .cloned()
113            .collect();
114        self.nodes = nodes;
115        // Aggregates are kept whole — there is nothing to filter against, since
116        // a group is not a node. `parent` is stamped from the anchor rather
117        // than believed from the wire.
118        self.aggregates = res.aggregates.iter().cloned().map(|mut a| {
119            a.parent = anchor.clone();
120            a
121        }).collect();
122        true
123    }
124
125    /// Move one provisional node out of the layer, along with the incident
126    /// edges that can safely follow it — those whose *other* endpoint is
127    /// already committed (or the node itself, for a self-loop). An edge to
128    /// another provisional node stays behind rather than dangling.
129    pub fn promote(&mut self, id: &str, committed: &Graph) -> Option<(Node, Vec<Edge>)> {
130        let idx = self.nodes.iter().position(|n| n.id == id)?;
131        let node = self.nodes.remove(idx);
132        let mut taken = Vec::new();
133        self.edges.retain(|e| {
134            if e.source != id && e.target != id {
135                return true;
136            }
137            let other = if e.source == id { &e.target } else { &e.source };
138            if other == id || committed.node(other).is_some() {
139                taken.push(e.clone());
140                false
141            } else {
142                true
143            }
144        });
145        Some((node, taken))
146    }
147
148    /// Drain the committable content (nodes and edges), for an explicit
149    /// commit. Retires the generation exactly as `clear` does — a fetch
150    /// still in flight for this preview must not repopulate it after the
151    /// fact. The **aggregates and the anchor are retained**: they are
152    /// reported-not-committed offers, and accepting the ghosts is no reason
153    /// to withdraw the "expand this group" affordance the host may be
154    /// showing. They die with the preview itself (next `begin`, supersede,
155    /// or `clear`), not with the commit.
156    pub fn take_all(&mut self) -> (Vec<Node>, Vec<Edge>) {
157        self.generation += 1;
158        (std::mem::take(&mut self.nodes), std::mem::take(&mut self.edges))
159    }
160
161    /// Discard without committing. Retires the generation as well: a discard is
162    /// exactly the case where an in-flight fetch is about to land, and refilling
163    /// the layer the user just dismissed would be the worst possible outcome.
164    pub fn clear(&mut self) {
165        self.generation += 1;
166        self.anchor = None;
167        self.nodes.clear();
168        self.edges.clear();
169        self.aggregates.clear();
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::{Aggregate, Edge, Graph, GroupBy, NeighborResult, Node, QueryParams};
177
178    fn committed(node_ids: &[&str], edges: &[(&str, &str, &str)]) -> Graph {
179        let mut g = Graph::default();
180        for id in node_ids { g.add_node(Node::new(*id)); }
181        for (id, s, t) in edges { g.add_edge(Edge::new(*id, *s, *t)); }
182        g
183    }
184
185    fn result(nodes: &[&str], edges: &[(&str, &str, &str)]) -> NeighborResult {
186        NeighborResult {
187            nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
188            edges: edges.iter().map(|(id, s, t)| Edge::new(*id, *s, *t)).collect(),
189            aggregates: vec![],
190            next: None,
191            pending: false,
192        }
193    }
194
195    fn result_with_agg(nodes: &[&str], aggs: &[(&str, &str, u64)]) -> NeighborResult {
196        NeighborResult {
197            nodes: nodes.iter().map(|id| Node::new(*id)).collect(),
198            edges: vec![],
199            aggregates: aggs.iter().map(|(id, value, count)| Aggregate {
200                id: (*id).into(),
201                parent: String::new(),
202                group_by: GroupBy::Label,
203                value: (*value).into(),
204                relationships: vec![],
205                count: *count,
206                query: QueryParams { limit: 8, cursor: None },
207            }).collect(),
208            next: None,
209            pending: false,
210        }
211    }
212
213    #[test]
214    fn begin_bumps_the_generation_and_clears_prior_contents() {
215        let g = committed(&["a"], &[]);
216        let mut p = PreviewLayer::new();
217        let g1 = p.begin("a".into());
218        assert!(p.fill(g1, &result(&["b"], &[]), &g));
219        assert_eq!(p.node_count(), 1);
220
221        let g2 = p.begin("b".into());
222        assert!(g2 > g1, "each preview gets a fresh generation");
223        assert!(p.is_empty(), "beginning a new preview clears the old contents");
224        assert_eq!(p.anchor(), Some(&"b".to_string()));
225    }
226
227    #[test]
228    fn fill_with_a_stale_generation_is_rejected_and_changes_nothing() {
229        let g = committed(&["a"], &[]);
230        let mut p = PreviewLayer::new();
231        let stale = p.begin("a".into());
232        p.begin("b".into());
233
234        assert!(!p.fill(stale, &result(&["x", "y"], &[]), &g), "stale fill must be refused");
235        assert!(p.is_empty(), "a refused fill must not leak nodes into the layer");
236    }
237
238    #[test]
239    fn fill_drops_nodes_already_committed() {
240        let g = committed(&["a", "known"], &[]);
241        let mut p = PreviewLayer::new();
242        let gen = p.begin("a".into());
243        p.fill(gen, &result(&["known", "fresh"], &[]), &g);
244
245        let ids: Vec<&str> = p.nodes().iter().map(|n| n.id.as_str()).collect();
246        assert_eq!(ids, vec!["fresh"]);
247    }
248
249    #[test]
250    fn fill_keeps_a_new_edge_between_two_committed_nodes() {
251        // Both endpoints are known but the relationship is not: that is still
252        // new information and belongs in the preview.
253        let g = committed(&["a", "b"], &[]);
254        let mut p = PreviewLayer::new();
255        let gen = p.begin("a".into());
256        p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
257
258        assert_eq!(p.edges().len(), 1);
259        assert!(!p.is_empty(), "edge-only content still counts as a preview");
260    }
261
262    #[test]
263    fn fill_drops_edges_already_committed() {
264        let g = committed(&["a", "b"], &[("e1", "a", "b")]);
265        let mut p = PreviewLayer::new();
266        let gen = p.begin("a".into());
267        p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g);
268        assert!(p.edges().is_empty());
269    }
270
271    #[test]
272    fn promote_takes_the_node_and_only_safely_committable_edges() {
273        // "a" is committed; "b" and "c" are provisional siblings.
274        // Promoting "b" may take b–a (other endpoint committed) but NOT b–c,
275        // which would dangle to a node that does not exist yet.
276        let g = committed(&["a"], &[]);
277        let mut p = PreviewLayer::new();
278        let gen = p.begin("a".into());
279        p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b"), ("bc", "b", "c")]), &g);
280
281        let (node, edges) = p.promote("b", &g).expect("b is provisional");
282        assert_eq!(node.id, "b");
283        let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
284        assert_eq!(ids, vec!["ab"]);
285
286        assert!(!p.contains("b"), "a promoted node leaves the preview");
287        assert!(p.contains("c"), "its sibling stays behind");
288        assert_eq!(p.edges().len(), 1, "b–c stays until c is committed too");
289    }
290
291    #[test]
292    fn fill_drops_an_edge_into_an_unknown_node() {
293        // The provider paged nodes but returned an edge spanning into the next
294        // page: neither endpoint exists yet, so admitting it would hand the
295        // caller a dangling edge once take_all commits it.
296        let g = committed(&["a"], &[]);
297        let mut p = PreviewLayer::new();
298        let gen = p.begin("a".into());
299        p.fill(gen, &result(&["b"], &[("bz", "b", "z")]), &g);
300        assert!(p.edges().is_empty(), "an edge to an unknown node must not survive fill");
301    }
302
303    #[test]
304    fn fill_on_a_fresh_layer_is_rejected() {
305        // generation starts at 0 with no anchor; fill(0, ..) must not be able
306        // to sneak data into a layer that was never begun.
307        let g = committed(&["a"], &[]);
308        let mut p = PreviewLayer::new();
309        assert!(!p.fill(0, &result(&["b"], &[]), &g));
310        assert!(p.is_empty());
311    }
312
313    #[test]
314    fn fill_dedupes_duplicate_node_ids() {
315        let g = committed(&["a"], &[]);
316        let mut p = PreviewLayer::new();
317        let gen = p.begin("a".into());
318        p.fill(gen, &result(&["b", "b"], &[]), &g);
319        assert_eq!(p.node_count(), 1, "duplicate ids collapse to one entry");
320    }
321
322    #[test]
323    fn promote_of_an_unknown_or_already_promoted_id_is_none() {
324        let g = committed(&["a"], &[]);
325        let mut p = PreviewLayer::new();
326        let gen = p.begin("a".into());
327        p.fill(gen, &result(&["b"], &[]), &g);
328
329        assert!(p.promote("nope", &g).is_none());
330        assert!(p.promote("b", &g).is_some());
331        assert!(p.promote("b", &g).is_none(), "promoting twice is not possible");
332    }
333
334    #[test]
335    fn take_all_drains_the_layer() {
336        let g = committed(&["a"], &[]);
337        let mut p = PreviewLayer::new();
338        let gen = p.begin("a".into());
339        p.fill(gen, &result(&["b", "c"], &[("ab", "a", "b")]), &g);
340
341        let (nodes, edges) = p.take_all();
342        assert_eq!(nodes.len(), 2);
343        assert_eq!(edges.len(), 1);
344        assert!(p.is_empty(), "take_all leaves nothing behind");
345        assert!(p.take_all().0.is_empty(), "draining twice yields nothing");
346    }
347
348    #[test]
349    fn take_all_retains_aggregates_and_anchor_while_clearing_committables() {
350        // A commit accepts the ghosts but the aggregate offers (and the
351        // anchor that makes them actionable) must survive it: the host's
352        // "expand this group" affordance is not tied to the ghosts' fate.
353        let g = committed(&["a"], &[]);
354        let mut p = PreviewLayer::new();
355        let gen = p.begin("a".into());
356        p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
357
358        let (nodes, edges) = p.take_all();
359        assert_eq!(nodes.len(), 1, "the committable node was drained");
360        assert!(edges.is_empty());
361
362        assert!(p.nodes().is_empty(), "committable nodes are gone from the layer");
363        assert!(p.edges().is_empty(), "committable edges are gone from the layer");
364        assert_eq!(p.aggregates().len(), 1, "the aggregate offer survives the commit");
365        assert_eq!(p.anchor(), Some(&"a".to_string()), "the anchor survives too");
366        assert!(!p.has_committable(), "nothing left to commit");
367        assert!(!p.is_empty(), "the surviving aggregate still counts as something to report");
368    }
369
370    #[test]
371    fn take_all_still_retires_the_generation() {
372        // Retaining the aggregates must not reopen the door for a late fetch:
373        // cancellation safety is untouched by this change.
374        let g = committed(&["a"], &[]);
375        let mut p = PreviewLayer::new();
376        let gen = p.begin("a".into());
377        p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
378        p.take_all();
379
380        assert!(!p.fill(gen, &result(&["c"], &[]), &g), "a late arrival must still be blocked");
381    }
382
383    #[test]
384    fn clear_after_take_all_drops_the_retained_aggregates() {
385        // The aggregates retained by take_all still die with the preview
386        // itself — the next begin, a supersede, or an explicit clear.
387        let g = committed(&["a"], &[]);
388        let mut p = PreviewLayer::new();
389        let gen = p.begin("a".into());
390        p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
391        p.take_all();
392        p.clear();
393
394        assert!(p.aggregates().is_empty());
395        assert_eq!(p.anchor(), None);
396    }
397
398    #[test]
399    fn fill_after_take_all_is_rejected() {
400        // take_all is a commit: a fetch still in flight for the drained preview
401        // must not repopulate it afterwards.
402        let g = committed(&["a"], &[]);
403        let mut p = PreviewLayer::new();
404        let gen = p.begin("a".into());
405        p.fill(gen, &result(&["b"], &[]), &g);
406        p.take_all();
407
408        assert!(!p.fill(gen, &result(&["c"], &[]), &g));
409        assert!(p.is_empty(), "no aggregates were ever filled, so nothing is retained here");
410        assert_eq!(
411            p.anchor(),
412            Some(&"a".to_string()),
413            "the anchor survives a commit — it only dies with the next begin/supersede/clear"
414        );
415    }
416
417    #[test]
418    fn fill_after_promote_is_still_accepted() {
419        // Promotion shrinks the preview but does not end it: the same fetch
420        // page can still land on what remains.
421        let g = committed(&["a"], &[]);
422        let mut p = PreviewLayer::new();
423        let gen = p.begin("a".into());
424        p.fill(gen, &result(&["b"], &[]), &g);
425        p.promote("b", &g);
426
427        assert!(p.fill(gen, &result(&["c"], &[]), &g));
428        assert!(p.contains("c"));
429    }
430
431    #[test]
432    fn promote_takes_a_self_loop_on_the_promoted_node() {
433        let g = committed(&["a"], &[]);
434        let mut p = PreviewLayer::new();
435        let gen = p.begin("a".into());
436        p.fill(gen, &result(&["b"], &[("loop", "b", "b")]), &g);
437
438        let (_, edges) = p.promote("b", &g).expect("b is provisional");
439        let ids: Vec<&str> = edges.iter().map(|e| e.id.as_str()).collect();
440        assert_eq!(ids, vec!["loop"], "a self-loop travels with its node");
441    }
442
443    #[test]
444    fn promote_leaves_non_incident_edges_untouched() {
445        // "c" and "d" are both provisional and unrelated to "b".
446        let g = committed(&["a"], &[]);
447        let mut p = PreviewLayer::new();
448        let gen = p.begin("a".into());
449        p.fill(gen, &result(&["b", "c", "d"], &[("cd", "c", "d")]), &g);
450
451        let (_, taken) = p.promote("b", &g).expect("b is provisional");
452        assert!(taken.is_empty(), "b has no incident edges");
453        assert_eq!(p.edges().len(), 1, "c-d is untouched by promoting b");
454    }
455
456    #[test]
457    fn clear_retires_the_generation_so_a_late_arrival_cannot_refill_it() {
458        // Without the bump, a discarded preview would be silently repopulated
459        // by the fetch it was discarded to escape.
460        let g = committed(&["a"], &[]);
461        let mut p = PreviewLayer::new();
462        let gen = p.begin("a".into());
463        p.clear();
464
465        assert!(!p.fill(gen, &result(&["b"], &[]), &g));
466        assert!(p.is_empty());
467        assert_eq!(p.anchor(), None);
468    }
469
470    #[test]
471    fn contains_reports_membership_of_the_provisional_set() {
472        let g = committed(&["a"], &[]);
473        let mut p = PreviewLayer::new();
474        let gen = p.begin("a".into());
475        p.fill(gen, &result(&["b"], &[]), &g);
476        assert!(p.contains("b"));
477        assert!(!p.contains("a"), "the anchor is committed, not provisional");
478    }
479
480    #[test]
481    fn fill_retains_aggregates_and_stamps_their_parent() {
482        // The parent is what makes an aggregate actionable; it is derived from
483        // the anchor we queried, never read from the wire.
484        let g = committed(&["a"], &[]);
485        let mut p = PreviewLayer::new();
486        let gen = p.begin("a".into());
487        p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
488
489        assert_eq!(p.aggregates().len(), 1);
490        assert_eq!(p.aggregates()[0].parent, "a", "stamped from the anchor");
491        assert_eq!(p.aggregates()[0].count, 32);
492    }
493
494    #[test]
495    fn fill_overwrites_a_wire_supplied_parent_with_the_anchor() {
496        // A provider that dutifully fills in `parent` itself must still be
497        // overruled: stamping is unconditional, not a fallback for an omitted
498        // value. Otherwise "derived, never trusted" only holds by accident.
499        let g = committed(&["a"], &[]);
500        let mut p = PreviewLayer::new();
501        let gen = p.begin("a".into());
502        let res = NeighborResult {
503            nodes: vec![],
504            edges: vec![],
505            aggregates: vec![Aggregate {
506                id: "agg:Sorcery".into(),
507                parent: "somewhere-else".into(),
508                group_by: GroupBy::Label,
509                value: "Sorcery".into(),
510                relationships: vec![],
511                count: 32,
512                query: QueryParams { limit: 8, cursor: None },
513            }],
514            next: None,
515            pending: false,
516        };
517        p.fill(gen, &res, &g);
518
519        assert_eq!(p.aggregates()[0].parent, "a", "the anchor overwrites whatever the wire sent");
520    }
521
522    #[test]
523    fn fill_keeps_aggregates_whole_even_if_their_id_collides_with_a_committed_node() {
524        // An aggregate is not a node, so the node-existence filter must never
525        // reach it: a group is fetchable, actionable content in its own right,
526        // regardless of what ids happen to already be committed.
527        let g = committed(&["a", "agg:Sorcery"], &[]);
528        let mut p = PreviewLayer::new();
529        let gen = p.begin("a".into());
530        p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
531
532        assert_eq!(p.aggregates().len(), 1, "kept whole, not filtered like a node");
533    }
534
535    #[test]
536    fn an_aggregate_only_neighborhood_is_not_empty() {
537        // This is the reported bug: walking to a fully-aggregated node showed
538        // zero ghosts and reported nothing, so the graph looked broken.
539        let g = committed(&["a"], &[]);
540        let mut p = PreviewLayer::new();
541        let gen = p.begin("a".into());
542        p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
543
544        assert_eq!(p.node_count(), 0, "no individual nodes");
545        assert!(!p.is_empty(), "but the layer is NOT empty — there is something to report");
546    }
547
548    #[test]
549    fn begin_and_clear_drop_aggregates_but_take_all_does_not() {
550        let g = committed(&["a"], &[]);
551        let mut p = PreviewLayer::new();
552        let gen = p.begin("a".into());
553        p.fill(gen, &result_with_agg(&["b"], &[("agg:X", "X", 3)]), &g);
554        assert_eq!(p.aggregates().len(), 1);
555
556        p.begin("b".into());
557        assert!(p.aggregates().is_empty(), "a new preview starts clean");
558
559        let gen2 = p.begin("a".into());
560        p.fill(gen2, &result_with_agg(&["c"], &[("agg:X", "X", 3)]), &g);
561        p.take_all();
562        assert_eq!(
563            p.aggregates().len(),
564            1,
565            "committing the ghosts retains the aggregate offers — they die with the preview, not the commit"
566        );
567
568        let gen3 = p.begin("a".into());
569        p.fill(gen3, &result_with_agg(&["d"], &[("agg:X", "X", 3)]), &g);
570        p.clear();
571        assert!(p.aggregates().is_empty(), "discarding drops them");
572    }
573
574    #[test]
575    fn has_committable_distinguishes_reportable_aggregates_from_committable_content() {
576        let g = committed(&["a"], &[]);
577
578        // Aggregate-only: something to report, nothing to commit.
579        let mut p = PreviewLayer::new();
580        let gen = p.begin("a".into());
581        p.fill(gen, &result_with_agg(&[], &[("agg:Sorcery", "Sorcery", 32)]), &g);
582        assert!(!p.is_empty(), "an aggregate is reportable");
583        assert!(!p.has_committable(), "but an aggregate is never committed into the Graph");
584
585        // Node-only: both reportable and committable.
586        let mut p = PreviewLayer::new();
587        let gen = p.begin("a".into());
588        p.fill(gen, &result(&["b"], &[]), &g);
589        assert!(!p.is_empty());
590        assert!(p.has_committable(), "a provisional node is committable");
591
592        // Edge-only: both reportable and committable.
593        let g2 = committed(&["a", "b"], &[]);
594        let mut p = PreviewLayer::new();
595        let gen = p.begin("a".into());
596        p.fill(gen, &result(&[], &[("e1", "a", "b")]), &g2);
597        assert!(!p.is_empty());
598        assert!(p.has_committable(), "a provisional edge is committable");
599    }
600
601    #[test]
602    fn a_stale_fill_does_not_leak_aggregates() {
603        let g = committed(&["a"], &[]);
604        let mut p = PreviewLayer::new();
605        let stale = p.begin("a".into());
606        p.begin("b".into());
607        assert!(!p.fill(stale, &result_with_agg(&[], &[("agg:X", "X", 9)]), &g));
608        assert!(p.aggregates().is_empty());
609    }
610}