macrame/graph/builder.rs
1use crate::error::{Result, StatedInstants};
2use crate::graph::lineage::{ancestry_params, resolve_for, Ancestor, LineageShape};
3use crate::graph::plan::{lower, Resolution};
4use crate::schema::ddl;
5use crate::temporal::as_of::NodeAttributes;
6
7/// Attribute hydration mode for temporal traversals (§5.2).
8#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[non_exhaustive]
10pub enum AttributeMode {
11 /// Live attributes from concepts table. Fast. Documented as WRONG for historical text.
12 Current,
13 /// Attributes as believed at ts, hydrated from transaction_log.
14 AtTime,
15 /// Topology only; concepts join is omitted.
16 ///
17 /// **Use [`TraversalBuilder::execute_ids`], not [`TraversalBuilder::execute`].**
18 /// `execute` returns `Vec<NodeAttributes>`, and there are no attributes to
19 /// return under this mode, so it answers `Ok(vec![])` — which a caller
20 /// cannot tell apart from a traversal that reached nothing. `execute_ids`
21 /// returns exactly what this mode is for, and distinguishes the two cases by
22 /// construction.
23 ///
24 /// Kept rather than removed (Wave 4.5) because it is meaningful where the
25 /// mode is a *parameter* — `hydrate_attributes` and `FilteredVectorSearch`
26 /// both take one and are right to accept "no attributes" as a choice. It is
27 /// only `execute`'s return type that cannot express it.
28 Omit,
29}
30
31/// Why a limited walk stopped (0.15.10, W13.5, C-8).
32///
33/// A list of ids cannot answer this. [`TraversalBuilder::limit`] bounds the
34/// walk's rows, and the projection then drops rows again — one node can enter
35/// the walk at two depths, and a retired concept is filtered after the walk has
36/// already spent the budget on it — so a limit of 100 can return 87 ids whether
37/// the graph held 87 or 87,000. That is the shape Doctrine II refuses to leave
38/// to a comment, and it is why [`TraversalBuilder::limit`] arrives with
39/// [`TraversalBuilder::execute_ids_explained`] rather than alone.
40///
41/// The answer is the walk's own row count, taken in the same statement, and it
42/// is exact rather than inferred: `LimitReached` means the walk produced the
43/// number of rows it was allowed and stopped, not that the id count happened to
44/// reach the ceiling.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum WalkOutcome {
48 /// The walk ran out of graph: the depth bound or the frontier stopped it,
49 /// and the ids are every node the traversal describes.
50 Complete,
51 /// The walk ran out of budget. More of the graph satisfies the traversal
52 /// than came back, and what came back is the near end of it — the walk's
53 /// queue is breadth-first, so a limit drops the farthest nodes first.
54 LimitReached,
55}
56
57impl WalkOutcome {
58 /// Whether the ceiling bit. Named like
59 /// [`CandidateCount::is_capped`](crate::graph::CandidateCount::is_capped),
60 /// which is the same question one layer up.
61 pub fn hit_limit(self) -> bool {
62 matches!(self, Self::LimitReached)
63 }
64}
65
66/// Recursive CTE traversal query builder (§5.2).
67///
68/// `#[non_exhaustive]` since 0.15.13 (W15.3, [C-11],
69/// [D-255](../../docs/architecture/s13-decision-register.md#d-255)), which for
70/// this struct is the attribute alone: every field below already had a setter,
71/// and [`Self::new`] is the only entry a caller ever used. The fields stay
72/// `pub` and stay readable — what is gone is the literal, and with it the
73/// property that the eleventh field breaks whoever wrote the first ten.
74///
75/// [C-11]: ../../docs/Macrame%20Update%20Plan%20v0.16.0.md
76#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub struct TraversalBuilder {
79 pub start_node: String,
80 pub max_depth: usize,
81 /// A ceiling on the rows the walk may produce, if the caller set one
82 /// (0.15.10, W13.5, C-8).
83 ///
84 /// `None` — the default — walks until [`Self::max_depth`] and the frontier
85 /// stop it. `Some(n)` emits `LIMIT ?n` **inside** the recursive CTE, which
86 /// is the placement that bounds work; [`Self::limit()`] carries the
87 /// measurement that decides it and the contract that follows.
88 ///
89 /// Set through [`Self::limit()`].
90 pub limit: Option<usize>,
91 pub edge_types: Vec<String>,
92 pub min_weight: f64,
93 /// `None` means *defaulted*, not `Current` (T3.2, D-085).
94 ///
95 /// The distinction is the whole mechanism. `Current` chosen by a caller who
96 /// knows what it means is a legitimate, fast answer; `Current` arrived at by
97 /// never touching the setting, on a query about the past, is a wrong answer
98 /// nobody asked for. Those two produce identical behaviour and must not be
99 /// stored identically, so the field records which happened.
100 ///
101 /// The `Option` outlived the reason it was one. It was public for
102 /// construction-by-struct-literal — a caller building the struct directly
103 /// should have to write down the same thing the setter records — and
104 /// 0.15.13 took the literal away. It stays an `Option` because the
105 /// distinction above is the mechanism and a private `bool` beside the mode
106 /// would say the same thing in two places; what is gone is the argument
107 /// that a *caller* needed to see it.
108 pub attribute_mode: Option<AttributeMode>,
109 /// The **valid-time** instant to traverse at, if it is not the present.
110 ///
111 /// Added in 0.6.0 as `as_of` so "as of Tuesday" is *expressible*. Before
112 /// that, the instant arrived as `execute`'s `now_ts` parameter and a
113 /// historical traversal was indistinguishable from a live one — which is why
114 /// the mismatch with `AttributeMode::Current` could only ever be a `warn!`:
115 /// nothing in the call had the information needed to raise an error.
116 ///
117 /// **Renamed from `as_of` in 0.13.2 (W7.1, D-174).** The old name carried
118 /// one instant onto two clocks; the method's own docs are where that is
119 /// argued out.
120 pub as_of_valid: Option<String>,
121 /// The **transaction-time** instant to traverse at: *what did we believe
122 /// then* (0.13.2, W7.1, D-174).
123 ///
124 /// `None` — the default — means current belief, and the walk reads
125 /// `links_current` exactly as it always has. `Some(t)` folds
126 /// `transaction_log` to `t` instead, so the topology is the one the ledger
127 /// held at `t` rather than the one it holds now. See
128 /// [`Self::as_of_recorded`].
129 pub as_of_recorded: Option<String>,
130 /// The lineage this traversal reads (§15.3, D-220).
131 ///
132 /// `None` is what every traversal written before v12 meant and what every
133 /// database without a fork still holds: the trunk. `Some(id)` reads that
134 /// branch's belief — one row per edge key, taken from the **nearest**
135 /// branch on the path from it to the root, so a branch that corrects or
136 /// retires an inherited edge is seen to have done so.
137 ///
138 /// **An unregistered lineage is refused rather than defaulted**, by
139 /// `graph::lineage::resolve_for`. Answering it
140 /// with the trunk's view is the answer a caller is least able to detect,
141 /// because on a database that has never forked it is the answer they
142 /// expected anyway.
143 ///
144 /// Set through [`Self::on_branch`].
145 pub branch: Option<String>,
146 /// Whether [`crate::Database::load_subgraph_with`] should fetch
147 /// `concepts.content` (0.8.0, B3, D-116).
148 ///
149 /// **Default `false`, which is a change in what a load returns.** No
150 /// algorithm reads document text, and at realistic document sizes it is
151 /// most of the byte budget, so the default was spending the budget on bytes
152 /// nothing would look at. A caller who needs it asks; one who does not gets
153 /// `NodeData::content() == None`, which is distinguishable from an empty
154 /// document.
155 ///
156 /// Ignored by [`crate::Database::load_subgraph`], which has no builder and
157 /// never loads content.
158 pub content: bool,
159}
160
161impl TraversalBuilder {
162 pub fn new(start_node: impl Into<String>) -> Self {
163 Self {
164 start_node: start_node.into(),
165 max_depth: 3,
166 limit: None,
167 edge_types: Vec::new(),
168 min_weight: 0.0,
169 attribute_mode: None,
170 as_of_valid: None,
171 as_of_recorded: None,
172 branch: None,
173 content: false,
174 }
175 }
176
177 /// Read one lineage's belief rather than the trunk's (§15.3, D-220).
178 ///
179 /// When this shipped, the only way to put a second lineage into a database
180 /// was raw SQL. The read went first because it is the half that had to be
181 /// measured (D-219), and because a write that creates something unreadable
182 /// is the worse order to ship the two halves in. `fork()` arrived at
183 /// **0.14.7** — this comment said 0.14.5 until 0.14.9, which is a stale
184 /// prediction rather than a record, and the kind D-223 and D-224 were both
185 /// found by reading. [`BranchView::traversal`](crate::BranchView::traversal)
186 /// seeds this from a lineage the caller already holds.
187 pub fn on_branch(mut self, branch: impl Into<String>) -> Self {
188 self.branch = Some(branch.into());
189 self
190 }
191
192 /// Take every read qualifier from one [`ReadPlan`](crate::ReadPlan) (0.15.9, W13.4,
193 /// [D-251]).
194 ///
195 /// Exactly [`Self::on_branch`], [`Self::as_of_valid`],
196 /// [`Self::as_of_recorded`] and [`Self::limit()`] applied in turn, and **a
197 /// `None` field unsets what was there** rather than leaving it.
198 ///
199 /// It was three qualifiers when this shipped and is four since 0.15.10
200 /// ([D-252]), which is the change the wording is deliberately no longer
201 /// counting: a plan is whatever a read is qualified by, and this method's
202 /// contract is that it applies all of it. That is the whole reason to
203 /// prefer a plan to three calls: a plan is the read, so applying one
204 /// answers what the read is instead of amending what it was. A caller who
205 /// wants to amend has the three setters and they are not going anywhere —
206 /// this release was additive on purpose so a caller pinned to `0.15` got
207 /// the plan without being broken by it. **W15.3 decided their fate: they
208 /// stay** (0.15.13, [C-11],
209 /// [D-255](../../docs/architecture/s13-decision-register.md#d-255)). C-11
210 /// asked for `#[non_exhaustive]` and a setter per field; this struct had
211 /// the setters already, and a plan that replaces does not make an amend
212 /// that is spelled out wrong.
213 ///
214 /// The round trip is exact in both directions: `b.plan(p).read_plan() == p`
215 /// for every plan, and `b.plan(b.read_plan())` leaves `b` alone.
216 ///
217 /// [C-11]: ../../docs/Macrame%20Update%20Plan%20v0.16.0.md
218 /// [D-251]: ../../docs/architecture/s13-decision-register.md#d-251
219 /// [D-252]: ../../docs/architecture/s13-decision-register.md#d-252
220 pub fn plan(mut self, plan: crate::plan::ReadPlan) -> Self {
221 self.branch = plan.branch.map(|b| b.as_str().to_string());
222 self.as_of_valid = plan.valid;
223 self.as_of_recorded = plan.recorded;
224 self.limit = plan.limit;
225 self
226 }
227
228 /// What this traversal's read qualifiers say, as a [`ReadPlan`](crate::ReadPlan).
229 ///
230 /// The inverse of [`Self::plan`], and the reason the pair is worth having
231 /// over a one-way setter: a caller can take the qualifiers off a traversal
232 /// they were handed and give the *same read* to
233 /// [`Database::edges`](crate::Database::edges), or to a second traversal
234 /// from a different start node, without restating the fields and without
235 /// the restatement being the place they drift.
236 ///
237 /// # Errors
238 ///
239 /// [`DbError::InvalidBranchId`](crate::DbError::InvalidBranchId) when the
240 /// name in [`Self::branch`] is not one. This builder takes its lineage as
241 /// a `String` and validates it nowhere — `Lineages::shape` refuses an
242 /// unregistered name at read time, which is a different question — so the
243 /// conversion to [`BranchId`](crate::BranchId) is where an unconstructible
244 /// name is finally noticed. Every branch that exists in a database passed
245 /// through `BranchId::new` to get there, so this fails only for a name
246 /// that could not have been read anyway.
247 pub fn read_plan(&self) -> Result<crate::plan::ReadPlan> {
248 let mut plan = crate::plan::ReadPlan::new();
249 if let Some(name) = self.branch.as_deref() {
250 plan = plan.on(crate::branch::BranchId::new(name)?);
251 }
252 plan.valid = self.as_of_valid.clone();
253 plan.recorded = self.as_of_recorded.clone();
254 plan.limit = self.limit;
255 Ok(plan)
256 }
257
258 pub fn max_depth(mut self, depth: usize) -> Self {
259 self.max_depth = depth;
260 self
261 }
262
263 /// Stop the walk once `n` rows have entered it (0.15.10, W13.5, C-8).
264 ///
265 /// # Why this is not a `LIMIT` on the projection
266 ///
267 /// C-8 is that `FilteredVectorSearch::probe_cap` "bounds memory, not work":
268 /// it ran the whole traversal and then truncated the tail, so a name that
269 /// reads as a ceiling on cost was a ceiling on the size of the answer. The
270 /// obvious repair — `LIMIT` on the statement's outer `SELECT` — repeats the
271 /// defect one line further down, because that projection sorts, and a sort
272 /// materialises the whole walk before the limit can apply. **Measured on
273 /// the same hub graph, counting edges visited by a walk of 20,050:**
274 ///
275 /// ```text
276 /// no limit 20,050 edges
277 /// LIMIT 20 on the outer SELECT 20,050 edges
278 /// LIMIT 20 inside the recursive CTE 7,250 edges
279 /// LIMIT 5 inside the recursive CTE 1,250 edges
280 /// ```
281 ///
282 /// So the limit goes inside the CTE, where SQLite's recursion halts as soon
283 /// as the recursive table reaches it. Work is then bounded by the fan-out of
284 /// the first `n` rows *taken out of the queue*, which is why the saving is
285 /// proportional rather than absolute: at `LIMIT 200` the same graph still
286 /// visits every edge, because expanding 51 rows already costs all of them.
287 /// A limit buys nothing until it is smaller than the expensive frontier.
288 ///
289 /// # What `n` counts, and what comes back
290 ///
291 /// `n` counts **walk rows**, not answers. The walk holds `(node_id, depth)`
292 /// and dedupes on the pair, so a node reachable at two depths spends two of
293 /// them; the projection then drops retired concepts. The result is
294 /// therefore **at most `n` ids**, and fewer than `n` does not mean the graph
295 /// was smaller. [`Self::execute_ids_explained`] is where that is answered —
296 /// exactly, from the walk's own row count — and it is the reason this method
297 /// did not ship alone.
298 ///
299 /// The subset is the near end. SQLite's recursive queue is FIFO, so the walk
300 /// is breadth-first and a limit drops the farthest nodes first. Among nodes
301 /// at the same depth the cut is arbitrary, which is the one thing
302 /// [`Self::max_depth`] — the crate's other stated bound — does not do.
303 ///
304 /// # Every surface that runs the walk honours it
305 ///
306 /// [`Self::execute`] and
307 /// [`Database::load_subgraph_with`](crate::Database::load_subgraph_with)
308 /// splice the same CTE, so a limit set here bounds those too, and neither
309 /// return type can report having been cut short. That is deliberate rather
310 /// than overlooked: a subgraph's own bound is `byte_budget`, which
311 /// *refuses* with
312 /// [`DbError::SubgraphTooLarge`](crate::DbError::SubgraphTooLarge) rather
313 /// than truncating, and a caller who wants a bounded walk and needs to know
314 /// whether the bound bit asks [`Self::execute_ids_explained`] first.
315 pub fn limit(mut self, n: usize) -> Self {
316 self.limit = Some(n);
317 self
318 }
319
320 pub fn edge_types(mut self, types: Vec<String>) -> Self {
321 self.edge_types = types;
322 self
323 }
324
325 pub fn min_weight(mut self, weight: f64) -> Self {
326 self.min_weight = weight;
327 self
328 }
329
330 /// State the attribute mode explicitly.
331 ///
332 /// Calling this is what turns `Current` from a default into a decision, and
333 /// [`Self::execute`] treats the two differently on a historical traversal —
334 /// see [`Self::as_of_valid`].
335 pub fn attribute_mode(mut self, mode: AttributeMode) -> Self {
336 self.attribute_mode = Some(mode);
337 self
338 }
339
340 /// Fetch `concepts.content` into every hydrated node (0.8.0, B3, D-116).
341 ///
342 /// Off by default. Turning it on is what the byte budget is then spent on:
343 /// at 20 KB per concept, document text is the large majority of a loaded
344 /// graph, and none of the six algorithms reads it.
345 pub fn content(mut self, content: bool) -> Self {
346 self.content = content;
347 self
348 }
349
350 /// Traverse the graph as it was **in the world** at `ts` — the valid-time
351 /// axis (§5.2, W7.1).
352 ///
353 /// # This was `as_of`, and the rename is the fix (0.13.2, W7.1, D-174)
354 ///
355 /// [Doctrine VIII](../docs/architecture/s0-s3-foundations.md#doctrine-viii)
356 /// says a query that mixes the two clocks says so in its signature. `as_of`
357 /// did not: one timestamp reached `links.valid_from`/`valid_to` on the
358 /// **valid-time** axis and `transaction_log.recorded_at` on the
359 /// **transaction-time** axis, so `as_of(t).attribute_mode(AtTime)` answered
360 /// *"the edges valid at `t`, labelled with what we believed at `t`"* — two
361 /// questions under one word. [§3.1](../docs/architecture/s0-s3-foundations.md)
362 /// named it; 0.12.17 (W5.6, D-160) wrote the semantics down without changing
363 /// them, precisely so this change could be reviewed against a stated
364 /// position; this is that change.
365 ///
366 /// The two axes are now two parameters, and they compose:
367 ///
368 /// | set | topology comes from | attributes come from |
369 /// |---|---|---|
370 /// | neither | `links_current`, at `now_ts` | live `concepts` |
371 /// | `as_of_valid(v)` | `links_current`, bounded at `v` | `concepts` valid at `v` |
372 /// | `as_of_recorded(r)` | `transaction_log` folded to `r`, bounded at `now_ts` | the payload believed at `r` |
373 /// | both | folded to `r`, bounded at `v` | believed at `r`, valid at `v` |
374 ///
375 /// The last row is the cell Jensen and Snodgrass's BCDM defines a bitemporal
376 /// database as answering — *what did we believe at `r` about what was true at
377 /// `v`* — and before this it was not expressible on any surface in the crate.
378 ///
379 /// # Setting either instant makes the attribute mode a required decision (T3.2, D-085)
380 ///
381 /// A historical traversal has two independent questions and until 0.6.0 only
382 /// one of them was asked. The topology comes from the instants. The node
383 /// *attributes* — titles, content — come from wherever [`AttributeMode`]
384 /// says, and the default said `Current`, which is live text. So a historical
385 /// traversal returned the past's graph wearing today's titles, and reported
386 /// that through a `tracing::warn!` — invisible in any application that has
387 /// not configured a subscriber, which is most of them at first run.
388 ///
389 /// So: with either instant set and no [`Self::attribute_mode`] call,
390 /// [`Self::execute`] returns
391 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
392 /// rather than guessing. Both answers stay available and neither is silent:
393 ///
394 /// ```no_run
395 /// # use macrame::graph::{AttributeMode, TraversalBuilder};
396 /// # async fn f(conn: &libsql::Connection, now: &str) -> macrame::Result<()> {
397 /// // What was true on Tuesday, as best we now know.
398 /// let then = TraversalBuilder::new("a")
399 /// .as_of_valid("2026-01-06T00:00:00.000000Z")
400 /// .attribute_mode(AttributeMode::AtTime)
401 /// .execute(conn, now)
402 /// .await?;
403 ///
404 /// // Tuesday's topology with today's titles — legitimate, and now stated.
405 /// let mixed = TraversalBuilder::new("a")
406 /// .as_of_valid("2026-01-06T00:00:00.000000Z")
407 /// .attribute_mode(AttributeMode::Current)
408 /// .execute(conn, now)
409 /// .await?;
410 /// # Ok(()) }
411 /// ```
412 ///
413 /// A traversal with neither instant is a query about now, where `Current`
414 /// and `AtTime` agree about which text to return, so the default stands and
415 /// no caller has to change.
416 ///
417 /// # What the rename buys, concretely
418 ///
419 /// Suppose a concept's title is corrected today, fixing a typo made in 2020.
420 /// Under the old `as_of("2020-06-01")` with `AtTime` the answer was the
421 /// **uncorrected** title, because the correction was *recorded* after `ts` —
422 /// the right answer to *what did we believe in 2020* and the wrong one to
423 /// *what was true in 2020*, which is what the name promised. Now
424 /// `as_of_valid("2020-06-01")` alone gives the corrected title,
425 /// `as_of_recorded("2020-06-01")` gives the uncorrected one, and a caller
426 /// asking for either says which.
427 ///
428 /// The second, smaller mismatch W5.6 recorded is closed by the same change:
429 /// `AtTime` hydration consulted the payload's `retired` flag and never the
430 /// concept's **own valid interval**, so a concept whose validity had ended
431 /// still hydrated. It is now bounded by whichever instants are set, so the
432 /// two halves of the answer agree about what "existed then" means.
433 pub fn as_of_valid(mut self, ts: impl Into<String>) -> Self {
434 self.as_of_valid = Some(ts.into());
435 self
436 }
437
438 /// Traverse the graph as the ledger **believed** it at `ts` — the
439 /// transaction-time axis (0.13.2, W7.1, D-174).
440 ///
441 /// Where [`Self::as_of_valid`] asks *what was true*, this asks *what did we
442 /// think was true*. Setting it moves the walk off `links_current` and onto a
443 /// fold of `transaction_log` bounded at `ts` — the same fold
444 /// [`crate::temporal::reconstruct`] performs. That operation still exists
445 /// and still returns the whole state; this makes the same instant reachable
446 /// from a *traversal*, which is what lets the two axes be set on one query.
447 ///
448 /// # This reads the hot log, and refuses rather than guessing
449 ///
450 /// A fold can only answer for instants the hot log still covers.
451 /// [`crate::Database::archive`] removes superseded rows, so an instant below
452 /// what remains is not *before history*, it is *history that is in the other
453 /// file* — and this surface takes a connection, not an archive path, so it
454 /// cannot go and get it. It returns
455 /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
456 /// naming the instant and pointing at `reconstruct`, which does take the
457 /// path. Answering from a partial fold would return *nearly* the right
458 /// topology, which is the worst failure available to a ledger.
459 ///
460 /// **The refusal is scoped to the instants the archive actually took**
461 /// (0.15.4, W14.2, D-246). The newest row per entity is never archivable, so
462 /// an instant at or after the newest stamp still in the log — `now`
463 /// included — folds completely and is answered. Through 0.15.3 the guard
464 /// discarded the instant and refused everything on any archived database.
465 ///
466 /// # Cost, stated rather than discovered
467 ///
468 /// `links_current` is a projection maintained for exactly this read and
469 /// indexed for it (`idx_lc_traversal_cover`). The fold is a window function
470 /// over `transaction_log` with a `json_extract` per column, materialised
471 /// once per query and joined per hop. It is not the fast path and is not
472 /// meant to be. W10.6 measures it and decides whether anything should be
473 /// built for it.
474 pub fn as_of_recorded(mut self, ts: impl Into<String>) -> Self {
475 self.as_of_recorded = Some(ts.into());
476 self
477 }
478
479 /// The valid-time instant this traversal reads at: [`Self::as_of_valid`] if
480 /// set, else `now_ts`.
481 ///
482 /// Note the asymmetry with [`Self::as_of_recorded`], which has no `now_ts`
483 /// fallback. An unset transaction-time instant means *current belief*, and
484 /// current belief is `links_current` rather than a fold bounded at the
485 /// present: the two are the same answer and only one of them is cheap.
486 pub(crate) fn valid_instant<'a>(&'a self, now_ts: &'a str) -> &'a str {
487 self.as_of_valid.as_deref().unwrap_or(now_ts)
488 }
489
490 /// The instant pair this traversal reads at, for the hydration layer.
491 pub(crate) fn instants(&self, now_ts: &str) -> crate::temporal::as_of::AsOf {
492 crate::temporal::as_of::AsOf {
493 valid: Some(self.valid_instant(now_ts).to_string()),
494 recorded: self.as_of_recorded.clone(),
495 }
496 }
497
498 /// Compile the recursive CTE query string as specified in §5.2.
499 ///
500 /// Edge types become bind placeholders, not quoted literals. An earlier
501 /// version spliced them in with `format!("'{t}'")`, which made any caller
502 /// string a SQL fragment on the *read* path — and the only validation in the
503 /// crate, [`super::edge::validate_edge_type`], runs in
504 /// [`super::EdgeAssertion::normalized`] on the *write* path, so a traversal
505 /// never passed through it. Binding removes the question rather than
506 /// answering it: unlike a table name, an edge type is a value, and values
507 /// can be parameters.
508 /// # This function cannot ask the database, and the shape depends on it
509 ///
510 /// It emits the resolved form when a branch is named and the trunk form
511 /// when one is not, which is the shape this
512 /// builder's own configuration implies. That is exact on every database
513 /// holding a single lineage — which is every database this crate has
514 /// written — and it is *not* exact on a forked one, where an unbranched
515 /// traversal reads the ancestor's and the descendant's rows alike.
516 ///
517 /// The execution paths do not have that gap: [`Self::execute_ids`],
518 /// [`Self::execute`] and `Database::load_subgraph_with` each ask
519 /// `graph::lineage::resolve_for` and pass the answer to the
520 /// shape-taking form of this method. This method stays for inspecting and
521 /// explaining the query — which is what its callers in `tests/` do — and
522 /// says so rather than quietly returning the shape that is usually right.
523 pub fn build_sql(&self) -> String {
524 self.build_sql_with(self.implied_shape(), &self.implied_ancestry())
525 }
526
527 /// The ancestry [`Self::build_sql`] assumes when nobody has asked the
528 /// database (0.15.17).
529 ///
530 /// One row: the named lineage itself, at `dist` 0 with no cutoff. That is
531 /// the true ancestry of a *root*, and it is the only one derivable without
532 /// reading `branches` — the parents and their fork points are exactly what
533 /// this method has no access to.
534 ///
535 /// It is the same bargain [`Self::implied_shape`] already made, extended to
536 /// the relation that shape emits: the SQL stays **valid and explainable**,
537 /// and its `lineage` table is a floor rather than the answer. A traversal
538 /// run through it would see the branch's own rows and none of what it
539 /// inherits. Every execution path resolves against the database instead,
540 /// which is what [`Self::build_sql`]'s own docs already say and this does
541 /// not change.
542 pub(crate) fn implied_ancestry(&self) -> Vec<Ancestor> {
543 match self.branch.as_deref() {
544 Some(b) => vec![Ancestor {
545 branch_id: b.to_string(),
546 dist: 0,
547 cutoff: None,
548 }],
549 None => Vec::new(),
550 }
551 }
552
553 /// The shape [`Self::build_sql`] assumes when nobody has asked the database.
554 pub(crate) fn implied_shape(&self) -> LineageShape {
555 if self.branch.is_some() {
556 LineageShape::Resolved
557 } else {
558 LineageShape::Trunk
559 }
560 }
561
562 /// [`Self::build_sql`] against a shape the caller has already established.
563 pub(crate) fn build_sql_with(&self, shape: LineageShape, ancestry: &[Ancestor]) -> String {
564 if self.limit.is_some() {
565 return format!(
566 "{}{}",
567 self.walk_cte(shape, ancestry),
568 Self::LIMITED_PROJECTION
569 );
570 }
571 format!(
572 "{}{}",
573 self.walk_cte(shape, ancestry),
574 r#"
575SELECT DISTINCT w.node_id
576FROM walk w JOIN concepts c ON c.id = w.node_id
577WHERE c.retired = 0
578ORDER BY w.node_id;
579 "#
580 )
581 }
582
583 /// The projection a limited walk uses, and why it is anchored on the count.
584 ///
585 /// [`WalkOutcome`] needs the walk's own row count, and the obvious way to
586 /// get it — a second column `(SELECT COUNT(*) FROM walk)` beside the id —
587 /// is free (the recursive CTE is materialised once; measured at identical
588 /// edge counts with and without it) and **unreadable in the one case that
589 /// matters most**: when every reached concept is retired the projection
590 /// returns no rows at all, so the walk hit its ceiling and nothing can say
591 /// so.
592 ///
593 /// So the count is the anchor and the ids are `LEFT JOIN`ed onto it. There
594 /// is always exactly one count row; `node_id` is `NULL` when the walk
595 /// reached nothing the projection kept, and
596 /// [`Self::execute_ids_explained`] skips those. Measured on the same hub
597 /// graph as [`Self::limit`]: identical edges visited, and the count present
598 /// in the all-retired case where the scalar-column form returned zero rows.
599 ///
600 /// The unlimited form is untouched and stays byte-identical, which is why
601 /// this is a separate string rather than a parameter of the other one.
602 const LIMITED_PROJECTION: &'static str = r#"
603SELECT r.n, w.node_id
604FROM (SELECT COUNT(*) AS n FROM walk) r
605LEFT JOIN (
606 SELECT DISTINCT w.node_id AS node_id
607 FROM walk w JOIN concepts c ON c.id = w.node_id
608 WHERE c.retired = 0
609) w ON 1 = 1
610ORDER BY w.node_id;
611 "#;
612
613 /// Where the reading branch binds, when the shape has one: `?5`.
614 ///
615 /// A [`LineageShape::Trunk`] read emits no `lineage` CTE and binds nothing
616 /// here, so everything after it moves back by one. That is why the shape has
617 /// to reach every method that lays out a placeholder rather than only the
618 /// one that emits the CTE.
619 pub(crate) const BRANCH_SLOT: usize = 5;
620
621 /// Where the transaction-time instant binds, when the traversal has one.
622 pub(crate) fn recorded_slot(shape: LineageShape) -> usize {
623 Self::BRANCH_SLOT + usize::from(shape.binds_branch())
624 }
625
626 /// Where edge types start binding (0.13.2, W7.1; lineage slot 0.14.4).
627 ///
628 /// `?1..?4` are start, depth, the valid instant and `min_weight`. A
629 /// resolved read binds its branch next; a traversal with
630 /// [`Self::as_of_recorded`] set binds that next again; the variadic edge
631 /// types follow whatever is there.
632 ///
633 /// **This exists so the offset is computed once rather than agreed twice.**
634 /// [`Self::bind_params`] and [`Self::edge_filter_sql`] are the only two
635 /// places that care, they must agree exactly, and the previous arrangement —
636 /// a hard-coded `5` in one file and a comment in the other saying both call
637 /// sites push in the same order — is the shape D-030 and D-035 are about.
638 /// The lineage slot is the second thing to shift this layout and it shifted
639 /// it in one place.
640 pub(crate) fn edge_type_base(&self, shape: LineageShape) -> usize {
641 Self::recorded_slot(shape) + usize::from(self.as_of_recorded.is_some())
642 }
643
644 /// Where [`Self::limit`] binds, when the traversal has one (0.15.10, W13.5).
645 ///
646 /// **After the edge types**, which is the only slot in this layout whose
647 /// position depends on how many parameters precede it rather than on which
648 /// of them are present. Put anywhere earlier it would have to shift the
649 /// variadic run, and [`Self::edge_filter_sql`] would need to know about a
650 /// clause it does not emit.
651 pub(crate) fn limit_slot(&self, shape: LineageShape) -> usize {
652 self.edge_type_base(shape) + self.edge_types.len()
653 }
654
655 /// Where the ancestry block starts, when the shape emits one (0.15.17).
656 ///
657 /// **After everything else**, including the limit. Two reasons, and the
658 /// second is the one that matters: it is variadic like the edge types, so
659 /// it could only go at an end; and its length is a function of *the
660 /// database's fork depth* rather than of anything the caller passed, so a
661 /// block anywhere earlier would make every slot after it move when an
662 /// unrelated branch was created.
663 ///
664 /// Under [`LineageShape::Trunk`] and [`LineageShape::TrunkOnForked`] no
665 /// `lineage` relation is emitted and this names a slot nothing binds, which
666 /// is the same thing [`Self::BRANCH_SLOT`] does under `Trunk`.
667 pub(crate) fn ancestry_slot(&self, shape: LineageShape) -> usize {
668 self.limit_slot(shape) + usize::from(self.limit.is_some())
669 }
670
671 /// The `AND l.edge_type IN (…)` fragment, or empty when unfiltered.
672 ///
673 /// Placeholders start at [`Self::edge_type_base`]. Bound, never spliced: an
674 /// edge type is caller data, and the crate's only validation of one runs on
675 /// the *write* path (D-039), so a traversal never passes through it.
676 pub(crate) fn edge_filter_sql(&self, shape: LineageShape) -> String {
677 if self.edge_types.is_empty() {
678 String::new()
679 } else {
680 let base = self.edge_type_base(shape);
681 let placeholders: Vec<String> = (0..self.edge_types.len())
682 .map(|i| format!("?{}", i + base))
683 .collect();
684 format!(" AND l.edge_type IN ({})", placeholders.join(", "))
685 }
686 }
687
688 /// Every parameter the walk and its projections bind, in placeholder order.
689 ///
690 /// One producer for both consumers ([`Self::execute_ids`] and
691 /// `Database::load_subgraph_with`), for the reason [`Self::edge_type_base`]
692 /// gives: they previously agreed by comment, and one of them had already
693 /// drifted — the subgraph loader bound `now_ts` at `?3` where the builder
694 /// bound the traversal's own instant, so **a historical `load_subgraph_with`
695 /// silently read the present** (F-35, W7.1).
696 pub(crate) fn bind_params(
697 &self,
698 now_ts: &str,
699 shape: LineageShape,
700 ancestry: &[Ancestor],
701 ) -> Vec<libsql::Value> {
702 let mut params: Vec<libsql::Value> = vec![
703 self.start_node.as_str().into(),
704 (self.max_depth as i64).into(),
705 self.valid_instant(now_ts).into(),
706 self.min_weight.into(),
707 ];
708 // Pushed only when the emitted SQL names `BRANCH_SLOT`, which is every
709 // shape but `Trunk`. An unbranched traversal on a forked database still
710 // reaches this arm — `Lineages::shape` answers for the database, not for
711 // the builder — and reads `main`'s own lineage, which is the trunk's
712 // belief and not the union of everything stored.
713 if shape.binds_branch() {
714 params.push(self.branch.as_deref().unwrap_or(ddl::MAIN_BRANCH).into());
715 }
716 if let Some(recorded) = self.as_of_recorded.as_deref() {
717 params.push(recorded.into());
718 }
719 params.extend(self.edge_types.iter().map(|t| t.as_str().into()));
720 // Last, matching `limit_slot`. Bound rather than spliced for the reason
721 // every other value here is: a `usize` cannot carry SQL, but a query
722 // whose text varies with a caller's argument is a second statement to
723 // prepare and a second plan to cache.
724 if let Some(n) = self.limit {
725 params.push((n as i64).into());
726 }
727 // Last, matching `ancestry_slot`, and only under the shape that emits
728 // the relation: the other two lower no `lineage` CTE, so binding it
729 // would push values at placeholders the SQL never names.
730 if shape == LineageShape::Resolved {
731 params.extend(ancestry_params(ancestry));
732 }
733 params
734 }
735
736 /// What this traversal has decided about its lineage read, for
737 /// [`lower`] to spell (0.15.1, W13.1).
738 ///
739 /// The builder owns the placeholder layout — [`Self::BRANCH_SLOT`] and
740 /// [`Self::recorded_slot`] — and the lowering owns the SQL; this is the
741 /// seam between them. `recorded_slot` is `Some` exactly when
742 /// [`Self::as_of_recorded`] is, and the layout it names is the one
743 /// [`Self::bind_params`] fills.
744 pub(crate) fn resolution<'a>(
745 &self,
746 shape: LineageShape,
747 ancestry: &'a [Ancestor],
748 ) -> Resolution<'a> {
749 Resolution {
750 shape,
751 branch_slot: Self::BRANCH_SLOT,
752 recorded_slot: self
753 .as_of_recorded
754 .as_ref()
755 .map(|_| Self::recorded_slot(shape)),
756 tag: "",
757 // A walk discovers its edges; there is no key to push down.
758 key: None,
759 ancestry,
760 ancestry_slot: self.ancestry_slot(shape),
761 }
762 }
763
764 /// The relation the walk and the projections read edges from.
765 ///
766 /// Under [`LineageShape::Resolved`] that is always `visible`, which holds
767 /// one row per edge key from the nearest lineage that has one *and was
768 /// entitled to be seen*; the walk and the projection do not need to know
769 /// which relation it reduced, nor that the reduction had two arms
770 /// (D-223). Under `Trunk` it is that relation directly: `links_current`
771 /// under current belief, the `links_at_tx` fold otherwise. See [`lower`]
772 /// for why the two shapes do not pick from the same pair.
773 pub(crate) fn link_source(&self, shape: LineageShape) -> String {
774 // `&[]`: neither field this reads depends on the ancestry — `source`
775 // names a relation and `filter` a predicate on the reader's own alias,
776 // and only `ctes` holds the `lineage` table. Passing the real ancestry
777 // would build a string this discards.
778 lower(&self.resolution(shape, &[])).source
779 }
780
781 /// The lineage predicate the walk and the projections append to their
782 /// own `WHERE`, or empty (0.15.2, D-244).
783 ///
784 /// Non-empty only under [`LineageShape::TrunkOnForked`] under current
785 /// belief; see `Lowered::filter`. Spliced in both places the edge-type
786 /// filter is, and for the same reason (D-073): a projection that skipped
787 /// it would populate the trunk's subgraph with every lineage's edges
788 /// between the nodes the trunk reached.
789 pub(crate) fn lineage_filter_sql(&self, shape: LineageShape) -> String {
790 // `&[]`: neither field this reads depends on the ancestry — `source`
791 // names a relation and `filter` a predicate on the reader's own alias,
792 // and only `ctes` holds the `lineage` table. Passing the real ancestry
793 // would build a string this discards.
794 lower(&self.resolution(shape, &[])).filter
795 }
796
797 /// Refuse a transaction-time instant the hot log can no longer answer for.
798 ///
799 /// See [`Self::as_of_recorded`]. It only runs on the folded path, so the
800 /// ordinary traversal pays nothing.
801 ///
802 /// **One index seek at or after the newest surviving stamp, which is where
803 /// this is asked** (0.15.5, W14.4, D-247). `as_of_recorded(now)` and every
804 /// read at a recent instant take [`crate::temporal::replay`]'s cheap arm:
805 /// 3.4 µs, flat in the size of the log. Below that stamp the guard also
806 /// establishes whether rows were removed, which is a `COUNT(*)` over
807 /// `transaction_log` and therefore linear — 0.1 ms at 2,000 rows, 24 ms at
808 /// 500,000. That arm cannot be made cheaper without a marker
809 /// [D-132](../../docs/architecture/s13-decision-register.md#d-132) refused,
810 /// and it is the arm that usually goes on to refuse anyway.
811 pub(crate) async fn check_recorded_reach(&self, conn: &libsql::Connection) -> Result<()> {
812 let Some(ts) = self.as_of_recorded.as_deref() else {
813 return Ok(());
814 };
815 if crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
816 return Ok(());
817 }
818 Err(crate::error::DbError::RecordedInstantUnreachable { ts: ts.to_string() })
819 }
820
821 /// The recursive `walk` CTE — **the one copy** (T0.1).
822 ///
823 /// [`Self::build_sql`] and `Database::load_subgraph_with` append their own
824 /// projections to this. They previously carried byte-identical copies of the
825 /// recursion in two files, and had already drifted once: D-073 found the
826 /// subgraph loader taking neither `edge_types` nor `min_weight` while this
827 /// builder took both. Two copies of a query that must agree is the same
828 /// failure class as [D-030](../../docs/architecture/s13-decision-register.md)
829 /// and D-035, applied to SQL.
830 ///
831 /// **`UNION`, not `UNION ALL`, and no `path` column (T0.1).** The shipped
832 /// form carried a `path` of visited ids and refused a target already in it,
833 /// which restricts the walk to *simple paths* — so `walk` held one row per
834 /// distinct path to each node rather than one row per node, and the trailing
835 /// `SELECT DISTINCT` collapsed the duplication only after the work was done.
836 /// On a tree that costs nothing, because a tree has exactly one path to each
837 /// node; on a graph the row count is multiplicative in branching factor per
838 /// hop. Measured on libSQL 0.9.30 over a layered fixture (root, then *L*
839 /// layers of *W*, each fully joined to the next): a **328-edge** graph at
840 /// depth 6 produced **299,593** walk rows and took **428 ms**. The same
841 /// traversal here produces 49 rows in 0.1 ms.
842 ///
843 /// `UNION` dedupes on `(node_id, depth)` as rows enter the queue, so `walk`
844 /// is bounded by `V × (depth+1)` and termination comes from the depth bound
845 /// rather than from inspecting the path. The projections keep their
846 /// `DISTINCT`, because a node still legitimately appears at several depths.
847 ///
848 /// **Equivalence, argued rather than only measured.** The old form admits
849 /// only simple paths; this one admits any walk. The reachable sets are the
850 /// same: if a walk of length `k ≤ D` reaches `X`, excising its cycles yields
851 /// a simple path of length `≤ k` that also reaches `X`. So simple-path
852 /// reachability within `D` equals walk reachability within `D`, and the two
853 /// forms differed only in how much redundant work they did to establish it.
854 /// A property test over generated graphs — cycles, self-loops, diamonds and
855 /// expired edges, the four shapes the proof steps over — compares this form
856 /// against the old one at depths 1–4 and requires identical node *and* edge
857 /// sets (`integrity_property_tests`, 512 cases).
858 ///
859 /// **The recursion is one copy across both lineage shapes too (0.14.4).**
860 /// `shape` changes what the prelude holds and what `{source}` names; the
861 /// walk itself is the same text either way, because
862 /// `visible` exposes the columns `links_current` does. A second copy
863 /// of the recursion for the resolved read would have been the T0.1 defect
864 /// re-introduced by a feature rather than inherited from one.
865 ///
866 /// **And the prelude is one copy across the three readers (0.15.1).**
867 /// What goes before `walk` is [`lower`]'s output, which
868 /// `query_as_of_edges_on` and `diff_sql` splice too; this method chooses
869 /// nothing about the lineage read beyond where its placeholders sit.
870 ///
871 /// **It is not free on a tree, and the plan that proposed it said it was.**
872 /// `UNION` maintains a dedupe b-tree over every row entering the queue; on a
873 /// tree nothing is ever deduped, so that is pure overhead. Measured on the
874 /// star-of-stars fixture at depth 3, best of 15, stable across runs:
875 /// 1,011 nodes 1.6 ms either way, 5,051 nodes 8.9 → 9.5 ms, 10,101 nodes
876 /// 17.8 → 19.6 ms — roughly **8–10% slower** where the old form was already
877 /// optimal, against ~2,000× faster where it was not. Recorded rather than
878 /// smoothed over: the trade is overwhelmingly worth taking and it is still a
879 /// trade, and "within noise" was a claim from a different engine's numbers.
880 pub(crate) fn walk_cte(&self, shape: LineageShape, ancestry: &[Ancestor]) -> String {
881 let edge_filter = self.edge_filter_sql(shape);
882 // The prelude and the source come from one lowering, shared with
883 // `query_as_of_edges_on` and `diff_sql` (0.15.1, W13.1). The walk
884 // splices what it is handed and knows nothing about what it holds,
885 // which is the point: a shape that lands in `graph::plan` lands here.
886 let lowered = lower(&self.resolution(shape, ancestry));
887 let source = &lowered.source;
888 let lineage_filter = &lowered.filter;
889 let prelude = lowered.prelude();
890 // Empty when unset, so every traversal written before 0.15.10 emits the
891 // byte-identical statement it always did — the property W13.1 spent a
892 // release establishing and every plan pin in `tests/` still asserts.
893 let limit_clause = match self.limit {
894 Some(_) => format!("\n LIMIT ?{}", self.limit_slot(shape)),
895 None => String::new(),
896 };
897
898 format!(
899 r#"
900WITH RECURSIVE {prelude}walk(node_id, depth) AS (
901 SELECT ?1, 0
902 UNION
903 SELECT l.target_id, w.depth + 1
904 FROM walk w
905 JOIN {source} l ON l.source_id = w.node_id
906 WHERE w.depth < ?2
907 AND l.valid_from <= ?3 AND ?3 < l.valid_to
908 AND l.weight >= ?4{lineage_filter}
909 {edge_filter}{limit_clause}
910)"#
911 )
912 }
913
914 /// Node ids reachable under this traversal, in id order (§5.2).
915 ///
916 /// Reads at [`Self::as_of_valid`] when set, else at `now_ts`, and under the
917 /// belief [`Self::as_of_recorded`] names when set, else current belief. No
918 /// attribute mode is involved, so this never returns
919 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated):
920 /// topology at an instant is unambiguous, and it is only the *pairing* with
921 /// live attributes that needed a decision.
922 ///
923 /// # Errors
924 ///
925 /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
926 /// when [`Self::as_of_recorded`] is below what the hot log still covers.
927 ///
928 /// [`DbError::UnknownBranch`](crate::DbError::UnknownBranch), naming the
929 /// branch, when [`Self::on_branch`] names a lineage that is not registered
930 /// (0.14.4; `NotFound` until 0.14.7).
931 pub async fn execute_ids(
932 &self,
933 conn: &libsql::Connection,
934 now_ts: &str,
935 ) -> Result<Vec<String>> {
936 Ok(self.execute_ids_explained(conn, now_ts).await?.0)
937 }
938
939 /// [`Self::execute_ids`], plus whether [`Self::limit`] cut the walk short
940 /// (0.15.10, W13.5, C-8).
941 ///
942 /// Named for [`FilteredVectorSearch::execute_explained`](crate::graph::FilteredVectorSearch::execute_explained),
943 /// which is the same bargain: the plain method answers the question, and
944 /// the explained one also hands back the fact a caller would otherwise have
945 /// to guess at from the shape of the answer.
946 ///
947 /// Without a limit this is always
948 /// [`WalkOutcome::Complete`] and costs exactly what [`Self::execute_ids`]
949 /// costs — the reporting column is emitted only when there is a ceiling to
950 /// report on, so an unlimited traversal runs the statement it has always
951 /// run.
952 ///
953 /// # Errors
954 ///
955 /// The same two as [`Self::execute_ids`], and for the same reasons.
956 pub async fn execute_ids_explained(
957 &self,
958 conn: &libsql::Connection,
959 now_ts: &str,
960 ) -> Result<(Vec<String>, WalkOutcome)> {
961 self.check_recorded_reach(conn).await?;
962 // The database decides the shape, not the builder: an unbranched
963 // traversal on a forked ledger must still resolve, or it reads every
964 // lineage's rows at once. See `build_sql` for why the pure function
965 // cannot answer this and does not pretend to.
966 let (shape, ancestry) = resolve_for(conn, self.branch.as_deref()).await?;
967 let sql = self.build_sql_with(shape, &ancestry);
968 let params = self.bind_params(now_ts, shape, &ancestry);
969
970 let mut rows = conn.query(&sql, params).await?;
971 let mut ids = Vec::new();
972 let mut walk_rows: i64 = 0;
973 while let Some(row) = rows.next().await? {
974 match self.limit {
975 // `LIMITED_PROJECTION` puts the walk's row count first and the
976 // id second, and emits one row with a `NULL` id when the walk
977 // reached nothing the projection kept.
978 Some(_) => {
979 walk_rows = row.get(0)?;
980 if let Some(id) = row.get::<Option<String>>(1)? {
981 ids.push(id);
982 }
983 }
984 None => ids.push(row.get(0)?),
985 }
986 }
987 // `>=` rather than `==` because the ceiling is what SQLite was told to
988 // stop at, not a number this code computed: a walk that ends level with
989 // it has been cut, and an engine that overshot by a row would still
990 // have been cut. Equality here would report `Complete` on the one
991 // reading that is certainly not.
992 let outcome = match self.limit {
993 Some(n) if walk_rows >= n as i64 => WalkOutcome::LimitReached,
994 _ => WalkOutcome::Complete,
995 };
996 Ok((ids, outcome))
997 }
998
999 /// Execute the traversal and hydrate attributes per [`Self::attribute_mode`]
1000 /// (§5.2).
1001 ///
1002 /// The hydration is a second step rather than a join in the CTE because the
1003 /// three modes read from two different places: `Current` and `Omit` from
1004 /// `concepts`, `AtTime` from `transaction_log`. The previous version always
1005 /// emitted the `concepts` join, so `attribute_mode` was stored, exposed by a
1006 /// builder method, and never read — a caller asking for `AtTime` got live
1007 /// attributes with no indication that the mode had been ignored. That is the
1008 /// exact failure Doctrine II exists to prevent, arriving as a silent wrong
1009 /// answer rather than as an error.
1010 ///
1011 /// [`Self::limit`] bounds this walk as it bounds every other, and this
1012 /// return type cannot say whether it bit — hydrated nodes leave no room for
1013 /// the answer. [`Self::execute_ids_explained`] is where that is asked.
1014 ///
1015 /// **[`AttributeMode::Omit`] returns `Ok(vec![])` here**, which is
1016 /// indistinguishable from a traversal that reached nothing. That is a
1017 /// limitation of this method's return type rather than of the mode; callers
1018 /// wanting topology only should use [`Self::execute_ids`], which says what it
1019 /// found.
1020 ///
1021 /// # Errors
1022 ///
1023 /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
1024 /// when either instant is set and [`Self::attribute_mode`] is not — see
1025 /// [`Self::as_of_valid`] for why that combination is a question rather than
1026 /// a default (T3.2, D-085).
1027 ///
1028 /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
1029 /// when [`Self::as_of_recorded`] is below what the hot log still covers.
1030 ///
1031 /// `now_ts` is the caller's present, and it is the fallback on *both* axes:
1032 /// a traversal with neither instant set reads live topology and live text.
1033 pub async fn execute(
1034 &self,
1035 conn: &libsql::Connection,
1036 now_ts: &str,
1037 ) -> Result<Vec<NodeAttributes>> {
1038 let mode = self.resolved_mode()?;
1039 let as_of = self.instants(now_ts);
1040 let ids = self.execute_ids(conn, now_ts).await?;
1041
1042 // `Current` hydrates from `concepts` live and ignores both instants, so
1043 // the pair it receives only matters for `AtTime`. Passing the traversal's
1044 // own instants rather than `now_ts` is what makes a historical traversal
1045 // with `AtTime` mean what it says — the whole point of the pairing this
1046 // method requires the caller to state.
1047 crate::temporal::as_of::hydrate_attributes(conn, &ids, &as_of, mode).await
1048 }
1049
1050 /// The mode to hydrate with, or the error that says the caller must choose.
1051 ///
1052 /// Kept separate from [`Self::execute`] so it is unit-testable without a
1053 /// database: the property under test is a decision about two `Option`s, and
1054 /// a test that needed a connection to check it would be testing something
1055 /// else as well.
1056 pub(crate) fn resolved_mode(&self) -> Result<AttributeMode> {
1057 // Either instant makes the question live, and the error names *which*
1058 // (0.13.10, W7.7, D-183). This was an `.or()` picking valid time first,
1059 // which answered the caller with an axis they might not have asked
1060 // about and dropped the other one when they had asked about both.
1061 let instants =
1062 StatedInstants::new(self.as_of_valid.as_deref(), self.as_of_recorded.as_deref());
1063 match (instants, self.attribute_mode) {
1064 (Some(instants), None) => {
1065 Err(crate::error::DbError::AttributeModeUnstated { instants })
1066 }
1067 (_, Some(mode)) => Ok(mode),
1068 (None, None) => Ok(AttributeMode::Current),
1069 }
1070 }
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076 /// A two-row ancestry: the reader, and one ancestor it inherits from.
1077 ///
1078 /// The smallest fixture that makes a `Resolved` lowering *mean* something.
1079 /// An empty ancestry lowers to `VALUES ()`, which SQLite refuses, so a
1080 /// golden-string test written against one would pin text no database would
1081 /// accept (0.15.17, [D-259]).
1082 ///
1083 /// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
1084 fn anc() -> Vec<Ancestor> {
1085 vec![
1086 Ancestor {
1087 branch_id: "b1".to_string(),
1088 dist: 0,
1089 cutoff: None,
1090 },
1091 Ancestor {
1092 branch_id: "main".to_string(),
1093 dist: 1,
1094 cutoff: Some("2026-01-01T00:00:00.000000Z".to_string()),
1095 },
1096 ]
1097 }
1098
1099 use crate::error::DbError;
1100
1101 const TUE: &str = "2026-01-06T00:00:00.000000Z";
1102
1103 /// The only combination that is a question, and it is now asked.
1104 #[test]
1105 fn as_of_without_a_stated_mode_is_an_error() {
1106 let err = TraversalBuilder::new("a")
1107 .as_of_valid(TUE)
1108 .resolved_mode()
1109 .expect_err("past topology plus present text must not be a default");
1110
1111 match &err {
1112 DbError::AttributeModeUnstated { instants } => {
1113 assert_eq!(instants.valid(), Some(TUE));
1114 assert_eq!(instants.recorded(), None, "no belief instant was set");
1115 }
1116 other => panic!("got {other:?}"),
1117 }
1118
1119 // And the message has to be actionable: a caller who reads only this
1120 // should know which axis they asked about and which two calls resolve
1121 // it. `as_of(…)` named a method that has not existed since 0.12.17.
1122 let text = err.to_string();
1123 assert!(
1124 text.contains(&format!("as_of_valid({TUE})")),
1125 "the message must name the call the caller made: {text}"
1126 );
1127 assert!(
1128 text.contains("AtTime") && text.contains("Current"),
1129 "{text}"
1130 );
1131 }
1132
1133 /// Stating `Current` on a historical traversal is legitimate and stays so.
1134 ///
1135 /// The fix must not be "forbid the fast path". Past topology with live text
1136 /// is a real query — a caller rendering a historical diagram with today's
1137 /// labels wants exactly it — and the objection was always to getting it
1138 /// without asking, never to asking for it.
1139 #[test]
1140 fn a_stated_mode_is_honoured_on_a_historical_traversal() {
1141 for mode in [
1142 AttributeMode::Current,
1143 AttributeMode::AtTime,
1144 AttributeMode::Omit,
1145 ] {
1146 let got = TraversalBuilder::new("a")
1147 .as_of_valid(TUE)
1148 .attribute_mode(mode)
1149 .resolved_mode()
1150 .unwrap();
1151 assert_eq!(got, mode);
1152 }
1153 }
1154
1155 /// The transaction-time instant raises the same question the valid-time one
1156 /// does, and asking it on only one axis would be the same gap in a new place.
1157 #[test]
1158 fn a_recorded_instant_also_demands_a_stated_mode() {
1159 let err = TraversalBuilder::new("a")
1160 .as_of_recorded(TUE)
1161 .resolved_mode()
1162 .expect_err("past belief plus present text must not be a default");
1163 assert!(
1164 matches!(err, DbError::AttributeModeUnstated { .. }),
1165 "{err:?}"
1166 );
1167 // The axis reaches the caller. Until 0.13.10 this said `as_of(…)`,
1168 // which is the valid-time method's old name and not what was called.
1169 let text = err.to_string();
1170 assert!(text.contains(&format!("as_of_recorded({TUE})")), "{text}");
1171 assert!(!text.contains("as_of("), "no dead method name: {text}");
1172 }
1173
1174 /// Both axes set is the bitemporal cell, and dropping half of it was the
1175 /// second half of the defect: the `.or()` reported valid time and said
1176 /// nothing about the belief instant the caller had also stated.
1177 #[test]
1178 fn both_instants_are_reported_when_both_were_stated() {
1179 const WED: &str = "2026-01-07T00:00:00.000000Z";
1180 let err = TraversalBuilder::new("a")
1181 .as_of_valid(TUE)
1182 .as_of_recorded(WED)
1183 .resolved_mode()
1184 .expect_err("the cell needs a stated mode as much as either axis");
1185
1186 match &err {
1187 DbError::AttributeModeUnstated { instants } => {
1188 assert_eq!(instants.valid(), Some(TUE));
1189 assert_eq!(instants.recorded(), Some(WED));
1190 }
1191 other => panic!("got {other:?}"),
1192 }
1193 let text = err.to_string();
1194 assert!(text.contains(TUE) && text.contains(WED), "{text}");
1195 }
1196
1197 /// A traversal about now still defaults, so no existing caller changes.
1198 ///
1199 /// This is what keeps the change from being a breaking one for the common
1200 /// case: with neither instant, `Current` and `AtTime` agree about which text
1201 /// to return, so there is nothing to decide and nothing to ask.
1202 #[test]
1203 fn a_live_traversal_still_defaults_to_current() {
1204 assert_eq!(
1205 TraversalBuilder::new("a").resolved_mode().unwrap(),
1206 AttributeMode::Current
1207 );
1208 }
1209
1210 /// `as_of_valid` supplies the instant the walk reads at; `now_ts` is the
1211 /// fallback, and `as_of_recorded` never is — see `valid_instant`.
1212 #[test]
1213 fn as_of_valid_overrides_the_execute_timestamp() {
1214 let now = "2026-06-01T00:00:00.000000Z";
1215 assert_eq!(TraversalBuilder::new("a").valid_instant(now), now);
1216 assert_eq!(
1217 TraversalBuilder::new("a")
1218 .as_of_valid(TUE)
1219 .valid_instant(now),
1220 TUE
1221 );
1222 assert_eq!(
1223 TraversalBuilder::new("a")
1224 .as_of_recorded(TUE)
1225 .valid_instant(now),
1226 now,
1227 "fixing belief must not move the valid-time instant"
1228 );
1229 }
1230
1231 /// The two axes reach the hydration layer separately (W7.1, D-174).
1232 ///
1233 /// The property that made the old single parameter wrong was that one
1234 /// instant arrived on both clocks. This asserts the negation directly, at the
1235 /// boundary where the split has to survive: what `execute` hands to
1236 /// `hydrate_attributes`.
1237 #[test]
1238 fn the_two_axes_reach_hydration_separately() {
1239 let now = "2026-06-01T00:00:00.000000Z";
1240 let mar = "2026-03-01T00:00:00.000000Z";
1241
1242 let live = TraversalBuilder::new("a").instants(now);
1243 assert_eq!(live.valid.as_deref(), Some(now));
1244 assert_eq!(live.recorded, None, "no instant means current belief");
1245
1246 let valid_only = TraversalBuilder::new("a").as_of_valid(TUE).instants(now);
1247 assert_eq!(valid_only.valid.as_deref(), Some(TUE));
1248 assert_eq!(valid_only.recorded, None);
1249
1250 let recorded_only = TraversalBuilder::new("a").as_of_recorded(mar).instants(now);
1251 assert_eq!(
1252 recorded_only.valid.as_deref(),
1253 Some(now),
1254 "fixing belief leaves valid time at the present"
1255 );
1256 assert_eq!(recorded_only.recorded.as_deref(), Some(mar));
1257
1258 let both = TraversalBuilder::new("a")
1259 .as_of_valid(TUE)
1260 .as_of_recorded(mar)
1261 .instants(now);
1262 assert_eq!(both.valid.as_deref(), Some(TUE));
1263 assert_eq!(both.recorded.as_deref(), Some(mar));
1264 }
1265
1266 /// Placeholder arithmetic is the one thing two call sites must agree on.
1267 ///
1268 /// `bind_params` and `edge_filter_sql` are separate functions that have to
1269 /// produce the same layout, and both the recorded instant and the lineage
1270 /// slot shift it. A test that counts is cheaper than the bug, which is an
1271 /// edge type silently compared against a timestamp — or, since 0.14.4, a
1272 /// branch id compared against one.
1273 #[test]
1274 fn the_recorded_instant_shifts_the_edge_type_placeholders() {
1275 let now = "2026-06-01T00:00:00.000000Z";
1276 let trunk = LineageShape::Trunk;
1277
1278 let plain = TraversalBuilder::new("a").edge_types(vec!["CITES".into()]);
1279 assert_eq!(plain.edge_type_base(trunk), 5);
1280 assert!(
1281 plain.edge_filter_sql(trunk).contains("?5"),
1282 "{}",
1283 plain.edge_filter_sql(trunk)
1284 );
1285 assert_eq!(plain.bind_params(now, trunk, &anc()).len(), 5);
1286
1287 let folded = plain.clone().as_of_recorded(TUE);
1288 assert_eq!(folded.edge_type_base(trunk), 6);
1289 assert!(
1290 folded.edge_filter_sql(trunk).contains("?6"),
1291 "{}",
1292 folded.edge_filter_sql(trunk)
1293 );
1294 assert_eq!(folded.bind_params(now, trunk, &anc()).len(), 6);
1295 }
1296
1297 /// The lineage slot shifts everything after it, in both functions (0.14.4).
1298 ///
1299 /// This is the same property as the test above and it is written twice on
1300 /// purpose: the layout now has two independent shifts, and a test that only
1301 /// varied one of them would pass on a `recorded_slot` that ignored the shape
1302 /// entirely.
1303 #[test]
1304 fn the_lineage_slot_shifts_everything_after_it() {
1305 let now = "2026-06-01T00:00:00.000000Z";
1306 let (trunk, resolved) = (LineageShape::Trunk, LineageShape::Resolved);
1307
1308 // Three values per ancestor, appended after everything the layout
1309 // names positionally (0.15.17, [D-259]). Written as `ancestry_slot - 1
1310 // + block` rather than as a number so that a slot added in the middle
1311 // fails on the slot it broke instead of on an arithmetic surprise here.
1312 let block = anc().len() * 3;
1313
1314 let plain = TraversalBuilder::new("a").edge_types(vec!["CITES".into()]);
1315 assert_eq!(plain.edge_type_base(resolved), 6);
1316 assert!(plain.edge_filter_sql(resolved).contains("?6"));
1317 assert_eq!(plain.ancestry_slot(resolved), 7);
1318 assert_eq!(plain.bind_params(now, resolved, &anc()).len(), 6 + block);
1319
1320 let folded = plain.clone().as_of_recorded(TUE);
1321 assert_eq!(folded.edge_type_base(resolved), 7);
1322 assert!(folded.edge_filter_sql(resolved).contains("?7"));
1323 assert_eq!(folded.ancestry_slot(resolved), 8);
1324 assert_eq!(folded.bind_params(now, resolved, &anc()).len(), 7 + block);
1325
1326 // The branch lands in the slot the CTE reads it from, and it is the
1327 // *builder's* branch — not a positional accident that happens to hold a
1328 // string. `?5` is `BRANCH_SLOT`; `?6` is the recorded instant.
1329 let named = folded.clone().on_branch("b9");
1330 let params = named.bind_params(now, resolved, &anc());
1331 assert_eq!(
1332 params[TraversalBuilder::BRANCH_SLOT - 1],
1333 libsql::Value::from("b9")
1334 );
1335 assert!(named
1336 .walk_cte(resolved, &anc())
1337 .contains("recorded_at <= ?6"));
1338 // And the ancestry lands where `ancestry_slot` says, after the limit
1339 // that is not there and the one edge type that is.
1340 assert!(named
1341 .walk_cte(resolved, &anc())
1342 .contains("AS (VALUES (?8, ?9, ?10)"));
1343
1344 // And an unnamed traversal that still has to resolve reads the trunk's
1345 // own lineage rather than the union of every lineage stored.
1346 assert_eq!(
1347 folded.bind_params(now, resolved, &anc())[TraversalBuilder::BRANCH_SLOT - 1],
1348 libsql::Value::from(ddl::MAIN_BRANCH)
1349 );
1350
1351 // Nothing is bound for a slot the SQL never names.
1352 assert!(!folded.walk_cte(trunk, &anc()).contains("lineage"));
1353 }
1354
1355 /// The fold replaces the projection, and only when it is asked for.
1356 #[test]
1357 fn the_link_source_follows_the_recorded_instant() {
1358 let trunk = LineageShape::Trunk;
1359
1360 let plain = TraversalBuilder::new("a");
1361 assert_eq!(plain.link_source(trunk), "links_current");
1362 assert!(!plain.walk_cte(trunk, &anc()).contains("transaction_log"));
1363
1364 let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
1365 assert_eq!(folded.link_source(trunk), "links_at_tx");
1366 let sql = folded.walk_cte(trunk, &anc());
1367 assert!(sql.contains("links_at_tx"), "{sql}");
1368 assert!(sql.contains("recorded_at <= ?5"), "{sql}");
1369 assert!(
1370 sql.contains("table_name = 'links'"),
1371 "the partition is only sound with the discriminator filtered: {sql}"
1372 );
1373 }
1374
1375 /// The fold partitions by lineage, and the resolution can therefore see it.
1376 ///
1377 /// The defect this pins is not that the SQL was ugly: `PARTITION BY
1378 /// entity_id` put an ancestor's assertion and a descendant's correction of
1379 /// it into one group and kept the higher `seq_id`, so a transaction-time
1380 /// traversal on a forked ledger lost one of the two before the resolution
1381 /// ever ran. D-216 fixed the same shape in `replay.rs` one release earlier
1382 /// and this fold was not in that sweep.
1383 #[test]
1384 fn the_folded_source_partitions_by_lineage() {
1385 let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
1386 let sql = folded.walk_cte(LineageShape::Resolved, &anc());
1387
1388 assert!(
1389 sql.contains("PARTITION BY transaction_log.entity_id, transaction_log.branch_id"),
1390 "two lineages' assertions collapse to one without this: {sql}"
1391 );
1392 // Qualified by table name rather than by an alias, and that is a plan
1393 // decision rather than a style one: 0.14.6's cutoff join brings a
1394 // second `branch_id` into scope so the columns must be qualified, and
1395 // `EXPLAIN QUERY PLAN` prints whatever the FROM clause named the table.
1396 // An alias would rewrite every plan guard that names `transaction_log`
1397 // — including the Trunk-shape seek assertion in `bitemporal_plan_tests`,
1398 // which is about a query this release does not otherwise touch.
1399 assert!(
1400 sql.contains("FROM transaction_log\n"),
1401 "the fold's table must keep its own name in the plan: {sql}"
1402 );
1403 // Carried out of the fold as well as partitioned on, because it is the
1404 // column the ancestry joins against.
1405 assert!(
1406 sql.contains("valid_to, weight, branch_id) AS MATERIALIZED ("),
1407 "the fold must expose what `visible` joins on: {sql}"
1408 );
1409 assert!(
1410 sql.contains("JOIN lineage g ON g.branch_id = l.branch_id"),
1411 "{sql}"
1412 );
1413 }
1414
1415 /// The resolution is one row per edge *key*, and the walk reads only that.
1416 #[test]
1417 fn the_resolved_shape_puts_the_resolution_between_the_walk_and_the_rows() {
1418 let walk = TraversalBuilder::new("a");
1419
1420 let resolved = walk.walk_cte(LineageShape::Resolved, &anc());
1421 assert_eq!(walk.link_source(LineageShape::Resolved), "visible");
1422 assert!(resolved.contains("JOIN visible l ON l.source_id = w.node_id"));
1423 assert!(
1424 resolved.contains("PARTITION BY l.source_id, l.target_id, l.edge_type, l.valid_from"),
1425 "the partition is the edge key, not the edge: {resolved}"
1426 );
1427
1428 // And the trunk shape is byte-for-byte what shipped before 0.14.4: no
1429 // ancestry, no window function, the walk reading the table directly.
1430 let trunk = walk.walk_cte(LineageShape::Trunk, &anc());
1431 assert!(!trunk.contains("lineage"), "{trunk}");
1432 assert!(!trunk.contains("ROW_NUMBER"), "{trunk}");
1433 assert!(trunk.contains("JOIN links_current l ON l.source_id = w.node_id"));
1434 }
1435
1436 /// `build_sql` answers for the configuration; only a connection knows more.
1437 #[test]
1438 fn the_pure_builder_takes_the_shape_its_configuration_implies() {
1439 assert_eq!(
1440 TraversalBuilder::new("a").implied_shape(),
1441 LineageShape::Trunk
1442 );
1443 assert_eq!(
1444 TraversalBuilder::new("a").on_branch("b9").implied_shape(),
1445 LineageShape::Resolved
1446 );
1447 assert!(TraversalBuilder::new("a")
1448 .on_branch("b9")
1449 .build_sql()
1450 .contains("lineage"));
1451 assert!(!TraversalBuilder::new("a").build_sql().contains("lineage"));
1452 }
1453
1454 /// The trunk on a forked ledger binds its name and filters on it, and
1455 /// resolves nothing (0.15.2, D-244).
1456 #[test]
1457 fn the_forked_trunk_walk_is_the_trunk_walk_plus_one_predicate() {
1458 let shape = LineageShape::TrunkOnForked;
1459 let walk = TraversalBuilder::new("a");
1460 let sql = walk.walk_cte(shape, &anc());
1461 assert!(sql.contains("JOIN links_current l ON l.source_id = w.node_id"));
1462 assert!(sql.contains("AND l.weight >= ?4 AND +l.branch_id = ?5\n"));
1463 assert!(!sql.contains("lineage"), "a root resolves nothing: {sql}");
1464 assert!(!sql.contains("ROW_NUMBER"), "{sql}");
1465 assert_eq!(walk.link_source(shape), "links_current");
1466 assert_eq!(walk.lineage_filter_sql(shape), " AND +l.branch_id = ?5");
1467
1468 // And the layout after the branch is the resolved layout: the branch
1469 // is bound, so the recorded instant and the edge types move by one.
1470 assert_eq!(TraversalBuilder::recorded_slot(shape), 6);
1471 assert_eq!(walk.edge_type_base(shape), 6);
1472 let params = walk.bind_params(TUE, shape, &anc());
1473 assert_eq!(params.len(), 5, "start, depth, valid, weight, branch");
1474 assert_eq!(
1475 params[4],
1476 libsql::Value::from(crate::schema::ddl::MAIN_BRANCH),
1477 "an unbranched traversal on the forked trunk reads main"
1478 );
1479
1480 let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
1481 let sql = folded.walk_cte(shape, &anc());
1482 assert!(sql.contains("JOIN links_at_tx l ON l.source_id = w.node_id"));
1483 assert!(sql.contains("AND +transaction_log.branch_id = ?5"));
1484 assert!(sql.contains("recorded_at <= ?6"));
1485 assert!(
1486 !sql.contains("l.branch_id"),
1487 "the fold already narrowed: {sql}"
1488 );
1489 assert!(!sql.contains("lineage"), "{sql}");
1490 assert_eq!(folded.bind_params(TUE, shape, &anc()).len(), 6);
1491 assert_eq!(folded.edge_type_base(shape), 7);
1492 }
1493
1494 /// The plan the third shape gets, pinned where the walk's other plans are
1495 /// pinned: it still seeks `idx_lc_traversal_cover` on `source_id`, and no
1496 /// materialised lineage relation appears anywhere in it.
1497 #[tokio::test]
1498 async fn the_forked_trunk_walk_seeks_the_traversal_index_and_materialises_nothing() {
1499 let db = libsql::Builder::new_local(":memory:")
1500 .build()
1501 .await
1502 .unwrap();
1503 let conn = db.connect().unwrap();
1504 crate::schema::run_migrations(&conn).await.unwrap();
1505 conn.execute(
1506 "INSERT INTO branches (branch_id, parent_id, forked_at, created_at) \
1507 VALUES ('b', 'main', ?1, ?1)",
1508 libsql::params![TUE],
1509 )
1510 .await
1511 .unwrap();
1512
1513 for (label, builder) in [
1514 ("unfiltered", TraversalBuilder::new("a").max_depth(3)),
1515 (
1516 "edge-typed",
1517 TraversalBuilder::new("a")
1518 .max_depth(3)
1519 .edge_types(vec!["CITES".into()]),
1520 ),
1521 ] {
1522 let shape = LineageShape::TrunkOnForked;
1523 let sql = format!(
1524 "EXPLAIN QUERY PLAN {}",
1525 builder.build_sql_with(shape, &anc())
1526 );
1527 let mut rows = conn
1528 .query(&sql, builder.bind_params(TUE, shape, &anc()))
1529 .await
1530 .unwrap();
1531 let mut plan = Vec::new();
1532 while let Some(row) = rows.next().await.unwrap() {
1533 plan.push(row.get::<String>(3).unwrap());
1534 }
1535 let text = plan.join("\n");
1536 assert!(
1537 plan.iter()
1538 .any(|s| s.contains("SEARCH l USING") && s.contains("idx_lc_traversal_cover")),
1539 "{label}: the forked trunk's walk left the traversal index:\n{text}"
1540 );
1541 assert!(
1542 !text.contains("MATERIALIZE") && !text.contains("lineage"),
1543 "{label}: the forked trunk's walk resolves an ancestry it does not have:\n{text}"
1544 );
1545 }
1546 }
1547
1548 /// **The fold is materialised once per query, not re-run once per walk
1549 /// row** (0.15.2, D-244).
1550 ///
1551 /// `links_at_tx` is referenced once, by the walk's recursive step, and
1552 /// SQLite's default for a single-reference CTE is a co-routine — which for
1553 /// a CTE joined *inside a recursive step* means the whole fold, window and
1554 /// all, runs again for every row the walk produces. Measured on 11,110
1555 /// trunk edges at depth 4: **10.6 s** as a co-routine, **59 ms**
1556 /// materialised. The resolved shape never showed it because its `visible`
1557 /// window forces materialisation on its own, which is how a 180× defect
1558 /// on the trunk's transaction-time read hid behind the branched read being
1559 /// the slower-looking one. Pinned on every shape that emits the fold; the
1560 /// two trunk shapes also keep the seek the transaction-time bound has
1561 /// always had (`bitemporal_plan_tests`).
1562 #[tokio::test]
1563 async fn the_fold_is_materialised_once_per_query_on_every_shape() {
1564 let db = libsql::Builder::new_local(":memory:")
1565 .build()
1566 .await
1567 .unwrap();
1568 let conn = db.connect().unwrap();
1569 crate::schema::run_migrations(&conn).await.unwrap();
1570 conn.execute(
1571 "INSERT INTO branches (branch_id, parent_id, forked_at, created_at) \
1572 VALUES ('b', 'main', ?1, ?1)",
1573 libsql::params![TUE],
1574 )
1575 .await
1576 .unwrap();
1577 let folded = TraversalBuilder::new("a").max_depth(3).as_of_recorded(TUE);
1578 for shape in [
1579 LineageShape::Trunk,
1580 LineageShape::TrunkOnForked,
1581 LineageShape::Resolved,
1582 ] {
1583 let sql = format!(
1584 "EXPLAIN QUERY PLAN {}",
1585 folded.build_sql_with(shape, &anc())
1586 );
1587 let mut rows = conn
1588 .query(&sql, folded.bind_params(TUE, shape, &anc()))
1589 .await
1590 .unwrap();
1591 let mut plan = Vec::new();
1592 while let Some(row) = rows.next().await.unwrap() {
1593 plan.push(row.get::<String>(3).unwrap());
1594 }
1595 let text = plan.join("\n");
1596 assert!(
1597 text.contains("MATERIALIZE links_at_tx"),
1598 "{shape:?}: the fold went back to being a co-routine inside the \
1599 recursive step, which is the 180x defect:\n{text}"
1600 );
1601 // The resolved fold joins the ancestry, and the planner takes the
1602 // equality over the range: an automatic index on
1603 // `(table_name, branch_id)` built by scanning the log once per
1604 // query, then a seek per lineage row. That is the fold as it has
1605 // been since 0.10.0, measured at 121 ms against the trunk's 59 ms
1606 // on the same fixture, and it is not this release's to change —
1607 // the point pinned here is that no shape re-runs the fold per row.
1608 // **The seek moved off `recorded_at` at 0.15.12** (W15.2,
1609 // D-254). `idx_txlog_fold_partition` leads on `table_name`, and
1610 // this fold's `WHERE table_name = 'links'` binds it — which leaves
1611 // the index's remaining order, `(entity_id, branch_id, seq_id
1612 // DESC)`, as exactly this window's `PARTITION BY entity_id,
1613 // branch_id ORDER BY seq_id DESC`. So the plan trades a seek on
1614 // the `recorded_at` bound for the whole ordering, and the temp
1615 // B-tree asserted absent below is what it buys.
1616 //
1617 // Measured before it was accepted, because it is a *trade* and
1618 // not a win everywhere (`examples/txlog_fold_index_probe.rs
1619 // --arm other-folds`, chain of 4,000, best of 21). The number in
1620 // the columns is how much of the log the transaction-time bound
1621 // admits:
1622 //
1623 // bound v16 plan v17 plan
1624 // 25% 1.91 ms 2.49 ms +31%
1625 // 50% 4.84 4.74 -2%
1626 // 75% 9.76 8.39 -14%
1627 // 100% 15.24 11.85 -22%
1628 //
1629 // The crossing is just under half the log. A transaction-time
1630 // read is normally asked about a recent instant and therefore
1631 // admits most of it, so the common case is the improving side;
1632 // the deep-history read pays 0.6 ms on this fixture. Steering the
1633 // planner back with a unary `+` on `table_name` — the technique
1634 // this CTE already uses on `branch_id` — was available and
1635 // refused: it would spend the common case to buy the rare one.
1636 if shape != LineageShape::Resolved {
1637 assert!(
1638 text.contains("SEARCH transaction_log USING INDEX idx_txlog_fold_partition"),
1639 "{shape:?}: the transaction-time bound stopped seeking:\n{text}"
1640 );
1641 assert!(
1642 !text.contains("USE TEMP B-TREE FOR ORDER BY"),
1643 "{shape:?}: the fold is sorting its input again, which is \
1644 the whole of what the partition index buys it:\n{text}"
1645 );
1646 }
1647 }
1648 }
1649}