Skip to main content

macrame/graph/
builder.rs

1use crate::error::{Result, StatedInstants};
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)]
6#[non_exhaustive]
7pub enum AttributeMode {
8    /// Live attributes from concepts table. Fast. Documented as WRONG for historical text.
9    Current,
10    /// Attributes as believed at ts, hydrated from transaction_log.
11    AtTime,
12    /// Topology only; concepts join is omitted.
13    ///
14    /// **Use [`TraversalBuilder::execute_ids`], not [`TraversalBuilder::execute`].**
15    /// `execute` returns `Vec<NodeAttributes>`, and there are no attributes to
16    /// return under this mode, so it answers `Ok(vec![])` — which a caller
17    /// cannot tell apart from a traversal that reached nothing. `execute_ids`
18    /// returns exactly what this mode is for, and distinguishes the two cases by
19    /// construction.
20    ///
21    /// Kept rather than removed (Wave 4.5) because it is meaningful where the
22    /// mode is a *parameter* — `hydrate_attributes` and `FilteredVectorSearch`
23    /// both take one and are right to accept "no attributes" as a choice. It is
24    /// only `execute`'s return type that cannot express it.
25    Omit,
26}
27
28/// Recursive CTE traversal query builder (§5.2).
29#[derive(Debug, Clone)]
30pub struct TraversalBuilder {
31    pub start_node: String,
32    pub max_depth: usize,
33    pub edge_types: Vec<String>,
34    pub min_weight: f64,
35    /// `None` means *defaulted*, not `Current` (T3.2, D-085).
36    ///
37    /// The distinction is the whole mechanism. `Current` chosen by a caller who
38    /// knows what it means is a legitimate, fast answer; `Current` arrived at by
39    /// never touching the setting, on a query about the past, is a wrong answer
40    /// nobody asked for. Those two produce identical behaviour and must not be
41    /// stored identically, so the field records which happened.
42    ///
43    /// Public for construction-by-struct-literal, which is why it is an `Option`
44    /// here rather than a private `bool` beside the mode: a caller building the
45    /// struct directly should have to write down the same thing the builder
46    /// method records.
47    pub attribute_mode: Option<AttributeMode>,
48    /// The **valid-time** instant to traverse at, if it is not the present.
49    ///
50    /// Added in 0.6.0 as `as_of` so "as of Tuesday" is *expressible*. Before
51    /// that, the instant arrived as `execute`'s `now_ts` parameter and a
52    /// historical traversal was indistinguishable from a live one — which is why
53    /// the mismatch with `AttributeMode::Current` could only ever be a `warn!`:
54    /// nothing in the call had the information needed to raise an error.
55    ///
56    /// **Renamed from `as_of` in 0.13.2 (W7.1, D-174).** The old name carried
57    /// one instant onto two clocks; the method's own docs are where that is
58    /// argued out.
59    pub as_of_valid: Option<String>,
60    /// The **transaction-time** instant to traverse at: *what did we believe
61    /// then* (0.13.2, W7.1, D-174).
62    ///
63    /// `None` — the default — means current belief, and the walk reads
64    /// `links_current` exactly as it always has. `Some(t)` folds
65    /// `transaction_log` to `t` instead, so the topology is the one the ledger
66    /// held at `t` rather than the one it holds now. See
67    /// [`Self::as_of_recorded`].
68    pub as_of_recorded: Option<String>,
69    /// Whether [`crate::Database::load_subgraph_with`] should fetch
70    /// `concepts.content` (0.8.0, B3, D-116).
71    ///
72    /// **Default `false`, which is a change in what a load returns.** No
73    /// algorithm reads document text, and at realistic document sizes it is
74    /// most of the byte budget, so the default was spending the budget on bytes
75    /// nothing would look at. A caller who needs it asks; one who does not gets
76    /// `NodeData::content() == None`, which is distinguishable from an empty
77    /// document.
78    ///
79    /// Ignored by [`crate::Database::load_subgraph`], which has no builder and
80    /// never loads content.
81    pub content: bool,
82}
83
84impl TraversalBuilder {
85    pub fn new(start_node: impl Into<String>) -> Self {
86        Self {
87            start_node: start_node.into(),
88            max_depth: 3,
89            edge_types: Vec::new(),
90            min_weight: 0.0,
91            attribute_mode: None,
92            as_of_valid: None,
93            as_of_recorded: None,
94            content: false,
95        }
96    }
97
98    pub fn max_depth(mut self, depth: usize) -> Self {
99        self.max_depth = depth;
100        self
101    }
102
103    pub fn edge_types(mut self, types: Vec<String>) -> Self {
104        self.edge_types = types;
105        self
106    }
107
108    pub fn min_weight(mut self, weight: f64) -> Self {
109        self.min_weight = weight;
110        self
111    }
112
113    /// State the attribute mode explicitly.
114    ///
115    /// Calling this is what turns `Current` from a default into a decision, and
116    /// [`Self::execute`] treats the two differently on a historical traversal —
117    /// see [`Self::as_of_valid`].
118    pub fn attribute_mode(mut self, mode: AttributeMode) -> Self {
119        self.attribute_mode = Some(mode);
120        self
121    }
122
123    /// Fetch `concepts.content` into every hydrated node (0.8.0, B3, D-116).
124    ///
125    /// Off by default. Turning it on is what the byte budget is then spent on:
126    /// at 20 KB per concept, document text is the large majority of a loaded
127    /// graph, and none of the six algorithms reads it.
128    pub fn content(mut self, content: bool) -> Self {
129        self.content = content;
130        self
131    }
132
133    /// Traverse the graph as it was **in the world** at `ts` — the valid-time
134    /// axis (§5.2, W7.1).
135    ///
136    /// # This was `as_of`, and the rename is the fix (0.13.2, W7.1, D-174)
137    ///
138    /// [Doctrine VIII](../docs/architecture/s0-s3-foundations.md#doctrine-viii)
139    /// says a query that mixes the two clocks says so in its signature. `as_of`
140    /// did not: one timestamp reached `links.valid_from`/`valid_to` on the
141    /// **valid-time** axis and `transaction_log.recorded_at` on the
142    /// **transaction-time** axis, so `as_of(t).attribute_mode(AtTime)` answered
143    /// *"the edges valid at `t`, labelled with what we believed at `t`"* — two
144    /// questions under one word. [§3.1](../docs/architecture/s0-s3-foundations.md)
145    /// named it; 0.12.17 (W5.6, D-160) wrote the semantics down without changing
146    /// them, precisely so this change could be reviewed against a stated
147    /// position; this is that change.
148    ///
149    /// The two axes are now two parameters, and they compose:
150    ///
151    /// | set | topology comes from | attributes come from |
152    /// |---|---|---|
153    /// | neither | `links_current`, at `now_ts` | live `concepts` |
154    /// | `as_of_valid(v)` | `links_current`, bounded at `v` | `concepts` valid at `v` |
155    /// | `as_of_recorded(r)` | `transaction_log` folded to `r`, bounded at `now_ts` | the payload believed at `r` |
156    /// | both | folded to `r`, bounded at `v` | believed at `r`, valid at `v` |
157    ///
158    /// The last row is the cell Jensen and Snodgrass's BCDM defines a bitemporal
159    /// database as answering — *what did we believe at `r` about what was true at
160    /// `v`* — and before this it was not expressible on any surface in the crate.
161    ///
162    /// # Setting either instant makes the attribute mode a required decision (T3.2, D-085)
163    ///
164    /// A historical traversal has two independent questions and until 0.6.0 only
165    /// one of them was asked. The topology comes from the instants. The node
166    /// *attributes* — titles, content — come from wherever [`AttributeMode`]
167    /// says, and the default said `Current`, which is live text. So a historical
168    /// traversal returned the past's graph wearing today's titles, and reported
169    /// that through a `tracing::warn!` — invisible in any application that has
170    /// not configured a subscriber, which is most of them at first run.
171    ///
172    /// So: with either instant set and no [`Self::attribute_mode`] call,
173    /// [`Self::execute`] returns
174    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
175    /// rather than guessing. Both answers stay available and neither is silent:
176    ///
177    /// ```no_run
178    /// # use macrame::graph::{AttributeMode, TraversalBuilder};
179    /// # async fn f(conn: &libsql::Connection, now: &str) -> macrame::Result<()> {
180    /// // What was true on Tuesday, as best we now know.
181    /// let then = TraversalBuilder::new("a")
182    ///     .as_of_valid("2026-01-06T00:00:00.000000Z")
183    ///     .attribute_mode(AttributeMode::AtTime)
184    ///     .execute(conn, now)
185    ///     .await?;
186    ///
187    /// // Tuesday's topology with today's titles — legitimate, and now stated.
188    /// let mixed = TraversalBuilder::new("a")
189    ///     .as_of_valid("2026-01-06T00:00:00.000000Z")
190    ///     .attribute_mode(AttributeMode::Current)
191    ///     .execute(conn, now)
192    ///     .await?;
193    /// # Ok(()) }
194    /// ```
195    ///
196    /// A traversal with neither instant is a query about now, where `Current`
197    /// and `AtTime` agree about which text to return, so the default stands and
198    /// no caller has to change.
199    ///
200    /// # What the rename buys, concretely
201    ///
202    /// Suppose a concept's title is corrected today, fixing a typo made in 2020.
203    /// Under the old `as_of("2020-06-01")` with `AtTime` the answer was the
204    /// **uncorrected** title, because the correction was *recorded* after `ts` —
205    /// the right answer to *what did we believe in 2020* and the wrong one to
206    /// *what was true in 2020*, which is what the name promised. Now
207    /// `as_of_valid("2020-06-01")` alone gives the corrected title,
208    /// `as_of_recorded("2020-06-01")` gives the uncorrected one, and a caller
209    /// asking for either says which.
210    ///
211    /// The second, smaller mismatch W5.6 recorded is closed by the same change:
212    /// `AtTime` hydration consulted the payload's `retired` flag and never the
213    /// concept's **own valid interval**, so a concept whose validity had ended
214    /// still hydrated. It is now bounded by whichever instants are set, so the
215    /// two halves of the answer agree about what "existed then" means.
216    pub fn as_of_valid(mut self, ts: impl Into<String>) -> Self {
217        self.as_of_valid = Some(ts.into());
218        self
219    }
220
221    /// Traverse the graph as the ledger **believed** it at `ts` — the
222    /// transaction-time axis (0.13.2, W7.1, D-174).
223    ///
224    /// Where [`Self::as_of_valid`] asks *what was true*, this asks *what did we
225    /// think was true*. Setting it moves the walk off `links_current` and onto a
226    /// fold of `transaction_log` bounded at `ts` — the same fold
227    /// [`crate::temporal::reconstruct`] performs. That operation still exists
228    /// and still returns the whole state; this makes the same instant reachable
229    /// from a *traversal*, which is what lets the two axes be set on one query.
230    ///
231    /// # This reads the hot log, and refuses rather than guessing
232    ///
233    /// A fold can only answer for instants the hot log still covers.
234    /// [`crate::Database::archive`] removes superseded rows, so an instant below
235    /// what remains is not *before history*, it is *history that is in the other
236    /// file* — and this surface takes a connection, not an archive path, so it
237    /// cannot go and get it. It returns
238    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
239    /// naming the instant and pointing at `reconstruct`, which does take the
240    /// path. Answering from a partial fold would return *nearly* the right
241    /// topology, which is the worst failure available to a ledger.
242    ///
243    /// # Cost, stated rather than discovered
244    ///
245    /// `links_current` is a projection maintained for exactly this read and
246    /// indexed for it (`idx_lc_traversal_cover`). The fold is a window function
247    /// over `transaction_log` with a `json_extract` per column, materialised
248    /// once per query and joined per hop. It is not the fast path and is not
249    /// meant to be. W10.6 measures it and decides whether anything should be
250    /// built for it.
251    pub fn as_of_recorded(mut self, ts: impl Into<String>) -> Self {
252        self.as_of_recorded = Some(ts.into());
253        self
254    }
255
256    /// The valid-time instant this traversal reads at: [`Self::as_of_valid`] if
257    /// set, else `now_ts`.
258    ///
259    /// Note the asymmetry with [`Self::as_of_recorded`], which has no `now_ts`
260    /// fallback. An unset transaction-time instant means *current belief*, and
261    /// current belief is `links_current` rather than a fold bounded at the
262    /// present: the two are the same answer and only one of them is cheap.
263    pub(crate) fn valid_instant<'a>(&'a self, now_ts: &'a str) -> &'a str {
264        self.as_of_valid.as_deref().unwrap_or(now_ts)
265    }
266
267    /// The instant pair this traversal reads at, for the hydration layer.
268    pub(crate) fn instants(&self, now_ts: &str) -> crate::temporal::as_of::AsOf {
269        crate::temporal::as_of::AsOf {
270            valid: Some(self.valid_instant(now_ts).to_string()),
271            recorded: self.as_of_recorded.clone(),
272        }
273    }
274
275    /// Compile the recursive CTE query string as specified in §5.2.
276    ///
277    /// Edge types become bind placeholders, not quoted literals. An earlier
278    /// version spliced them in with `format!("'{t}'")`, which made any caller
279    /// string a SQL fragment on the *read* path — and the only validation in the
280    /// crate, [`super::edge::validate_edge_type`], runs in
281    /// [`super::EdgeAssertion::normalized`] on the *write* path, so a traversal
282    /// never passed through it. Binding removes the question rather than
283    /// answering it: unlike a table name, an edge type is a value, and values
284    /// can be parameters.
285    pub fn build_sql(&self) -> String {
286        format!(
287            "{}{}",
288            self.walk_cte(),
289            r#"
290SELECT DISTINCT w.node_id
291FROM walk w JOIN concepts c ON c.id = w.node_id
292WHERE c.retired = 0
293ORDER BY w.node_id;
294            "#
295        )
296    }
297
298    /// Where edge types start binding: `?5`, or `?6` when the walk carries a
299    /// transaction-time instant (0.13.2, W7.1).
300    ///
301    /// `?1..?4` are start, depth, the valid instant and `min_weight`. A traversal
302    /// with [`Self::as_of_recorded`] set binds it at `?5` and pushes the variadic
303    /// edge types along by one.
304    ///
305    /// **This exists so the offset is computed once rather than agreed twice.**
306    /// [`Self::bind_params`] and [`Self::edge_filter_sql`] are the only two
307    /// places that care, they must agree exactly, and the previous arrangement —
308    /// a hard-coded `5` in one file and a comment in the other saying both call
309    /// sites push in the same order — is the shape D-030 and D-035 are about.
310    pub(crate) fn edge_type_base(&self) -> usize {
311        if self.as_of_recorded.is_some() {
312            6
313        } else {
314            5
315        }
316    }
317
318    /// The `AND l.edge_type IN (…)` fragment, or empty when unfiltered.
319    ///
320    /// Placeholders start at [`Self::edge_type_base`]. Bound, never spliced: an
321    /// edge type is caller data, and the crate's only validation of one runs on
322    /// the *write* path (D-039), so a traversal never passes through it.
323    pub(crate) fn edge_filter_sql(&self) -> String {
324        if self.edge_types.is_empty() {
325            String::new()
326        } else {
327            let base = self.edge_type_base();
328            let placeholders: Vec<String> = (0..self.edge_types.len())
329                .map(|i| format!("?{}", i + base))
330                .collect();
331            format!(" AND l.edge_type IN ({})", placeholders.join(", "))
332        }
333    }
334
335    /// Every parameter the walk and its projections bind, in placeholder order.
336    ///
337    /// One producer for both consumers ([`Self::execute_ids`] and
338    /// `Database::load_subgraph_with`), for the reason [`Self::edge_type_base`]
339    /// gives: they previously agreed by comment, and one of them had already
340    /// drifted — the subgraph loader bound `now_ts` at `?3` where the builder
341    /// bound the traversal's own instant, so **a historical `load_subgraph_with`
342    /// silently read the present** (F-35, W7.1).
343    pub(crate) fn bind_params(&self, now_ts: &str) -> Vec<libsql::Value> {
344        let mut params: Vec<libsql::Value> = vec![
345            self.start_node.as_str().into(),
346            (self.max_depth as i64).into(),
347            self.valid_instant(now_ts).into(),
348            self.min_weight.into(),
349        ];
350        if let Some(recorded) = self.as_of_recorded.as_deref() {
351            params.push(recorded.into());
352        }
353        params.extend(self.edge_types.iter().map(|t| t.as_str().into()));
354        params
355    }
356
357    /// The relation the walk and the projections read edges from.
358    ///
359    /// `links_current` under current belief; the `links_at_tx` fold otherwise.
360    /// Both expose the same six columns under the same names, which is what lets
361    /// the rest of the SQL be written once.
362    pub(crate) fn link_source(&self) -> &'static str {
363        if self.as_of_recorded.is_some() {
364            "links_at_tx"
365        } else {
366            "links_current"
367        }
368    }
369
370    /// `links_current` as the ledger believed it at `?5`, or empty (W7.1, D-174).
371    ///
372    /// `links_current` is a *projection of current belief*: the sync trigger
373    /// upserts each corrected edge over its predecessor, so the row that was
374    /// there before a correction is not in the table any more. It is in
375    /// `transaction_log`, because links are strictly append-only — every
376    /// assertion and every correction is an `INSERT`, each logged `'I'` with
377    /// `entity_id = source|target|type|valid_from` — so the last log row per
378    /// entity at or before `?5` *is* what `links_current` held at `?5`.
379    ///
380    /// **Partitioning on `entity_id` alone is sound here only because
381    /// `table_name = 'links'` is already in the `WHERE`.** The discriminator is
382    /// applied by the filter instead of by the partition, so the concept/link
383    /// collision defect W is about cannot arise. The four folds in `replay.rs`
384    /// carry it in the partition instead; the difference is deliberate and
385    /// stated so it does not read as an oversight — the same note
386    /// `as_of::hydrate_at_time` carries, for the same reason.
387    ///
388    /// There is no `'D'` arm because there are no link deletes:
389    /// `trg_links_guard_delete` refuses them outside an archive session, and an
390    /// archive session removes the *log rows* rather than logging a removal.
391    fn links_at_tx_cte(&self) -> String {
392        if self.as_of_recorded.is_none() {
393            return String::new();
394        }
395        r#"links_at_tx(source_id, target_id, edge_type, valid_from, valid_to, weight) AS (
396    SELECT json_extract(payload, '$.source_id'),
397           json_extract(payload, '$.target_id'),
398           json_extract(payload, '$.edge_type'),
399           json_extract(payload, '$.valid_from'),
400           json_extract(payload, '$.valid_to'),
401           json_extract(payload, '$.weight')
402    FROM (
403        SELECT payload,
404               ROW_NUMBER() OVER (PARTITION BY entity_id ORDER BY seq_id DESC) AS rn
405        FROM transaction_log
406        WHERE table_name = 'links' AND recorded_at <= ?5
407    ) WHERE rn = 1
408),
409"#
410        .to_string()
411    }
412
413    /// Refuse a transaction-time instant the hot log can no longer answer for.
414    ///
415    /// See [`Self::as_of_recorded`]. Cheap enough to run unconditionally on the
416    /// folded path — two aggregates over an indexed column — and it only runs
417    /// there, so the ordinary traversal pays nothing.
418    pub(crate) async fn check_recorded_reach(&self, conn: &libsql::Connection) -> Result<()> {
419        let Some(ts) = self.as_of_recorded.as_deref() else {
420            return Ok(());
421        };
422        if crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
423            return Ok(());
424        }
425        Err(crate::error::DbError::RecordedInstantUnreachable { ts: ts.to_string() })
426    }
427
428    /// The recursive `walk` CTE — **the one copy** (T0.1).
429    ///
430    /// [`Self::build_sql`] and `Database::load_subgraph_with` append their own
431    /// projections to this. They previously carried byte-identical copies of the
432    /// recursion in two files, and had already drifted once: D-073 found the
433    /// subgraph loader taking neither `edge_types` nor `min_weight` while this
434    /// builder took both. Two copies of a query that must agree is the same
435    /// failure class as [D-030](../../docs/architecture/s13-decision-register.md)
436    /// and D-035, applied to SQL.
437    ///
438    /// **`UNION`, not `UNION ALL`, and no `path` column (T0.1).** The shipped
439    /// form carried a `path` of visited ids and refused a target already in it,
440    /// which restricts the walk to *simple paths* — so `walk` held one row per
441    /// distinct path to each node rather than one row per node, and the trailing
442    /// `SELECT DISTINCT` collapsed the duplication only after the work was done.
443    /// On a tree that costs nothing, because a tree has exactly one path to each
444    /// node; on a graph the row count is multiplicative in branching factor per
445    /// hop. Measured on libSQL 0.9.30 over a layered fixture (root, then *L*
446    /// layers of *W*, each fully joined to the next): a **328-edge** graph at
447    /// depth 6 produced **299,593** walk rows and took **428 ms**. The same
448    /// traversal here produces 49 rows in 0.1 ms.
449    ///
450    /// `UNION` dedupes on `(node_id, depth)` as rows enter the queue, so `walk`
451    /// is bounded by `V × (depth+1)` and termination comes from the depth bound
452    /// rather than from inspecting the path. The projections keep their
453    /// `DISTINCT`, because a node still legitimately appears at several depths.
454    ///
455    /// **Equivalence, argued rather than only measured.** The old form admits
456    /// only simple paths; this one admits any walk. The reachable sets are the
457    /// same: if a walk of length `k ≤ D` reaches `X`, excising its cycles yields
458    /// a simple path of length `≤ k` that also reaches `X`. So simple-path
459    /// reachability within `D` equals walk reachability within `D`, and the two
460    /// forms differed only in how much redundant work they did to establish it.
461    /// A property test over generated graphs — cycles, self-loops, diamonds and
462    /// expired edges, the four shapes the proof steps over — compares this form
463    /// against the old one at depths 1–4 and requires identical node *and* edge
464    /// sets (`integrity_property_tests`, 512 cases).
465    ///
466    /// **It is not free on a tree, and the plan that proposed it said it was.**
467    /// `UNION` maintains a dedupe b-tree over every row entering the queue; on a
468    /// tree nothing is ever deduped, so that is pure overhead. Measured on the
469    /// star-of-stars fixture at depth 3, best of 15, stable across runs:
470    /// 1,011 nodes 1.6 ms either way, 5,051 nodes 8.9 → 9.5 ms, 10,101 nodes
471    /// 17.8 → 19.6 ms — roughly **8–10% slower** where the old form was already
472    /// optimal, against ~2,000× faster where it was not. Recorded rather than
473    /// smoothed over: the trade is overwhelmingly worth taking and it is still a
474    /// trade, and "within noise" was a claim from a different engine's numbers.
475    pub(crate) fn walk_cte(&self) -> String {
476        let edge_filter = self.edge_filter_sql();
477        let fold = self.links_at_tx_cte();
478        let source = self.link_source();
479        format!(
480            r#"
481WITH RECURSIVE {fold}walk(node_id, depth) AS (
482    SELECT ?1, 0
483    UNION
484    SELECT l.target_id, w.depth + 1
485    FROM walk w
486    JOIN {source} l ON l.source_id = w.node_id
487    WHERE w.depth < ?2
488      AND l.valid_from <= ?3 AND ?3 < l.valid_to
489      AND l.weight >= ?4
490      {edge_filter}
491)"#
492        )
493    }
494
495    /// Node ids reachable under this traversal, in id order (§5.2).
496    ///
497    /// Reads at [`Self::as_of_valid`] when set, else at `now_ts`, and under the
498    /// belief [`Self::as_of_recorded`] names when set, else current belief. No
499    /// attribute mode is involved, so this never returns
500    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated):
501    /// topology at an instant is unambiguous, and it is only the *pairing* with
502    /// live attributes that needed a decision.
503    ///
504    /// # Errors
505    ///
506    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
507    /// when [`Self::as_of_recorded`] is below what the hot log still covers.
508    pub async fn execute_ids(
509        &self,
510        conn: &libsql::Connection,
511        now_ts: &str,
512    ) -> Result<Vec<String>> {
513        self.check_recorded_reach(conn).await?;
514        let sql = self.build_sql();
515        let params = self.bind_params(now_ts);
516
517        let mut rows = conn.query(&sql, params).await?;
518        let mut ids = Vec::new();
519        while let Some(row) = rows.next().await? {
520            ids.push(row.get(0)?);
521        }
522        Ok(ids)
523    }
524
525    /// Execute the traversal and hydrate attributes per [`Self::attribute_mode`]
526    /// (§5.2).
527    ///
528    /// The hydration is a second step rather than a join in the CTE because the
529    /// three modes read from two different places: `Current` and `Omit` from
530    /// `concepts`, `AtTime` from `transaction_log`. The previous version always
531    /// emitted the `concepts` join, so `attribute_mode` was stored, exposed by a
532    /// builder method, and never read — a caller asking for `AtTime` got live
533    /// attributes with no indication that the mode had been ignored. That is the
534    /// exact failure Doctrine II exists to prevent, arriving as a silent wrong
535    /// answer rather than as an error.
536    ///
537    /// **[`AttributeMode::Omit`] returns `Ok(vec![])` here**, which is
538    /// indistinguishable from a traversal that reached nothing. That is a
539    /// limitation of this method's return type rather than of the mode; callers
540    /// wanting topology only should use [`Self::execute_ids`], which says what it
541    /// found.
542    ///
543    /// # Errors
544    ///
545    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
546    /// when either instant is set and [`Self::attribute_mode`] is not — see
547    /// [`Self::as_of_valid`] for why that combination is a question rather than
548    /// a default (T3.2, D-085).
549    ///
550    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
551    /// when [`Self::as_of_recorded`] is below what the hot log still covers.
552    ///
553    /// `now_ts` is the caller's present, and it is the fallback on *both* axes:
554    /// a traversal with neither instant set reads live topology and live text.
555    pub async fn execute(
556        &self,
557        conn: &libsql::Connection,
558        now_ts: &str,
559    ) -> Result<Vec<NodeAttributes>> {
560        let mode = self.resolved_mode()?;
561        let as_of = self.instants(now_ts);
562        let ids = self.execute_ids(conn, now_ts).await?;
563
564        // `Current` hydrates from `concepts` live and ignores both instants, so
565        // the pair it receives only matters for `AtTime`. Passing the traversal's
566        // own instants rather than `now_ts` is what makes a historical traversal
567        // with `AtTime` mean what it says — the whole point of the pairing this
568        // method requires the caller to state.
569        crate::temporal::as_of::hydrate_attributes(conn, &ids, &as_of, mode).await
570    }
571
572    /// The mode to hydrate with, or the error that says the caller must choose.
573    ///
574    /// Kept separate from [`Self::execute`] so it is unit-testable without a
575    /// database: the property under test is a decision about two `Option`s, and
576    /// a test that needed a connection to check it would be testing something
577    /// else as well.
578    pub(crate) fn resolved_mode(&self) -> Result<AttributeMode> {
579        // Either instant makes the question live, and the error names *which*
580        // (0.13.10, W7.7, D-183). This was an `.or()` picking valid time first,
581        // which answered the caller with an axis they might not have asked
582        // about and dropped the other one when they had asked about both.
583        let instants =
584            StatedInstants::new(self.as_of_valid.as_deref(), self.as_of_recorded.as_deref());
585        match (instants, self.attribute_mode) {
586            (Some(instants), None) => {
587                Err(crate::error::DbError::AttributeModeUnstated { instants })
588            }
589            (_, Some(mode)) => Ok(mode),
590            (None, None) => Ok(AttributeMode::Current),
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::error::DbError;
599
600    const TUE: &str = "2026-01-06T00:00:00.000000Z";
601
602    /// The only combination that is a question, and it is now asked.
603    #[test]
604    fn as_of_without_a_stated_mode_is_an_error() {
605        let err = TraversalBuilder::new("a")
606            .as_of_valid(TUE)
607            .resolved_mode()
608            .expect_err("past topology plus present text must not be a default");
609
610        match &err {
611            DbError::AttributeModeUnstated { instants } => {
612                assert_eq!(instants.valid(), Some(TUE));
613                assert_eq!(instants.recorded(), None, "no belief instant was set");
614            }
615            other => panic!("got {other:?}"),
616        }
617
618        // And the message has to be actionable: a caller who reads only this
619        // should know which axis they asked about and which two calls resolve
620        // it. `as_of(…)` named a method that has not existed since 0.12.17.
621        let text = err.to_string();
622        assert!(
623            text.contains(&format!("as_of_valid({TUE})")),
624            "the message must name the call the caller made: {text}"
625        );
626        assert!(
627            text.contains("AtTime") && text.contains("Current"),
628            "{text}"
629        );
630    }
631
632    /// Stating `Current` on a historical traversal is legitimate and stays so.
633    ///
634    /// The fix must not be "forbid the fast path". Past topology with live text
635    /// is a real query — a caller rendering a historical diagram with today's
636    /// labels wants exactly it — and the objection was always to getting it
637    /// without asking, never to asking for it.
638    #[test]
639    fn a_stated_mode_is_honoured_on_a_historical_traversal() {
640        for mode in [
641            AttributeMode::Current,
642            AttributeMode::AtTime,
643            AttributeMode::Omit,
644        ] {
645            let got = TraversalBuilder::new("a")
646                .as_of_valid(TUE)
647                .attribute_mode(mode)
648                .resolved_mode()
649                .unwrap();
650            assert_eq!(got, mode);
651        }
652    }
653
654    /// The transaction-time instant raises the same question the valid-time one
655    /// does, and asking it on only one axis would be the same gap in a new place.
656    #[test]
657    fn a_recorded_instant_also_demands_a_stated_mode() {
658        let err = TraversalBuilder::new("a")
659            .as_of_recorded(TUE)
660            .resolved_mode()
661            .expect_err("past belief plus present text must not be a default");
662        assert!(
663            matches!(err, DbError::AttributeModeUnstated { .. }),
664            "{err:?}"
665        );
666        // The axis reaches the caller. Until 0.13.10 this said `as_of(…)`,
667        // which is the valid-time method's old name and not what was called.
668        let text = err.to_string();
669        assert!(text.contains(&format!("as_of_recorded({TUE})")), "{text}");
670        assert!(!text.contains("as_of("), "no dead method name: {text}");
671    }
672
673    /// Both axes set is the bitemporal cell, and dropping half of it was the
674    /// second half of the defect: the `.or()` reported valid time and said
675    /// nothing about the belief instant the caller had also stated.
676    #[test]
677    fn both_instants_are_reported_when_both_were_stated() {
678        const WED: &str = "2026-01-07T00:00:00.000000Z";
679        let err = TraversalBuilder::new("a")
680            .as_of_valid(TUE)
681            .as_of_recorded(WED)
682            .resolved_mode()
683            .expect_err("the cell needs a stated mode as much as either axis");
684
685        match &err {
686            DbError::AttributeModeUnstated { instants } => {
687                assert_eq!(instants.valid(), Some(TUE));
688                assert_eq!(instants.recorded(), Some(WED));
689            }
690            other => panic!("got {other:?}"),
691        }
692        let text = err.to_string();
693        assert!(text.contains(TUE) && text.contains(WED), "{text}");
694    }
695
696    /// A traversal about now still defaults, so no existing caller changes.
697    ///
698    /// This is what keeps the change from being a breaking one for the common
699    /// case: with neither instant, `Current` and `AtTime` agree about which text
700    /// to return, so there is nothing to decide and nothing to ask.
701    #[test]
702    fn a_live_traversal_still_defaults_to_current() {
703        assert_eq!(
704            TraversalBuilder::new("a").resolved_mode().unwrap(),
705            AttributeMode::Current
706        );
707    }
708
709    /// `as_of_valid` supplies the instant the walk reads at; `now_ts` is the
710    /// fallback, and `as_of_recorded` never is — see `valid_instant`.
711    #[test]
712    fn as_of_valid_overrides_the_execute_timestamp() {
713        let now = "2026-06-01T00:00:00.000000Z";
714        assert_eq!(TraversalBuilder::new("a").valid_instant(now), now);
715        assert_eq!(
716            TraversalBuilder::new("a")
717                .as_of_valid(TUE)
718                .valid_instant(now),
719            TUE
720        );
721        assert_eq!(
722            TraversalBuilder::new("a")
723                .as_of_recorded(TUE)
724                .valid_instant(now),
725            now,
726            "fixing belief must not move the valid-time instant"
727        );
728    }
729
730    /// The two axes reach the hydration layer separately (W7.1, D-174).
731    ///
732    /// The property that made the old single parameter wrong was that one
733    /// instant arrived on both clocks. This asserts the negation directly, at the
734    /// boundary where the split has to survive: what `execute` hands to
735    /// `hydrate_attributes`.
736    #[test]
737    fn the_two_axes_reach_hydration_separately() {
738        let now = "2026-06-01T00:00:00.000000Z";
739        let mar = "2026-03-01T00:00:00.000000Z";
740
741        let live = TraversalBuilder::new("a").instants(now);
742        assert_eq!(live.valid.as_deref(), Some(now));
743        assert_eq!(live.recorded, None, "no instant means current belief");
744
745        let valid_only = TraversalBuilder::new("a").as_of_valid(TUE).instants(now);
746        assert_eq!(valid_only.valid.as_deref(), Some(TUE));
747        assert_eq!(valid_only.recorded, None);
748
749        let recorded_only = TraversalBuilder::new("a").as_of_recorded(mar).instants(now);
750        assert_eq!(
751            recorded_only.valid.as_deref(),
752            Some(now),
753            "fixing belief leaves valid time at the present"
754        );
755        assert_eq!(recorded_only.recorded.as_deref(), Some(mar));
756
757        let both = TraversalBuilder::new("a")
758            .as_of_valid(TUE)
759            .as_of_recorded(mar)
760            .instants(now);
761        assert_eq!(both.valid.as_deref(), Some(TUE));
762        assert_eq!(both.recorded.as_deref(), Some(mar));
763    }
764
765    /// Placeholder arithmetic is the one thing two call sites must agree on.
766    ///
767    /// `bind_params` and `edge_filter_sql` are separate functions that have to
768    /// produce the same layout, and the recorded instant shifts it. A test that
769    /// counts is cheaper than the bug, which is an edge type silently compared
770    /// against a timestamp.
771    #[test]
772    fn the_recorded_instant_shifts_the_edge_type_placeholders() {
773        let now = "2026-06-01T00:00:00.000000Z";
774
775        let plain = TraversalBuilder::new("a").edge_types(vec!["CITES".into()]);
776        assert_eq!(plain.edge_type_base(), 5);
777        assert!(
778            plain.edge_filter_sql().contains("?5"),
779            "{}",
780            plain.edge_filter_sql()
781        );
782        assert_eq!(plain.bind_params(now).len(), 5);
783
784        let folded = plain.clone().as_of_recorded(TUE);
785        assert_eq!(folded.edge_type_base(), 6);
786        assert!(
787            folded.edge_filter_sql().contains("?6"),
788            "{}",
789            folded.edge_filter_sql()
790        );
791        assert_eq!(folded.bind_params(now).len(), 6);
792    }
793
794    /// The fold replaces the projection, and only when it is asked for.
795    #[test]
796    fn the_link_source_follows_the_recorded_instant() {
797        let plain = TraversalBuilder::new("a");
798        assert_eq!(plain.link_source(), "links_current");
799        assert!(!plain.walk_cte().contains("transaction_log"));
800
801        let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
802        assert_eq!(folded.link_source(), "links_at_tx");
803        let sql = folded.walk_cte();
804        assert!(sql.contains("links_at_tx"), "{sql}");
805        assert!(sql.contains("recorded_at <= ?5"), "{sql}");
806        assert!(
807            sql.contains("table_name = 'links'"),
808            "the partition is only sound with the discriminator filtered: {sql}"
809        );
810    }
811}