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}