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}