macrame/graph/builder.rs
1use crate::error::Result;
2use crate::temporal::as_of::NodeAttributes;
3
4/// Attribute hydration mode for temporal traversals (§5.2).
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
6pub enum AttributeMode {
7 /// Live attributes from concepts table. Fast. Documented as WRONG for historical text.
8 Current,
9 /// Attributes as believed at ts, hydrated from transaction_log.
10 AtTime,
11 /// Topology only; concepts join is omitted.
12 ///
13 /// **Use [`TraversalBuilder::execute_ids`], not [`TraversalBuilder::execute`].**
14 /// `execute` returns `Vec<NodeAttributes>`, and there are no attributes to
15 /// return under this mode, so it answers `Ok(vec![])` — which a caller
16 /// cannot tell apart from a traversal that reached nothing. `execute_ids`
17 /// returns exactly what this mode is for, and distinguishes the two cases by
18 /// construction.
19 ///
20 /// Kept rather than removed (Wave 4.5) because it is meaningful where the
21 /// mode is a *parameter* — `hydrate_attributes` and `FilteredVectorSearch`
22 /// both take one and are right to accept "no attributes" as a choice. It is
23 /// only `execute`'s return type that cannot express it.
24 Omit,
25}
26
27/// Recursive CTE traversal query builder (§5.2).
28#[derive(Debug, Clone)]
29pub struct TraversalBuilder {
30 pub start_node: String,
31 pub max_depth: usize,
32 pub edge_types: Vec<String>,
33 pub min_weight: f64,
34 /// `None` means *defaulted*, not `Current` (T3.2, D-085).
35 ///
36 /// The distinction is the whole mechanism. `Current` chosen by a caller who
37 /// knows what it means is a legitimate, fast answer; `Current` arrived at by
38 /// never touching the setting, on a query about the past, is a wrong answer
39 /// nobody asked for. Those two produce identical behaviour and must not be
40 /// stored identically, so the field records which happened.
41 ///
42 /// Public for construction-by-struct-literal, which is why it is an `Option`
43 /// here rather than a private `bool` beside the mode: a caller building the
44 /// struct directly should have to write down the same thing the builder
45 /// method records.
46 pub attribute_mode: Option<AttributeMode>,
47 /// The valid-time instant to traverse at, if it is not the present.
48 ///
49 /// Added in 0.6.0 so "as of Tuesday" is *expressible*. Before this, the
50 /// instant arrived as `execute`'s `now_ts` parameter and a historical
51 /// traversal was indistinguishable from a live one — which is why the
52 /// mismatch with `AttributeMode::Current` could only ever be a `warn!`:
53 /// nothing in the call had the information needed to raise an error.
54 pub as_of: Option<String>,
55 /// Whether [`crate::Database::load_subgraph_with`] should fetch
56 /// `concepts.content` (0.8.0, B3, D-116).
57 ///
58 /// **Default `false`, which is a change in what a load returns.** No
59 /// algorithm reads document text, and at realistic document sizes it is
60 /// most of the byte budget, so the default was spending the budget on bytes
61 /// nothing would look at. A caller who needs it asks; one who does not gets
62 /// `NodeData::content() == None`, which is distinguishable from an empty
63 /// document.
64 ///
65 /// Ignored by [`crate::Database::load_subgraph`], which has no builder and
66 /// never loads content.
67 pub content: bool,
68}
69
70impl TraversalBuilder {
71 pub fn new(start_node: impl Into<String>) -> Self {
72 Self {
73 start_node: start_node.into(),
74 max_depth: 3,
75 edge_types: Vec::new(),
76 min_weight: 0.0,
77 attribute_mode: None,
78 as_of: None,
79 content: false,
80 }
81 }
82
83 pub fn max_depth(mut self, depth: usize) -> Self {
84 self.max_depth = depth;
85 self
86 }
87
88 pub fn edge_types(mut self, types: Vec<String>) -> Self {
89 self.edge_types = types;
90 self
91 }
92
93 pub fn min_weight(mut self, weight: f64) -> Self {
94 self.min_weight = weight;
95 self
96 }
97
98 /// State the attribute mode explicitly.
99 ///
100 /// Calling this is what turns `Current` from a default into a decision, and
101 /// [`Self::execute`] treats the two differently on a historical traversal —
102 /// see [`Self::as_of`].
103 pub fn attribute_mode(mut self, mode: AttributeMode) -> Self {
104 self.attribute_mode = Some(mode);
105 self
106 }
107
108 /// Fetch `concepts.content` into every hydrated node (0.8.0, B3, D-116).
109 ///
110 /// Off by default. Turning it on is what the byte budget is then spent on:
111 /// at 20 KB per concept, document text is the large majority of a loaded
112 /// graph, and none of the six algorithms reads it.
113 pub fn content(mut self, content: bool) -> Self {
114 self.content = content;
115 self
116 }
117
118 /// Traverse the graph as it was at `ts` rather than at the present (§5.2).
119 ///
120 /// # Setting this makes the attribute mode a required decision (T3.2, D-085)
121 ///
122 /// A historical traversal has two independent temporal questions, and until
123 /// 0.6.0 only one of them was asked. The topology comes from `ts`. The node
124 /// *attributes* — titles, content — come from wherever
125 /// [`AttributeMode`] says, and the default said `Current`, which is live
126 /// text. So `as_of(Tuesday)` returned Tuesday's graph wearing today's
127 /// titles, and reported that through a `tracing::warn!` — invisible in any
128 /// application that has not configured a subscriber, which is most of them
129 /// at first run.
130 ///
131 /// So: with `as_of` set and no [`Self::attribute_mode`] call,
132 /// [`Self::execute`] returns
133 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
134 /// rather than guessing. Both answers stay available and neither is silent:
135 ///
136 /// ```no_run
137 /// # use macrame::graph::{AttributeMode, TraversalBuilder};
138 /// # async fn f(conn: &libsql::Connection, now: &str) -> macrame::Result<()> {
139 /// // Tuesday's graph with Tuesday's titles — usually what was meant.
140 /// let then = TraversalBuilder::new("a")
141 /// .as_of("2026-01-06T00:00:00.000000Z")
142 /// .attribute_mode(AttributeMode::AtTime)
143 /// .execute(conn, now)
144 /// .await?;
145 ///
146 /// // Tuesday's topology with today's titles — legitimate, and now stated.
147 /// let mixed = TraversalBuilder::new("a")
148 /// .as_of("2026-01-06T00:00:00.000000Z")
149 /// .attribute_mode(AttributeMode::Current)
150 /// .execute(conn, now)
151 /// .await?;
152 /// # Ok(()) }
153 /// ```
154 ///
155 /// A traversal with no `as_of` is a query about now, where `Current` and
156 /// `AtTime` agree about which text to return, so the default stands and no
157 /// caller has to change.
158 pub fn as_of(mut self, ts: impl Into<String>) -> Self {
159 self.as_of = Some(ts.into());
160 self
161 }
162
163 /// The instant this traversal reads at: [`Self::as_of`] if set, else `now_ts`.
164 fn instant<'a>(&'a self, now_ts: &'a str) -> &'a str {
165 self.as_of.as_deref().unwrap_or(now_ts)
166 }
167
168 /// Compile the recursive CTE query string as specified in §5.2.
169 ///
170 /// Edge types become bind placeholders, not quoted literals. An earlier
171 /// version spliced them in with `format!("'{t}'")`, which made any caller
172 /// string a SQL fragment on the *read* path — and the only validation in the
173 /// crate, [`super::edge::validate_edge_type`], runs in
174 /// [`super::EdgeAssertion::normalized`] on the *write* path, so a traversal
175 /// never passed through it. Binding removes the question rather than
176 /// answering it: unlike a table name, an edge type is a value, and values
177 /// can be parameters.
178 pub fn build_sql(&self) -> String {
179 format!(
180 "{}{}",
181 self.walk_cte(),
182 r#"
183SELECT DISTINCT w.node_id
184FROM walk w JOIN concepts c ON c.id = w.node_id
185WHERE c.retired = 0
186ORDER BY w.node_id;
187 "#
188 )
189 }
190
191 /// The `AND l.edge_type IN (…)` fragment, or empty when unfiltered.
192 ///
193 /// `?1..?4` are start, depth, `now_ts` and `min_weight`, so edge types bind
194 /// from `?5`. Both call sites push them in the same order after those four,
195 /// which is why this lives beside the CTE rather than at either of them.
196 pub(crate) fn edge_filter_sql(&self) -> String {
197 if self.edge_types.is_empty() {
198 String::new()
199 } else {
200 let placeholders: Vec<String> = (0..self.edge_types.len())
201 .map(|i| format!("?{}", i + 5))
202 .collect();
203 format!(" AND l.edge_type IN ({})", placeholders.join(", "))
204 }
205 }
206
207 /// The recursive `walk` CTE — **the one copy** (T0.1).
208 ///
209 /// [`Self::build_sql`] and `Database::load_subgraph_with` append their own
210 /// projections to this. They previously carried byte-identical copies of the
211 /// recursion in two files, and had already drifted once: D-073 found the
212 /// subgraph loader taking neither `edge_types` nor `min_weight` while this
213 /// builder took both. Two copies of a query that must agree is the same
214 /// failure class as [D-030](../../docs/architecture/s13-decision-register.md)
215 /// and D-035, applied to SQL.
216 ///
217 /// **`UNION`, not `UNION ALL`, and no `path` column (T0.1).** The shipped
218 /// form carried a `path` of visited ids and refused a target already in it,
219 /// which restricts the walk to *simple paths* — so `walk` held one row per
220 /// distinct path to each node rather than one row per node, and the trailing
221 /// `SELECT DISTINCT` collapsed the duplication only after the work was done.
222 /// On a tree that costs nothing, because a tree has exactly one path to each
223 /// node; on a graph the row count is multiplicative in branching factor per
224 /// hop. Measured on libSQL 0.9.30 over a layered fixture (root, then *L*
225 /// layers of *W*, each fully joined to the next): a **328-edge** graph at
226 /// depth 6 produced **299,593** walk rows and took **428 ms**. The same
227 /// traversal here produces 49 rows in 0.1 ms.
228 ///
229 /// `UNION` dedupes on `(node_id, depth)` as rows enter the queue, so `walk`
230 /// is bounded by `V × (depth+1)` and termination comes from the depth bound
231 /// rather than from inspecting the path. The projections keep their
232 /// `DISTINCT`, because a node still legitimately appears at several depths.
233 ///
234 /// **Equivalence, argued rather than only measured.** The old form admits
235 /// only simple paths; this one admits any walk. The reachable sets are the
236 /// same: if a walk of length `k ≤ D` reaches `X`, excising its cycles yields
237 /// a simple path of length `≤ k` that also reaches `X`. So simple-path
238 /// reachability within `D` equals walk reachability within `D`, and the two
239 /// forms differed only in how much redundant work they did to establish it.
240 /// A property test over generated graphs — cycles, self-loops, diamonds and
241 /// expired edges, the four shapes the proof steps over — compares this form
242 /// against the old one at depths 1–4 and requires identical node *and* edge
243 /// sets (`integrity_property_tests`, 512 cases).
244 ///
245 /// **It is not free on a tree, and the plan that proposed it said it was.**
246 /// `UNION` maintains a dedupe b-tree over every row entering the queue; on a
247 /// tree nothing is ever deduped, so that is pure overhead. Measured on the
248 /// star-of-stars fixture at depth 3, best of 15, stable across runs:
249 /// 1,011 nodes 1.6 ms either way, 5,051 nodes 8.9 → 9.5 ms, 10,101 nodes
250 /// 17.8 → 19.6 ms — roughly **8–10% slower** where the old form was already
251 /// optimal, against ~2,000× faster where it was not. Recorded rather than
252 /// smoothed over: the trade is overwhelmingly worth taking and it is still a
253 /// trade, and "within noise" was a claim from a different engine's numbers.
254 pub(crate) fn walk_cte(&self) -> String {
255 let edge_filter = self.edge_filter_sql();
256 format!(
257 r#"
258WITH RECURSIVE walk(node_id, depth) AS (
259 SELECT ?1, 0
260 UNION
261 SELECT l.target_id, w.depth + 1
262 FROM walk w
263 JOIN links_current l ON l.source_id = w.node_id
264 WHERE w.depth < ?2
265 AND l.valid_from <= ?3 AND ?3 < l.valid_to
266 AND l.weight >= ?4
267 {edge_filter}
268)"#
269 )
270 }
271
272 /// Node ids reachable under this traversal, in id order (§5.2).
273 ///
274 /// Reads at [`Self::as_of`] when set, else at `now_ts`. No attribute mode is
275 /// involved, so this never returns
276 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated):
277 /// topology at an instant is unambiguous, and it is only the *pairing* with
278 /// live attributes that needed a decision.
279 pub async fn execute_ids(
280 &self,
281 conn: &libsql::Connection,
282 now_ts: &str,
283 ) -> Result<Vec<String>> {
284 let sql = self.build_sql();
285
286 let mut params: Vec<libsql::Value> = vec![
287 self.start_node.as_str().into(),
288 (self.max_depth as i64).into(),
289 self.instant(now_ts).into(),
290 self.min_weight.into(),
291 ];
292 params.extend(self.edge_types.iter().map(|t| t.as_str().into()));
293
294 let mut rows = conn.query(&sql, params).await?;
295 let mut ids = Vec::new();
296 while let Some(row) = rows.next().await? {
297 ids.push(row.get(0)?);
298 }
299 Ok(ids)
300 }
301
302 /// Execute the traversal and hydrate attributes per [`Self::attribute_mode`]
303 /// (§5.2).
304 ///
305 /// The hydration is a second step rather than a join in the CTE because the
306 /// three modes read from two different places: `Current` and `Omit` from
307 /// `concepts`, `AtTime` from `transaction_log`. The previous version always
308 /// emitted the `concepts` join, so `attribute_mode` was stored, exposed by a
309 /// builder method, and never read — a caller asking for `AtTime` got live
310 /// attributes with no indication that the mode had been ignored. That is the
311 /// exact failure Doctrine II exists to prevent, arriving as a silent wrong
312 /// answer rather than as an error.
313 ///
314 /// **[`AttributeMode::Omit`] returns `Ok(vec![])` here**, which is
315 /// indistinguishable from a traversal that reached nothing. That is a
316 /// limitation of this method's return type rather than of the mode; callers
317 /// wanting topology only should use [`Self::execute_ids`], which says what it
318 /// found.
319 ///
320 /// # Errors
321 ///
322 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
323 /// when [`Self::as_of`] is set and [`Self::attribute_mode`] is not — see
324 /// `as_of` for why that combination is a question rather than a default
325 /// (T3.2, D-085).
326 ///
327 /// `now_ts` is the caller's present. A traversal with `as_of` set reads
328 /// topology at that instant instead; `now_ts` is still what an
329 /// `AttributeMode::Current` hydrate means by "current".
330 pub async fn execute(
331 &self,
332 conn: &libsql::Connection,
333 now_ts: &str,
334 ) -> Result<Vec<NodeAttributes>> {
335 let mode = self.resolved_mode()?;
336 let instant = self.instant(now_ts);
337 let ids = self.execute_ids(conn, now_ts).await?;
338
339 // `Current` hydrates from `concepts`, which is live regardless of the
340 // instant, so the ts it receives only matters for `AtTime`. Passing the
341 // traversal's instant rather than `now_ts` is what makes
342 // `as_of(t) + AtTime` mean "as believed at t" — the whole point of the
343 // pairing this method now requires the caller to state.
344 crate::temporal::as_of::hydrate_attributes(conn, &ids, instant, mode).await
345 }
346
347 /// The mode to hydrate with, or the error that says the caller must choose.
348 ///
349 /// Kept separate from [`Self::execute`] so it is unit-testable without a
350 /// database: the property under test is a decision about two `Option`s, and
351 /// a test that needed a connection to check it would be testing something
352 /// else as well.
353 pub(crate) fn resolved_mode(&self) -> Result<AttributeMode> {
354 match (self.as_of.as_deref(), self.attribute_mode) {
355 (Some(as_of), None) => Err(crate::error::DbError::AttributeModeUnstated {
356 as_of: as_of.to_string(),
357 }),
358 (_, Some(mode)) => Ok(mode),
359 (None, None) => Ok(AttributeMode::Current),
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use crate::error::DbError;
368
369 const TUE: &str = "2026-01-06T00:00:00.000000Z";
370
371 /// The only combination that is a question, and it is now asked.
372 #[test]
373 fn as_of_without_a_stated_mode_is_an_error() {
374 let err = TraversalBuilder::new("a")
375 .as_of(TUE)
376 .resolved_mode()
377 .expect_err("past topology plus present text must not be a default");
378
379 match err {
380 DbError::AttributeModeUnstated { as_of } => assert_eq!(as_of, TUE),
381 other => panic!("got {other:?}"),
382 }
383
384 // And the message has to be actionable: a caller who reads only this
385 // should know which two calls resolve it.
386 let text = DbError::AttributeModeUnstated {
387 as_of: TUE.to_string(),
388 }
389 .to_string();
390 assert!(
391 text.contains("AtTime") && text.contains("Current"),
392 "{text}"
393 );
394 }
395
396 /// Stating `Current` on a historical traversal is legitimate and stays so.
397 ///
398 /// The fix must not be "forbid the fast path". Past topology with live text
399 /// is a real query — a caller rendering a historical diagram with today's
400 /// labels wants exactly it — and the objection was always to getting it
401 /// without asking, never to asking for it.
402 #[test]
403 fn a_stated_mode_is_honoured_on_a_historical_traversal() {
404 for mode in [
405 AttributeMode::Current,
406 AttributeMode::AtTime,
407 AttributeMode::Omit,
408 ] {
409 let got = TraversalBuilder::new("a")
410 .as_of(TUE)
411 .attribute_mode(mode)
412 .resolved_mode()
413 .unwrap();
414 assert_eq!(got, mode);
415 }
416 }
417
418 /// A traversal about now still defaults, so no existing caller changes.
419 ///
420 /// This is what keeps the change from being a breaking one for the common
421 /// case: with no `as_of`, `Current` and `AtTime` agree about which text to
422 /// return, so there is nothing to decide and nothing to ask.
423 #[test]
424 fn a_live_traversal_still_defaults_to_current() {
425 assert_eq!(
426 TraversalBuilder::new("a").resolved_mode().unwrap(),
427 AttributeMode::Current
428 );
429 }
430
431 /// `as_of` supplies the instant the walk reads at; `now_ts` is the fallback.
432 #[test]
433 fn as_of_overrides_the_execute_timestamp() {
434 let now = "2026-06-01T00:00:00.000000Z";
435 assert_eq!(TraversalBuilder::new("a").instant(now), now);
436 assert_eq!(TraversalBuilder::new("a").as_of(TUE).instant(now), TUE);
437 }
438}