Skip to main content

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    ///
159    /// # `ts` is read on two different clocks, and that is a defect (0.12.17, W5.6, D-160)
160    ///
161    /// [Doctrine VIII](../docs/architecture/s0-s3-foundations.md#doctrine-viii)
162    /// says `as_of(ts)` means **valid time under current belief** and returns
163    /// exactly that, and that queries mixing axes say so in their signatures.
164    /// This one does not. Precisely what the single `ts` is compared against
165    /// today:
166    ///
167    /// | half of the answer | the column `ts` is compared to | the axis that is |
168    /// |---|---|---|
169    /// | topology (which edges exist) | `links.valid_from` / `links.valid_to` | **valid time**, under current belief — Doctrine VIII's contract, met |
170    /// | attributes, [`AttributeMode::AtTime`] | `transaction_log.recorded_at` | **transaction time** — belief *as of* `ts`, which is `reconstruct`'s axis |
171    /// | attributes, [`AttributeMode::Current`] | nothing — `concepts WHERE retired = 0` | valid time **now**, belief now |
172    ///
173    /// So `as_of(t).attribute_mode(AtTime)` — the combination the example above
174    /// calls "usually what was meant" — answers **"the edges that were valid at
175    /// `t`, labelled with what we believed at `t`"**. Both halves are
176    /// defensible; the pairing is two questions under one timestamp, which is
177    /// the conflation [§3.1](../docs/architecture/s0-s3-foundations.md) names.
178    ///
179    /// **What that costs a caller, concretely.** Suppose a concept's title is
180    /// corrected today, fixing a typo made in 2020. `as_of("2020-06-01")` with
181    /// `AtTime` returns the **uncorrected** title, because the correction was
182    /// *recorded* after `ts`. That is the right answer to "what did we believe
183    /// in 2020" and the wrong one to "what was true in 2020", and `as_of`'s name
184    /// promises the second.
185    ///
186    /// A second, smaller mismatch in the same direction: `AtTime` hydration
187    /// filters on `recorded_at` and on the payload's `retired` flag, and never
188    /// consults the concept's **own valid interval**. A concept whose validity
189    /// had ended before `ts` still hydrates, so the two halves of the answer do
190    /// not even agree about what "existed at `ts`" means.
191    ///
192    /// **This is stated and not fixed, deliberately.** Changing it changes
193    /// answers callers already depend on, so it is a break and belongs to a
194    /// release that is allowed to make one — W7.1, in 0.14.0. Writing the
195    /// semantics down one release early is what makes that change reviewable
196    /// against a stated position instead of argued in the commit that makes it.
197    ///
198    /// Until then: if you want *belief as of `t`*, that operation exists and is
199    /// named for it — [`crate::temporal::reconstruct`]. If you want *what was
200    /// true at `t`, as best we now know*, no combination here gives it, because
201    /// no attribute mode reads concept attributes on the valid-time axis.
202    pub fn as_of(mut self, ts: impl Into<String>) -> Self {
203        self.as_of = Some(ts.into());
204        self
205    }
206
207    /// The instant this traversal reads at: [`Self::as_of`] if set, else `now_ts`.
208    fn instant<'a>(&'a self, now_ts: &'a str) -> &'a str {
209        self.as_of.as_deref().unwrap_or(now_ts)
210    }
211
212    /// Compile the recursive CTE query string as specified in §5.2.
213    ///
214    /// Edge types become bind placeholders, not quoted literals. An earlier
215    /// version spliced them in with `format!("'{t}'")`, which made any caller
216    /// string a SQL fragment on the *read* path — and the only validation in the
217    /// crate, [`super::edge::validate_edge_type`], runs in
218    /// [`super::EdgeAssertion::normalized`] on the *write* path, so a traversal
219    /// never passed through it. Binding removes the question rather than
220    /// answering it: unlike a table name, an edge type is a value, and values
221    /// can be parameters.
222    pub fn build_sql(&self) -> String {
223        format!(
224            "{}{}",
225            self.walk_cte(),
226            r#"
227SELECT DISTINCT w.node_id
228FROM walk w JOIN concepts c ON c.id = w.node_id
229WHERE c.retired = 0
230ORDER BY w.node_id;
231            "#
232        )
233    }
234
235    /// The `AND l.edge_type IN (…)` fragment, or empty when unfiltered.
236    ///
237    /// `?1..?4` are start, depth, `now_ts` and `min_weight`, so edge types bind
238    /// from `?5`. Both call sites push them in the same order after those four,
239    /// which is why this lives beside the CTE rather than at either of them.
240    pub(crate) fn edge_filter_sql(&self) -> String {
241        if self.edge_types.is_empty() {
242            String::new()
243        } else {
244            let placeholders: Vec<String> = (0..self.edge_types.len())
245                .map(|i| format!("?{}", i + 5))
246                .collect();
247            format!(" AND l.edge_type IN ({})", placeholders.join(", "))
248        }
249    }
250
251    /// The recursive `walk` CTE — **the one copy** (T0.1).
252    ///
253    /// [`Self::build_sql`] and `Database::load_subgraph_with` append their own
254    /// projections to this. They previously carried byte-identical copies of the
255    /// recursion in two files, and had already drifted once: D-073 found the
256    /// subgraph loader taking neither `edge_types` nor `min_weight` while this
257    /// builder took both. Two copies of a query that must agree is the same
258    /// failure class as [D-030](../../docs/architecture/s13-decision-register.md)
259    /// and D-035, applied to SQL.
260    ///
261    /// **`UNION`, not `UNION ALL`, and no `path` column (T0.1).** The shipped
262    /// form carried a `path` of visited ids and refused a target already in it,
263    /// which restricts the walk to *simple paths* — so `walk` held one row per
264    /// distinct path to each node rather than one row per node, and the trailing
265    /// `SELECT DISTINCT` collapsed the duplication only after the work was done.
266    /// On a tree that costs nothing, because a tree has exactly one path to each
267    /// node; on a graph the row count is multiplicative in branching factor per
268    /// hop. Measured on libSQL 0.9.30 over a layered fixture (root, then *L*
269    /// layers of *W*, each fully joined to the next): a **328-edge** graph at
270    /// depth 6 produced **299,593** walk rows and took **428 ms**. The same
271    /// traversal here produces 49 rows in 0.1 ms.
272    ///
273    /// `UNION` dedupes on `(node_id, depth)` as rows enter the queue, so `walk`
274    /// is bounded by `V × (depth+1)` and termination comes from the depth bound
275    /// rather than from inspecting the path. The projections keep their
276    /// `DISTINCT`, because a node still legitimately appears at several depths.
277    ///
278    /// **Equivalence, argued rather than only measured.** The old form admits
279    /// only simple paths; this one admits any walk. The reachable sets are the
280    /// same: if a walk of length `k ≤ D` reaches `X`, excising its cycles yields
281    /// a simple path of length `≤ k` that also reaches `X`. So simple-path
282    /// reachability within `D` equals walk reachability within `D`, and the two
283    /// forms differed only in how much redundant work they did to establish it.
284    /// A property test over generated graphs — cycles, self-loops, diamonds and
285    /// expired edges, the four shapes the proof steps over — compares this form
286    /// against the old one at depths 1–4 and requires identical node *and* edge
287    /// sets (`integrity_property_tests`, 512 cases).
288    ///
289    /// **It is not free on a tree, and the plan that proposed it said it was.**
290    /// `UNION` maintains a dedupe b-tree over every row entering the queue; on a
291    /// tree nothing is ever deduped, so that is pure overhead. Measured on the
292    /// star-of-stars fixture at depth 3, best of 15, stable across runs:
293    /// 1,011 nodes 1.6 ms either way, 5,051 nodes 8.9 → 9.5 ms, 10,101 nodes
294    /// 17.8 → 19.6 ms — roughly **8–10% slower** where the old form was already
295    /// optimal, against ~2,000× faster where it was not. Recorded rather than
296    /// smoothed over: the trade is overwhelmingly worth taking and it is still a
297    /// trade, and "within noise" was a claim from a different engine's numbers.
298    pub(crate) fn walk_cte(&self) -> String {
299        let edge_filter = self.edge_filter_sql();
300        format!(
301            r#"
302WITH RECURSIVE walk(node_id, depth) AS (
303    SELECT ?1, 0
304    UNION
305    SELECT l.target_id, w.depth + 1
306    FROM walk w
307    JOIN links_current l ON l.source_id = w.node_id
308    WHERE w.depth < ?2
309      AND l.valid_from <= ?3 AND ?3 < l.valid_to
310      AND l.weight >= ?4
311      {edge_filter}
312)"#
313        )
314    }
315
316    /// Node ids reachable under this traversal, in id order (§5.2).
317    ///
318    /// Reads at [`Self::as_of`] when set, else at `now_ts`. No attribute mode is
319    /// involved, so this never returns
320    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated):
321    /// topology at an instant is unambiguous, and it is only the *pairing* with
322    /// live attributes that needed a decision.
323    pub async fn execute_ids(
324        &self,
325        conn: &libsql::Connection,
326        now_ts: &str,
327    ) -> Result<Vec<String>> {
328        let sql = self.build_sql();
329
330        let mut params: Vec<libsql::Value> = vec![
331            self.start_node.as_str().into(),
332            (self.max_depth as i64).into(),
333            self.instant(now_ts).into(),
334            self.min_weight.into(),
335        ];
336        params.extend(self.edge_types.iter().map(|t| t.as_str().into()));
337
338        let mut rows = conn.query(&sql, params).await?;
339        let mut ids = Vec::new();
340        while let Some(row) = rows.next().await? {
341            ids.push(row.get(0)?);
342        }
343        Ok(ids)
344    }
345
346    /// Execute the traversal and hydrate attributes per [`Self::attribute_mode`]
347    /// (§5.2).
348    ///
349    /// The hydration is a second step rather than a join in the CTE because the
350    /// three modes read from two different places: `Current` and `Omit` from
351    /// `concepts`, `AtTime` from `transaction_log`. The previous version always
352    /// emitted the `concepts` join, so `attribute_mode` was stored, exposed by a
353    /// builder method, and never read — a caller asking for `AtTime` got live
354    /// attributes with no indication that the mode had been ignored. That is the
355    /// exact failure Doctrine II exists to prevent, arriving as a silent wrong
356    /// answer rather than as an error.
357    ///
358    /// **[`AttributeMode::Omit`] returns `Ok(vec![])` here**, which is
359    /// indistinguishable from a traversal that reached nothing. That is a
360    /// limitation of this method's return type rather than of the mode; callers
361    /// wanting topology only should use [`Self::execute_ids`], which says what it
362    /// found.
363    ///
364    /// # Errors
365    ///
366    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
367    /// when [`Self::as_of`] is set and [`Self::attribute_mode`] is not — see
368    /// `as_of` for why that combination is a question rather than a default
369    /// (T3.2, D-085).
370    ///
371    /// `now_ts` is the caller's present. A traversal with `as_of` set reads
372    /// topology at that instant instead; `now_ts` is still what an
373    /// `AttributeMode::Current` hydrate means by "current".
374    pub async fn execute(
375        &self,
376        conn: &libsql::Connection,
377        now_ts: &str,
378    ) -> Result<Vec<NodeAttributes>> {
379        let mode = self.resolved_mode()?;
380        let instant = self.instant(now_ts);
381        let ids = self.execute_ids(conn, now_ts).await?;
382
383        // `Current` hydrates from `concepts`, which is live regardless of the
384        // instant, so the ts it receives only matters for `AtTime`. Passing the
385        // traversal's instant rather than `now_ts` is what makes
386        // `as_of(t) + AtTime` mean "as believed at t" — the whole point of the
387        // pairing this method now requires the caller to state.
388        crate::temporal::as_of::hydrate_attributes(conn, &ids, instant, mode).await
389    }
390
391    /// The mode to hydrate with, or the error that says the caller must choose.
392    ///
393    /// Kept separate from [`Self::execute`] so it is unit-testable without a
394    /// database: the property under test is a decision about two `Option`s, and
395    /// a test that needed a connection to check it would be testing something
396    /// else as well.
397    pub(crate) fn resolved_mode(&self) -> Result<AttributeMode> {
398        match (self.as_of.as_deref(), self.attribute_mode) {
399            (Some(as_of), None) => Err(crate::error::DbError::AttributeModeUnstated {
400                as_of: as_of.to_string(),
401            }),
402            (_, Some(mode)) => Ok(mode),
403            (None, None) => Ok(AttributeMode::Current),
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::error::DbError;
412
413    const TUE: &str = "2026-01-06T00:00:00.000000Z";
414
415    /// The only combination that is a question, and it is now asked.
416    #[test]
417    fn as_of_without_a_stated_mode_is_an_error() {
418        let err = TraversalBuilder::new("a")
419            .as_of(TUE)
420            .resolved_mode()
421            .expect_err("past topology plus present text must not be a default");
422
423        match err {
424            DbError::AttributeModeUnstated { as_of } => assert_eq!(as_of, TUE),
425            other => panic!("got {other:?}"),
426        }
427
428        // And the message has to be actionable: a caller who reads only this
429        // should know which two calls resolve it.
430        let text = DbError::AttributeModeUnstated {
431            as_of: TUE.to_string(),
432        }
433        .to_string();
434        assert!(
435            text.contains("AtTime") && text.contains("Current"),
436            "{text}"
437        );
438    }
439
440    /// Stating `Current` on a historical traversal is legitimate and stays so.
441    ///
442    /// The fix must not be "forbid the fast path". Past topology with live text
443    /// is a real query — a caller rendering a historical diagram with today's
444    /// labels wants exactly it — and the objection was always to getting it
445    /// without asking, never to asking for it.
446    #[test]
447    fn a_stated_mode_is_honoured_on_a_historical_traversal() {
448        for mode in [
449            AttributeMode::Current,
450            AttributeMode::AtTime,
451            AttributeMode::Omit,
452        ] {
453            let got = TraversalBuilder::new("a")
454                .as_of(TUE)
455                .attribute_mode(mode)
456                .resolved_mode()
457                .unwrap();
458            assert_eq!(got, mode);
459        }
460    }
461
462    /// A traversal about now still defaults, so no existing caller changes.
463    ///
464    /// This is what keeps the change from being a breaking one for the common
465    /// case: with no `as_of`, `Current` and `AtTime` agree about which text to
466    /// return, so there is nothing to decide and nothing to ask.
467    #[test]
468    fn a_live_traversal_still_defaults_to_current() {
469        assert_eq!(
470            TraversalBuilder::new("a").resolved_mode().unwrap(),
471            AttributeMode::Current
472        );
473    }
474
475    /// `as_of` supplies the instant the walk reads at; `now_ts` is the fallback.
476    #[test]
477    fn as_of_overrides_the_execute_timestamp() {
478        let now = "2026-06-01T00:00:00.000000Z";
479        assert_eq!(TraversalBuilder::new("a").instant(now), now);
480        assert_eq!(TraversalBuilder::new("a").as_of(TUE).instant(now), TUE);
481    }
482}