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