Skip to main content

macrame/graph/
subgraph.rs

1//! The in-memory graph loaded from `links_current`, and its loader (§5.4).
2//!
3//! A `Subgraph` is derivative state (Doctrine VI): every field is re-derivable
4//! from the ledger, nothing here is authoritative, and dropping one loses
5//! nothing. That is what lets analytics run on a snapshot without a third clock
6//! — the graph is the topology as of one instant, and the instant is the
7//! caller's `now_ts`, not a property of the structure.
8
9use std::collections::BTreeMap;
10
11use crate::connection::{Annotation, Database};
12use crate::error::{DbError, Result};
13
14/// Edges returned to a caller asking for a node with no edges in that direction.
15const NO_EDGES: &[EdgeRef] = &[];
16
17/// A transient, in-memory graph loaded from `links_current`.
18///
19/// The maps are `BTreeMap`, not `HashMap`, so iteration follows node id order.
20/// Every algorithm in [`super::algorithms`] inherits its determinism from that
21/// choice, and Louvain in particular returns a different partition under a
22/// randomised iteration order.
23///
24/// # Closure
25///
26/// **Every id appearing in `out_adj` or `in_adj` — as a key or as an
27/// [`EdgeRef::node`] — is a key of `nodes`.** [`Subgraph::drop_dangling_adjacency`]
28/// establishes it and [`Subgraph::is_closed`] checks it; every algorithm in
29/// [`super::algorithms`] is written assuming it and none of them re-checks.
30///
31/// It did not hold before Wave 1 (defect Z), and the way it failed is the reason
32/// it is now stated on the type rather than left to the loader. Adjacency comes
33/// from `links_current`, which carries edges to retired concepts; `hydrate`
34/// filters `retired = 0`. So a retired neighbour left an `EdgeRef` pointing at
35/// an id with no `NodeData`, and the five algorithms each met that differently:
36/// `louvain` panicked on the missing map entry, `scc` emitted the absent node as
37/// a phantom component of its own, `k_core` counted a degree of 2 where one edge
38/// was in the graph, and `dijkstra` returned a finite distance to a node the
39/// caller could not then look up. Four handlings of one violated invariant, none
40/// of them chosen — and the panic was the least damaging, because the other
41/// three answer.
42///
43/// Dangling entries are **dropped** rather than admitted with a tombstone node.
44/// A retired concept is not visible (§4.1), analytics over a graph is analytics
45/// over what is visible, and the alternative pushes a three-state node onto
46/// every present and future algorithm to preserve edges whose endpoint the
47/// caller is not entitled to read. Retirement is the supported path — concepts
48/// are never deleted (D-022) — so this is ordinary use, not a corner.
49#[derive(Debug, Clone, Default)]
50pub struct Subgraph {
51    pub nodes: BTreeMap<String, NodeData>,
52    pub out_adj: BTreeMap<String, Vec<EdgeRef>>,
53    pub in_adj: BTreeMap<String, Vec<EdgeRef>>,
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub struct NodeData {
58    pub title: String,
59    pub content: String,
60    pub embedding_model: Option<String>,
61    pub valid_from: String,
62    pub valid_to: String,
63}
64
65/// One end of an edge in an adjacency list.
66///
67/// `node` is the *other* end: the target in `out_adj`, the source in `in_adj`.
68#[derive(Debug, Clone, PartialEq)]
69pub struct EdgeRef {
70    pub node: String,
71    pub edge_type: String,
72    pub weight: f64,
73    pub valid_from: String,
74    pub valid_to: String,
75}
76
77impl Subgraph {
78    /// Outgoing edges of `node`, empty when it has none or is absent.
79    pub fn out_edges(&self, node: &str) -> &[EdgeRef] {
80        self.out_adj.get(node).map_or(NO_EDGES, Vec::as_slice)
81    }
82
83    /// Incoming edges of `node`, empty when it has none or is absent.
84    pub fn in_edges(&self, node: &str) -> &[EdgeRef] {
85        self.in_adj.get(node).map_or(NO_EDGES, Vec::as_slice)
86    }
87
88    /// Undirected edge count incident to `node`, counting parallel edges once
89    /// each and a self-loop twice.
90    pub fn degree(&self, node: &str) -> usize {
91        self.out_edges(node).len() + self.in_edges(node).len()
92    }
93
94    /// Undirected weight incident to `node`. Summed over both directions, so
95    /// summing this over all nodes gives `2 * total_weight`.
96    pub fn weighted_degree(&self, node: &str) -> f64 {
97        self.out_edges(node).iter().map(|e| e.weight).sum::<f64>()
98            + self.in_edges(node).iter().map(|e| e.weight).sum::<f64>()
99    }
100
101    /// Total edge weight, each edge counted once — the `m` of the modularity
102    /// formulas.
103    pub fn total_weight(&self) -> f64 {
104        self.out_adj
105            .values()
106            .flat_map(|edges| edges.iter().map(|e| e.weight))
107            .sum()
108    }
109
110    pub fn edge_count(&self) -> usize {
111        self.out_adj.values().map(Vec::len).sum()
112    }
113
114    /// Remove adjacency entries whose endpoint is not a hydrated node.
115    ///
116    /// This is what establishes the closure invariant on the type's docs, and it
117    /// runs after `hydrate` because that is the first moment the set of visible
118    /// nodes is known — the walk is over `links_current`, which does not record
119    /// retirement.
120    ///
121    /// A node left with no edges keeps its (now empty) entry only if it had one;
122    /// entries emptied by the prune are removed outright, so `out_adj` and
123    /// `in_adj` do not accumulate keys for nodes that turned out to have nothing.
124    /// Keys that are themselves not hydrated go too, which covers the case where
125    /// the *source* is the retired concept rather than the target.
126    ///
127    /// The byte accounting is deliberately not rewound. `bytes` bounded the load
128    /// as it ran and refused early on that basis, so a graph that would have fit
129    /// after pruning can still be refused before it. That is conservative in the
130    /// safe direction — the budget exists to stop an allocation, and the
131    /// allocation happens during the walk, not after it.
132    fn drop_dangling_adjacency(&mut self) {
133        // Destructured so `nodes` is borrowed separately from the two maps being
134        // mutated — the same borrow through `self` inside the closure would not
135        // compile.
136        let Subgraph {
137            nodes,
138            out_adj,
139            in_adj,
140        } = self;
141
142        for adj in [out_adj, in_adj] {
143            adj.retain(|id, edges| {
144                if !nodes.contains_key(id) {
145                    return false;
146                }
147                edges.retain(|e| nodes.contains_key(&e.node));
148                !edges.is_empty()
149            });
150        }
151    }
152
153    /// Whether the closure invariant holds. Used by tests and `debug_assert`s.
154    ///
155    /// Cheap enough to call in a test and O(V + E), so not on any hot path.
156    pub fn is_closed(&self) -> bool {
157        self.out_adj
158            .iter()
159            .chain(self.in_adj.iter())
160            .all(|(id, edges)| {
161                self.nodes.contains_key(id)
162                    && edges.iter().all(|e| self.nodes.contains_key(&e.node))
163            })
164    }
165
166    /// Record an edge in both directions.
167    ///
168    /// Both indices are maintained together because every undirected quantity
169    /// here — degree, k-core peeling, Louvain's `k_i` — reads them as a pair. An
170    /// `in_adj` that lags `out_adj` would not fail loudly; it would return a
171    /// plausible wrong number.
172    fn add_edge(&mut self, source: String, target: String, edge: EdgeRef) {
173        let mut incoming = edge.clone();
174        incoming.node = source.clone();
175        self.out_adj.entry(source).or_default().push(edge);
176        self.in_adj.entry(target).or_default().push(incoming);
177    }
178
179    /// Estimated payload bytes for one node, keyed by `id`.
180    ///
181    /// The per-item functions are the single definition of the estimate.
182    /// [`Self::estimated_bytes`] sums them over a whole graph; the loader adds
183    /// them as it inserts, so the running total it checks against the budget and
184    /// the total a caller can compute are the same arithmetic rather than two
185    /// descriptions of it. `load_subgraph_totals_agree_with_the_derivation`
186    /// pins that they stay equal.
187    fn node_bytes(id: &str, d: &NodeData) -> usize {
188        id.len()
189            + d.title.len()
190            + d.content.len()
191            + d.embedding_model.as_ref().map_or(0, String::len)
192            + d.valid_from.len()
193            + d.valid_to.len()
194            + std::mem::size_of::<NodeData>()
195    }
196
197    /// Estimated payload bytes for one adjacency entry.
198    ///
199    /// An edge occupies two of these — one in `out_adj`, one in `in_adj` — so a
200    /// caller accounting for a newly added edge counts it twice.
201    fn edge_bytes(e: &EdgeRef) -> usize {
202        e.node.len()
203            + e.edge_type.len()
204            + e.valid_from.len()
205            + e.valid_to.len()
206            + std::mem::size_of::<EdgeRef>()
207    }
208
209    /// Estimated heap footprint (D-007).
210    ///
211    /// Deliberately an estimate of the *payload*, not a precise `size_of` walk:
212    /// the budget exists to stop a dense neighbourhood exhausting memory, and a
213    /// figure that tracks string bytes and per-item overhead is accurate enough
214    /// for that.
215    ///
216    /// **O(V + E), and therefore not for use inside a loop over rows.** The
217    /// loader used to call this per row, which made loading O(E²): 500 edges in
218    /// 26 ms, 1,000 in 76 ms, 2,000 in 231 ms — time tripling for each doubling.
219    /// The byte budget is what bounds a load, and the budget *check* was the
220    /// thing that did not scale (D-047).
221    pub fn estimated_bytes(&self) -> usize {
222        let nodes: usize = self
223            .nodes
224            .iter()
225            .map(|(id, d)| Self::node_bytes(id, d))
226            .sum();
227        let edges: usize = self
228            .out_adj
229            .values()
230            .chain(self.in_adj.values())
231            .flat_map(|v| v.iter())
232            .map(Self::edge_bytes)
233            .sum();
234        nodes + edges
235    }
236
237    /// Write one derived result per node under `label` (§5.4, D-041).
238    ///
239    /// Goes through [`Database::write_analytics_annotations`], which chunks at
240    /// [`crate::connection::chunk_rows::ANNOTATIONS`] and sends on the
241    /// low-priority channel,
242    /// so a community assignment over a large subgraph cannot starve interactive
243    /// writes.
244    ///
245    /// Rows land in `analytics_annotations`, which carries no log trigger.
246    /// Before 0.5.4 this method built a `ConceptUpsert` per node and put the
247    /// value in `content`, so writing back a partition **overwrote every
248    /// annotated concept's document text** — and, because the write went through
249    /// the ledger, recorded each rerun of the algorithm as a fresh version of a
250    /// world that had not changed. The old doc comment defended that as "a
251    /// normal bitemporal write," which was true of the mechanism and false of
252    /// the intent: it is the right mechanism for a domain fact, and a community
253    /// label is not one.
254    ///
255    /// `values` is keyed by node id; nodes absent from it are not annotated.
256    pub async fn write_back_annotations(
257        &self,
258        db: &Database,
259        label: &str,
260        values: &BTreeMap<String, String>,
261    ) -> Result<usize> {
262        let rows: Vec<Annotation> = self
263            .nodes
264            .keys()
265            .filter_map(|id| {
266                values
267                    .get(id)
268                    .map(|value| Annotation::new(id.clone(), label, value.clone()))
269            })
270            .collect();
271
272        db.write_analytics_annotations(rows).await
273    }
274}
275
276impl Database {
277    /// Load the topology reachable from `start_node` within `max_hops` (§5.4).
278    ///
279    /// Runs on the read connection, so it cannot contend with the write actor.
280    /// `byte_budget` bounds the result: a hub node in a dense graph can reach
281    /// most of the database in three hops, and the budget is what turns that
282    /// into [`DbError::SubgraphTooLarge`] rather than into an allocation
283    /// failure.
284    ///
285    /// Unfiltered: every edge type, **every weight**. See
286    /// [`Self::load_subgraph_with`] for the filtered form, which this delegates
287    /// to.
288    ///
289    /// `min_weight` is `NEG_INFINITY` rather than [`TraversalBuilder`]'s default
290    /// of `0.0`, and the difference is load-bearing. A floor of `0.0` silently
291    /// drops negative-weight edges — which is precisely the input
292    /// [`DbError::NegativeEdgeWeight`] exists to *report*, since Dijkstra and A*
293    /// are unsound over them and D-039 chose to refuse at the boundary rather
294    /// than return a shortest path that is merely a path. Delegating with the
295    /// builder default turned that typed refusal into a graph quietly missing
296    /// edges; `a_negative_edge_weight_is_refused_at_load` caught it.
297    ///
298    /// So the two mechanisms are made to agree instead of overlapping: an edge a
299    /// caller has **not** filtered out reaches the weight guard, and an edge they
300    /// have is theirs to exclude. See [`Self::load_subgraph_with`] for what that
301    /// means when a caller passes a default builder.
302    pub async fn load_subgraph(
303        &self,
304        start_node: &str,
305        max_hops: u32,
306        now_ts: &str,
307        byte_budget: usize,
308    ) -> Result<Subgraph> {
309        self.load_subgraph_with(
310            &super::TraversalBuilder::new(start_node)
311                .max_depth(max_hops as usize)
312                .min_weight(f64::NEG_INFINITY),
313            now_ts,
314            byte_budget,
315        )
316        .await
317    }
318
319    /// Load the topology a [`TraversalBuilder`] describes, as a [`Subgraph`]
320    /// (§5.4, D-073).
321    ///
322    /// `load_subgraph` took neither `edge_types` nor `min_weight` while
323    /// `TraversalBuilder` took both — the same walk over the same table with two
324    /// fewer knobs. That was a **reachability** limit rather than a convenience
325    /// one: the byte budget bounds the *unfiltered* neighbourhood, so a caller
326    /// wanting one edge type out of a hub got [`DbError::SubgraphTooLarge`] for a
327    /// graph whose filtered form would have fitted easily, and filtering the
328    /// returned `Subgraph` afterwards cannot help because the refusal happens
329    /// during the walk.
330    ///
331    /// # The filters apply to the walk *and* to the returned edges
332    ///
333    /// This is the decision the change turned on, and the two are separable.
334    /// `TraversalBuilder` applies its filters to the **recursive step** — which
335    /// edges are followed — while this loader's final projection returns every
336    /// edge of every node it reached. Wiring the two together naively gives a
337    /// caller who asked for `CITES` a graph reached via `CITES` and populated
338    /// with `KNOWS` edges as well, which is surprising enough to be read as a
339    /// bug.
340    ///
341    /// So both halves filter. If a caller names edge types or a minimum weight,
342    /// they are asking for a subgraph **of those edges**: the walk uses them to
343    /// bound which nodes are reached, and the projection uses them to decide
344    /// which adjacency lands in the result. `load_subgraph` passes a default
345    /// builder — no types, weight ≥ 0 — so its behaviour is unchanged.
346    ///
347    /// # `min_weight` and the negative-weight guard
348    ///
349    /// [`TraversalBuilder`] defaults `min_weight` to `0.0`, so a **default
350    /// builder passed here filters negative-weight edges out** rather than
351    /// letting them reach [`DbError::NegativeEdgeWeight`]. That is a real
352    /// difference from [`Self::load_subgraph`], which passes `NEG_INFINITY`.
353    ///
354    /// It is deliberate and it is the coherent reading: a caller who states a
355    /// weight floor has asked to exclude what falls below it, and excluding it
356    /// is not an error. A caller who states none should be told, because
357    /// Dijkstra and A* are unsound over negative weights. Pass
358    /// `.min_weight(f64::NEG_INFINITY)` to get the guard with a filtered builder.
359    ///
360    /// `attribute_mode` is ignored: hydration here is always the live concept
361    /// row, which is what a `Subgraph` has always carried.
362    pub async fn load_subgraph_with(
363        &self,
364        traversal: &super::TraversalBuilder,
365        now_ts: &str,
366        byte_budget: usize,
367    ) -> Result<Subgraph> {
368        let start_node = traversal.start_node.as_str();
369        let max_hops = traversal.max_depth as u32;
370        let conn = self.read_conn();
371        let mut graph = Subgraph::default();
372        // Running payload total, carried through the load and into `hydrate`.
373        // See `estimated_bytes` for why this is not recomputed per row (D-047).
374        let mut bytes = 0usize;
375
376        // `?1..?4` are start, depth, ts and min_weight; edge types take `?5`
377        // onwards. Bound, never spliced — an edge type is a value, and the only
378        // validation in the crate runs on the *write* path (D-039), so a
379        // traversal never passes through it.
380        let edge_filter = traversal.edge_filter_sql();
381
382        // Topology first. The recursion itself is `TraversalBuilder::walk_cte`
383        // and is **not** duplicated here (T0.1): this file and `builder.rs` held
384        // byte-identical copies, and they had already drifted once — D-073 found
385        // this loader taking neither `edge_types` nor `min_weight` while the
386        // builder took both.
387        let sql = format!(
388            "{}{}",
389            traversal.walk_cte(),
390            format_args!(
391                r#"
392-- **The `DISTINCT` is why this query is superlinear, and it is not removable.**
393--
394-- Wave 3 measured `load_subgraph` at 12.5x for 10x the nodes and could not say
395-- why; Wave 4 answered it from the plan. `EXPLAIN` reports
396-- `USE TEMP B-TREE FOR DISTINCT`: an O(E log E) sort over the output, and
397-- n log n predicts ~13.3x for 10x, against the 12.5x measured. That is the term.
398--
399-- It is load-bearing: two branches can reach the same node, so a node appears in
400-- `walk` at more than one depth and the join would otherwise emit its edges once
401-- per depth. Without `DISTINCT` a caller gets duplicate edges.
402--
403-- **Corrected in 0.6.0 (T0.1), and the correction is not that the analysis was
404-- wrong.** Everything above holds, and D-070's two rejected fixes were measured
405-- honestly. What was wrong was the fixture: `benches/` seeds a chain of stars,
406-- which is a *tree*, and in a tree there is exactly one path to each node — so
407-- the term that actually dominated was identically 1 and invisible. D-070
408-- concluded the growth was "inherent to producing a deduplicated result", which
409-- is true of trees and false of graphs. The real cost was the walk enumerating
410-- **paths** rather than nodes; see `walk_cte`. On a 328-edge layered graph at
411-- depth 6 that was 299,593 walk rows and 428 ms, against 49 rows and 0.1 ms now.
412-- The `DISTINCT` stays, and it is no longer the leading term.
413--
414-- The filters appear **twice**, and that is the contract (D-073). The walk uses
415-- them to bound which nodes are reached; the projection uses them to decide
416-- which adjacency lands in the result. Filtering only the walk would hand a
417-- caller who asked for `CITES` a graph reached via `CITES` and populated with
418-- every other edge type those nodes happen to have.
419SELECT DISTINCT l.source_id, l.target_id, l.edge_type, l.weight, l.valid_from, l.valid_to
420FROM walk w
421JOIN links_current l ON l.source_id = w.node_id
422WHERE l.valid_from <= ?3 AND ?3 < l.valid_to
423  AND l.weight >= ?4
424  {edge_filter}
425ORDER BY l.source_id, l.target_id, l.edge_type
426"#
427            )
428        );
429
430        let mut params: Vec<libsql::Value> = vec![
431            start_node.into(),
432            (max_hops as i64).into(),
433            now_ts.into(),
434            traversal.min_weight.into(),
435        ];
436        params.extend(traversal.edge_types.iter().map(|t| t.as_str().into()));
437
438        let mut rows = conn.query(&sql, params).await?;
439
440        while let Some(row) = rows.next().await? {
441            let source: String = row.get(0)?;
442            let target: String = row.get(1)?;
443            let weight: f64 = row.get(3)?;
444
445            // Dijkstra and A* are only correct for non-negative weights, and the
446            // schema does not constrain the column. Refusing here keeps the
447            // wrongness at the boundary: the alternative is a shortest path that
448            // is merely a path, returned with no indication of it.
449            //
450            // **The `is_nan()` arm is unreachable on a file this schema created
451            // (T0.3, D-078).** SQLite stores a NaN double as NULL, so
452            // `weight REAL NOT NULL` refuses it — measured on libSQL 0.9.30
453            // through `assert_edge`, through a raw `INSERT` binding NaN, and
454            // through a raw `INSERT` computing `0.0/0.0` in the engine; all three
455            // fail with `NOT NULL constraint failed`. §4.7 used to list NaN as a
456            // gap this loader covered, which had it backwards.
457            //
458            // Kept anyway, as defence rather than decoration: a future engine
459            // that stores NaN as a real double would make it live again, and the
460            // cost of a comparison per edge against reading a shortest path
461            // computed over NaN is not a close call. `storage_boundary_tests`
462            // pins the engine's current behaviour, so that change would arrive
463            // as a failing test rather than as a silent answer.
464            if weight < 0.0 || weight.is_nan() {
465                return Err(DbError::NegativeEdgeWeight {
466                    source_id: source,
467                    target_id: target,
468                    weight,
469                });
470            }
471
472            let edge = EdgeRef {
473                node: target.clone(),
474                edge_type: row.get(2)?,
475                weight,
476                valid_from: row.get(4)?,
477                valid_to: row.get(5)?,
478            };
479            // Accounted before the move, and *not* simply doubled.
480            //
481            // `add_edge` stores this entry in `out_adj` and a clone in `in_adj`
482            // with `node` rewritten to the **source**, so the two differ by
483            // exactly the two id lengths — `edge_bytes` sums `e.node.len()`.
484            // `2 * edge_bytes(&edge)` counted the target twice and the source
485            // never, so the running total drifted from `estimated_bytes()` by
486            // `target.len() - source.len()` per edge, in whichever direction the
487            // ids happened to differ.
488            //
489            // A byte an edge, and it made the loader refuse a graph sized at its
490            // own `estimated_bytes()` — found by `a_filtered_load_fits_a_budget_
491            // the_unfiltered_one_exceeds`, and invisible to
492            // `load_subgraph_totals_agree_with_the_derivation` because that one
493            // asserts refusal at *half* the budget, where a one-byte-per-edge
494            // drift cannot show. The doc on `node_bytes` claims the loader's
495            // arithmetic and the caller's are the same; now they are.
496            let outgoing = Subgraph::edge_bytes(&edge);
497            let incoming = outgoing - target.len() + source.len();
498            bytes += outgoing + incoming;
499            graph.add_edge(source, target, edge);
500
501            if bytes > byte_budget {
502                return Err(DbError::SubgraphTooLarge {
503                    n: bytes,
504                    budget: byte_budget,
505                });
506            }
507        }
508
509        // Every endpoint is a node, plus the start itself so a lone node still
510        // loads as a one-node graph rather than an empty one.
511        let mut ids: Vec<String> = graph
512            .out_adj
513            .keys()
514            .chain(graph.in_adj.keys())
515            .cloned()
516            .collect();
517        ids.push(start_node.to_string());
518        ids.sort();
519        ids.dedup();
520
521        hydrate(conn, &mut graph, &ids, bytes, byte_budget).await?;
522        graph.drop_dangling_adjacency();
523        Ok(graph)
524    }
525}
526
527use crate::util::limits::HYDRATE_CHUNK;
528
529/// Fill in `nodes` from `concepts` for the ids the topology touched.
530/// Attach node attributes, continuing the caller's byte accounting.
531///
532/// `bytes_so_far` is the topology's payload total; this adds each node as it
533/// lands and refuses as soon as the running total passes the budget rather than
534/// after the whole set is in hand. Checking once at the end would allocate the
535/// whole oversized result before declining to return it, which is the failure
536/// the budget exists to prevent rather than to report.
537///
538/// **One query per [`HYDRATE_CHUNK`] ids, not one per node (defect AE).** The
539/// previous version issued a round trip per id: 400 nodes cost 400 of them and
540/// 13.2 ms, essentially all of it latency rather than work, and linear in node
541/// count on a path whose whole purpose is to bound the result by *bytes*.
542async fn hydrate(
543    conn: &libsql::Connection,
544    graph: &mut Subgraph,
545    ids: &[String],
546    bytes_so_far: usize,
547    byte_budget: usize,
548) -> Result<()> {
549    let mut bytes = bytes_so_far;
550
551    for chunk in ids.chunks(HYDRATE_CHUNK) {
552        // Only the placeholders are built; the ids themselves are bound.
553        let list = (1..=chunk.len())
554            .map(|i| format!("?{i}"))
555            .collect::<Vec<_>>()
556            .join(", ");
557        let sql = format!(
558            "SELECT id, title, content, embedding_model, valid_from, valid_to \
559             FROM concepts WHERE retired = 0 AND id IN ({list})"
560        );
561        let params: Vec<libsql::Value> = chunk
562            .iter()
563            .map(|id| libsql::Value::Text(id.clone()))
564            .collect();
565
566        let mut rows = conn.query(&sql, params).await?;
567        while let Some(row) = rows.next().await? {
568            let id: String = row.get(0)?;
569            let data = NodeData {
570                title: row.get(1)?,
571                content: row.get(2)?,
572                embedding_model: row.get(3).ok(),
573                valid_from: row.get(4)?,
574                valid_to: row.get(5)?,
575            };
576            bytes += Subgraph::node_bytes(&id, &data);
577            graph.nodes.insert(id, data);
578
579            if bytes > byte_budget {
580                return Err(DbError::SubgraphTooLarge {
581                    n: bytes,
582                    budget: byte_budget,
583                });
584            }
585        }
586    }
587
588    Ok(())
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn edge(node: &str, weight: f64) -> EdgeRef {
596        EdgeRef {
597            node: node.to_string(),
598            edge_type: "KNOWS".to_string(),
599            weight,
600            valid_from: "2026-01-01T00:00:00.000000Z".to_string(),
601            valid_to: "9999-12-31T23:59:59.999999Z".to_string(),
602        }
603    }
604
605    #[test]
606    fn adding_an_edge_indexes_it_in_both_directions() {
607        let mut g = Subgraph::default();
608        g.add_edge("A".into(), "B".into(), edge("B", 0.5));
609
610        assert_eq!(g.out_edges("A").len(), 1);
611        assert_eq!(g.out_edges("A")[0].node, "B");
612        assert_eq!(g.in_edges("B").len(), 1);
613        assert_eq!(g.in_edges("B")[0].node, "A", "in_adj holds the source");
614
615        // The undirected view has to agree with itself: total degree is twice
616        // the edge weight total, which is the identity every undirected
617        // quantity in `algorithms` is derived from.
618        assert_eq!(g.degree("A") + g.degree("B"), 2);
619        assert_eq!(g.weighted_degree("A") + g.weighted_degree("B"), 1.0);
620        assert_eq!(g.total_weight(), 0.5);
621    }
622
623    #[test]
624    fn a_missing_node_has_no_edges_rather_than_panicking() {
625        let g = Subgraph::default();
626        assert!(g.out_edges("nobody").is_empty());
627        assert!(g.in_edges("nobody").is_empty());
628        assert_eq!(g.degree("nobody"), 0);
629        assert_eq!(g.weighted_degree("nobody"), 0.0);
630    }
631}