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`.** `drop_dangling_adjacency` — private,
28/// and named here because it is the sole establisher — establishes it and
29/// [`Subgraph::is_closed`] checks it; every algorithm in
30/// [`super::algorithms`] is written assuming it and none of them re-checks.
31///
32/// It did not hold before Wave 1 (defect Z), and the way it failed is the reason
33/// it is now stated on the type rather than left to the loader. Adjacency comes
34/// from `links_current`, which carries edges to retired concepts; `hydrate`
35/// filters `retired = 0`. So a retired neighbour left an `EdgeRef` pointing at
36/// an id with no `NodeData`, and the five algorithms each met that differently:
37/// `louvain` panicked on the missing map entry, `scc` emitted the absent node as
38/// a phantom component of its own, `k_core` counted a degree of 2 where one edge
39/// was in the graph, and `dijkstra` returned a finite distance to a node the
40/// caller could not then look up. Four handlings of one violated invariant, none
41/// of them chosen — and the panic was the least damaging, because the other
42/// three answer.
43///
44/// Dangling entries are **dropped** rather than admitted with a tombstone node.
45/// A retired concept is not visible (§4.1), analytics over a graph is analytics
46/// over what is visible, and the alternative pushes a three-state node onto
47/// every present and future algorithm to preserve edges whose endpoint the
48/// caller is not entitled to read. Retirement is the supported path — concepts
49/// are never deleted (D-022) — so this is ordinary use, not a corner.
50///
51/// # Why the fields are private (0.8.0, B1, D-114)
52///
53/// They were `pub` through 0.7.0, and the three maps were the crate's most
54/// widely read data structure. That made **every detail of the representation
55/// part of the public API** — the `BTreeMap`, the `String` keys, the fact that
56/// adjacency is stored as two maps at all — none of which was ever a promise
57/// anyone intended to make.
58///
59/// The immediate reason is D-087: interning the keys to `u32` cannot be done
60/// at all while `EdgeRef::node` is a public `String`. The break is taken **once**, here, with the representation
61/// unchanged, so that anything depending on the old shape fails against code
62/// that still behaves identically.
63///
64/// Accessors return borrowed views, so nothing here costs an allocation that
65/// field access did not.
66#[derive(Debug, Clone, Default)]
67pub struct Subgraph {
68 nodes: BTreeMap<String, NodeData>,
69 out_adj: BTreeMap<String, Vec<EdgeRef>>,
70 in_adj: BTreeMap<String, Vec<EdgeRef>>,
71 /// Every string an `EdgeRef` carries. See [`Interner`].
72 pool: Interner,
73}
74
75/// The attributes of one node, as of the instant the graph was loaded.
76///
77/// Fields are private for the reason given on [`Subgraph`]; `content` is the
78/// one whose type is expected to move.
79#[derive(Debug, Clone, PartialEq)]
80pub struct NodeData {
81 title: String,
82 /// **`None` means "not loaded", not "empty" (0.8.0, B3, D-116).**
83 ///
84 /// Document text is not loaded unless a caller asks. No algorithm reads it
85 /// — `dijkstra`, `astar`, `scc`, `k_core`, `louvain` and `modularity` touch
86 /// topology and weight only — and at realistic document sizes it is most of
87 /// the byte budget, so the default load spent the budget on bytes nothing
88 /// would look at.
89 ///
90 /// An `Option` rather than an empty `String` because a sentinel that is a
91 /// *valid value of the type* cannot be told apart from the real thing: a
92 /// concept with genuinely empty content and one whose content was not
93 /// requested are different facts, and they differ exactly when a caller is
94 /// deciding whether to go back to the database. Same refusal
95 /// [D-096](../../docs/architecture/s13-decision-register.md) made for the
96 /// open interval.
97 content: Option<String>,
98 embedding_model: Option<String>,
99 valid_from: String,
100 valid_to: String,
101}
102
103impl NodeData {
104 /// A node with no content and no embedding model — what the default load
105 /// produces. Use [`Self::with_content`] and [`Self::with_embedding_model`]
106 /// to add either.
107 pub fn new(
108 title: impl Into<String>,
109 valid_from: impl Into<String>,
110 valid_to: impl Into<String>,
111 ) -> Self {
112 Self {
113 title: title.into(),
114 content: None,
115 embedding_model: None,
116 valid_from: valid_from.into(),
117 valid_to: valid_to.into(),
118 }
119 }
120
121 #[must_use]
122 pub fn with_content(mut self, content: impl Into<String>) -> Self {
123 self.content = Some(content.into());
124 self
125 }
126
127 #[must_use]
128 pub fn with_embedding_model(mut self, model: Option<String>) -> Self {
129 self.embedding_model = model;
130 self
131 }
132
133 pub fn title(&self) -> &str {
134 &self.title
135 }
136
137 /// The document text, or `None` when it was not requested.
138 ///
139 /// **`None` is not an empty document.** See the field's own note: the
140 /// default load does not fetch content, so a caller that did not ask gets
141 /// `None` and can tell that apart from a concept whose content really is
142 /// `""`.
143 pub fn content(&self) -> Option<&str> {
144 self.content.as_deref()
145 }
146
147 pub fn embedding_model(&self) -> Option<&str> {
148 self.embedding_model.as_deref()
149 }
150
151 pub fn valid_from(&self) -> &str {
152 &self.valid_from
153 }
154
155 pub fn valid_to(&self) -> &str {
156 &self.valid_to
157 }
158}
159
160/// The string pool an interned [`EdgeRef`] indexes into (0.8.0, B2, D-115).
161///
162/// One pool for every string an edge carries — node ids, edge types and the two
163/// timestamps — because they dedupe against each other for free and the whole
164/// point is that the cost is per **distinct string** rather than per edge.
165///
166/// Indices are handed out first-seen. **Nothing observable depends on them**:
167/// node order comes from `nodes`, which is still a `BTreeMap` keyed by id, and
168/// adjacency order is the order edges were added, exactly as before. That is
169/// the deliberate answer to D-063's warning that "determinism stops being
170/// structural and becomes procedural" — it does not, because the node map was
171/// never what needed interning. `node_order_does_not_depend_on_construction_order`
172/// is the gate that holds it.
173#[derive(Debug, Clone, Default)]
174struct Interner {
175 strings: Vec<String>,
176 index: BTreeMap<String, u32>,
177 /// Running payload total, maintained on insert.
178 ///
179 /// **Not recomputed.** The first version of the loader called
180 /// `estimated_bytes()` before and after every edge to charge the marginal
181 /// pool cost, which is O(pool) per row and made loading quadratic — the
182 /// exact defect [D-047](../../docs/architecture/s13-decision-register.md)
183 /// diagnosed and fixed, re-introduced by the change that was supposed to
184 /// make loading *cheaper*. `loading_scales_linearly_in_the_number_of_edges`
185 /// caught it, which is what that test is for.
186 bytes: usize,
187}
188
189impl Interner {
190 /// Intern `s`, returning its index and **how many bytes that cost** — zero
191 /// when the string was already pooled.
192 ///
193 /// The caller needs the marginal figure to charge the byte budget as it
194 /// loads, and it has to be O(1) or the budget check is quadratic again.
195 fn intern(&mut self, s: &str) -> (u32, usize) {
196 if let Some(&i) = self.index.get(s) {
197 return (i, 0);
198 }
199 let i = u32::try_from(self.strings.len())
200 .expect("a subgraph cannot hold 2^32 distinct strings within any byte budget");
201 self.strings.push(s.to_string());
202 self.index.insert(s.to_string(), i);
203 let cost = Self::entry_bytes(s);
204 self.bytes += cost;
205 (i, cost)
206 }
207
208 /// Once in `strings`, once as the key of `index`, plus both containers'
209 /// per-entry overhead.
210 fn entry_bytes(s: &str) -> usize {
211 2 * s.len() + std::mem::size_of::<String>() + std::mem::size_of::<u32>()
212 }
213
214 fn get(&self, i: u32) -> &str {
215 &self.strings[i as usize]
216 }
217
218 /// Payload bytes held by the pool, counted the way [`Subgraph::node_bytes`]
219 /// counts: string bytes plus per-item overhead.
220 ///
221 /// **This is the arithmetic D-063 asked for.** Its objection to interning
222 /// was that an id table "stores every id a second time, partly cancelling
223 /// the memory win". It is counted here rather than argued about: the
224 /// duplication is per distinct string, the saving is per edge entry, and
225 /// `estimated_bytes()` reports the sum so a caller can see both.
226 fn estimated_bytes(&self) -> usize {
227 self.bytes
228 }
229}
230
231/// One end of an edge in an adjacency list — **interned** (0.8.0, B2, D-115).
232///
233/// Five fields, no heap payload, `size_of` 24 bytes against 104 bytes of struct
234/// plus around 250 of strings before. Every field but the weight is an index
235/// into its [`Subgraph`]'s pool, so reading one needs the graph:
236///
237/// ```ignore
238/// for e in graph.out_edges("a") {
239/// println!("{} {} {}", e.node(&graph), e.edge_type(&graph), e.weight());
240/// }
241/// ```
242///
243/// That is the visible cost of the change, and it is the reason B1 had to
244/// privatise these fields first: a public `node: String` cannot become a `u32`.
245/// The win is **reachability**, not speed ([D-073](../../docs/architecture/s13-decision-register.md)'s
246/// category): graphs that did not fit the byte budget start fitting.
247///
248/// # Invariants
249///
250/// An `EdgeRef` is tied to the specific [`Subgraph`] it was retrieved from.
251/// Querying it against a different one — via an accessor like [`Self::node`],
252/// or via derived `PartialEq` — is a **logic error** that will silently return
253/// incorrect data or report equality where none exists. Because the handle is
254/// `Copy` it can be stored in a struct that outlives the graph; it stays
255/// well-formed and becomes meaningless without its pool.
256///
257/// `PartialEq` is the sharp edge, and it is kept rather than removed: *within*
258/// one graph, index equality is exactly the comparison a caller wants, and it
259/// is cheaper and stricter than comparing five strings. Across two graphs it
260/// compares indices that mean different things — a wrong answer that needs no
261/// accessor call at all, so it sits outside the mental model of "querying".
262/// Before interning, `==` compared the strings and could not be wrong this way.
263///
264/// This logic error does not result in undefined behaviour — every index goes
265/// through bounds-checked slice indexing and there is no `unsafe` here — but
266/// the results are otherwise unspecified.
267///
268/// The handle is intentionally **not** lifetime-branded, which would make the
269/// invariant a compile error, because that propagates a generic parameter
270/// through every algorithm and every signature that mentions a `Subgraph`. See
271/// D-115 for the argument and for what to do if this is ever hit in practice.
272#[derive(Clone, Copy, PartialEq)]
273pub struct EdgeRef {
274 node: u32,
275 edge_type: u32,
276 weight: f64,
277 valid_from: u32,
278 valid_to: u32,
279}
280
281/// Written by hand so a failing `assert_eq!` cannot be mistaken for one about
282/// strings.
283///
284/// The derived form printed `EdgeRef { node: 3, edge_type: 1, .. }`, which
285/// reads as data and is not: those are pool indices, meaningless without the
286/// graph. The `#` is there to say so at a glance.
287impl std::fmt::Debug for EdgeRef {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 write!(
290 f,
291 "EdgeRef(node=#{}, type=#{}, w={}, from=#{}, to=#{})",
292 self.node, self.edge_type, self.weight, self.valid_from, self.valid_to
293 )
294 }
295}
296
297impl EdgeRef {
298 /// The far end of the edge: the target in `out_edges`, the source in
299 /// `in_edges`.
300 ///
301 /// Takes the graph because the string lives in its pool. `graph` must be
302 /// the one this edge came from; passing another is a programming error and
303 /// will panic or answer nonsense, exactly as indexing the wrong slice would.
304 pub fn node<'a>(&self, graph: &'a Subgraph) -> &'a str {
305 graph.pool.get(self.node)
306 }
307
308 pub fn edge_type<'a>(&self, graph: &'a Subgraph) -> &'a str {
309 graph.pool.get(self.edge_type)
310 }
311
312 /// The only field that is not interned, because an `f64` is already 8 bytes
313 /// and a pool of them would cost more than it saved.
314 pub fn weight(&self) -> f64 {
315 self.weight
316 }
317
318 pub fn valid_from<'a>(&self, graph: &'a Subgraph) -> &'a str {
319 graph.pool.get(self.valid_from)
320 }
321
322 pub fn valid_to<'a>(&self, graph: &'a Subgraph) -> &'a str {
323 graph.pool.get(self.valid_to)
324 }
325}
326
327impl Subgraph {
328 /// Whether `id` is a hydrated node of this graph.
329 ///
330 /// By the closure invariant this is also the answer to "may an algorithm
331 /// look this id up", which is why every algorithm asks it rather than
332 /// probing adjacency.
333 pub fn contains_node(&self, id: &str) -> bool {
334 self.nodes.contains_key(id)
335 }
336
337 /// The attributes of `id`, or `None` when it is not in the graph.
338 pub fn node(&self, id: &str) -> Option<&NodeData> {
339 self.nodes.get(id)
340 }
341
342 /// Node ids in ascending order.
343 ///
344 /// The order is `BTreeMap`'s and is load-bearing rather than incidental:
345 /// Louvain breaks ties by first-seen community and returns a different
346 /// partition under a randomised order.
347 pub fn node_ids(&self) -> impl ExactSizeIterator<Item = &str> + '_ {
348 self.nodes.keys().map(String::as_str)
349 }
350
351 pub fn node_count(&self) -> usize {
352 self.nodes.len()
353 }
354
355 /// Every node with its attributes, in id order.
356 pub fn nodes(&self) -> impl ExactSizeIterator<Item = (&str, &NodeData)> + '_ {
357 self.nodes.iter().map(|(id, d)| (id.as_str(), d))
358 }
359
360 /// The outgoing index: each node that has outgoing edges, with them.
361 ///
362 /// For one node prefer [`Self::out_edges`]. This exists for callers that
363 /// must walk the whole index — the Python `to_dict`, and the diagnostics.
364 pub fn out_adjacency(&self) -> impl Iterator<Item = (&str, &[EdgeRef])> + '_ {
365 self.out_adj
366 .iter()
367 .map(|(id, e)| (id.as_str(), e.as_slice()))
368 }
369
370 /// The incoming index. See [`Self::out_adjacency`].
371 pub fn in_adjacency(&self) -> impl Iterator<Item = (&str, &[EdgeRef])> + '_ {
372 self.in_adj
373 .iter()
374 .map(|(id, e)| (id.as_str(), e.as_slice()))
375 }
376
377 /// Add or replace a node, returning what was there before.
378 ///
379 /// Public so that callers who build a graph by hand — the test fixtures,
380 /// the diagnostics — can still do so now the fields are private. It does
381 /// **not** establish the closure invariant on its own: adjacency naming an
382 /// id never inserted is still dangling, exactly as before.
383 pub fn insert_node(&mut self, id: impl Into<String>, data: NodeData) -> Option<NodeData> {
384 self.nodes.insert(id.into(), data)
385 }
386
387 /// Outgoing edges of `node`, empty when it has none or is absent.
388 pub fn out_edges(&self, node: &str) -> &[EdgeRef] {
389 self.out_adj.get(node).map_or(NO_EDGES, Vec::as_slice)
390 }
391
392 /// Incoming edges of `node`, empty when it has none or is absent.
393 pub fn in_edges(&self, node: &str) -> &[EdgeRef] {
394 self.in_adj.get(node).map_or(NO_EDGES, Vec::as_slice)
395 }
396
397 /// Undirected edge count incident to `node`, counting parallel edges once
398 /// each and a self-loop twice.
399 pub fn degree(&self, node: &str) -> usize {
400 self.out_edges(node).len() + self.in_edges(node).len()
401 }
402
403 /// Undirected weight incident to `node`. Summed over both directions, so
404 /// summing this over all nodes gives `2 * total_weight`.
405 pub fn weighted_degree(&self, node: &str) -> f64 {
406 self.out_edges(node).iter().map(|e| e.weight).sum::<f64>()
407 + self.in_edges(node).iter().map(|e| e.weight).sum::<f64>()
408 }
409
410 /// Total edge weight, each edge counted once — the `m` of the modularity
411 /// formulas.
412 pub fn total_weight(&self) -> f64 {
413 self.out_adj
414 .values()
415 .flat_map(|edges| edges.iter().map(|e| e.weight))
416 .sum()
417 }
418
419 pub fn edge_count(&self) -> usize {
420 self.out_adj.values().map(Vec::len).sum()
421 }
422
423 /// Remove adjacency entries whose endpoint is not a hydrated node.
424 ///
425 /// This is what establishes the closure invariant on the type's docs, and it
426 /// runs after `hydrate` because that is the first moment the set of visible
427 /// nodes is known — the walk is over `links_current`, which does not record
428 /// retirement.
429 ///
430 /// A node left with no edges keeps its (now empty) entry only if it had one;
431 /// entries emptied by the prune are removed outright, so `out_adj` and
432 /// `in_adj` do not accumulate keys for nodes that turned out to have nothing.
433 /// Keys that are themselves not hydrated go too, which covers the case where
434 /// the *source* is the retired concept rather than the target.
435 ///
436 /// The byte accounting is deliberately not rewound. `bytes` bounded the load
437 /// as it ran and refused early on that basis, so a graph that would have fit
438 /// after pruning can still be refused before it. That is conservative in the
439 /// safe direction — the budget exists to stop an allocation, and the
440 /// allocation happens during the walk, not after it.
441 fn drop_dangling_adjacency(&mut self) {
442 // Destructured so `nodes` is borrowed separately from the two maps being
443 // mutated — the same borrow through `self` inside the closure would not
444 // compile.
445 let Subgraph {
446 nodes,
447 out_adj,
448 in_adj,
449 pool,
450 } = self;
451
452 for adj in [out_adj, in_adj] {
453 adj.retain(|id, edges| {
454 if !nodes.contains_key(id) {
455 return false;
456 }
457 edges.retain(|e| nodes.contains_key(pool.get(e.node)));
458 !edges.is_empty()
459 });
460 }
461 }
462
463 /// Whether the closure invariant holds. Used by tests and `debug_assert`s.
464 ///
465 /// Cheap enough to call in a test and O(V + E), so not on any hot path.
466 ///
467 /// **The `debug_assert`s were only a claim until 0.10.0** (W4.8). This
468 /// sentence shipped in 0.6.0 and none existed in `src/`; they now sit at the
469 /// entry of `dijkstra`, `astar`, `scc`, `k_core` and `louvain`
470 /// (`algorithms::CLOSURE`). Writing them was the fix rather than weakening
471 /// the sentence: the type docs above say every algorithm assumes closure and
472 /// none re-checks it, and an assert is the auditable form of that.
473 pub fn is_closed(&self) -> bool {
474 self.out_adj
475 .iter()
476 .chain(self.in_adj.iter())
477 .all(|(id, edges)| {
478 self.nodes.contains_key(id)
479 && edges
480 .iter()
481 .all(|e| self.nodes.contains_key(self.pool.get(e.node)))
482 })
483 }
484
485 /// Record an edge in both directions.
486 ///
487 /// Both indices are maintained together because every undirected quantity
488 /// here — degree, k-core peeling, Louvain's `k_i` — reads them as a pair. An
489 /// `in_adj` that lags `out_adj` would not fail loudly; it would return a
490 /// plausible wrong number.
491 /// **Public since 0.8.0.** The callers that used to push into both maps by
492 /// hand cannot now the fields are private, and routing them through the one
493 /// function that maintains the pair is the point rather than a consolation:
494 /// hand-written adjacency was two chances to get the reverse edge wrong,
495 /// and every such call site was already doing the `back.node = source`
496 /// dance itself. `edge.node` is expected to be `target`; the reverse entry
497 /// is derived here.
498 /// Returns the bytes this edge added to [`Self::estimated_bytes`] — the two
499 /// fixed-size entries plus whatever strings were genuinely new. The loader
500 /// charges its budget with it, and it is O(1) by construction.
501 pub fn add_edge(
502 &mut self,
503 source: &str,
504 target: &str,
505 edge_type: &str,
506 weight: f64,
507 valid_from: &str,
508 valid_to: &str,
509 ) -> usize {
510 let (src, b1) = self.pool.intern(source);
511 let (tgt, b2) = self.pool.intern(target);
512 let (ty, b3) = self.pool.intern(edge_type);
513 let (from, b4) = self.pool.intern(valid_from);
514 let (to, b5) = self.pool.intern(valid_to);
515 let pooled = b1 + b2 + b3 + b4 + b5;
516
517 self.out_adj
518 .entry(source.to_string())
519 .or_default()
520 .push(EdgeRef {
521 node: tgt,
522 edge_type: ty,
523 weight,
524 valid_from: from,
525 valid_to: to,
526 });
527 self.in_adj
528 .entry(target.to_string())
529 .or_default()
530 .push(EdgeRef {
531 node: src,
532 edge_type: ty,
533 weight,
534 valid_from: from,
535 valid_to: to,
536 });
537 2 * std::mem::size_of::<EdgeRef>() + pooled
538 }
539
540 /// Estimated payload bytes for one node, keyed by `id`.
541 ///
542 /// The per-item functions are the single definition of the estimate.
543 /// [`Self::estimated_bytes`] sums them over a whole graph; the loader adds
544 /// them as it inserts, so the running total it checks against the budget and
545 /// the total a caller can compute are the same arithmetic rather than two
546 /// descriptions of it. `load_subgraph_totals_agree_with_the_derivation`
547 /// pins that they stay equal.
548 fn node_bytes(id: &str, d: &NodeData) -> usize {
549 id.len()
550 + d.title.len()
551 + d.content.as_ref().map_or(0, String::len)
552 + d.embedding_model.as_ref().map_or(0, String::len)
553 + d.valid_from.len()
554 + d.valid_to.len()
555 + std::mem::size_of::<NodeData>()
556 }
557
558 /// Estimated payload bytes for one adjacency entry.
559 ///
560 /// An edge occupies two of these — one in `out_adj`, one in `in_adj` — so a
561 /// caller accounting for a newly added edge counts it twice.
562 /// **24 bytes, and nothing else** since B2 (D-115).
563 ///
564 /// Before interning this summed four string lengths as well, around 189
565 /// bytes for a ULID-keyed edge. The strings did not disappear — they moved
566 /// into the pool, where they are counted once per *distinct* value by
567 /// [`Interner::estimated_bytes`] rather than once per edge entry.
568 fn edge_bytes(_e: &EdgeRef) -> usize {
569 std::mem::size_of::<EdgeRef>()
570 }
571
572 /// Estimated heap footprint (D-007).
573 ///
574 /// Deliberately an estimate of the *payload*, not a precise `size_of` walk:
575 /// the budget exists to stop a dense neighbourhood exhausting memory, and a
576 /// figure that tracks string bytes and per-item overhead is accurate enough
577 /// for that.
578 ///
579 /// **O(V + E), and therefore not for use inside a loop over rows.** The
580 /// loader used to call this per row, which made loading O(E²): 500 edges in
581 /// 26 ms, 1,000 in 76 ms, 2,000 in 231 ms — time tripling for each doubling.
582 /// The byte budget is what bounds a load, and the budget *check* was the
583 /// thing that did not scale (D-047).
584 pub fn estimated_bytes(&self) -> usize {
585 let nodes: usize = self
586 .nodes
587 .iter()
588 .map(|(id, d)| Self::node_bytes(id, d))
589 .sum();
590 let edges: usize = self
591 .out_adj
592 .values()
593 .chain(self.in_adj.values())
594 .flat_map(|v| v.iter())
595 .map(Self::edge_bytes)
596 .sum();
597 nodes + edges + self.pool.estimated_bytes()
598 }
599
600 /// Write one derived result per node under `label` (§5.4, D-041).
601 ///
602 /// Goes through [`Database::write_analytics_annotations`], which chunks at
603 /// [`crate::connection::chunk_rows::ANNOTATIONS`] and sends on the
604 /// low-priority channel,
605 /// so a community assignment over a large subgraph cannot starve interactive
606 /// writes.
607 ///
608 /// Rows land in `analytics_annotations`, which carries no log trigger.
609 /// Before 0.5.4 this method built a `ConceptUpsert` per node and put the
610 /// value in `content`, so writing back a partition **overwrote every
611 /// annotated concept's document text** — and, because the write went through
612 /// the ledger, recorded each rerun of the algorithm as a fresh version of a
613 /// world that had not changed. The old doc comment defended that as "a
614 /// normal bitemporal write," which was true of the mechanism and false of
615 /// the intent: it is the right mechanism for a domain fact, and a community
616 /// label is not one.
617 ///
618 /// `values` is keyed by node id; nodes absent from it are not annotated.
619 pub async fn write_back_annotations(
620 &self,
621 db: &Database,
622 label: &str,
623 values: &BTreeMap<String, String>,
624 ) -> Result<usize> {
625 let rows: Vec<Annotation> = self
626 .nodes
627 .keys()
628 .filter_map(|id| {
629 values
630 .get(id)
631 .map(|value| Annotation::new(id.clone(), label, value.clone()))
632 })
633 .collect();
634
635 db.write_analytics_annotations(rows).await
636 }
637}
638
639impl Database {
640 /// Load the topology reachable from `start_node` within `max_hops` (§5.4).
641 ///
642 /// Runs on the read connection, so it cannot contend with the write actor.
643 /// `byte_budget` bounds the result: a hub node in a dense graph can reach
644 /// most of the database in three hops, and the budget is what turns that
645 /// into [`DbError::SubgraphTooLarge`] rather than into an allocation
646 /// failure.
647 ///
648 /// Unfiltered: every edge type, **every weight**. See
649 /// [`Self::load_subgraph_with`] for the filtered form, which this delegates
650 /// to.
651 ///
652 /// `min_weight` is `NEG_INFINITY` rather than
653 /// [`TraversalBuilder`](super::TraversalBuilder)'s default
654 /// of `0.0`, and the difference is load-bearing. A floor of `0.0` silently
655 /// drops negative-weight edges — which is precisely the input
656 /// [`DbError::NegativeEdgeWeight`] exists to *report*, since Dijkstra and A*
657 /// are unsound over them and D-039 chose to refuse at the boundary rather
658 /// than return a shortest path that is merely a path. Delegating with the
659 /// builder default turned that typed refusal into a graph quietly missing
660 /// edges; `a_negative_edge_weight_is_refused_at_load` caught it.
661 ///
662 /// So the two mechanisms are made to agree instead of overlapping: an edge a
663 /// caller has **not** filtered out reaches the weight guard, and an edge they
664 /// have is theirs to exclude. See [`Self::load_subgraph_with`] for what that
665 /// means when a caller passes a default builder.
666 pub async fn load_subgraph(
667 &self,
668 start_node: &str,
669 max_hops: u32,
670 now_ts: &str,
671 byte_budget: usize,
672 ) -> Result<Subgraph> {
673 self.load_subgraph_with(
674 &super::TraversalBuilder::new(start_node)
675 .max_depth(max_hops as usize)
676 .min_weight(f64::NEG_INFINITY),
677 now_ts,
678 byte_budget,
679 )
680 .await
681 }
682
683 /// Load the topology a [`TraversalBuilder`](super::TraversalBuilder)
684 /// describes, as a [`Subgraph`]
685 /// (§5.4, D-073).
686 ///
687 /// `load_subgraph` took neither `edge_types` nor `min_weight` while
688 /// `TraversalBuilder` took both — the same walk over the same table with two
689 /// fewer knobs. That was a **reachability** limit rather than a convenience
690 /// one: the byte budget bounds the *unfiltered* neighbourhood, so a caller
691 /// wanting one edge type out of a hub got [`DbError::SubgraphTooLarge`] for a
692 /// graph whose filtered form would have fitted easily, and filtering the
693 /// returned `Subgraph` afterwards cannot help because the refusal happens
694 /// during the walk.
695 ///
696 /// # The filters apply to the walk *and* to the returned edges
697 ///
698 /// This is the decision the change turned on, and the two are separable.
699 /// `TraversalBuilder` applies its filters to the **recursive step** — which
700 /// edges are followed — while this loader's final projection returns every
701 /// edge of every node it reached. Wiring the two together naively gives a
702 /// caller who asked for `CITES` a graph reached via `CITES` and populated
703 /// with `KNOWS` edges as well, which is surprising enough to be read as a
704 /// bug.
705 ///
706 /// So both halves filter. If a caller names edge types or a minimum weight,
707 /// they are asking for a subgraph **of those edges**: the walk uses them to
708 /// bound which nodes are reached, and the projection uses them to decide
709 /// which adjacency lands in the result. `load_subgraph` passes a default
710 /// builder — no types, weight ≥ 0 — so its behaviour is unchanged.
711 ///
712 /// # `min_weight` and the negative-weight guard
713 ///
714 /// [`TraversalBuilder`](super::TraversalBuilder) defaults `min_weight` to
715 /// `0.0`, so a **default
716 /// builder passed here filters negative-weight edges out** rather than
717 /// letting them reach [`DbError::NegativeEdgeWeight`]. That is a real
718 /// difference from [`Self::load_subgraph`], which passes `NEG_INFINITY`.
719 ///
720 /// It is deliberate and it is the coherent reading: a caller who states a
721 /// weight floor has asked to exclude what falls below it, and excluding it
722 /// is not an error. A caller who states none should be told, because
723 /// Dijkstra and A* are unsound over negative weights. Pass
724 /// `.min_weight(f64::NEG_INFINITY)` to get the guard with a filtered builder.
725 ///
726 /// `attribute_mode` is ignored: hydration here is always the live concept
727 /// row, which is what a `Subgraph` has always carried.
728 pub async fn load_subgraph_with(
729 &self,
730 traversal: &super::TraversalBuilder,
731 now_ts: &str,
732 byte_budget: usize,
733 ) -> Result<Subgraph> {
734 let start_node = traversal.start_node.as_str();
735 let max_hops = traversal.max_depth as u32;
736 let conn = self.read_conn();
737 let mut graph = Subgraph::default();
738 // Running payload total, carried through the load and into `hydrate`.
739 // See `estimated_bytes` for why this is not recomputed per row (D-047).
740 let mut bytes = 0usize;
741
742 // `?1..?4` are start, depth, ts and min_weight; edge types take `?5`
743 // onwards. Bound, never spliced — an edge type is a value, and the only
744 // validation in the crate runs on the *write* path (D-039), so a
745 // traversal never passes through it.
746 let edge_filter = traversal.edge_filter_sql();
747
748 // Topology first. The recursion itself is `TraversalBuilder::walk_cte`
749 // and is **not** duplicated here (T0.1): this file and `builder.rs` held
750 // byte-identical copies, and they had already drifted once — D-073 found
751 // this loader taking neither `edge_types` nor `min_weight` while the
752 // builder took both.
753 let sql = format!(
754 "{}{}",
755 traversal.walk_cte(),
756 format_args!(
757 r#"
758-- **The `DISTINCT` is why this query is superlinear, and it is not removable.**
759--
760-- Wave 3 measured `load_subgraph` at 12.5x for 10x the nodes and could not say
761-- why; Wave 4 answered it from the plan. `EXPLAIN` reports
762-- `USE TEMP B-TREE FOR DISTINCT`: an O(E log E) sort over the output, and
763-- n log n predicts ~13.3x for 10x, against the 12.5x measured. That is the term.
764--
765-- It is load-bearing: two branches can reach the same node, so a node appears in
766-- `walk` at more than one depth and the join would otherwise emit its edges once
767-- per depth. Without `DISTINCT` a caller gets duplicate edges.
768--
769-- **Corrected in 0.6.0 (T0.1), and the correction is not that the analysis was
770-- wrong.** Everything above holds, and D-070's two rejected fixes were measured
771-- honestly. What was wrong was the fixture: `benches/` seeds a chain of stars,
772-- which is a *tree*, and in a tree there is exactly one path to each node — so
773-- the term that actually dominated was identically 1 and invisible. D-070
774-- concluded the growth was "inherent to producing a deduplicated result", which
775-- is true of trees and false of graphs. The real cost was the walk enumerating
776-- **paths** rather than nodes; see `walk_cte`. On a 328-edge layered graph at
777-- depth 6 that was 299,593 walk rows and 428 ms, against 49 rows and 0.1 ms now.
778-- The `DISTINCT` stays, and it is no longer the leading term.
779--
780-- The filters appear **twice**, and that is the contract (D-073). The walk uses
781-- them to bound which nodes are reached; the projection uses them to decide
782-- which adjacency lands in the result. Filtering only the walk would hand a
783-- caller who asked for `CITES` a graph reached via `CITES` and populated with
784-- every other edge type those nodes happen to have.
785SELECT DISTINCT l.source_id, l.target_id, l.edge_type, l.weight, l.valid_from, l.valid_to
786FROM walk w
787JOIN links_current l ON l.source_id = w.node_id
788WHERE l.valid_from <= ?3 AND ?3 < l.valid_to
789 AND l.weight >= ?4
790 {edge_filter}
791ORDER BY l.source_id, l.target_id, l.edge_type
792"#
793 )
794 );
795
796 let mut params: Vec<libsql::Value> = vec![
797 start_node.into(),
798 (max_hops as i64).into(),
799 now_ts.into(),
800 traversal.min_weight.into(),
801 ];
802 params.extend(traversal.edge_types.iter().map(|t| t.as_str().into()));
803
804 let mut rows = conn.query(&sql, params).await?;
805
806 while let Some(row) = rows.next().await? {
807 let source: String = row.get(0)?;
808 let target: String = row.get(1)?;
809 let weight: f64 = row.get(3)?;
810
811 // Dijkstra and A* are only correct for non-negative weights, and the
812 // schema does not constrain the column. Refusing here keeps the
813 // wrongness at the boundary: the alternative is a shortest path that
814 // is merely a path, returned with no indication of it.
815 //
816 // **The `is_nan()` arm is unreachable on a file this schema created
817 // (T0.3, D-078).** SQLite stores a NaN double as NULL, so
818 // `weight REAL NOT NULL` refuses it — measured on libSQL 0.9.30
819 // through `assert_edge`, through a raw `INSERT` binding NaN, and
820 // through a raw `INSERT` computing `0.0/0.0` in the engine; all three
821 // fail with `NOT NULL constraint failed`. §4.7 used to list NaN as a
822 // gap this loader covered, which had it backwards.
823 //
824 // Kept anyway, as defence rather than decoration: a future engine
825 // that stores NaN as a real double would make it live again, and the
826 // cost of a comparison per edge against reading a shortest path
827 // computed over NaN is not a close call. `storage_boundary_tests`
828 // pins the engine's current behaviour, so that change would arrive
829 // as a failing test rather than as a silent answer.
830 if weight < 0.0 || weight.is_nan() {
831 return Err(DbError::NegativeEdgeWeight {
832 source_id: source,
833 target_id: target,
834 weight,
835 });
836 }
837
838 let edge_type: String = row.get(2)?;
839 let valid_from: String = row.get(4)?;
840 let valid_to: String = row.get(5)?;
841
842 // Accounted before the insert, and the arithmetic is far simpler
843 // than it was: an interned entry is a fixed 24 bytes whichever
844 // endpoint it names, so the two entries `add_edge` writes cost the
845 // same and there is no id-length asymmetry to get wrong.
846 //
847 // The strings have not vanished, they have moved into the pool, so
848 // what a *new* distinct string costs is charged here too. Only the
849 // ones actually new: `intern` dedupes, and charging every edge for
850 // its type and timestamps would re-introduce exactly the per-edge
851 // cost B2 removes.
852 bytes += graph.add_edge(&source, &target, &edge_type, weight, &valid_from, &valid_to);
853
854 if bytes > byte_budget {
855 return Err(DbError::SubgraphTooLarge {
856 n: bytes,
857 budget: byte_budget,
858 });
859 }
860 }
861
862 // Every endpoint is a node, plus the start itself so a lone node still
863 // loads as a one-node graph rather than an empty one.
864 let mut ids: Vec<String> = graph
865 .out_adj
866 .keys()
867 .chain(graph.in_adj.keys())
868 .cloned()
869 .collect();
870 ids.push(start_node.to_string());
871 ids.sort();
872 ids.dedup();
873
874 hydrate(
875 conn,
876 &mut graph,
877 &ids,
878 bytes,
879 byte_budget,
880 traversal.content,
881 )
882 .await?;
883 graph.drop_dangling_adjacency();
884 Ok(graph)
885 }
886}
887
888use crate::util::limits::HYDRATE_CHUNK;
889
890/// Fill in `nodes` from `concepts` for the ids the topology touched.
891/// Attach node attributes, continuing the caller's byte accounting.
892///
893/// `bytes_so_far` is the topology's payload total; this adds each node as it
894/// lands and refuses as soon as the running total passes the budget rather than
895/// after the whole set is in hand. Checking once at the end would allocate the
896/// whole oversized result before declining to return it, which is the failure
897/// the budget exists to prevent rather than to report.
898///
899/// **One query per [`HYDRATE_CHUNK`] ids, not one per node (defect AE).** The
900/// previous version issued a round trip per id: 400 nodes cost 400 of them and
901/// 13.2 ms, essentially all of it latency rather than work, and linear in node
902/// count on a path whose whole purpose is to bound the result by *bytes*.
903async fn hydrate(
904 conn: &libsql::Connection,
905 graph: &mut Subgraph,
906 ids: &[String],
907 bytes_so_far: usize,
908 byte_budget: usize,
909 with_content: bool,
910) -> Result<()> {
911 let mut bytes = bytes_so_far;
912
913 for chunk in ids.chunks(HYDRATE_CHUNK) {
914 // Only the placeholders are built; the ids themselves are bound.
915 let list = (1..=chunk.len())
916 .map(|i| format!("?{i}"))
917 .collect::<Vec<_>>()
918 .join(", ");
919 let sql = format!(
920 "SELECT id, title, content, embedding_model, valid_from, valid_to \
921 FROM concepts WHERE retired = 0 AND id IN ({list})"
922 );
923 let params: Vec<libsql::Value> = chunk
924 .iter()
925 .map(|id| libsql::Value::Text(id.clone()))
926 .collect();
927
928 let mut rows = conn.query(&sql, params).await?;
929 while let Some(row) = rows.next().await? {
930 let id: String = row.get(0)?;
931 let data = NodeData {
932 title: row.get(1)?,
933 content: if with_content { row.get(2).ok() } else { None },
934 embedding_model: row.get(3).ok(),
935 valid_from: row.get(4)?,
936 valid_to: row.get(5)?,
937 };
938 bytes += Subgraph::node_bytes(&id, &data);
939 graph.nodes.insert(id, data);
940
941 if bytes > byte_budget {
942 return Err(DbError::SubgraphTooLarge {
943 n: bytes,
944 budget: byte_budget,
945 });
946 }
947 }
948 }
949
950 Ok(())
951}
952
953#[cfg(test)]
954mod tests {
955 use super::*;
956
957 #[test]
958 fn adding_an_edge_indexes_it_in_both_directions() {
959 let mut g = Subgraph::default();
960 g.add_edge(
961 "A",
962 "B",
963 "KNOWS",
964 0.5,
965 "2026-01-01T00:00:00.000000Z",
966 "9999-12-31T23:59:59.999999Z",
967 );
968
969 assert_eq!(g.out_edges("A").len(), 1);
970 assert_eq!(g.out_edges("A")[0].node(&g), "B");
971 assert_eq!(g.in_edges("B").len(), 1);
972 assert_eq!(g.in_edges("B")[0].node(&g), "A", "in_adj holds the source");
973
974 // The undirected view has to agree with itself: total degree is twice
975 // the edge weight total, which is the identity every undirected
976 // quantity in `algorithms` is derived from.
977 assert_eq!(g.degree("A") + g.degree("B"), 2);
978 assert_eq!(g.weighted_degree("A") + g.weighted_degree("B"), 1.0);
979 assert_eq!(g.total_weight(), 0.5);
980 }
981
982 #[test]
983 fn a_missing_node_has_no_edges_rather_than_panicking() {
984 let g = Subgraph::default();
985 assert!(g.out_edges("nobody").is_empty());
986 assert!(g.in_edges("nobody").is_empty());
987 assert_eq!(g.degree("nobody"), 0);
988 assert_eq!(g.weighted_degree("nobody"), 0.0);
989 }
990}