macrame/graph/lineage.rs
1//! Reading one lineage's belief out of a ledger that holds several (§15.2,
2//! §15.3, D-219, D-220, D-223).
3//!
4//! # Three shapes, and the measurements that forced them
5//!
6//! `links_current` is keyed `(source_id, target_id, edge_type, valid_from,
7//! branch_id)` since v12, so a branch that corrects or retires an edge it
8//! inherited writes its **own** row beside the ancestor's rather than over it.
9//! Reading a lineage therefore means picking one row per edge key: the one
10//! belonging to the **nearest** branch on the path from the reader to the root.
11//!
12//! The naive alternative — admit every row whose `branch_id` is anywhere on the
13//! path — is not a resolution and `branch_traversal_probe` §4b measures what it
14//! costs. A branch that retires an inherited edge by shadowing it gets the
15//! *whole subtree back*: 1,111 nodes where the resolved read gives 1,000. The
16//! union form does not merely report a stale weight, it discards retirement.
17//!
18//! Resolution is not free and does not become free when there is nothing to
19//! resolve. Measured on a single-lineage database — every database this crate
20//! has ever written — the resolved traversal is **3.0×** the unresolved one,
21//! because a window function is opaque to the planner and it cannot see that
22//! every partition holds exactly one row. So the read path picks a shape, the
23//! way `temporal::replay::cold_lineage` picks one at the archive boundary
24//! (D-216): [`LineageShape::Trunk`] emits today's SQL, [`LineageShape::Resolved`]
25//! emits the ancestry join.
26//!
27//! The third shape is the trunk's, on a database that has forked
28//! ([`LineageShape::TrunkOnForked`], 0.15.2, D-244). Once `branches` holds
29//! two rows the trunk's read was `Resolved` like everyone else's, and paid
30//! the hybrid's fixed cost — D-223 measured 1.45× at zero churn, and the
31//! trunk has zero churn *structurally*: it has no ancestors, so no cutoff
32//! and no churned set. Its resolved read reduces to its own rows, and the
33//! third shape emits that reduction as one predicate on `branch_id`. It is
34//! the escalation D-223 named — the naive filter, emitted when the answer
35//! to *does any ancestor hold a post-cutoff row* is no — taken where the
36//! answer is no by construction rather than by probing.
37//!
38//! # Why one lineage is a sufficient condition for the fast shape
39//!
40//! `branch_id` is `NOT NULL DEFAULT 'main' REFERENCES branches(branch_id)` on
41//! every ledger table (D-215), and the key is real. So a `branches` table
42//! holding one row is a database in which every ledger row reads `'main'` — not
43//! by convention but because nothing else could have been stored. The ancestry
44//! of `main` is `{main}`, every partition has one member, and the resolved form
45//! and the plain form return the same rows by construction. The check is
46//! therefore exact rather than a heuristic, which is what makes it safe to skip
47//! the work rather than merely cheap.
48//!
49//! # The fork point is a visibility cutoff, and one filter cannot apply it
50//!
51//! 0.14.4 resolved *which lineage holds an edge* and never looked at
52//! `branches.forked_at`, so a branch kept absorbing writes its parent made
53//! after the fork. §15.3 says the opposite in as many words — rows written on
54//! each ancestor **before the fork point on the path down from A** — and
55//! `ddl`'s own comment calls `forked_at` "what §15.3's visibility cutoffs are
56//! computed over". Nothing computed them. 0.14.6 does (D-223).
57//!
58//! **The finding is not that a filter was missing.** `links_current` answers
59//! *current as of now* and structurally cannot answer *current as of t*: the
60//! projection holds one belief per key per lineage, and
61//! `trg_links_current_sync` is `ON CONFLICT … DO UPDATE … recorded_at =
62//! excluded.recorded_at`. So the moment the trunk reweights or retires an edge
63//! after the fork, the pre-fork version is **not in the table at all**, and its
64//! only home is `transaction_log`. Adding `recorded_at <= cutoff` to the
65//! existing read would therefore not show the branch its inherited edge — it
66//! would make that edge *vanish*, which is wrong in a new and quieter way.
67//! Every "just add the filter" instinct fails for that one reason.
68//!
69//! So the read is a **hybrid**: [`links_cut_cte`] takes the `links_current`
70//! rows a lineage may still see directly, and folds the log for exactly the
71//! keys where it may not. The fold arm's cost scales with **post-fork churn on
72//! the ancestors**, not with history size, and the untouched keys — the common
73//! case, because a fork exists to diverge from a trunk that mostly stands still
74//! — stay on the projection.
75//!
76//! **The two arms are disjoint by construction, and that is why [`churned_cte`]
77//! is defined over `links_current` rather than over the log.** One predicate on
78//! one row decides which arm emits a given `(edge key, lineage)`:
79//! `recorded_at <= cutoff` sends it to the projection arm, `>` sends it to the
80//! fold arm. Deriving the churned set from `transaction_log` instead would have
81//! been a cheaper seek — `idx_txlog_time` is a range index and the projection
82//! has none on `recorded_at` — but disjointness would then be an argument about
83//! what the archive does and does not remove, rather than a property of a
84//! single comparison. Two arms emitting one key would put two rows at the same
85//! `dist` into [`visible_cte`]'s partition, where the winner is whichever the
86//! engine happened to order first.
87//!
88//! **Where this degrades, named rather than left to be found.** The fold arm
89//! reads the *hot* log, and `LOG_ARCHIVABLE` archives any entry superseded by a
90//! later one for the same entity. A pre-fork assertion superseded by a
91//! post-fork correction is superseded, so once the retention horizon passes the
92//! fork point that entry can be cold — and then the branch loses an edge its
93//! ancestor churned. That is §3.2's already-carried `AtTime` degradation
94//! reached from the branch side, not a new class of loss, and it is bounded to
95//! churned keys: the projection arm never degrades, because `links_current` is
96//! re-derived from surviving `links` rather than deleted from. It is
97//! deliberately **not** guarded by `check_recorded_reach`, and the reason has
98//! changed shape without changing sides (0.15.4, D-246). It used to be that the
99//! guard's bit was coarse — any archive at all flipped it, so guarding here
100//! would have refused every branched read on every archived database. The guard
101//! is now scoped to the instants the archive really took, which removes that
102//! objection and leaves the smaller one: `links_cut` reads the log for a *fork
103//! point*, not for a belief, and a fork point that has gone cold is a different
104//! question from an instant that has. A cold arm for `links_cut` is the fix if
105//! it is ever needed, and it belongs with §3.2 rather than with the cutoff.
106
107use crate::error::{DbError, Result};
108use crate::graph::plan::{lower, Resolution};
109use crate::schema::ddl;
110
111/// Which form of the read to emit. See the module docs.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub(crate) enum LineageShape {
114 /// One lineage exists, so there is nothing to resolve and the plain SQL is
115 /// exact. Not an optimisation with a correctness caveat — see the module
116 /// docs for why the condition is sufficient.
117 Trunk,
118 /// More than one lineage exists. The read resolves along the ancestry.
119 Resolved,
120 /// More than one lineage exists and the reader is a **root** — the
121 /// trunk, on every database this crate can write (0.15.2, D-244).
122 ///
123 /// A root has no ancestors, so its ancestry is one row with no cutoff,
124 /// its churned set is empty by construction, and the resolved read
125 /// reduces exactly to *its own rows*: `links_current WHERE branch_id =
126 /// ?` under current belief, and the fold over its own log entries at a
127 /// recorded instant. This shape emits that reduction directly. It is
128 /// the third shape [D-223] named as the escalation — "the naive filter
129 /// emitted when no ancestor holds a post-cutoff row" — taken at the
130 /// one lineage for which the condition is structural rather than
131 /// measured, and it is what stops the trunk paying for the branches
132 /// (review C-7). It **binds** the branch, unlike `Trunk`, because its
133 /// SQL names it.
134 ///
135 /// [D-223]: ../../docs/architecture/s13-decision-register.md#d-223
136 TrunkOnForked,
137}
138
139impl LineageShape {
140 /// Whether the emitted SQL names the reading branch at all.
141 ///
142 /// `Trunk` is the one shape that does not: there is one lineage and
143 /// nothing to name. Every placeholder layout that puts the branch at a
144 /// slot asks this rather than comparing against `Resolved`, so that a
145 /// shape added later binds correctly by default.
146 pub(crate) fn binds_branch(self) -> bool {
147 !matches!(self, LineageShape::Trunk)
148 }
149}
150
151/// The shape, and the ancestry the shape needs — one round trip for both.
152///
153/// This is what `lineage_shape` became (0.15.17, [D-259]). Where that asked
154/// SQLite for three aggregates, this loads the rows and answers from them;
155/// measured, that is **10.0 µs against 11.0**
156/// (`examples/ancestry_resolve_probe.rs` §5), so the ancestry arrives for less
157/// than the shape alone used to cost and nothing has to be cached to afford it.
158///
159/// The ancestry is empty under the two trunk shapes. Neither emits a `lineage`
160/// relation, so resolving one would be a walk whose result no SQL names — and
161/// an empty slice is what makes [`crate::graph::plan::lower`] able to ignore
162/// the field rather than branch on whether it was populated.
163///
164/// A caller wanting the question and not the answer goes through [`Lineages`]
165/// directly: `branch::diff` loads the register once and asks [`Lineages::shape`]
166/// per name, which refuses an unregistered lineage before either side is
167/// lowered — and is one round trip for both, where a free function per name was
168/// two.
169///
170/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
171pub(crate) async fn resolve_for(
172 conn: &libsql::Connection,
173 branch: Option<&str>,
174) -> Result<(LineageShape, Vec<Ancestor>)> {
175 let named = branch.unwrap_or(ddl::MAIN_BRANCH);
176 let lineages = Lineages::load(conn).await?;
177 let shape = lineages.shape(named)?;
178 let ancestry = match shape {
179 LineageShape::Resolved => lineages.ancestry(named),
180 _ => Vec::new(),
181 };
182 Ok((shape, ancestry))
183}
184
185/// One lineage's row in `branches`, as the resolution needs it.
186///
187/// Three columns and not the table: `created_at` is the audit column and no
188/// read resolves over it, so loading it would be bytes moved for nothing.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub(crate) struct BranchRow {
191 pub(crate) id: String,
192 pub(crate) parent: Option<String>,
193 pub(crate) forked_at: Option<String>,
194}
195
196/// Every lineage the database holds — the table [`resolve`] walks.
197///
198/// A `Vec` and a linear scan, for the reason `distinct_branches` gives: the
199/// bound is the number of lineages, which is small and human-authored, and a
200/// map would allocate to index a list that is usually of length one.
201///
202/// # Why this is loaded per read rather than cached (0.15.17, [D-259])
203///
204/// [A-2] proposed a cached `Vec<Branch>` with a generation counter, on the
205/// premise that resolving ancestry in Rust needs the *rows* where the shape
206/// needed only three aggregates, and that the extra read has to be paid for.
207/// Measured (`examples/ancestry_resolve_probe.rs`, §5), it does not: loading 17
208/// rows costs **10.0 µs** against the three-aggregate `SELECT`'s **11.0 µs**,
209/// so the rows arrive for *less* than the answer they replace. The cache is an
210/// optimisation nothing has asked for, and a cache the read side does not need
211/// is a coherency question the read side does not have to answer.
212///
213/// The actor keeps its copy ([`crate::connection`]'s `ActorState`) because the
214/// write path already had one and invalidates it on the two commands that write
215/// `branches`. That is a cache with an owner; this is not.
216///
217/// [A-2]: ../../docs/Macrame%20Codebase%20Review%20v0.15.0.md
218/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub(crate) struct Lineages {
221 pub(crate) rows: Vec<BranchRow>,
222}
223
224impl Lineages {
225 /// Load `branches`, in no particular order.
226 ///
227 /// Unordered because every consumer here searches by name or walks parent
228 /// links, and neither cares; `Database::branches` is the listing that
229 /// promises trunk-first and it sorts for itself.
230 pub(crate) async fn load(conn: &libsql::Connection) -> Result<Self> {
231 let mut rows = conn
232 .query("SELECT branch_id, parent_id, forked_at FROM branches", ())
233 .await?;
234 let mut out = Vec::with_capacity(1);
235 while let Some(row) = rows.next().await? {
236 out.push(BranchRow {
237 id: row.get::<String>(0)?,
238 parent: row.get::<Option<String>>(1)?,
239 forked_at: row.get::<Option<String>>(2)?,
240 });
241 }
242 Ok(Self { rows: out })
243 }
244
245 fn find(&self, name: &str) -> Option<&BranchRow> {
246 self.rows.iter().find(|b| b.id == name)
247 }
248
249 /// The shape for one name, or [`DbError::UnknownBranch`].
250 ///
251 /// The same three facts `lineage_shape`'s `SELECT` asked for until 0.15.17 — the total,
252 /// the name's existence, and whether it is a root — read off the rows
253 /// instead of counted by SQLite.
254 /// # Refusing an unregistered lineage rather than answering for the trunk
255 ///
256 /// A read naming a branch that is not in `branches` has asked a question
257 /// about something that does not exist. Answering it with the trunk's view
258 /// would be the [D-069] failure — a right-looking answer to a question that
259 /// was not asked — and it is the answer a caller is *least* able to detect,
260 /// because on a database that has never forked the trunk's view is what
261 /// they expected to see anyway.
262 ///
263 /// The refusal is [`DbError::UnknownBranch`] from 0.14.7 and was
264 /// [`DbError::NotFound`] before it, whose `Display` reads *"node {0} not
265 /// found"* — the wrong noun, pointing a caller at their concept ids. There
266 /// was no better variant until `fork()` needed one, and shipping the right
267 /// variant while leaving this on the old one would have been two spellings
268 /// of one fact.
269 ///
270 /// [D-069]: ../../docs/architecture/s13-decision-register.md
271 pub(crate) fn shape(&self, name: &str) -> Result<LineageShape> {
272 let row = self
273 .find(name)
274 .ok_or_else(|| DbError::UnknownBranch(name.to_string()))?;
275 Ok(if self.rows.len() <= 1 {
276 LineageShape::Trunk
277 } else if row.parent.is_none() {
278 LineageShape::TrunkOnForked
279 } else {
280 LineageShape::Resolved
281 })
282 }
283
284 /// The shape a batch takes, given every lineage it names.
285 ///
286 /// Every name is checked, because that is the first thing the caller wants:
287 /// a batch naming a lineage that does not exist is refused **by name**,
288 /// rather than by whatever the guard finds when it looks in the wrong place.
289 ///
290 /// # Why the last answer used to be every answer, and why it is not one now
291 ///
292 /// Before 0.15.2 the shape was a function of the row *count* alone, so
293 /// asking per name and keeping the last was correct and read like a bug —
294 /// review C-24. [`LineageShape::TrunkOnForked`] (D-244) made the shape a
295 /// function of the **name** as well: a root and a fork on one database now
296 /// have different shapes, and the loop went on keeping whichever came last.
297 /// Where the names disagree the resolved form is the one exact for all of
298 /// them, roots included, so the ambiguity is resolved rather than
299 /// tie-broken by iteration order.
300 pub(crate) fn shape_of(&self, names: &[&str]) -> Result<LineageShape> {
301 let mut agreed: Option<LineageShape> = None;
302 for name in names {
303 let shape = self.shape(name)?;
304 agreed = Some(match agreed {
305 None => shape,
306 Some(prev) if prev == shape => prev,
307 Some(_) => LineageShape::Resolved,
308 });
309 }
310 // No names is the trunk: `distinct_branches` never returns empty, and
311 // `write_concepts_atomic` refuses an empty chunk before it gets here.
312 Ok(agreed.unwrap_or(LineageShape::Trunk))
313 }
314
315 /// [`resolve`], against these rows.
316 pub(crate) fn ancestry(&self, start: &str) -> Vec<Ancestor> {
317 resolve(&self.rows, start)
318 }
319}
320
321/// One resolved ancestor: exactly the three columns the recursive
322/// `ancestry_cte` produced until 0.15.17.
323///
324/// Public through [`crate::branch`] as the answer `reconstruct_on` resolves
325/// over, which is review C-10: until 0.15.17 the resolution rule existed only
326/// as SQL, so a caller holding a `Vec<EdgeBelief>` had no function to finish
327/// the question the fold started.
328///
329/// # Usually read, occasionally built (0.15.17, [D-255])
330///
331/// `#[non_exhaustive]`, and [`new`](Ancestor::new) is the way in — a fourth
332/// column is plausible (the fork's own `created_at` has been wanted twice) and
333/// a struct literal outside this crate would make it a major version.
334///
335/// Almost every caller *reads* one: [`Database::ancestry`] resolves it out of
336/// `branches`, refusing an unregistered lineage first, and
337/// [`resolve_beliefs`](crate::temporal::resolve_beliefs) consumes what it
338/// returned. The constructor exists for the caller who is exercising that pure
339/// function rather than a database, which is a real case — it is what this
340/// crate's own test for it does — and is worth naming, because an ancestry
341/// assembled by hand is a distance rule the caller has stated and
342/// `resolve_beliefs` will apply as faithfully as it applies a resolved one.
343///
344/// [D-255]: ../../docs/architecture/s13-decision-register.md#d-255
345/// [`Database::ancestry`]: crate::Database::ancestry
346#[derive(Debug, Clone, PartialEq, Eq)]
347#[non_exhaustive]
348pub struct Ancestor {
349 /// The lineage.
350 pub branch_id: String,
351 /// Steps from the reader: 0 is the reader itself.
352 pub dist: i64,
353 /// The instant past which this ancestor's rows are not visible, or `None`
354 /// for the reader, which has no cutoff.
355 pub cutoff: Option<String>,
356}
357
358impl Ancestor {
359 /// One ancestor at `dist` steps up the parent chain, uncut.
360 ///
361 /// `cutoff` is `None`, which is the reader's own row and the only shape
362 /// with no fork point above it; [`cutoff`](Ancestor::cutoff) sets it for
363 /// the rest. Two arguments and a setter rather than three arguments,
364 /// because the third is an `Option` that is `None` on exactly one row of
365 /// every ancestry and a positional `None` at every call site reads as a
366 /// decision nobody made.
367 ///
368 /// Building one states a distance rule rather than reporting a resolved
369 /// one. [`crate::Database::ancestry`] is what to use against a real ledger.
370 pub fn new(branch_id: impl Into<String>, dist: i64) -> Self {
371 Self {
372 branch_id: branch_id.into(),
373 dist,
374 cutoff: None,
375 }
376 }
377
378 /// Cut this ancestor at `ts`.
379 pub fn cutoff(mut self, ts: impl Into<String>) -> Self {
380 self.cutoff = Some(ts.into());
381 self
382 }
383}
384
385/// Walk `branches` from `start` to its root, carrying the running minimum.
386///
387/// The Rust half of [D-259]: the same relation [`ancestry_cte`] computed with
388/// `WITH RECURSIVE`, computed here instead. Term for term — the reader at
389/// `dist` 0 with no cutoff, then one row per ancestor, each carrying the
390/// **minimum** `forked_at` seen on the path down to it.
391///
392/// See [`ancestry_cte`] for why the cutoff is the *child's* `forked_at` and why
393/// the minimum is running rather than assigned. The property that matters here
394/// is that this is a second implementation of a rule the crate already had, so
395/// it is pinned against the original differentially rather than against a
396/// restatement of the rule (`the_rust_walk_agrees_with_the_cte`, below).
397///
398/// # Termination without trusting the data
399///
400/// `branches.parent_id` is a foreign key into an append-only table whose parent
401/// must exist before its child, so the graph is a forest and the walk is finite.
402/// The loop is bounded by the row count anyway. That bound is not the
403/// termination argument — it is what stops a corrupted file from hanging a read,
404/// which is a different failure from the one the schema rules out, and the walk
405/// returns the prefix it had rather than raising: a caller reading a broken
406/// `branches` gets a narrower answer, not a panic.
407///
408/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
409pub(crate) fn resolve(rows: &[BranchRow], start: &str) -> Vec<Ancestor> {
410 let mut out = Vec::new();
411 let mut cur = start.to_string();
412 let mut cutoff: Option<String> = None;
413 for dist in 0..=(rows.len() as i64) {
414 out.push(Ancestor {
415 branch_id: cur.clone(),
416 dist,
417 cutoff: cutoff.clone(),
418 });
419 let Some(node) = rows.iter().find(|b| b.id == cur) else {
420 break;
421 };
422 // The CHECK pairs these two, so a row with one and not the other is a
423 // file that has been edited outside this crate. Treated as the root.
424 let (Some(parent), Some(forked)) = (node.parent.as_ref(), node.forked_at.as_ref()) else {
425 break;
426 };
427 cutoff = Some(match cutoff {
428 Some(c) if c.as_str() <= forked.as_str() => c,
429 _ => forked.clone(),
430 });
431 cur = parent.clone();
432 }
433 out
434}
435
436/// The ancestry as a bound `VALUES` table, replacing the recursive CTE.
437///
438/// `first_slot` is where the block starts; it occupies `3 × rows` placeholders
439/// from there, and every reader puts it **after** its own fixed slots so that no
440/// existing layout moves.
441///
442/// # Bound and not interpolated
443///
444/// A branch id is caller-supplied text. The crate has exactly one arbitrary-SQL
445/// surface ([D-258]) and this is not a second one, so every value binds — the
446/// cutoff too, `NULL` included, which libSQL accepts inside `VALUES`
447/// (`ancestry_resolve_probe.rs` §1 checks it rather than assuming it). The
448/// consequence is that the statement *text* varies with ancestry **depth** and
449/// with nothing else, so a prepared-statement cache keyed on text sees one entry
450/// per distinct fork depth rather than one per lineage.
451///
452/// `dist` binds too, though it is the row's own index and could be a literal.
453/// Measured (`ancestry_resolve_probe.rs` §7) that saves a third of the
454/// placeholders and **1–5%**, inside the noise, so it is spelled the way the
455/// other two columns are rather than differently for a gain that did not
456/// survive being measured.
457///
458/// [D-258]: ../../docs/architecture/s13-decision-register.md#d-258
459pub(crate) fn ancestry_values(rows: usize, first_slot: usize, tag: &str) -> String {
460 let tuples: Vec<String> = (0..rows)
461 .map(|i| {
462 let b = first_slot + i * 3;
463 format!("(?{}, ?{}, ?{})", b, b + 1, b + 2)
464 })
465 .collect();
466 format!(
467 "lineage{tag}(branch_id, dist, cutoff) AS (VALUES {})",
468 tuples.join(", ")
469 )
470}
471
472/// The ancestry's placeholder values, in the order [`ancestry_values`] names
473/// them.
474pub(crate) fn ancestry_params(ancestry: &[Ancestor]) -> Vec<libsql::Value> {
475 let mut v = Vec::with_capacity(ancestry.len() * 3);
476 for a in ancestry {
477 v.push(libsql::Value::Text(a.branch_id.clone()));
478 v.push(libsql::Value::Integer(a.dist));
479 v.push(match &a.cutoff {
480 Some(c) => libsql::Value::Text(c.clone()),
481 None => libsql::Value::Null,
482 });
483 }
484 v
485}
486
487/// The ancestry of the reading branch, nearest first, each with its cutoff.
488///
489/// `dist` is what makes this a resolution rather than a union: it is the
490/// distance from the reader to each ancestor, and the row a lineage sees is the
491/// one belonging to the smallest `dist` holding that edge key.
492///
493/// The recursion terminates on `parent_id IS NULL`, which the `branches` CHECK
494/// pairs with `forked_at IS NULL` so that exactly one row — the root — can end
495/// it. A cycle is not representable: `parent_id` is a foreign key into a table
496/// whose rows are append-only and whose parent must already exist when the child
497/// is written, so the graph is a forest by construction and the walk is finite
498/// without a depth bound.
499/// `slot` is where the reading branch binds, and it is passed rather than
500/// spelled: the placeholder layout belongs to
501/// [`TraversalBuilder`](crate::graph::TraversalBuilder), which computes it once
502/// so that no two call sites can agree on it separately (D-030, D-035).
503///
504/// # `cutoff`, and why it is the child's `forked_at` rather than the row's
505///
506/// §15.3: a read on B sees rows written on each ancestor A *before the fork
507/// point on the path down from A*. The fork point on the path down from A is
508/// where **A's child on that path** diverged — so stepping from a branch to its
509/// parent, the parent's cutoff is the stepping branch's own `forked_at`. The
510/// reader itself has no cutoff at all, which is `NULL` in the anchor row and
511/// the only `NULL` the column ever holds.
512///
513/// It is a running minimum rather than a plain assignment. `b` forks from `a`
514/// at *t₂* and `a` from `main` at *t₁*, so `b` sees `main` as of *t₁* and not
515/// as of *t₂* — inheritance composes, and each step can only narrow the window.
516/// With `forked_at` monotone down a chain the `min` never fires; it is here
517/// because the schema does not enforce that ordering and a read should not be
518/// the thing that discovers it isn't.
519///
520/// # The equivalence that makes the fold arm safe
521///
522/// Under these cutoffs, **nearest-ancestor-wins and latest-`recorded_at`-wins
523/// coincide**: each ancestor's visible window ends where its descendant's
524/// begins, so the deepest lineage member holding a pre-cutoff row for a key is
525/// also the one holding the newest such row. That is why
526/// [`links_cut_cte`]'s fold arm may bound per lineage with a plain
527/// `ROW_NUMBER() … PARTITION BY entity_id, branch_id` and hand the cross-lineage
528/// question to [`visible_cte`], with no tiebreak between ancestors anywhere.
529///
530/// Stated as a consequence of the write path, not as a theorem about the
531/// schema: it holds because a branch's own writes follow its fork, which
532/// `fork()` and branch-scoped writes guarantee and no CHECK does. The
533/// resolution still orders by `dist`, which is the definition; the equivalence
534/// is what says the fold cannot disagree with it.
535///
536/// # `tag`, and the one query that needs two of these (0.14.11, [D-228])
537///
538/// Every read before `diff` resolved **one** lineage, so the four CTEs could
539/// take their names as constants. A diff resolves two in one statement — it
540/// has to, because two statements compare two snapshots and can report a
541/// difference that never existed — and SQLite has one namespace per `WITH`
542/// list. So each name here takes a suffix, and every caller that resolves one
543/// lineage passes `""` and emits exactly the text it emitted before.
544///
545/// A suffix rather than a second copy of the hybrid, for the reason
546/// [D-227](../../docs/architecture/s13-decision-register.md#d-227) gave when it
547/// declined to hand-write the cutoff into `query_as_of_edges_on`: the two arms
548/// of [`links_cut_cte`] must partition, and that is a property of one
549/// comparison written once, not an argument to restate in a second place.
550///
551/// [D-228]: ../../docs/architecture/s13-decision-register.md#d-228
552/// The three placeholders that fix one edge key, when the reader has one.
553///
554/// The write path always does: [`crate::connection`]'s overlap guard and its
555/// retirement both hold a `(source, target, edge_type)` before any SQL exists,
556/// and asking the resolution about the whole projection to then discard all but
557/// one key would make a branched bulk write O(rows) per row.
558///
559/// Narrowing is **not** a filter appended to the resolved relation. It is
560/// pushed into the base scans of [`churned_cte`] and [`links_cut_cte`], where
561/// `idx_lc_open_interval` leads with exactly these three columns and each arm
562/// becomes a seek (0.14.8, [D-225]; lowered 0.15.8, W13.3, [D-250]).
563///
564/// It also decides one column. A keyed read is a *write* path read, and the
565/// write path carries `properties` through the resolution because
566/// [`retire_from_resolved`] restates it on the shadow row; an
567/// unkeyed traversal does not, and carrying a JSON blob through a window over
568/// the whole projection is not a cost a reader should pay for a column it never
569/// selects.
570///
571/// [D-225]: ../../docs/architecture/s13-decision-register.md#d-225
572/// [D-250]: ../../docs/architecture/s13-decision-register.md#d-250
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub(crate) struct KeySlots {
575 pub source: usize,
576 pub target: usize,
577 pub edge_type: usize,
578}
579
580impl KeySlots {
581 /// The three equalities, on `alias`, as one line of a `WHERE`.
582 pub(crate) fn equalities(&self, alias: &str) -> String {
583 format!(
584 "{alias}.source_id = ?{} AND {alias}.target_id = ?{} AND {alias}.edge_type = ?{}",
585 self.source, self.target, self.edge_type
586 )
587 }
588}
589
590/// # Retained as the oracle, not as production SQL (0.15.17, [D-259])
591///
592/// Nothing emits this any more — [`ancestry_values`] does, with the same three
593/// columns computed by [`resolve`] instead of by SQLite. It is compiled for
594/// tests only, and it stays because deleting it would leave
595/// [`resolve`] pinned against a *restatement* of the rule rather than against
596/// the implementation it replaced. `the_rust_walk_agrees_with_the_cte` is the
597/// differential test; this is the half of it that cannot be wrong by the same
598/// mistake.
599///
600/// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
601#[cfg(test)]
602pub(crate) fn ancestry_cte(slot: usize, tag: &str) -> String {
603 format!(
604 r#"lineage{tag}(branch_id, dist, cutoff) AS (
605 SELECT ?{slot}, 0, NULL
606 UNION ALL
607 SELECT b.parent_id, g.dist + 1,
608 CASE WHEN g.cutoff IS NULL OR b.forked_at < g.cutoff
609 THEN b.forked_at ELSE g.cutoff END
610 FROM branches b JOIN lineage{tag} g ON b.branch_id = g.branch_id
611 WHERE b.parent_id IS NOT NULL
612)"#
613 )
614}
615
616/// The `(edge key, lineage)` pairs whose projected row is *younger* than the
617/// cutoff, and therefore cannot be shown to the reader.
618///
619/// One row here means: this ancestor holds a belief about this edge, and it
620/// wrote that belief after the reader's line diverged. `links_current` has
621/// overwritten whatever it believed before — the sync trigger's `DO UPDATE`
622/// carries `recorded_at` forward — so the pre-fork version has to come from the
623/// log. [`links_cut_cte`] is what goes and gets it.
624///
625/// **`entity_id` is composed here in the same order the log triggers compose
626/// it**, `source|target|type|valid_from`, because that is the key the fold
627/// joins on. It is a second spelling of a format the schema owns, and the thing
628/// that keeps it honest is that a mismatch cannot be quiet: the join would
629/// match nothing, every churned key would drop out of both arms, and
630/// `branch_read_tests`' retire and reweight cases would go red together.
631///
632/// The `cutoff IS NOT NULL` clause is what makes this empty for a read on the
633/// root — `main` has no ancestors and no cutoff, so a forked database still
634/// pays nothing here to read its own trunk.
635pub(crate) fn churned_cte(tag: &str, key: Option<KeySlots>) -> String {
636 // Keyed, the three equalities go first and the base scan becomes a seek on
637 // `idx_lc_open_interval`. Composed from the *columns* either way rather
638 // than from the key's own placeholders: the rows are already narrowed to
639 // that key, so the two spellings hold the same string, and one of them is
640 // the spelling the unkeyed arm has to use anyway.
641 let narrow = match key {
642 Some(k) => format!("{} AND ", k.equalities("lc")),
643 None => String::new(),
644 };
645 format!(
646 r#"churned{tag}(entity_id, branch_id, cutoff) AS (
647 SELECT lc.source_id || '|' || lc.target_id || '|' || lc.edge_type || '|' || lc.valid_from,
648 lc.branch_id, g.cutoff
649 FROM links_current lc
650 JOIN lineage{tag} g ON g.branch_id = lc.branch_id
651 WHERE {narrow}g.cutoff IS NOT NULL AND lc.recorded_at > g.cutoff
652)"#
653 )
654}
655
656/// `links_current` as each lineage on the ancestry was entitled to see it.
657///
658/// The hybrid §15.3 did not have a name for, entered there as option (4). Two
659/// arms over one predicate:
660///
661/// * rows whose lineage may still show them directly — the reader's own
662/// (`cutoff IS NULL`) and every ancestor row recorded at or before its cutoff;
663/// * for the rest, the last log entry that lineage wrote at or before its
664/// cutoff, which is what `links_current` held for that key at the fork.
665///
666/// A key the ancestor first asserted *after* the cutoff contributes nothing
667/// from either arm, and that is correct rather than a gap: the branch's line
668/// diverged before that edge existed, so there is no pre-fork row to resurrect.
669/// The fold returning empty is the answer.
670///
671/// **`UNION ALL` is sound because the arms partition, not because duplicates
672/// are harmless.** They split `links_current ⋈ lineage` on `recorded_at <=
673/// cutoff`; see [`churned_cte`] for why the churned set is derived from the
674/// projection and not from the log, which is the same point from the other
675/// side.
676///
677/// The column list matches `links_current`'s and `links_at_tx`'s exactly, which
678/// is what lets [`visible_cte`] reduce any of the three without knowing which.
679///
680/// # The log arm's join order, which is four words and was worth 1,387x
681/// (0.15.29, [D-272])
682///
683/// The `CROSS JOIN` is not a different join. It is the same inner join with
684/// the loops nailed down, which is what `CROSS` means to SQLite and the only
685/// thing it means: *do not reorder this*.
686///
687/// Left to choose, the planner drove from `transaction_log` on the one
688/// equality it could see — `table_name = 'links'` — and inlined `churned` as
689/// the inner loop, where the key narrowing and the `recorded_at > cutoff`
690/// bound both fell out of the access path:
691///
692/// ```text
693/// SEARCH transaction_log USING INDEX idx_txlog_fold_partition (table_name=?)
694/// SEARCH lc USING COVERING INDEX idx_lc_lineage_cut (branch_id=?)
695/// ```
696///
697/// One column bound on the inner scan, so the whole of that lineage's
698/// `links_current` per row of the links log, per execution of this relation.
699/// [`crate::connection`]'s write path executes it **once per asserted row**,
700/// which is how a 200-edge batch on a fork of a 2,000-edge trunk came to cost
701/// **68 s against the trunk's 26 ms** — and 20x that for a 4x trunk, because
702/// the shape is a product of two things that both grow.
703///
704/// With `churned` driving, the log is reached on three bound columns instead
705/// of one:
706///
707/// ```text
708/// SEARCH lc USING COVERING INDEX idx_lc_lineage_cut (branch_id=? AND recorded_at>?)
709/// SEARCH transaction_log USING INDEX idx_txlog_fold_partition
710/// (table_name=? AND entity_id=? AND branch_id=?)
711/// ```
712///
713/// **`idx_txlog_fold_partition` is not the villain and did not move.** Its
714/// leading `table_name` is what the planner grabbed, but the index serves this
715/// arm better than the `idx_txlog_entity (entity_id=?)` seek the arm had
716/// before [D-254] existed — three equality columns rather than one. It was
717/// being used with a third of itself bound.
718///
719/// **Why reordering cannot change the answer.** `links_current`'s primary key
720/// is `(source_id, target_id, edge_type, valid_from, branch_id)` and
721/// [`churned_cte`] composes `entity_id` from the first four, so `churned` is
722/// unique on the `(entity_id, branch_id)` pair this join matches. The join is
723/// one-to-many in exactly one direction whichever side drives, so the window
724/// below sees the same input rows and `ROW_NUMBER` picks the same one.
725///
726/// **This arm is not only the write path's.** `key` is `None` for every
727/// branched *current-belief read*, where the churned set is the whole of it
728/// rather than one edge, and the same plan was there. Measured through the
729/// public API on a fixture with deliberate post-fork churn
730/// (`examples/resolved_read_probe.rs`, best of 40, 400 concepts):
731///
732/// ```text
733/// depth 1 depth 8
734/// edges(plan), current belief 5.6 -> 1.3 ms 13.6 -> 1.3 ms
735/// traverse, depth 6 5.4 -> 1.2 ms 13.7 -> 1.4 ms
736/// edges(plan), recorded instant 1.87 -> 1.90 2.43 -> 2.44 (no arm here)
737/// edges(plan) on the trunk 0.33 -> 0.33 0.33 -> 0.33 [control]
738/// ```
739///
740/// The branched read **stopped growing with fork depth**, which is the durable
741/// half of that table: depth multiplies the ancestry, the ancestry multiplied
742/// the churned set, and the churned set was being re-derived per log row.
743///
744/// The gate is `the_log_arm_is_driven_by_the_churned_set` below, and it pins
745/// the bound column rather than a millisecond — [D-055] is why.
746///
747/// [D-254]: ../../docs/architecture/s13-decision-register.md#d-254
748/// [D-272]: ../../docs/architecture/s13-decision-register.md#d-272
749/// [D-055]: ../../docs/architecture/s13-decision-register.md#d-055
750pub(crate) fn links_cut_cte(tag: &str, key: Option<KeySlots>) -> String {
751 // The narrowing goes on the **projection** arm only. The log arm joins
752 // `churned{tag}`, which is already one key when this one is, so repeating
753 // the equalities there would narrow nothing and read as though it did.
754 //
755 // The parentheses around the cutoff disjunction are the whole reason this
756 // is built rather than concatenated: `A AND B OR C` is `(A AND B) OR C` in
757 // SQL, so appending the key to this arm without them would return every
758 // ancestor's pre-cutoff row for every edge in the ledger.
759 let (narrow, props, log_props) = match key {
760 Some(k) => (
761 format!(
762 "{} AND (g.cutoff IS NULL OR lc.recorded_at <= g.cutoff)",
763 k.equalities("lc")
764 ),
765 ", properties",
766 "\n json_extract(payload, '$.properties'),",
767 ),
768 None => (
769 "g.cutoff IS NULL OR lc.recorded_at <= g.cutoff".to_string(),
770 "",
771 "",
772 ),
773 };
774 let carried = if key.is_some() {
775 "lc.weight, lc.properties, lc.branch_id"
776 } else {
777 "lc.weight, lc.branch_id"
778 };
779 format!(
780 r#"links_cut{tag}(source_id, target_id, edge_type, valid_from, valid_to, weight{props}, branch_id) AS (
781 SELECT lc.source_id, lc.target_id, lc.edge_type, lc.valid_from, lc.valid_to,
782 {carried}
783 FROM links_current lc
784 JOIN lineage{tag} g ON g.branch_id = lc.branch_id
785 WHERE {narrow}
786 UNION ALL
787 SELECT json_extract(payload, '$.source_id'),
788 json_extract(payload, '$.target_id'),
789 json_extract(payload, '$.edge_type'),
790 json_extract(payload, '$.valid_from'),
791 json_extract(payload, '$.valid_to'),
792 json_extract(payload, '$.weight'),{log_props}
793 branch_id
794 FROM (
795 SELECT transaction_log.payload, transaction_log.branch_id,
796 ROW_NUMBER() OVER (
797 PARTITION BY transaction_log.entity_id, transaction_log.branch_id
798 ORDER BY transaction_log.seq_id DESC
799 ) AS rn
800 FROM churned{tag} k
801 -- CROSS, so `churned` drives. See this function's rustdoc: the same
802 -- join with the loops the other way round is O(log rows x lineage
803 -- rows) per execution, and the write path executes it per asserted
804 -- row (0.15.29, D-272).
805 CROSS JOIN transaction_log ON transaction_log.entity_id = k.entity_id
806 AND transaction_log.branch_id = k.branch_id
807 WHERE transaction_log.table_name = 'links'
808 AND transaction_log.recorded_at <= k.cutoff
809 ) WHERE rn = 1
810)"#
811 )
812}
813
814/// The slots the write path binds, which are fixed by its two statements
815/// rather than by a layout type (0.14.8, D-225).
816///
817/// `?1` source, `?2` target, `?3` edge type, `?4` the `valid_from` each tail
818/// selects on, `?5` the writing lineage, and for the retirement `?6` the new
819/// `valid_to` and `?7` the stamp. Both callers bind all of them positionally
820/// at one site each.
821const WRITE_KEY: KeySlots = KeySlots {
822 source: 1,
823 target: 2,
824 edge_type: 3,
825};
826const WRITE_BRANCH_SLOT: usize = 5;
827
828/// Where the guard's ancestry block starts: after the five it already binds.
829///
830/// The two write statements have different layouts, so the slot is passed to
831/// [`write_resolution`] rather than named once here — the guard binds five
832/// (`key`, `valid_from`, `branch`) and the retirement binds seven.
833const GUARD_ANCESTRY_SLOT: usize = 6;
834
835/// Where the retirement's ancestry block starts: after `valid_to` and `stamp`.
836const RETIRE_ANCESTRY_SLOT: usize = 8;
837
838/// What the write path has decided before any SQL exists.
839///
840/// Current belief always: the guard asks what this lineage believes *now*, and
841/// an assertion is made against now. There is no recorded slot to name.
842fn write_resolution<'a>(
843 shape: LineageShape,
844 ancestry: &'a [Ancestor],
845 ancestry_slot: usize,
846) -> Resolution<'a> {
847 Resolution {
848 shape,
849 branch_slot: WRITE_BRANCH_SLOT,
850 recorded_slot: None,
851 tag: "",
852 key: Some(WRITE_KEY),
853 ancestry,
854 ancestry_slot,
855 }
856}
857
858/// Overlap candidates as the writing lineage can see them (0.14.8, §15.4,
859/// [D-225]; lowered 0.15.8, W13.3, [D-250]).
860///
861/// # Why the write path needs a resolution at all
862///
863/// [`crate::connection`]'s overlap guard reads `links_current` for the edge key
864/// being asserted and refuses an assertion whose valid-time interval overlaps
865/// one already recorded (defect AA, D-060). Until 0.14.8 every row in the table
866/// was `main`'s, so reading the key with no lineage predicate was exact. The
867/// moment a second lineage can write, the same statement is wrong in **both
868/// directions at once**: a branch would be refused for overlapping its parent's
869/// belief that it is entitled to supersede, and the trunk would be refused for
870/// overlapping a branch's belief it cannot even see. An unfiltered read is not
871/// a conservative approximation of a filtered one here; it is a different
872/// question.
873///
874/// Adding `AND branch_id = ?` would fix the trunk direction and leave the
875/// branch one wrong the other way — a branch would then be checked against
876/// *only its own* rows and could assert `[10,20)` over an inherited `[5,15)`,
877/// putting two overlapping intervals into its own view. That is defect AA
878/// reintroduced across lineages, and it is the shape
879/// `trg_links_single_open`'s v12 comment left open as "§15.4's write-path
880/// question": the trigger sees one row and cannot answer it. This is the
881/// answer. **What a lineage may not overlap is what that lineage can see**,
882/// which is the read's definition and now the write's.
883///
884/// # It was a second spelling of that definition until 0.15.8
885///
886/// The narrowing was real and is unchanged — [`churned_cte`] and
887/// [`links_cut_cte`] are written for a traversal and scan `links_current`
888/// whole, so calling them per assertion would make a branched bulk write
889/// O(rows) per row, and pushing the key into the base scans turns each arm
890/// into a seek on `idx_lc_open_interval`. What was not real was the *copy*:
891/// a `key_visibility_cte` holding its own `lineage`, its own churned set, its
892/// own two-arm hybrid and its own nearest-lineage window, four relations that
893/// had to keep agreeing with four in [`crate::graph::plan`] and were kept
894/// honest only by `branch_write_tests` asserting the two answers match on one
895/// fixture. [D-227](../../docs/architecture/s13-decision-register.md#d-227) is
896/// four releases of what happens when a reader spells its own. The key is a
897/// [`KeySlots`] on [`Resolution`] now, and this function is the lowering plus
898/// one line.
899///
900/// # What that cost, and bought, on `examples/edge_write_probe`
901///
902/// Best of 500 `assert_edge` calls, three runs each, release build:
903///
904/// ```text
905/// 0.15.7 0.15.8
906/// trunk 0.0958 0.0966 ms unchanged
907/// forked 0.1044 0.0975 ms -6.6%
908/// branch 0.1059 0.1091 ms +3.0%
909/// ```
910///
911/// The forked trunk gains because it stopped taking the resolved form: it was
912/// exact there only because a root's ancestry is itself, and D-248's C-24
913/// repair is what lets [`crate::graph::LineageShape`] tell a root apart from a
914/// branch at all. It now lowers to a two-predicate lookup on `links_current`.
915///
916/// The branch loses because the shared [`visible_cte`] joins `lineage` to order
917/// by `dist`, where the deleted `key_visibility_cte` carried `dist` through its
918/// own relations and needed no join. Buying that 3% back means giving the keyed
919/// spelling its own `dist` column in three functions — a second shape for the
920/// hybrid, decided by the caller — which is the divergence this release exists
921/// to remove, over a join against a materialised ancestry of two rows.
922///
923/// **The `churned` base scan is unchanged and was never the cost.** It planned
924/// as `SEARCH lc USING COVERING INDEX idx_lc_lineage_cut (branch_id=? AND
925/// recorded_at>?)` in 0.15.7 and it plans that way now: SQLite inlines the
926/// key-narrowed CTE into each use, so the equalities and the `recorded_at`
927/// range meet in one scan either way. Splitting them apart with
928/// `AS MATERIALIZED` does restore the key seek, and costs more than it saves —
929/// the log arm then loses `SEARCH transaction_log USING INDEX idx_txlog_entity
930/// (entity_id=?)` and scans the whole log, because a materialised `churned` is
931/// no longer a small driving set the planner can see through. It was measured
932/// and not taken.
933///
934/// **The middle sentence of that paragraph stopped being true at 0.15.12 and
935/// was true again at 0.15.29** ([D-272]). `idx_txlog_fold_partition` ([D-254])
936/// gave the planner a reason to drive from `transaction_log` instead, and the
937/// churned base scan then planned as `idx_lc_lineage_cut (branch_id=?)` —
938/// **one** column, not the two the paragraph reports — because it had become
939/// an inner loop. The paragraph is left as written because what it says about
940/// 0.15.7 and 0.15.8 is still what happened; what it could not know is that
941/// this plan was a *choice* the planner was free to revisit, and it did. It is
942/// nailed down now: see [`links_cut_cte`]'s `CROSS JOIN`.
943///
944/// The refusal of `AS MATERIALIZED` survives that and is now refused twice
945/// over. Re-measured against the current schema
946/// (`examples/branch_write_guard_probe.rs`, per asserted row): materialised
947/// alone is **0.158 ms** at a 2,000-edge trunk and **0.565 ms** at 8,000,
948/// against the forced join order's **0.012** and **0.016** — it fixes the
949/// symptom partially and the growth not at all, because a materialised
950/// `churned` is still the inner loop.
951///
952/// # The predicate set, which is three equalities and nothing else
953///
954/// `valid_from <> ?4` excludes the row being re-asserted: re-assertion at the
955/// same `valid_from` is Doctrine III's ordinary case and is settled by the
956/// primary key and the single-open trigger, not by this guard.
957///
958/// **The "and nothing else" was measured, not assumed**, and it is the half of
959/// this statement a lowering must not quietly improve. The first version added
960/// `AND valid_from < :new_valid_to`, a provably safe narrowing — overlap
961/// requires `max(start) < min(end)`, so an interval starting at or after the
962/// new one's end cannot overlap it. It cost **9.8 ms on a 90-edge chunk into a
963/// 2,000-edge hub**, because it walked the planner straight into D-059's trap:
964///
965/// ```text
966/// with the range: SEARCH links_current USING COVERING INDEX
967/// idx_lc_traversal_cover (source_id=? AND valid_from<?)
968/// without it: SEARCH links_current USING COVERING INDEX
969/// idx_lc_open_interval (source_id=? AND target_id=? AND edge_type=?)
970/// ```
971///
972/// `idx_lc_traversal_cover` leads on `(source_id, valid_from, …)` and contains
973/// every column that query mentions, so with a `valid_from` range available it
974/// wins as a covering index while binding **one** equality column — and the
975/// guard scans the source's entire out-degree. Same shape as the defect D-059
976/// diagnosed in `trg_links_single_open`, reintroduced by an optimisation one
977/// wave after it was fixed. Three equalities make it a point lookup that
978/// `idx_lc_open_interval` serves exactly, and the rows it returns are a version
979/// count rather than an out-degree. **A narrowing predicate is not free if it
980/// changes the plan** — which is also why [`KeySlots`] pushes its equalities
981/// into the base scans rather than appending them to the resolved relation,
982/// and why `index_plan_tests` pins this statement's plan on every shape.
983///
984/// [D-225]: ../../docs/architecture/s13-decision-register.md#d-225
985/// [D-250]: ../../docs/architecture/s13-decision-register.md#d-250
986pub(crate) fn overlap_candidates_resolved(shape: LineageShape, ancestry: &[Ancestor]) -> String {
987 let l = lower(&write_resolution(shape, ancestry, GUARD_ANCESTRY_SLOT));
988 format!(
989 "{}SELECT l.valid_from, l.valid_to FROM {} l WHERE l.valid_from <> ?4{}",
990 l.with_clause(),
991 l.source,
992 l.filter
993 )
994}
995
996/// The row a branch is retiring, which may belong to an ancestor.
997///
998/// Retirement on a branch is **shadow retirement**: the branch writes its own
999/// row at the ancestor's key carrying a closed interval, the read prefers it by
1000/// `dist`, and the ancestor's row is untouched. [`visible_cte`]'s rustdoc has
1001/// described this write since 0.14.4; this is it. Closing the ancestor's own
1002/// row is the parent corruption
1003/// [Doctrine III](../../docs/architecture/s0-s3-foundations.md#doctrine-iii)
1004/// forbids, and it is not merely avoided by policy — `links` is append-only and
1005/// there is no statement in the crate that could do it.
1006///
1007/// `?6` is the new `valid_to`, `?7` the stamp. `weight` and `properties` are
1008/// carried from the visible row rather than restated, which is what makes this
1009/// a retirement rather than a new assertion that happens to be closed — and it
1010/// is the reason a keyed resolution carries `properties` at all (see
1011/// [`KeySlots`]).
1012pub(crate) fn retire_from_resolved(shape: LineageShape, ancestry: &[Ancestor]) -> String {
1013 let l = lower(&write_resolution(shape, ancestry, RETIRE_ANCESTRY_SLOT));
1014 format!(
1015 "{}INSERT INTO links \
1016 (source_id, target_id, edge_type, valid_from, valid_to, weight, \
1017 properties, recorded_at, branch_id) \
1018 SELECT ?1, ?2, ?3, ?4, ?6, l.weight, l.properties, ?7, ?5 \
1019 FROM {} l WHERE l.valid_from = ?4{}",
1020 l.with_clause(),
1021 l.source,
1022 l.filter
1023 )
1024}
1025
1026/// What one lineage believes and another does not, in **one** statement
1027/// (0.14.11, §15.4, [D-228]).
1028///
1029/// # Why one statement rather than two reads and a difference in Rust
1030///
1031/// Not for the round trip. A diff is a *comparison*, and two statements
1032/// against [`Database::read_conn`](crate::Database::read_conn) are two
1033/// snapshots — a write landing between them can make the answer report a
1034/// difference that never existed at any instant. The obvious repair, a read
1035/// transaction, is not available: `read_conn()` is public and shared, so
1036/// beginning one inside a library call would change what every other holder of
1037/// that connection sees. One statement gets the single snapshot for free.
1038///
1039/// # Why the CTEs are tagged rather than copied
1040///
1041/// This resolves two lineages, so it needs two of each of the four CTEs, and
1042/// SQLite has one namespace per `WITH` list. Hence the `tag` parameter on
1043/// [`ancestry_cte`] and its three companions rather than a second spelling of
1044/// the hybrid — which [D-227](../../docs/architecture/s13-decision-register.md#d-227)
1045/// declined for `query_as_of_edges_on` and would be the same mistake here.
1046/// Every single-lineage caller passes `""` and emits the text it always did.
1047///
1048/// # What it compares, and what it does not
1049///
1050/// A `LEFT JOIN` on the **edge key** — `(source, target, type, valid_from)` —
1051/// and a row survives when `b` holds no belief about that key, or holds one
1052/// whose interval or weight differs. So a retirement is reported: `a`'s row is
1053/// the closed one, `b`'s is open, and they differ. That is the case a
1054/// valid-time filter would have hidden, which is why there is no `ts` here at
1055/// all — a diff filtered to an instant cannot see the one divergence that is
1056/// *about* an instant having passed.
1057///
1058/// **`properties` is not compared**, and that is a limit rather than an
1059/// oversight: no read surface in the crate returns edge properties —
1060/// `EdgeAssertion::properties` writes them and nothing reads them back — so a
1061/// diff reporting a change there would be the only reader resolving a column,
1062/// and it would name a difference the caller has no way to look at. It is the
1063/// first thing to widen if edge properties ever become readable.
1064///
1065/// Float equality on `weight` is deliberate. The question is whether `b` holds
1066/// the *same belief*, and a belief is a stored value; an epsilon here would
1067/// invent a tolerance the ledger does not have and would make `diff(a, b)`
1068/// disagree with what a traversal on either lineage shows.
1069///
1070/// Slots: `?1` the lineage being asked about, `?2` the one it is compared to.
1071///
1072/// [D-228]: ../../docs/architecture/s13-decision-register.md#d-228
1073pub(crate) fn diff_sql(a_ancestry: &[Ancestor], b_ancestry: &[Ancestor]) -> String {
1074 // Two lowerings in one `WITH` list (0.15.1, W13.1): the same prelude
1075 // the traversal and `query_as_of_edges_on` splice, told apart by tag.
1076 //
1077 // Two ancestry blocks as well, and they are the reason this function stopped
1078 // being a constant in 0.15.17: `?1` and `?2` still name the two lineages,
1079 // `a`'s ancestry follows at `?3`, and `b`'s follows that. Both lengths are
1080 // read from the database, so the caller resolves before it lowers.
1081 let a = lower(&Resolution {
1082 shape: LineageShape::Resolved,
1083 branch_slot: 1,
1084 recorded_slot: None,
1085 tag: "_a",
1086 key: None,
1087 ancestry: a_ancestry,
1088 ancestry_slot: 3,
1089 });
1090 let b = lower(&Resolution {
1091 shape: LineageShape::Resolved,
1092 branch_slot: 2,
1093 recorded_slot: None,
1094 tag: "_b",
1095 key: None,
1096 ancestry: b_ancestry,
1097 ancestry_slot: 3 + a_ancestry.len() * 3,
1098 });
1099 format!(
1100 "WITH RECURSIVE {},\n{}\n\
1101 SELECT a.source_id, a.target_id, a.edge_type, a.valid_from, \
1102 a.valid_to, a.weight, a.branch_id \
1103 FROM {} a \
1104 LEFT JOIN {} b ON b.source_id = a.source_id \
1105 AND b.target_id = a.target_id \
1106 AND b.edge_type = a.edge_type \
1107 AND b.valid_from = a.valid_from \
1108 WHERE b.source_id IS NULL \
1109 OR b.valid_to <> a.valid_to \
1110 OR b.weight <> a.weight \
1111 ORDER BY a.source_id, a.target_id, a.edge_type, a.valid_from",
1112 a.with_list(),
1113 b.with_list(),
1114 a.source,
1115 b.source,
1116 )
1117}
1118
1119/// One row per edge key, from the nearest lineage that holds it.
1120///
1121/// `source` is the relation to resolve — [`links_cut_cte`] under current
1122/// belief, or the `links_at_tx` fold when the traversal names a transaction-time
1123/// instant. Both expose the same columns under the same names, which is what
1124/// lets this be written once, and both have already applied the ancestry's
1125/// cutoffs before this sees them.
1126///
1127/// **The partition is the edge key and not the edge.** Two lineages asserting
1128/// the same `(source, target, type)` at *different* `valid_from` are two
1129/// assertions in valid time and stay two rows; two lineages asserting at the
1130/// same `valid_from` are one edge believed twice and resolve to one. That is
1131/// also what makes shadow-retirement work: a branch writes its own row at the
1132/// ancestor's key with a closed interval, the resolution prefers it, and the
1133/// edge is gone from that lineage's view while the ancestor's row is untouched.
1134/// Closing the ancestor's own row is the parent corruption
1135/// [Doctrine III](../../docs/architecture/s0-s3-foundations.md#doctrine-iii)
1136/// forbids, and shadowing is the only retirement across lineages that does not
1137/// commit it.
1138///
1139/// **`ORDER BY g.dist` is a total order over the surviving rows**, because each
1140/// source contributes at most one row per `(edge key, lineage)` and each
1141/// lineage appears in `lineage` once. See [`ancestry_cte`] for why ordering by
1142/// `recorded_at` instead would pick the same row.
1143pub(crate) fn visible_cte(source: &str, tag: &str, key: Option<KeySlots>) -> String {
1144 // The partition stays the whole edge key even when three quarters of it is
1145 // a constant: it is the same rows either way, and a partition that changed
1146 // shape with the caller would be a second definition of what one edge is.
1147 let props = if key.is_some() { ", properties" } else { "" };
1148 let carried = if key.is_some() {
1149 "l.properties, l.branch_id,"
1150 } else {
1151 "l.branch_id,"
1152 };
1153 format!(
1154 r#"visible{tag}(source_id, target_id, edge_type, valid_from, valid_to, weight{props}, branch_id) AS (
1155 SELECT source_id, target_id, edge_type, valid_from, valid_to, weight{props}, branch_id FROM (
1156 SELECT l.source_id, l.target_id, l.edge_type, l.valid_from, l.valid_to, l.weight,
1157 {carried}
1158 ROW_NUMBER() OVER (
1159 PARTITION BY l.source_id, l.target_id, l.edge_type, l.valid_from
1160 ORDER BY g.dist
1161 ) AS rn
1162 FROM {source} l
1163 JOIN lineage{tag} g ON g.branch_id = l.branch_id
1164 ) WHERE rn = 1
1165)"#
1166 )
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172
1173 /// The reader plus one ancestor: the smallest ancestry that resolves
1174 /// anything. An empty one lowers to `VALUES ()`, which SQLite refuses.
1175 fn anc() -> Vec<Ancestor> {
1176 vec![
1177 Ancestor {
1178 branch_id: "exp".to_string(),
1179 dist: 0,
1180 cutoff: None,
1181 },
1182 Ancestor {
1183 branch_id: "main".to_string(),
1184 dist: 1,
1185 cutoff: Some("2026-01-06T00:00:00.000000Z".to_string()),
1186 },
1187 ]
1188 }
1189
1190 const TS: &str = "2026-01-06T00:00:00.000000Z";
1191
1192 async fn fresh() -> libsql::Connection {
1193 let db = libsql::Builder::new_local(":memory:")
1194 .build()
1195 .await
1196 .unwrap();
1197 let conn = db.connect().unwrap();
1198 crate::schema::run_migrations(&conn).await.unwrap();
1199 conn
1200 }
1201
1202 async fn fork(conn: &libsql::Connection, child: &str, parent: &str) {
1203 conn.execute(
1204 "INSERT INTO branches (branch_id, parent_id, forked_at, created_at) \
1205 VALUES (?1, ?2, ?3, ?3)",
1206 libsql::params![child, parent, TS],
1207 )
1208 .await
1209 .unwrap();
1210 }
1211
1212 /// **The guard seeks the edge key on every shape it can be given.**
1213 ///
1214 /// D-060's overlap guard fell into D-059's trap one wave after D-059 fixed
1215 /// it: a provably safe `AND valid_from < :new_valid_to` handed the planner
1216 /// a range, `idx_lc_traversal_cover` won as a covering index while binding
1217 /// **one** column, and the guard scanned the source's whole out-degree —
1218 /// **+9.8 ms** on a 90-edge chunk into a 2,000-edge hub, invisible to every
1219 /// correctness test because the rows returned were right.
1220 ///
1221 /// Until 0.15.8 that was pinned in `migration_tests` against a *hand-copied*
1222 /// reproduction of `OVERLAP_CANDIDATES`, which is the weakest form of this
1223 /// test: it pins a string next to the code rather than the code. The
1224 /// statements are generated now ([`overlap_candidates_resolved`],
1225 /// [`retire_from_resolved`]), so the plan is taken from the bytes the guard
1226 /// will prepare — on all three shapes, including the two the old pin could
1227 /// not reach because it predated them.
1228 ///
1229 /// **Three columns bound is the assertion**, not the index name: the
1230 /// resolved shape reaches `links_current` through four CTEs and the trunk
1231 /// shapes reach it directly, so which index serves the seek differs, and
1232 /// what must not differ is that the seek is on the whole key. One column
1233 /// bound is O(out-degree) wherever it appears.
1234 #[tokio::test]
1235 async fn the_guard_seeks_the_edge_key_on_every_shape() {
1236 let conn = fresh().await;
1237 fork(&conn, "exp", "main").await;
1238
1239 for shape in [
1240 LineageShape::Trunk,
1241 LineageShape::TrunkOnForked,
1242 LineageShape::Resolved,
1243 ] {
1244 for (what, sql) in [
1245 ("overlap", overlap_candidates_resolved(shape, &anc())),
1246 ("retire", retire_from_resolved(shape, &anc())),
1247 ] {
1248 let mut rows = conn
1249 .query(&format!("EXPLAIN QUERY PLAN {sql}"), ())
1250 .await
1251 .unwrap();
1252 let mut plan = Vec::new();
1253 while let Some(r) = rows.next().await.unwrap() {
1254 plan.push(r.get::<String>(3).unwrap());
1255 }
1256 let step = plan.join(" | ");
1257
1258 assert!(
1259 step.contains("source_id=? AND target_id=? AND edge_type=?"),
1260 "{shape:?}/{what} binds fewer columns than the key has, so \
1261 it scans the source's out-degree — D-059 in D-060's \
1262 guard: {step}"
1263 );
1264 // The overlap read has no `valid_from` equality to fall back
1265 // on, so it is the one that needs the index built for it.
1266 assert!(
1267 what != "overlap" || step.contains("idx_lc_open_interval"),
1268 "{shape:?} overlap is off the index added for it: {step}"
1269 );
1270 }
1271 }
1272 }
1273
1274 /// **The log arm is driven by the churned set, not by the whole links
1275 /// log** (0.15.29, [D-272]).
1276 ///
1277 /// Sibling of the test above and the same kind of claim: not *which* index
1278 /// serves the seek, but how much of it is bound. The arm joins
1279 /// `transaction_log` to `churned` on `(entity_id, branch_id)` under
1280 /// `table_name = 'links'`. Driven from `churned`, that is three equality
1281 /// columns and a seek per churned row. Driven from the log — which is what
1282 /// the planner chose for seventeen releases — it is `(table_name=?)`, the
1283 /// whole links log, with `churned` re-derived inside it as a one-column
1284 /// scan of the lineage's `links_current`. The write path runs this once per
1285 /// asserted row, so the second shape is a 200-edge batch costing 68 s
1286 /// against the trunk's 26 ms.
1287 ///
1288 /// `entity_id=?` in the plan is therefore the assertion. It cannot be
1289 /// present unless `churned` is on the outside, and it is what the
1290 /// `CROSS JOIN` in [`links_cut_cte`] exists to guarantee — so reverting
1291 /// those four words makes this red, which is how the gate was checked.
1292 ///
1293 /// **The trunk shapes are asserted to have no arm at all**, because a
1294 /// repair to a relation two of the three shapes never emit would otherwise
1295 /// look like it had been verified on all of them.
1296 ///
1297 /// The fixture is empty and that is deliberate: with no rows and no
1298 /// statistics the planner has nothing but the query text to go on, which is
1299 /// the weakest position the pin can be held from. A pin that needs a
1300 /// populated table is a pin that can be satisfied by a distribution.
1301 ///
1302 /// [D-272]: ../../docs/architecture/s13-decision-register.md#d-272
1303 #[tokio::test]
1304 async fn the_log_arm_is_driven_by_the_churned_set() {
1305 let conn = fresh().await;
1306 fork(&conn, "exp", "main").await;
1307
1308 for shape in [
1309 LineageShape::Trunk,
1310 LineageShape::TrunkOnForked,
1311 LineageShape::Resolved,
1312 ] {
1313 let sql = overlap_candidates_resolved(shape, &anc());
1314 let mut rows = conn
1315 .query(&format!("EXPLAIN QUERY PLAN {sql}"), ())
1316 .await
1317 .unwrap();
1318 let mut plan = Vec::new();
1319 while let Some(r) = rows.next().await.unwrap() {
1320 plan.push(r.get::<String>(3).unwrap());
1321 }
1322 let step = plan.join(" | ");
1323
1324 let arm: Vec<&String> = plan
1325 .iter()
1326 .filter(|s| s.contains("transaction_log"))
1327 .collect();
1328
1329 if shape == LineageShape::Resolved {
1330 assert_eq!(
1331 arm.len(),
1332 1,
1333 "the resolved guard reads the log once and only in the \
1334 fold arm: {step}"
1335 );
1336 assert!(
1337 arm[0].contains("entity_id=?"),
1338 "the log arm is not seeking the churned key, so the whole \
1339 links log is being read once per asserted row: {step}"
1340 );
1341 } else {
1342 assert!(
1343 arm.is_empty(),
1344 "{shape:?} emits no `links_cut`, so it must not touch the \
1345 log at all: {step}"
1346 );
1347 }
1348 }
1349 }
1350
1351 /// One row in `branches` is the trunk shape whatever name is asked for,
1352 /// and an unknown name is refused before any shape is chosen.
1353 #[tokio::test]
1354 async fn one_lineage_is_the_trunk_shape() {
1355 let conn = fresh().await;
1356 assert_eq!(
1357 resolve_for(&conn, None).await.unwrap().0,
1358 LineageShape::Trunk
1359 );
1360 assert_eq!(
1361 resolve_for(&conn, Some("main")).await.unwrap().0,
1362 LineageShape::Trunk
1363 );
1364 assert!(matches!(
1365 resolve_for(&conn, Some("ghost")).await,
1366 Err(DbError::UnknownBranch(name)) if name == "ghost"
1367 ));
1368 }
1369
1370 /// Once the ledger has forked, the root reads as itself and every other
1371 /// lineage resolves (0.15.2, D-244).
1372 #[tokio::test]
1373 async fn a_forked_ledger_gives_the_root_its_own_shape() {
1374 let conn = fresh().await;
1375 fork(&conn, "b1", "main").await;
1376 fork(&conn, "b2", "b1").await;
1377 assert_eq!(
1378 resolve_for(&conn, None).await.unwrap().0,
1379 LineageShape::TrunkOnForked,
1380 "an unbranched read on a forked ledger is the trunk's own read"
1381 );
1382 assert_eq!(
1383 resolve_for(&conn, Some("main")).await.unwrap().0,
1384 LineageShape::TrunkOnForked
1385 );
1386 assert_eq!(
1387 resolve_for(&conn, Some("b1")).await.unwrap().0,
1388 LineageShape::Resolved
1389 );
1390 assert_eq!(
1391 resolve_for(&conn, Some("b2")).await.unwrap().0,
1392 LineageShape::Resolved
1393 );
1394 assert!(matches!(
1395 resolve_for(&conn, Some("ghost")).await,
1396 Err(DbError::UnknownBranch(_))
1397 ));
1398 }
1399
1400 /// Read the ancestry back from whichever relation produced it, `dist` first.
1401 async fn ancestry_from(conn: &libsql::Connection, sql: &str) -> Vec<Ancestor> {
1402 let mut rows = conn.query(sql, ()).await.unwrap();
1403 let mut out = Vec::new();
1404 while let Some(r) = rows.next().await.unwrap() {
1405 out.push(Ancestor {
1406 branch_id: r.get::<String>(0).unwrap(),
1407 dist: r.get::<i64>(1).unwrap(),
1408 cutoff: r.get::<Option<String>>(2).unwrap(),
1409 });
1410 }
1411 out.sort_by_key(|a| a.dist);
1412 out
1413 }
1414
1415 /// The CTE's answer for `start`, through the oracle.
1416 ///
1417 /// The branch is spliced rather than bound because this is test-only code
1418 /// reading a name this test wrote; the production form binds, which is what
1419 /// [`ancestry_values`] is about.
1420 async fn from_the_cte(conn: &libsql::Connection, start: &str) -> Vec<Ancestor> {
1421 let sql = format!(
1422 "WITH RECURSIVE {} SELECT branch_id, dist, cutoff FROM lineage",
1423 ancestry_cte(1, "").replace("?1", &format!("'{start}'"))
1424 );
1425 ancestry_from(conn, &sql).await
1426 }
1427
1428 /// The Rust walk's answer for `start`, round-tripped through the SQL it
1429 /// generates — so this checks [`resolve`] *and* [`ancestry_values`].
1430 async fn from_the_values(conn: &libsql::Connection, start: &str) -> Vec<Ancestor> {
1431 let lineages = Lineages::load(conn).await.unwrap();
1432 let ancestry = lineages.ancestry(start);
1433 let sql = format!(
1434 "WITH {} SELECT branch_id, dist, cutoff FROM lineage",
1435 ancestry_values(ancestry.len(), 1, "")
1436 );
1437 let mut rows = conn.query(&sql, ancestry_params(&ancestry)).await.unwrap();
1438 let mut out = Vec::new();
1439 while let Some(r) = rows.next().await.unwrap() {
1440 out.push(Ancestor {
1441 branch_id: r.get::<String>(0).unwrap(),
1442 dist: r.get::<i64>(1).unwrap(),
1443 cutoff: r.get::<Option<String>>(2).unwrap(),
1444 });
1445 }
1446 out.sort_by_key(|a| a.dist);
1447 out
1448 }
1449
1450 async fn fork_at(conn: &libsql::Connection, name: &str, parent: &str, at: &str) {
1451 conn.execute(
1452 "INSERT INTO branches (branch_id, parent_id, forked_at, created_at) \
1453 VALUES (?1, ?2, ?3, ?3)",
1454 libsql::params![name, parent, at],
1455 )
1456 .await
1457 .unwrap();
1458 }
1459
1460 /// **The differential test this release exists to pass** ([D-259]).
1461 ///
1462 /// [`resolve`] replaced a recursive CTE, and the way that goes wrong is not
1463 /// a crash: it is a cutoff that is one step too wide, on a lineage nobody
1464 /// reads for a month. So the Rust walk is checked against the SQL it
1465 /// replaced rather than against a restatement of the rule — every lineage
1466 /// on every shape below, both answers, byte for byte.
1467 ///
1468 /// Four shapes, and the third is the one that matters. A **chain** with
1469 /// increasing fork points never fires the running minimum. A chain with
1470 /// *decreasing* ones fires it at every step. Those two are the pair that
1471 /// tells a `min` apart from a plain assignment, which is the mistake a
1472 /// hand-written walk actually makes.
1473 ///
1474 /// [D-259]: ../../docs/architecture/s13-decision-register.md#d-259
1475 #[tokio::test]
1476 async fn the_rust_walk_agrees_with_the_cte() {
1477 for (label, forks) in [
1478 // A chain whose fork points increase down it: the minimum never bites.
1479 (
1480 "increasing chain",
1481 vec![
1482 ("b1", "main", "2026-01-01T00:00:00.000000Z"),
1483 ("b2", "b1", "2026-02-01T00:00:00.000000Z"),
1484 ("b3", "b2", "2026-03-01T00:00:00.000000Z"),
1485 ("b4", "b3", "2026-04-01T00:00:00.000000Z"),
1486 ],
1487 ),
1488 // The same chain with the fork points running the other way. Every
1489 // step must clamp to the earliest seen, and a walk that assigns
1490 // instead of taking a minimum widens each ancestor's window.
1491 (
1492 "decreasing chain",
1493 vec![
1494 ("b1", "main", "2026-04-01T00:00:00.000000Z"),
1495 ("b2", "b1", "2026-03-01T00:00:00.000000Z"),
1496 ("b3", "b2", "2026-02-01T00:00:00.000000Z"),
1497 ("b4", "b3", "2026-01-01T00:00:00.000000Z"),
1498 ],
1499 ),
1500 // Siblings: two lineages off one parent, so the walk must not carry
1501 // a cutoff sideways.
1502 (
1503 "siblings",
1504 vec![
1505 ("l", "main", "2026-01-01T00:00:00.000000Z"),
1506 ("r", "main", "2026-06-01T00:00:00.000000Z"),
1507 ("ll", "l", "2026-02-01T00:00:00.000000Z"),
1508 ],
1509 ),
1510 // A root with nothing under it: the ancestry is one row, no cutoff.
1511 (
1512 "lone fork",
1513 vec![("only", "main", "2026-01-01T00:00:00.000000Z")],
1514 ),
1515 ] {
1516 let conn = fresh().await;
1517 for (name, parent, at) in &forks {
1518 fork_at(&conn, name, parent, at).await;
1519 }
1520 let mut names = vec!["main".to_string()];
1521 names.extend(forks.iter().map(|(n, _, _)| n.to_string()));
1522
1523 for name in &names {
1524 let cte = from_the_cte(&conn, name).await;
1525 let values = from_the_values(&conn, name).await;
1526 assert_eq!(cte, values, "{label}: the two forms disagree for `{name}`");
1527 // Not vacuous: the reader is always there, and a lineage that
1528 // is not the trunk always has at least one ancestor.
1529 assert_eq!(
1530 cte.first().map(|a| a.branch_id.as_str()),
1531 Some(name.as_str())
1532 );
1533 assert!(cte.first().is_some_and(|a| a.cutoff.is_none()));
1534 }
1535 }
1536 }
1537
1538 /// The clamp, stated directly, so a failure says which rule broke.
1539 ///
1540 /// [`the_rust_walk_agrees_with_the_cte`] would catch a dropped minimum, but
1541 /// it would report it as "the two forms disagree" — true, and one step away
1542 /// from what went wrong. This names it.
1543 #[tokio::test]
1544 async fn the_cutoff_is_the_earliest_fork_on_the_path_and_not_the_nearest() {
1545 let conn = fresh().await;
1546 fork_at(&conn, "early", "main", "2026-01-01T00:00:00.000000Z").await;
1547 fork_at(&conn, "late", "early", "2026-09-01T00:00:00.000000Z").await;
1548
1549 let lineages = Lineages::load(&conn).await.unwrap();
1550 let ancestry = lineages.ancestry("late");
1551
1552 // `late` sees `early` up to the point *it* diverged, and `main` up to
1553 // the point `early` diverged — the earlier of the two, not the nearer.
1554 assert_eq!(ancestry[0].cutoff, None, "the reader has no cutoff");
1555 assert_eq!(
1556 ancestry[1].cutoff.as_deref(),
1557 Some("2026-09-01T00:00:00.000000Z"),
1558 "`early` is seen up to where `late` left it"
1559 );
1560 assert_eq!(
1561 ancestry[2].cutoff.as_deref(),
1562 Some("2026-01-01T00:00:00.000000Z"),
1563 "`main` is clamped to where `early` left it, not to where `late` did"
1564 );
1565 }
1566
1567 /// A `branches` table that cannot be walked returns a prefix, not a hang.
1568 ///
1569 /// The schema makes this unreachable — `parent_id` is a foreign key into an
1570 /// append-only table — so this stages it by writing a row the schema would
1571 /// refuse, with foreign keys off. The bound in [`resolve`] is what stops a
1572 /// corrupted file from hanging a read, and a bound nothing tests is a
1573 /// comment.
1574 #[tokio::test]
1575 async fn a_parent_that_is_not_there_ends_the_walk() {
1576 let conn = fresh().await;
1577 conn.execute("PRAGMA foreign_keys = OFF", ()).await.unwrap();
1578 fork_at(&conn, "orphan", "vanished", "2026-01-01T00:00:00.000000Z").await;
1579
1580 let lineages = Lineages::load(&conn).await.unwrap();
1581 let ancestry = lineages.ancestry("orphan");
1582 // The orphan, then the parent it names — which no row describes, so the
1583 // walk stops there rather than looking for its parent.
1584 assert_eq!(ancestry.len(), 2);
1585 assert_eq!(ancestry[1].branch_id, "vanished");
1586 }
1587
1588 /// The `VALUES` text names three placeholders per ancestor, from the slot
1589 /// it was given, and nothing before it.
1590 #[test]
1591 fn the_bound_ancestry_starts_where_the_reader_put_it() {
1592 let sql = ancestry_values(2, 6, "");
1593 assert!(sql.contains("(?6, ?7, ?8)"), "{sql}");
1594 assert!(sql.contains("(?9, ?10, ?11)"), "{sql}");
1595 assert!(
1596 !sql.contains("?5"),
1597 "nothing below the slot it was given: {sql}"
1598 );
1599 // Tagged, for the diff's two lowerings in one `WITH` list.
1600 assert!(ancestry_values(1, 1, "_a").starts_with("lineage_a("));
1601 }
1602
1603 /// Only `Trunk` leaves the branch unbound; the layouts ask this rather
1604 /// than comparing against `Resolved`.
1605 #[test]
1606 fn every_shape_but_the_trunk_binds_the_branch() {
1607 assert!(!LineageShape::Trunk.binds_branch());
1608 assert!(LineageShape::Resolved.binds_branch());
1609 assert!(LineageShape::TrunkOnForked.binds_branch());
1610 }
1611}
1612