Skip to main content

macrame/graph/
builder.rs

1use crate::error::{Result, StatedInstants};
2use crate::graph::lineage::{
3    ancestry_cte, churned_cte, lineage_shape, links_cut_cte, visible_cte, LineageShape,
4};
5use crate::schema::ddl;
6use crate::temporal::as_of::NodeAttributes;
7
8/// Attribute hydration mode for temporal traversals (§5.2).
9#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
10#[non_exhaustive]
11pub enum AttributeMode {
12    /// Live attributes from concepts table. Fast. Documented as WRONG for historical text.
13    Current,
14    /// Attributes as believed at ts, hydrated from transaction_log.
15    AtTime,
16    /// Topology only; concepts join is omitted.
17    ///
18    /// **Use [`TraversalBuilder::execute_ids`], not [`TraversalBuilder::execute`].**
19    /// `execute` returns `Vec<NodeAttributes>`, and there are no attributes to
20    /// return under this mode, so it answers `Ok(vec![])` — which a caller
21    /// cannot tell apart from a traversal that reached nothing. `execute_ids`
22    /// returns exactly what this mode is for, and distinguishes the two cases by
23    /// construction.
24    ///
25    /// Kept rather than removed (Wave 4.5) because it is meaningful where the
26    /// mode is a *parameter* — `hydrate_attributes` and `FilteredVectorSearch`
27    /// both take one and are right to accept "no attributes" as a choice. It is
28    /// only `execute`'s return type that cannot express it.
29    Omit,
30}
31
32/// Recursive CTE traversal query builder (§5.2).
33#[derive(Debug, Clone)]
34pub struct TraversalBuilder {
35    pub start_node: String,
36    pub max_depth: usize,
37    pub edge_types: Vec<String>,
38    pub min_weight: f64,
39    /// `None` means *defaulted*, not `Current` (T3.2, D-085).
40    ///
41    /// The distinction is the whole mechanism. `Current` chosen by a caller who
42    /// knows what it means is a legitimate, fast answer; `Current` arrived at by
43    /// never touching the setting, on a query about the past, is a wrong answer
44    /// nobody asked for. Those two produce identical behaviour and must not be
45    /// stored identically, so the field records which happened.
46    ///
47    /// Public for construction-by-struct-literal, which is why it is an `Option`
48    /// here rather than a private `bool` beside the mode: a caller building the
49    /// struct directly should have to write down the same thing the builder
50    /// method records.
51    pub attribute_mode: Option<AttributeMode>,
52    /// The **valid-time** instant to traverse at, if it is not the present.
53    ///
54    /// Added in 0.6.0 as `as_of` so "as of Tuesday" is *expressible*. Before
55    /// that, the instant arrived as `execute`'s `now_ts` parameter and a
56    /// historical traversal was indistinguishable from a live one — which is why
57    /// the mismatch with `AttributeMode::Current` could only ever be a `warn!`:
58    /// nothing in the call had the information needed to raise an error.
59    ///
60    /// **Renamed from `as_of` in 0.13.2 (W7.1, D-174).** The old name carried
61    /// one instant onto two clocks; the method's own docs are where that is
62    /// argued out.
63    pub as_of_valid: Option<String>,
64    /// The **transaction-time** instant to traverse at: *what did we believe
65    /// then* (0.13.2, W7.1, D-174).
66    ///
67    /// `None` — the default — means current belief, and the walk reads
68    /// `links_current` exactly as it always has. `Some(t)` folds
69    /// `transaction_log` to `t` instead, so the topology is the one the ledger
70    /// held at `t` rather than the one it holds now. See
71    /// [`Self::as_of_recorded`].
72    pub as_of_recorded: Option<String>,
73    /// The lineage this traversal reads (§15.3, D-220).
74    ///
75    /// `None` is what every traversal written before v12 meant and what every
76    /// database without a fork still holds: the trunk. `Some(id)` reads that
77    /// branch's belief — one row per edge key, taken from the **nearest**
78    /// branch on the path from it to the root, so a branch that corrects or
79    /// retires an inherited edge is seen to have done so.
80    ///
81    /// **An unregistered lineage is refused rather than defaulted**, by
82    /// `graph::lineage::lineage_shape`. Answering it
83    /// with the trunk's view is the answer a caller is least able to detect,
84    /// because on a database that has never forked it is the answer they
85    /// expected anyway.
86    ///
87    /// Set through [`Self::on_branch`].
88    pub branch: Option<String>,
89    /// Whether [`crate::Database::load_subgraph_with`] should fetch
90    /// `concepts.content` (0.8.0, B3, D-116).
91    ///
92    /// **Default `false`, which is a change in what a load returns.** No
93    /// algorithm reads document text, and at realistic document sizes it is
94    /// most of the byte budget, so the default was spending the budget on bytes
95    /// nothing would look at. A caller who needs it asks; one who does not gets
96    /// `NodeData::content() == None`, which is distinguishable from an empty
97    /// document.
98    ///
99    /// Ignored by [`crate::Database::load_subgraph`], which has no builder and
100    /// never loads content.
101    pub content: bool,
102}
103
104impl TraversalBuilder {
105    pub fn new(start_node: impl Into<String>) -> Self {
106        Self {
107            start_node: start_node.into(),
108            max_depth: 3,
109            edge_types: Vec::new(),
110            min_weight: 0.0,
111            attribute_mode: None,
112            as_of_valid: None,
113            as_of_recorded: None,
114            branch: None,
115            content: false,
116        }
117    }
118
119    /// Read one lineage's belief rather than the trunk's (§15.3, D-220).
120    ///
121    /// When this shipped, the only way to put a second lineage into a database
122    /// was raw SQL. The read went first because it is the half that had to be
123    /// measured (D-219), and because a write that creates something unreadable
124    /// is the worse order to ship the two halves in. `fork()` arrived at
125    /// **0.14.7** — this comment said 0.14.5 until 0.14.9, which is a stale
126    /// prediction rather than a record, and the kind D-223 and D-224 were both
127    /// found by reading. [`BranchView::traversal`](crate::BranchView::traversal)
128    /// seeds this from a lineage the caller already holds.
129    pub fn on_branch(mut self, branch: impl Into<String>) -> Self {
130        self.branch = Some(branch.into());
131        self
132    }
133
134    pub fn max_depth(mut self, depth: usize) -> Self {
135        self.max_depth = depth;
136        self
137    }
138
139    pub fn edge_types(mut self, types: Vec<String>) -> Self {
140        self.edge_types = types;
141        self
142    }
143
144    pub fn min_weight(mut self, weight: f64) -> Self {
145        self.min_weight = weight;
146        self
147    }
148
149    /// State the attribute mode explicitly.
150    ///
151    /// Calling this is what turns `Current` from a default into a decision, and
152    /// [`Self::execute`] treats the two differently on a historical traversal —
153    /// see [`Self::as_of_valid`].
154    pub fn attribute_mode(mut self, mode: AttributeMode) -> Self {
155        self.attribute_mode = Some(mode);
156        self
157    }
158
159    /// Fetch `concepts.content` into every hydrated node (0.8.0, B3, D-116).
160    ///
161    /// Off by default. Turning it on is what the byte budget is then spent on:
162    /// at 20 KB per concept, document text is the large majority of a loaded
163    /// graph, and none of the six algorithms reads it.
164    pub fn content(mut self, content: bool) -> Self {
165        self.content = content;
166        self
167    }
168
169    /// Traverse the graph as it was **in the world** at `ts` — the valid-time
170    /// axis (§5.2, W7.1).
171    ///
172    /// # This was `as_of`, and the rename is the fix (0.13.2, W7.1, D-174)
173    ///
174    /// [Doctrine VIII](../docs/architecture/s0-s3-foundations.md#doctrine-viii)
175    /// says a query that mixes the two clocks says so in its signature. `as_of`
176    /// did not: one timestamp reached `links.valid_from`/`valid_to` on the
177    /// **valid-time** axis and `transaction_log.recorded_at` on the
178    /// **transaction-time** axis, so `as_of(t).attribute_mode(AtTime)` answered
179    /// *"the edges valid at `t`, labelled with what we believed at `t`"* — two
180    /// questions under one word. [§3.1](../docs/architecture/s0-s3-foundations.md)
181    /// named it; 0.12.17 (W5.6, D-160) wrote the semantics down without changing
182    /// them, precisely so this change could be reviewed against a stated
183    /// position; this is that change.
184    ///
185    /// The two axes are now two parameters, and they compose:
186    ///
187    /// | set | topology comes from | attributes come from |
188    /// |---|---|---|
189    /// | neither | `links_current`, at `now_ts` | live `concepts` |
190    /// | `as_of_valid(v)` | `links_current`, bounded at `v` | `concepts` valid at `v` |
191    /// | `as_of_recorded(r)` | `transaction_log` folded to `r`, bounded at `now_ts` | the payload believed at `r` |
192    /// | both | folded to `r`, bounded at `v` | believed at `r`, valid at `v` |
193    ///
194    /// The last row is the cell Jensen and Snodgrass's BCDM defines a bitemporal
195    /// database as answering — *what did we believe at `r` about what was true at
196    /// `v`* — and before this it was not expressible on any surface in the crate.
197    ///
198    /// # Setting either instant makes the attribute mode a required decision (T3.2, D-085)
199    ///
200    /// A historical traversal has two independent questions and until 0.6.0 only
201    /// one of them was asked. The topology comes from the instants. The node
202    /// *attributes* — titles, content — come from wherever [`AttributeMode`]
203    /// says, and the default said `Current`, which is live text. So a historical
204    /// traversal returned the past's graph wearing today's titles, and reported
205    /// that through a `tracing::warn!` — invisible in any application that has
206    /// not configured a subscriber, which is most of them at first run.
207    ///
208    /// So: with either instant set and no [`Self::attribute_mode`] call,
209    /// [`Self::execute`] returns
210    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
211    /// rather than guessing. Both answers stay available and neither is silent:
212    ///
213    /// ```no_run
214    /// # use macrame::graph::{AttributeMode, TraversalBuilder};
215    /// # async fn f(conn: &libsql::Connection, now: &str) -> macrame::Result<()> {
216    /// // What was true on Tuesday, as best we now know.
217    /// let then = TraversalBuilder::new("a")
218    ///     .as_of_valid("2026-01-06T00:00:00.000000Z")
219    ///     .attribute_mode(AttributeMode::AtTime)
220    ///     .execute(conn, now)
221    ///     .await?;
222    ///
223    /// // Tuesday's topology with today's titles — legitimate, and now stated.
224    /// let mixed = TraversalBuilder::new("a")
225    ///     .as_of_valid("2026-01-06T00:00:00.000000Z")
226    ///     .attribute_mode(AttributeMode::Current)
227    ///     .execute(conn, now)
228    ///     .await?;
229    /// # Ok(()) }
230    /// ```
231    ///
232    /// A traversal with neither instant is a query about now, where `Current`
233    /// and `AtTime` agree about which text to return, so the default stands and
234    /// no caller has to change.
235    ///
236    /// # What the rename buys, concretely
237    ///
238    /// Suppose a concept's title is corrected today, fixing a typo made in 2020.
239    /// Under the old `as_of("2020-06-01")` with `AtTime` the answer was the
240    /// **uncorrected** title, because the correction was *recorded* after `ts` —
241    /// the right answer to *what did we believe in 2020* and the wrong one to
242    /// *what was true in 2020*, which is what the name promised. Now
243    /// `as_of_valid("2020-06-01")` alone gives the corrected title,
244    /// `as_of_recorded("2020-06-01")` gives the uncorrected one, and a caller
245    /// asking for either says which.
246    ///
247    /// The second, smaller mismatch W5.6 recorded is closed by the same change:
248    /// `AtTime` hydration consulted the payload's `retired` flag and never the
249    /// concept's **own valid interval**, so a concept whose validity had ended
250    /// still hydrated. It is now bounded by whichever instants are set, so the
251    /// two halves of the answer agree about what "existed then" means.
252    pub fn as_of_valid(mut self, ts: impl Into<String>) -> Self {
253        self.as_of_valid = Some(ts.into());
254        self
255    }
256
257    /// Traverse the graph as the ledger **believed** it at `ts` — the
258    /// transaction-time axis (0.13.2, W7.1, D-174).
259    ///
260    /// Where [`Self::as_of_valid`] asks *what was true*, this asks *what did we
261    /// think was true*. Setting it moves the walk off `links_current` and onto a
262    /// fold of `transaction_log` bounded at `ts` — the same fold
263    /// [`crate::temporal::reconstruct`] performs. That operation still exists
264    /// and still returns the whole state; this makes the same instant reachable
265    /// from a *traversal*, which is what lets the two axes be set on one query.
266    ///
267    /// # This reads the hot log, and refuses rather than guessing
268    ///
269    /// A fold can only answer for instants the hot log still covers.
270    /// [`crate::Database::archive`] removes superseded rows, so an instant below
271    /// what remains is not *before history*, it is *history that is in the other
272    /// file* — and this surface takes a connection, not an archive path, so it
273    /// cannot go and get it. It returns
274    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
275    /// naming the instant and pointing at `reconstruct`, which does take the
276    /// path. Answering from a partial fold would return *nearly* the right
277    /// topology, which is the worst failure available to a ledger.
278    ///
279    /// # Cost, stated rather than discovered
280    ///
281    /// `links_current` is a projection maintained for exactly this read and
282    /// indexed for it (`idx_lc_traversal_cover`). The fold is a window function
283    /// over `transaction_log` with a `json_extract` per column, materialised
284    /// once per query and joined per hop. It is not the fast path and is not
285    /// meant to be. W10.6 measures it and decides whether anything should be
286    /// built for it.
287    pub fn as_of_recorded(mut self, ts: impl Into<String>) -> Self {
288        self.as_of_recorded = Some(ts.into());
289        self
290    }
291
292    /// The valid-time instant this traversal reads at: [`Self::as_of_valid`] if
293    /// set, else `now_ts`.
294    ///
295    /// Note the asymmetry with [`Self::as_of_recorded`], which has no `now_ts`
296    /// fallback. An unset transaction-time instant means *current belief*, and
297    /// current belief is `links_current` rather than a fold bounded at the
298    /// present: the two are the same answer and only one of them is cheap.
299    pub(crate) fn valid_instant<'a>(&'a self, now_ts: &'a str) -> &'a str {
300        self.as_of_valid.as_deref().unwrap_or(now_ts)
301    }
302
303    /// The instant pair this traversal reads at, for the hydration layer.
304    pub(crate) fn instants(&self, now_ts: &str) -> crate::temporal::as_of::AsOf {
305        crate::temporal::as_of::AsOf {
306            valid: Some(self.valid_instant(now_ts).to_string()),
307            recorded: self.as_of_recorded.clone(),
308        }
309    }
310
311    /// Compile the recursive CTE query string as specified in §5.2.
312    ///
313    /// Edge types become bind placeholders, not quoted literals. An earlier
314    /// version spliced them in with `format!("'{t}'")`, which made any caller
315    /// string a SQL fragment on the *read* path — and the only validation in the
316    /// crate, [`super::edge::validate_edge_type`], runs in
317    /// [`super::EdgeAssertion::normalized`] on the *write* path, so a traversal
318    /// never passed through it. Binding removes the question rather than
319    /// answering it: unlike a table name, an edge type is a value, and values
320    /// can be parameters.
321    /// # This function cannot ask the database, and the shape depends on it
322    ///
323    /// It emits the resolved form when a branch is named and the trunk form
324    /// when one is not, which is the shape this
325    /// builder's own configuration implies. That is exact on every database
326    /// holding a single lineage — which is every database this crate has
327    /// written — and it is *not* exact on a forked one, where an unbranched
328    /// traversal reads the ancestor's and the descendant's rows alike.
329    ///
330    /// The execution paths do not have that gap: [`Self::execute_ids`],
331    /// [`Self::execute`] and `Database::load_subgraph_with` each ask
332    /// `graph::lineage::lineage_shape` and pass the answer to the
333    /// shape-taking form of this method. This method stays for inspecting and
334    /// explaining the query — which is what its callers in `tests/` do — and
335    /// says so rather than quietly returning the shape that is usually right.
336    pub fn build_sql(&self) -> String {
337        self.build_sql_with(self.implied_shape())
338    }
339
340    /// The shape [`Self::build_sql`] assumes when nobody has asked the database.
341    pub(crate) fn implied_shape(&self) -> LineageShape {
342        if self.branch.is_some() {
343            LineageShape::Resolved
344        } else {
345            LineageShape::Trunk
346        }
347    }
348
349    /// [`Self::build_sql`] against a shape the caller has already established.
350    pub(crate) fn build_sql_with(&self, shape: LineageShape) -> String {
351        format!(
352            "{}{}",
353            self.walk_cte(shape),
354            r#"
355SELECT DISTINCT w.node_id
356FROM walk w JOIN concepts c ON c.id = w.node_id
357WHERE c.retired = 0
358ORDER BY w.node_id;
359            "#
360        )
361    }
362
363    /// Where the reading branch binds, when the shape has one: `?5`.
364    ///
365    /// A [`LineageShape::Trunk`] read emits no `lineage` CTE and binds nothing
366    /// here, so everything after it moves back by one. That is why the shape has
367    /// to reach every method that lays out a placeholder rather than only the
368    /// one that emits the CTE.
369    pub(crate) const BRANCH_SLOT: usize = 5;
370
371    /// Where the transaction-time instant binds, when the traversal has one.
372    pub(crate) fn recorded_slot(shape: LineageShape) -> usize {
373        Self::BRANCH_SLOT + usize::from(shape == LineageShape::Resolved)
374    }
375
376    /// Where edge types start binding (0.13.2, W7.1; lineage slot 0.14.4).
377    ///
378    /// `?1..?4` are start, depth, the valid instant and `min_weight`. A
379    /// resolved read binds its branch next; a traversal with
380    /// [`Self::as_of_recorded`] set binds that next again; the variadic edge
381    /// types follow whatever is there.
382    ///
383    /// **This exists so the offset is computed once rather than agreed twice.**
384    /// [`Self::bind_params`] and [`Self::edge_filter_sql`] are the only two
385    /// places that care, they must agree exactly, and the previous arrangement —
386    /// a hard-coded `5` in one file and a comment in the other saying both call
387    /// sites push in the same order — is the shape D-030 and D-035 are about.
388    /// The lineage slot is the second thing to shift this layout and it shifted
389    /// it in one place.
390    pub(crate) fn edge_type_base(&self, shape: LineageShape) -> usize {
391        Self::recorded_slot(shape) + usize::from(self.as_of_recorded.is_some())
392    }
393
394    /// The `AND l.edge_type IN (…)` fragment, or empty when unfiltered.
395    ///
396    /// Placeholders start at [`Self::edge_type_base`]. Bound, never spliced: an
397    /// edge type is caller data, and the crate's only validation of one runs on
398    /// the *write* path (D-039), so a traversal never passes through it.
399    pub(crate) fn edge_filter_sql(&self, shape: LineageShape) -> String {
400        if self.edge_types.is_empty() {
401            String::new()
402        } else {
403            let base = self.edge_type_base(shape);
404            let placeholders: Vec<String> = (0..self.edge_types.len())
405                .map(|i| format!("?{}", i + base))
406                .collect();
407            format!(" AND l.edge_type IN ({})", placeholders.join(", "))
408        }
409    }
410
411    /// Every parameter the walk and its projections bind, in placeholder order.
412    ///
413    /// One producer for both consumers ([`Self::execute_ids`] and
414    /// `Database::load_subgraph_with`), for the reason [`Self::edge_type_base`]
415    /// gives: they previously agreed by comment, and one of them had already
416    /// drifted — the subgraph loader bound `now_ts` at `?3` where the builder
417    /// bound the traversal's own instant, so **a historical `load_subgraph_with`
418    /// silently read the present** (F-35, W7.1).
419    pub(crate) fn bind_params(&self, now_ts: &str, shape: LineageShape) -> Vec<libsql::Value> {
420        let mut params: Vec<libsql::Value> = vec![
421            self.start_node.as_str().into(),
422            (self.max_depth as i64).into(),
423            self.valid_instant(now_ts).into(),
424            self.min_weight.into(),
425        ];
426        // Pushed only under `Resolved`, because only there does any emitted SQL
427        // name `BRANCH_SLOT`. An unbranched traversal on a forked database still
428        // reaches this arm — `lineage_shape` answers for the database, not for
429        // the builder — and reads `main`'s own lineage, which is the trunk's
430        // belief and not the union of everything stored.
431        if shape == LineageShape::Resolved {
432            params.push(self.branch.as_deref().unwrap_or(ddl::MAIN_BRANCH).into());
433        }
434        if let Some(recorded) = self.as_of_recorded.as_deref() {
435            params.push(recorded.into());
436        }
437        params.extend(self.edge_types.iter().map(|t| t.as_str().into()));
438        params
439    }
440
441    /// The relation the walk and the projections read edges from.
442    ///
443    /// Under [`LineageShape::Resolved`] that is always `visible`, which holds
444    /// one row per edge key from the nearest lineage that has one *and was
445    /// entitled to be seen*; the walk and the projection do not need to know
446    /// which relation it reduced, nor that the reduction had two arms
447    /// (D-223). Under `Trunk` it is that relation directly.
448    pub(crate) fn link_source(&self, shape: LineageShape) -> &'static str {
449        match shape {
450            LineageShape::Resolved => "visible",
451            LineageShape::Trunk => self.unresolved_source(),
452        }
453    }
454
455    /// The relation a [`LineageShape::Trunk`] walk reads directly.
456    ///
457    /// `links_current` under current belief; the `links_at_tx` fold otherwise.
458    /// On a database with one lineage there is nothing to resolve and nothing
459    /// to bound: the only cutoff a read could apply is a fork point, and a
460    /// one-row `branches` has none.
461    pub(crate) fn unresolved_source(&self) -> &'static str {
462        if self.as_of_recorded.is_some() {
463            "links_at_tx"
464        } else {
465            "links_current"
466        }
467    }
468
469    /// The relation [`visible_cte`] reduces under [`LineageShape::Resolved`].
470    ///
471    /// Not the same pair as [`Self::unresolved_source`], and the difference is
472    /// the whole of D-223. A transaction-time read already folds the log, so
473    /// `links_at_tx` takes the ancestry's cutoffs as one more bound on rows it
474    /// was going to read anyway. A **current-belief** read cannot: the
475    /// projection holds one row per key per lineage and the sync trigger
476    /// overwrites it, so a lineage's pre-fork belief about a churned edge is
477    /// not in `links_current` to be filtered — it is in the log, and
478    /// [`links_cut_cte`] is the hybrid that goes and gets exactly those.
479    ///
480    /// Both expose the columns `links_current` does, which is what lets the
481    /// rest of the SQL — and [`visible_cte`] — be written once.
482    pub(crate) fn resolved_source(&self) -> &'static str {
483        if self.as_of_recorded.is_some() {
484            "links_at_tx"
485        } else {
486            "links_cut"
487        }
488    }
489
490    /// `links_current` as the ledger believed it at the recorded instant, or
491    /// nothing at all (W7.1, D-174; lineage 0.14.4, D-220).
492    ///
493    /// `links_current` is a *projection of current belief*: the sync trigger
494    /// upserts each corrected edge over its predecessor, so the row that was
495    /// there before a correction is not in the table any more. It is in
496    /// `transaction_log`, because links are strictly append-only — every
497    /// assertion and every correction is an `INSERT`, each logged `'I'` with
498    /// `entity_id = source|target|type|valid_from` — so the last log row per
499    /// entity at or before the instant *is* what `links_current` held then.
500    ///
501    /// # The partition, and the third column it needed
502    ///
503    /// `table_name` is not in the partition because it is in the `WHERE`, which
504    /// is the same discriminator applied one step earlier; that much has always
505    /// been sound and the four folds in `replay.rs` make the other choice
506    /// deliberately.
507    ///
508    /// `branch_id` **was** missing, and that was a defect rather than a
509    /// difference of style. `entity_id` is the edge key and it is shared across
510    /// lineages by design — that is exactly how a branch corrects an edge it
511    /// inherited — so a partition on `entity_id` alone put an ancestor's row and
512    /// a descendant's row in one group and kept whichever carried the higher
513    /// `seq_id`. Two lineages' assertions collapsed to one, and which one
514    /// survived was decided by write order.
515    ///
516    /// D-216 fixed this shape in `replay.rs`, [`ddl`]'s own log triggers were
517    /// written knowing it, and this fold was left behind because its rustdoc
518    /// argued the partition was sound and the argument it gave — about the
519    /// concept/link collision — was true and about something else. A correct
520    /// justification for the wrong claim reads exactly like a correct claim,
521    /// which is why the note now names what it does *not* cover.
522    ///
523    /// `branch_id` is also **selected**, not only partitioned on: it is the
524    /// column [`visible_cte`] joins the ancestry against, so the fold has to
525    /// carry it out.
526    ///
527    /// There is no `'D'` arm because there are no link deletes:
528    /// `trg_links_guard_delete` refuses them outside an archive session, and an
529    /// archive session removes the *log rows* rather than logging a removal.
530    ///
531    /// # The second bound, which is per row rather than per query (D-223)
532    ///
533    /// Under [`LineageShape::Resolved`] the fold is bounded twice: by the
534    /// traversal's own transaction instant, which is one value for the whole
535    /// query, and by each ancestor's visibility cutoff, which is a different
536    /// value per lineage. Joining `lineage` here rather than filtering after
537    /// the window is not a style choice — `ROW_NUMBER()` picks the last entry
538    /// *per partition*, so a post-cutoff row left in the input wins its
539    /// partition and is then discarded, taking the pre-cutoff row that should
540    /// have won with it. The bound has to be inside.
541    ///
542    /// This is also why the transaction-time path needs no
543    /// [`links_cut_cte`]: it is already reading the log, so the cutoff is one
544    /// more `WHERE` clause rather than a second source.
545    fn links_at_tx_cte(&self, shape: LineageShape) -> Option<String> {
546        self.as_of_recorded.as_ref()?;
547        let ts = Self::recorded_slot(shape);
548        let (lineage_join, cutoff) = match shape {
549            LineageShape::Resolved => (
550                "\n        JOIN lineage g ON g.branch_id = transaction_log.branch_id",
551                "\n          AND (g.cutoff IS NULL OR transaction_log.recorded_at <= g.cutoff)",
552            ),
553            LineageShape::Trunk => ("", ""),
554        };
555        Some(format!(
556            r#"links_at_tx(source_id, target_id, edge_type, valid_from, valid_to, weight, branch_id) AS (
557    SELECT json_extract(payload, '$.source_id'),
558           json_extract(payload, '$.target_id'),
559           json_extract(payload, '$.edge_type'),
560           json_extract(payload, '$.valid_from'),
561           json_extract(payload, '$.valid_to'),
562           json_extract(payload, '$.weight'),
563           branch_id
564    FROM (
565        SELECT transaction_log.payload, transaction_log.branch_id,
566               ROW_NUMBER() OVER (
567                   PARTITION BY transaction_log.entity_id, transaction_log.branch_id
568                   ORDER BY transaction_log.seq_id DESC
569               ) AS rn
570        FROM transaction_log{lineage_join}
571        WHERE transaction_log.table_name = 'links'
572          AND transaction_log.recorded_at <= ?{ts}{cutoff}
573    ) WHERE rn = 1
574)"#
575        ))
576    }
577
578    /// Refuse a transaction-time instant the hot log can no longer answer for.
579    ///
580    /// See [`Self::as_of_recorded`]. Cheap enough to run unconditionally on the
581    /// folded path — two aggregates over an indexed column — and it only runs
582    /// there, so the ordinary traversal pays nothing.
583    pub(crate) async fn check_recorded_reach(&self, conn: &libsql::Connection) -> Result<()> {
584        let Some(ts) = self.as_of_recorded.as_deref() else {
585            return Ok(());
586        };
587        if crate::temporal::replay::hot_log_answers_for(conn, ts).await? {
588            return Ok(());
589        }
590        Err(crate::error::DbError::RecordedInstantUnreachable { ts: ts.to_string() })
591    }
592
593    /// The recursive `walk` CTE — **the one copy** (T0.1).
594    ///
595    /// [`Self::build_sql`] and `Database::load_subgraph_with` append their own
596    /// projections to this. They previously carried byte-identical copies of the
597    /// recursion in two files, and had already drifted once: D-073 found the
598    /// subgraph loader taking neither `edge_types` nor `min_weight` while this
599    /// builder took both. Two copies of a query that must agree is the same
600    /// failure class as [D-030](../../docs/architecture/s13-decision-register.md)
601    /// and D-035, applied to SQL.
602    ///
603    /// **`UNION`, not `UNION ALL`, and no `path` column (T0.1).** The shipped
604    /// form carried a `path` of visited ids and refused a target already in it,
605    /// which restricts the walk to *simple paths* — so `walk` held one row per
606    /// distinct path to each node rather than one row per node, and the trailing
607    /// `SELECT DISTINCT` collapsed the duplication only after the work was done.
608    /// On a tree that costs nothing, because a tree has exactly one path to each
609    /// node; on a graph the row count is multiplicative in branching factor per
610    /// hop. Measured on libSQL 0.9.30 over a layered fixture (root, then *L*
611    /// layers of *W*, each fully joined to the next): a **328-edge** graph at
612    /// depth 6 produced **299,593** walk rows and took **428 ms**. The same
613    /// traversal here produces 49 rows in 0.1 ms.
614    ///
615    /// `UNION` dedupes on `(node_id, depth)` as rows enter the queue, so `walk`
616    /// is bounded by `V × (depth+1)` and termination comes from the depth bound
617    /// rather than from inspecting the path. The projections keep their
618    /// `DISTINCT`, because a node still legitimately appears at several depths.
619    ///
620    /// **Equivalence, argued rather than only measured.** The old form admits
621    /// only simple paths; this one admits any walk. The reachable sets are the
622    /// same: if a walk of length `k ≤ D` reaches `X`, excising its cycles yields
623    /// a simple path of length `≤ k` that also reaches `X`. So simple-path
624    /// reachability within `D` equals walk reachability within `D`, and the two
625    /// forms differed only in how much redundant work they did to establish it.
626    /// A property test over generated graphs — cycles, self-loops, diamonds and
627    /// expired edges, the four shapes the proof steps over — compares this form
628    /// against the old one at depths 1–4 and requires identical node *and* edge
629    /// sets (`integrity_property_tests`, 512 cases).
630    ///
631    /// **The recursion is one copy across both lineage shapes too (0.14.4).**
632    /// `shape` changes what the prelude holds and what `{source}` names; the
633    /// walk itself is the same text either way, because
634    /// [`visible_cte`] exposes the columns `links_current` does. A second copy
635    /// of the recursion for the resolved read would have been the T0.1 defect
636    /// re-introduced by a feature rather than inherited from one.
637    ///
638    /// **It is not free on a tree, and the plan that proposed it said it was.**
639    /// `UNION` maintains a dedupe b-tree over every row entering the queue; on a
640    /// tree nothing is ever deduped, so that is pure overhead. Measured on the
641    /// star-of-stars fixture at depth 3, best of 15, stable across runs:
642    /// 1,011 nodes 1.6 ms either way, 5,051 nodes 8.9 → 9.5 ms, 10,101 nodes
643    /// 17.8 → 19.6 ms — roughly **8–10% slower** where the old form was already
644    /// optimal, against ~2,000× faster where it was not. Recorded rather than
645    /// smoothed over: the trade is overwhelmingly worth taking and it is still a
646    /// trade, and "within noise" was a claim from a different engine's numbers.
647    pub(crate) fn walk_cte(&self, shape: LineageShape) -> String {
648        let edge_filter = self.edge_filter_sql(shape);
649        let source = self.link_source(shape);
650
651        // The prelude is assembled rather than concatenated so that the commas
652        // between CTEs are placed once. Order matters twice: `visible` reads
653        // both `lineage` and the fold, and SQLite resolves a `WITH` list in the
654        // order it is written.
655        let mut prelude: Vec<String> = Vec::new();
656        if shape == LineageShape::Resolved {
657            prelude.push(ancestry_cte(Self::BRANCH_SLOT, ""));
658        }
659        prelude.extend(self.links_at_tx_cte(shape));
660        if shape == LineageShape::Resolved {
661            // The hybrid is for *current* belief only; see `resolved_source`
662            // for why the folded path applies its cutoffs in place instead.
663            if self.as_of_recorded.is_none() {
664                prelude.push(churned_cte(""));
665                prelude.push(links_cut_cte(""));
666            }
667            prelude.push(visible_cte(self.resolved_source(), ""));
668        }
669        let prelude: String = prelude.iter().map(|cte| format!("{cte},\n")).collect();
670
671        format!(
672            r#"
673WITH RECURSIVE {prelude}walk(node_id, depth) AS (
674    SELECT ?1, 0
675    UNION
676    SELECT l.target_id, w.depth + 1
677    FROM walk w
678    JOIN {source} l ON l.source_id = w.node_id
679    WHERE w.depth < ?2
680      AND l.valid_from <= ?3 AND ?3 < l.valid_to
681      AND l.weight >= ?4
682      {edge_filter}
683)"#
684        )
685    }
686
687    /// Node ids reachable under this traversal, in id order (§5.2).
688    ///
689    /// Reads at [`Self::as_of_valid`] when set, else at `now_ts`, and under the
690    /// belief [`Self::as_of_recorded`] names when set, else current belief. No
691    /// attribute mode is involved, so this never returns
692    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated):
693    /// topology at an instant is unambiguous, and it is only the *pairing* with
694    /// live attributes that needed a decision.
695    ///
696    /// # Errors
697    ///
698    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
699    /// when [`Self::as_of_recorded`] is below what the hot log still covers.
700    ///
701    /// [`DbError::UnknownBranch`](crate::DbError::UnknownBranch), naming the
702    /// branch, when [`Self::on_branch`] names a lineage that is not registered
703    /// (0.14.4; `NotFound` until 0.14.7).
704    pub async fn execute_ids(
705        &self,
706        conn: &libsql::Connection,
707        now_ts: &str,
708    ) -> Result<Vec<String>> {
709        self.check_recorded_reach(conn).await?;
710        // The database decides the shape, not the builder: an unbranched
711        // traversal on a forked ledger must still resolve, or it reads every
712        // lineage's rows at once. See `build_sql` for why the pure function
713        // cannot answer this and does not pretend to.
714        let shape = lineage_shape(conn, self.branch.as_deref()).await?;
715        let sql = self.build_sql_with(shape);
716        let params = self.bind_params(now_ts, shape);
717
718        let mut rows = conn.query(&sql, params).await?;
719        let mut ids = Vec::new();
720        while let Some(row) = rows.next().await? {
721            ids.push(row.get(0)?);
722        }
723        Ok(ids)
724    }
725
726    /// Execute the traversal and hydrate attributes per [`Self::attribute_mode`]
727    /// (§5.2).
728    ///
729    /// The hydration is a second step rather than a join in the CTE because the
730    /// three modes read from two different places: `Current` and `Omit` from
731    /// `concepts`, `AtTime` from `transaction_log`. The previous version always
732    /// emitted the `concepts` join, so `attribute_mode` was stored, exposed by a
733    /// builder method, and never read — a caller asking for `AtTime` got live
734    /// attributes with no indication that the mode had been ignored. That is the
735    /// exact failure Doctrine II exists to prevent, arriving as a silent wrong
736    /// answer rather than as an error.
737    ///
738    /// **[`AttributeMode::Omit`] returns `Ok(vec![])` here**, which is
739    /// indistinguishable from a traversal that reached nothing. That is a
740    /// limitation of this method's return type rather than of the mode; callers
741    /// wanting topology only should use [`Self::execute_ids`], which says what it
742    /// found.
743    ///
744    /// # Errors
745    ///
746    /// [`DbError::AttributeModeUnstated`](crate::DbError::AttributeModeUnstated)
747    /// when either instant is set and [`Self::attribute_mode`] is not — see
748    /// [`Self::as_of_valid`] for why that combination is a question rather than
749    /// a default (T3.2, D-085).
750    ///
751    /// [`DbError::RecordedInstantUnreachable`](crate::DbError::RecordedInstantUnreachable)
752    /// when [`Self::as_of_recorded`] is below what the hot log still covers.
753    ///
754    /// `now_ts` is the caller's present, and it is the fallback on *both* axes:
755    /// a traversal with neither instant set reads live topology and live text.
756    pub async fn execute(
757        &self,
758        conn: &libsql::Connection,
759        now_ts: &str,
760    ) -> Result<Vec<NodeAttributes>> {
761        let mode = self.resolved_mode()?;
762        let as_of = self.instants(now_ts);
763        let ids = self.execute_ids(conn, now_ts).await?;
764
765        // `Current` hydrates from `concepts` live and ignores both instants, so
766        // the pair it receives only matters for `AtTime`. Passing the traversal's
767        // own instants rather than `now_ts` is what makes a historical traversal
768        // with `AtTime` mean what it says — the whole point of the pairing this
769        // method requires the caller to state.
770        crate::temporal::as_of::hydrate_attributes(conn, &ids, &as_of, mode).await
771    }
772
773    /// The mode to hydrate with, or the error that says the caller must choose.
774    ///
775    /// Kept separate from [`Self::execute`] so it is unit-testable without a
776    /// database: the property under test is a decision about two `Option`s, and
777    /// a test that needed a connection to check it would be testing something
778    /// else as well.
779    pub(crate) fn resolved_mode(&self) -> Result<AttributeMode> {
780        // Either instant makes the question live, and the error names *which*
781        // (0.13.10, W7.7, D-183). This was an `.or()` picking valid time first,
782        // which answered the caller with an axis they might not have asked
783        // about and dropped the other one when they had asked about both.
784        let instants =
785            StatedInstants::new(self.as_of_valid.as_deref(), self.as_of_recorded.as_deref());
786        match (instants, self.attribute_mode) {
787            (Some(instants), None) => {
788                Err(crate::error::DbError::AttributeModeUnstated { instants })
789            }
790            (_, Some(mode)) => Ok(mode),
791            (None, None) => Ok(AttributeMode::Current),
792        }
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799    use crate::error::DbError;
800
801    const TUE: &str = "2026-01-06T00:00:00.000000Z";
802
803    /// The only combination that is a question, and it is now asked.
804    #[test]
805    fn as_of_without_a_stated_mode_is_an_error() {
806        let err = TraversalBuilder::new("a")
807            .as_of_valid(TUE)
808            .resolved_mode()
809            .expect_err("past topology plus present text must not be a default");
810
811        match &err {
812            DbError::AttributeModeUnstated { instants } => {
813                assert_eq!(instants.valid(), Some(TUE));
814                assert_eq!(instants.recorded(), None, "no belief instant was set");
815            }
816            other => panic!("got {other:?}"),
817        }
818
819        // And the message has to be actionable: a caller who reads only this
820        // should know which axis they asked about and which two calls resolve
821        // it. `as_of(…)` named a method that has not existed since 0.12.17.
822        let text = err.to_string();
823        assert!(
824            text.contains(&format!("as_of_valid({TUE})")),
825            "the message must name the call the caller made: {text}"
826        );
827        assert!(
828            text.contains("AtTime") && text.contains("Current"),
829            "{text}"
830        );
831    }
832
833    /// Stating `Current` on a historical traversal is legitimate and stays so.
834    ///
835    /// The fix must not be "forbid the fast path". Past topology with live text
836    /// is a real query — a caller rendering a historical diagram with today's
837    /// labels wants exactly it — and the objection was always to getting it
838    /// without asking, never to asking for it.
839    #[test]
840    fn a_stated_mode_is_honoured_on_a_historical_traversal() {
841        for mode in [
842            AttributeMode::Current,
843            AttributeMode::AtTime,
844            AttributeMode::Omit,
845        ] {
846            let got = TraversalBuilder::new("a")
847                .as_of_valid(TUE)
848                .attribute_mode(mode)
849                .resolved_mode()
850                .unwrap();
851            assert_eq!(got, mode);
852        }
853    }
854
855    /// The transaction-time instant raises the same question the valid-time one
856    /// does, and asking it on only one axis would be the same gap in a new place.
857    #[test]
858    fn a_recorded_instant_also_demands_a_stated_mode() {
859        let err = TraversalBuilder::new("a")
860            .as_of_recorded(TUE)
861            .resolved_mode()
862            .expect_err("past belief plus present text must not be a default");
863        assert!(
864            matches!(err, DbError::AttributeModeUnstated { .. }),
865            "{err:?}"
866        );
867        // The axis reaches the caller. Until 0.13.10 this said `as_of(…)`,
868        // which is the valid-time method's old name and not what was called.
869        let text = err.to_string();
870        assert!(text.contains(&format!("as_of_recorded({TUE})")), "{text}");
871        assert!(!text.contains("as_of("), "no dead method name: {text}");
872    }
873
874    /// Both axes set is the bitemporal cell, and dropping half of it was the
875    /// second half of the defect: the `.or()` reported valid time and said
876    /// nothing about the belief instant the caller had also stated.
877    #[test]
878    fn both_instants_are_reported_when_both_were_stated() {
879        const WED: &str = "2026-01-07T00:00:00.000000Z";
880        let err = TraversalBuilder::new("a")
881            .as_of_valid(TUE)
882            .as_of_recorded(WED)
883            .resolved_mode()
884            .expect_err("the cell needs a stated mode as much as either axis");
885
886        match &err {
887            DbError::AttributeModeUnstated { instants } => {
888                assert_eq!(instants.valid(), Some(TUE));
889                assert_eq!(instants.recorded(), Some(WED));
890            }
891            other => panic!("got {other:?}"),
892        }
893        let text = err.to_string();
894        assert!(text.contains(TUE) && text.contains(WED), "{text}");
895    }
896
897    /// A traversal about now still defaults, so no existing caller changes.
898    ///
899    /// This is what keeps the change from being a breaking one for the common
900    /// case: with neither instant, `Current` and `AtTime` agree about which text
901    /// to return, so there is nothing to decide and nothing to ask.
902    #[test]
903    fn a_live_traversal_still_defaults_to_current() {
904        assert_eq!(
905            TraversalBuilder::new("a").resolved_mode().unwrap(),
906            AttributeMode::Current
907        );
908    }
909
910    /// `as_of_valid` supplies the instant the walk reads at; `now_ts` is the
911    /// fallback, and `as_of_recorded` never is — see `valid_instant`.
912    #[test]
913    fn as_of_valid_overrides_the_execute_timestamp() {
914        let now = "2026-06-01T00:00:00.000000Z";
915        assert_eq!(TraversalBuilder::new("a").valid_instant(now), now);
916        assert_eq!(
917            TraversalBuilder::new("a")
918                .as_of_valid(TUE)
919                .valid_instant(now),
920            TUE
921        );
922        assert_eq!(
923            TraversalBuilder::new("a")
924                .as_of_recorded(TUE)
925                .valid_instant(now),
926            now,
927            "fixing belief must not move the valid-time instant"
928        );
929    }
930
931    /// The two axes reach the hydration layer separately (W7.1, D-174).
932    ///
933    /// The property that made the old single parameter wrong was that one
934    /// instant arrived on both clocks. This asserts the negation directly, at the
935    /// boundary where the split has to survive: what `execute` hands to
936    /// `hydrate_attributes`.
937    #[test]
938    fn the_two_axes_reach_hydration_separately() {
939        let now = "2026-06-01T00:00:00.000000Z";
940        let mar = "2026-03-01T00:00:00.000000Z";
941
942        let live = TraversalBuilder::new("a").instants(now);
943        assert_eq!(live.valid.as_deref(), Some(now));
944        assert_eq!(live.recorded, None, "no instant means current belief");
945
946        let valid_only = TraversalBuilder::new("a").as_of_valid(TUE).instants(now);
947        assert_eq!(valid_only.valid.as_deref(), Some(TUE));
948        assert_eq!(valid_only.recorded, None);
949
950        let recorded_only = TraversalBuilder::new("a").as_of_recorded(mar).instants(now);
951        assert_eq!(
952            recorded_only.valid.as_deref(),
953            Some(now),
954            "fixing belief leaves valid time at the present"
955        );
956        assert_eq!(recorded_only.recorded.as_deref(), Some(mar));
957
958        let both = TraversalBuilder::new("a")
959            .as_of_valid(TUE)
960            .as_of_recorded(mar)
961            .instants(now);
962        assert_eq!(both.valid.as_deref(), Some(TUE));
963        assert_eq!(both.recorded.as_deref(), Some(mar));
964    }
965
966    /// Placeholder arithmetic is the one thing two call sites must agree on.
967    ///
968    /// `bind_params` and `edge_filter_sql` are separate functions that have to
969    /// produce the same layout, and both the recorded instant and the lineage
970    /// slot shift it. A test that counts is cheaper than the bug, which is an
971    /// edge type silently compared against a timestamp — or, since 0.14.4, a
972    /// branch id compared against one.
973    #[test]
974    fn the_recorded_instant_shifts_the_edge_type_placeholders() {
975        let now = "2026-06-01T00:00:00.000000Z";
976        let trunk = LineageShape::Trunk;
977
978        let plain = TraversalBuilder::new("a").edge_types(vec!["CITES".into()]);
979        assert_eq!(plain.edge_type_base(trunk), 5);
980        assert!(
981            plain.edge_filter_sql(trunk).contains("?5"),
982            "{}",
983            plain.edge_filter_sql(trunk)
984        );
985        assert_eq!(plain.bind_params(now, trunk).len(), 5);
986
987        let folded = plain.clone().as_of_recorded(TUE);
988        assert_eq!(folded.edge_type_base(trunk), 6);
989        assert!(
990            folded.edge_filter_sql(trunk).contains("?6"),
991            "{}",
992            folded.edge_filter_sql(trunk)
993        );
994        assert_eq!(folded.bind_params(now, trunk).len(), 6);
995    }
996
997    /// The lineage slot shifts everything after it, in both functions (0.14.4).
998    ///
999    /// This is the same property as the test above and it is written twice on
1000    /// purpose: the layout now has two independent shifts, and a test that only
1001    /// varied one of them would pass on a `recorded_slot` that ignored the shape
1002    /// entirely.
1003    #[test]
1004    fn the_lineage_slot_shifts_everything_after_it() {
1005        let now = "2026-06-01T00:00:00.000000Z";
1006        let (trunk, resolved) = (LineageShape::Trunk, LineageShape::Resolved);
1007
1008        let plain = TraversalBuilder::new("a").edge_types(vec!["CITES".into()]);
1009        assert_eq!(plain.edge_type_base(resolved), 6);
1010        assert!(plain.edge_filter_sql(resolved).contains("?6"));
1011        assert_eq!(plain.bind_params(now, resolved).len(), 6);
1012
1013        let folded = plain.clone().as_of_recorded(TUE);
1014        assert_eq!(folded.edge_type_base(resolved), 7);
1015        assert!(folded.edge_filter_sql(resolved).contains("?7"));
1016        assert_eq!(folded.bind_params(now, resolved).len(), 7);
1017
1018        // The branch lands in the slot the CTE reads it from, and it is the
1019        // *builder's* branch — not a positional accident that happens to hold a
1020        // string. `?5` is `BRANCH_SLOT`; `?6` is the recorded instant.
1021        let named = folded.clone().on_branch("b9");
1022        let params = named.bind_params(now, resolved);
1023        assert_eq!(
1024            params[TraversalBuilder::BRANCH_SLOT - 1],
1025            libsql::Value::from("b9")
1026        );
1027        assert!(ancestry_cte(TraversalBuilder::BRANCH_SLOT, "").contains("?5"));
1028        assert!(named.walk_cte(resolved).contains("recorded_at <= ?6"));
1029
1030        // And an unnamed traversal that still has to resolve reads the trunk's
1031        // own lineage rather than the union of every lineage stored.
1032        assert_eq!(
1033            folded.bind_params(now, resolved)[TraversalBuilder::BRANCH_SLOT - 1],
1034            libsql::Value::from(ddl::MAIN_BRANCH)
1035        );
1036
1037        // Nothing is bound for a slot the SQL never names.
1038        assert!(!folded.walk_cte(trunk).contains("lineage"));
1039    }
1040
1041    /// The fold replaces the projection, and only when it is asked for.
1042    #[test]
1043    fn the_link_source_follows_the_recorded_instant() {
1044        let trunk = LineageShape::Trunk;
1045
1046        let plain = TraversalBuilder::new("a");
1047        assert_eq!(plain.link_source(trunk), "links_current");
1048        assert!(!plain.walk_cte(trunk).contains("transaction_log"));
1049
1050        let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
1051        assert_eq!(folded.link_source(trunk), "links_at_tx");
1052        let sql = folded.walk_cte(trunk);
1053        assert!(sql.contains("links_at_tx"), "{sql}");
1054        assert!(sql.contains("recorded_at <= ?5"), "{sql}");
1055        assert!(
1056            sql.contains("table_name = 'links'"),
1057            "the partition is only sound with the discriminator filtered: {sql}"
1058        );
1059    }
1060
1061    /// The fold partitions by lineage, and the resolution can therefore see it.
1062    ///
1063    /// The defect this pins is not that the SQL was ugly: `PARTITION BY
1064    /// entity_id` put an ancestor's assertion and a descendant's correction of
1065    /// it into one group and kept the higher `seq_id`, so a transaction-time
1066    /// traversal on a forked ledger lost one of the two before the resolution
1067    /// ever ran. D-216 fixed the same shape in `replay.rs` one release earlier
1068    /// and this fold was not in that sweep.
1069    #[test]
1070    fn the_folded_source_partitions_by_lineage() {
1071        let folded = TraversalBuilder::new("a").as_of_recorded(TUE);
1072        let sql = folded.walk_cte(LineageShape::Resolved);
1073
1074        assert!(
1075            sql.contains("PARTITION BY transaction_log.entity_id, transaction_log.branch_id"),
1076            "two lineages' assertions collapse to one without this: {sql}"
1077        );
1078        // Qualified by table name rather than by an alias, and that is a plan
1079        // decision rather than a style one: 0.14.6's cutoff join brings a
1080        // second `branch_id` into scope so the columns must be qualified, and
1081        // `EXPLAIN QUERY PLAN` prints whatever the FROM clause named the table.
1082        // An alias would rewrite every plan guard that names `transaction_log`
1083        // — including the Trunk-shape seek assertion in `bitemporal_plan_tests`,
1084        // which is about a query this release does not otherwise touch.
1085        assert!(
1086            sql.contains("FROM transaction_log\n"),
1087            "the fold's table must keep its own name in the plan: {sql}"
1088        );
1089        // Carried out of the fold as well as partitioned on, because it is the
1090        // column the ancestry joins against.
1091        assert!(
1092            sql.contains("valid_to, weight, branch_id) AS ("),
1093            "the fold must expose what `visible` joins on: {sql}"
1094        );
1095        assert!(
1096            sql.contains("JOIN lineage g ON g.branch_id = l.branch_id"),
1097            "{sql}"
1098        );
1099    }
1100
1101    /// The resolution is one row per edge *key*, and the walk reads only that.
1102    #[test]
1103    fn the_resolved_shape_puts_the_resolution_between_the_walk_and_the_rows() {
1104        let walk = TraversalBuilder::new("a");
1105
1106        let resolved = walk.walk_cte(LineageShape::Resolved);
1107        assert_eq!(walk.link_source(LineageShape::Resolved), "visible");
1108        assert!(resolved.contains("JOIN visible l ON l.source_id = w.node_id"));
1109        assert!(
1110            resolved.contains("PARTITION BY l.source_id, l.target_id, l.edge_type, l.valid_from"),
1111            "the partition is the edge key, not the edge: {resolved}"
1112        );
1113
1114        // And the trunk shape is byte-for-byte what shipped before 0.14.4: no
1115        // ancestry, no window function, the walk reading the table directly.
1116        let trunk = walk.walk_cte(LineageShape::Trunk);
1117        assert!(!trunk.contains("lineage"), "{trunk}");
1118        assert!(!trunk.contains("ROW_NUMBER"), "{trunk}");
1119        assert!(trunk.contains("JOIN links_current l ON l.source_id = w.node_id"));
1120    }
1121
1122    /// `build_sql` answers for the configuration; only a connection knows more.
1123    #[test]
1124    fn the_pure_builder_takes_the_shape_its_configuration_implies() {
1125        assert_eq!(
1126            TraversalBuilder::new("a").implied_shape(),
1127            LineageShape::Trunk
1128        );
1129        assert_eq!(
1130            TraversalBuilder::new("a").on_branch("b9").implied_shape(),
1131            LineageShape::Resolved
1132        );
1133        assert!(TraversalBuilder::new("a")
1134            .on_branch("b9")
1135            .build_sql()
1136            .contains("lineage"));
1137        assert!(!TraversalBuilder::new("a").build_sql().contains("lineage"));
1138    }
1139}