Skip to main content

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}