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}