Skip to main content

macrame/
connection.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use tokio::sync::{mpsc, oneshot};
4
5use crate::error::{classify, DbError, Result, WriteOp};
6use crate::graph::edge::EdgeAssertion;
7use crate::integrity::{rebuild_current, RebuildReport};
8use crate::schema::migrations;
9use crate::temporal::archive::{archive, rehydrate, ArchiveReport, RehydrateReport};
10use crate::temporal::interval::Interval;
11use crate::temporal::snapshot::{self, SnapshotCadence};
12use crate::util::clock::{Clock, SystemClock};
13use crate::util::timestamp;
14use crate::vector::ModelName;
15
16/// Rows per chunk on the background write paths (§5.1.5, D-011, D-014, D-058).
17///
18/// The Write Actor holds the sole write connection, so a single large statement
19/// blocks every other writer for its duration. Chunking bounds that stall; the
20/// cost is that a bulk import is *not* atomic across chunks, which is why it is
21/// a separate command from [`HighPriCommand::WriteBulkAtomic`] rather than a
22/// tuning parameter on it.
23///
24/// # Why these are four constants and not one
25///
26/// Through 0.5.5 this was a single `CHUNK_ROWS = 1000` for all four bulk paths.
27/// The golden rule it was meant to serve is a bound on *duration* — a background
28/// chunk must commit fast enough that an interactive write queued behind it is
29/// not made to wait — and one row count cannot express one duration across paths
30/// whose measured per-row costs differ by 60× (D-058). At 1,000 rows the four
31/// paths took 3.5 ms, 24 ms, 89 ms and 143 ms: the same constant, four answers,
32/// three of them far outside the bound.
33///
34/// Each size below is derived from `benches/budgets.rs`'s `chunk_scaling`
35/// sweep against [`CHUNK_BUDGET`], then verified by measuring that size directly.
36/// They are *measurements of this machine*, not universal constants — D-055's
37/// reasoning about reference hardware applies here too, and re-deriving them on
38/// materially different storage is a `cargo bench` away.
39///
40/// # Sized for the tail, not the median
41///
42/// The first derivation solved `f + c·n = 3 ms` exactly and produced sizes whose
43/// *median* commit was 2.93 ms and whose upper estimate was 2.96 — inside the
44/// bound as reported and outside it for any chunk slower than typical. A latency
45/// bound is a statement about the chunk an unlucky interactive write actually
46/// queues behind, so these solve for ≈2.5 ms instead, leaving the remainder as
47/// headroom for the tail. That costs a few percent of throughput on the two
48/// linear paths and nothing on the two superlinear ones.
49///
50/// As measured by `chunk_budget`, each at its own size: edges **2.39 ms**,
51/// concepts **2.35 ms**, annotations **2.36 ms**, embeddings **2.06 ms**, no
52/// upper estimate above 2.42.
53///
54/// # Known limitation: these are empty-database figures
55///
56/// `chunk_budget` seeds concepts and starts with **no links and no vectors**,
57/// and D-059 established that per-row cost on the edge and embedding paths grows
58/// with the size of the structure being written, not with the chunk. The same
59/// 90-edge chunk takes **9.06 ms** into an 8,000-edge table. So the bound is met
60/// as measured here and *not* met on a populated database.
61///
62/// That gap was published as 47.7 ms until 0.10.0 and attributed to the schema
63/// defect D-059 documents. The defect was fixed by the `v5 → v6` rung and the
64/// figure was never updated. 9.08 ms is a 0.10.0 measurement, not D-059's 8.0 ms
65/// carried forward: `chunk_budget` gained a seeded arm, because until it did,
66/// nothing in the bench suite wrote a chunk into a populated table and this
67/// number was unfalsifiable. It agrees with D-059 once the session is accounted
68/// for — the empty arm read 2.69 and 2.65 ms beside it against the 2.39 ms
69/// published above, so the *ratio* is 3.4× here and 3.35× there.
70///
71/// **The residual is attributed as of 0.11.0 (D-142).** It is not the missing
72/// index, which shipped in 0.5.6; it is the `links_current` write. Dropping the
73/// three `links` insert triggers one at a time puts effectively all of the
74/// growth in `trg_links_current_sync` — the single-open guard contributes none,
75/// the log trigger and the base insert ~0.35 ms of a 4.15 ms rise — and within
76/// that trigger, 89% of the growth is maintenance of `idx_lc_traversal_cover`
77/// and `idx_lc_open_interval` rather than the upsert itself, which costs 0.49 ms
78/// run directly against the same table. Page-cache size, foreign keys and the
79/// fixture's key distribution were each tested and are each not the cause.
80///
81/// Knowing the cause does not by itself change the constant: the expensive index
82/// is D-042's covering index for the traversal, so narrowing it moves cost onto
83/// the read path it exists to protect. Re-deriving these constants against the
84/// D-088 fixture matrix is the named successor.
85///
86/// # These are ceilings as of 0.12.0, not sizes
87///
88/// D-143 re-derived all four against the D-088 matrix and the edge path came
89/// back **20** against a shipped 90 — and 20 would have been wrong at 80,000
90/// edges for the same reason 90 is wrong at 8,000, because per-row cost there
91/// grows with `links_current`. The finding was that no row count can bound a
92/// duration on such a path.
93///
94/// So the chunk loop stopped trying to pick one ahead of time. Each chunk is
95/// timed by the actor and its measured hold chooses the next size; these
96/// constants are the **largest** size that will ever be asked for, and every
97/// derivation below still applies to them as such. A path may run well under its
98/// constant on a populated database and at exactly it on an empty one, and both
99/// are the bound being met rather than a size being missed.
100pub mod chunk_rows {
101    /// Edge assertions (`bulk_import`).
102    ///
103    /// Per-row cost on this path rises with the size of `links_current`, not
104    /// with the chunk (D-059) — so cutting the chunk buys latency and costs
105    /// throughput, ~11% for 1,000 edges. An earlier version of this comment
106    /// claimed it was 3.3× *faster*; that came from multiplying eleven copies of
107    /// a chunk measured into an empty database.
108    ///
109    /// **This size does not meet the 3 ms bound on a populated database.** 90
110    /// edges into an 8,000-edge table take **9.06 ms** — measured, two sessions
111    /// at 9.08 and 9.05, against an empty-table arm of 2.69 and 2.65 beside
112    /// them (D-136).
113    ///
114    /// The reason given here until 0.10.0 — that `trg_links_single_open`'s
115    /// `EXISTS` scans the whole out-degree, "a schema defect with a proven fix,
116    /// recorded in D-059 and not applied here" — described 0.5.5. The fix *was*
117    /// applied, as the `v5 → v6` rung, and took this from 47.7 ms to ~8 ms.
118    /// What survives is the miss: the bound is still exceeded ~3×. Its cause is
119    /// no longer unknown — D-142 attributes it to `trg_links_current_sync`, and
120    /// within that to secondary-index maintenance on `links_current` — and the
121    /// guard this comment used to blame contributes **no** growth at all.
122    ///
123    /// **The constant is unchanged, and that is now a measured decision**
124    /// (D-143). Re-derived against all four D-088 shapes at 8,000 edges, they
125    /// agree that the largest size meeting the bound is **20**. It stays at 90
126    /// because 20 is the same miss at a larger population — per-row cost grows
127    /// with `links_current`, so a constant fitted at 8,000 edges is wrong at
128    /// 80,000 — while the throughput cost of turning eleven chunks into fifty
129    /// is certain and immediate (D-058). The fix is not a row count: it is for
130    /// the chunk loop to stop on elapsed time, **delivered in 0.12.0**. This
131    /// number is now the ceiling that loop starts from and never exceeds; on a
132    /// populated table it converges below it within a chunk or two.
133    ///
134    /// D-134 retired the growth claim on the neighbouring *single-assertion*
135    /// path and did not measure this one; D-136 is why this line now carries a
136    /// measurement rather than a figure quoted from 0.5.6.
137    pub const EDGES: usize = 90;
138
139    /// Concept upserts (`write_concepts`).
140    ///
141    /// Linear at ~23 µs per row, so unlike [`EDGES`] this size *is* a genuine
142    /// throughput sacrifice: 1,000-row chunks ran at 23.6 µs per row against
143    /// ~35 µs here. Paid deliberately — a 1,000-row chunk takes 24 ms, eight
144    /// times the bound.
145    pub const CONCEPTS: usize = 70;
146
147    /// Analytics annotations (`write_analytics_annotations`).
148    ///
149    /// The one path where the old constant was nearly right, and the only bulk
150    /// table with no triggers at all: ~2.5 µs per row, linear, so the bound buys
151    /// a large chunk. 1,000 rows would be 3.5 ms — over, but only just.
152    pub const ANNOTATIONS: usize = 600;
153
154    /// Embedding vectors (`upsert_embeddings`).
155    ///
156    /// The smallest by a wide margin, because DiskANN index maintenance makes an
157    /// embedding the most expensive row in the system. That cost grows with the
158    /// **corpus**, not the chunk (D-059): a fixed 30-vector chunk costs 49 µs per
159    /// vector into an empty corpus and 224 µs into an 8,000-vector one. Graph
160    /// insertion getting dearer as the graph grows is what DiskANN is, so unlike
161    /// [`EDGES`] there is nothing here to fix — but it does mean this size buys
162    /// latency at some throughput, not for free.
163    pub const EMBEDDINGS: usize = 30;
164}
165
166/// What one chunk transaction cost, reported by the actor to the caller-side
167/// chunk loop (0.12.0, W1).
168///
169/// `held` is measured **inside** the actor, around its own transaction, and
170/// therefore excludes the time the command spent queued. That exclusion is the
171/// point: queue time is what strict preemption *does*, and a controller fed
172/// `send + await` would shrink chunks as punishment for the actor correctly
173/// serving an interactive write first.
174///
175/// Public only because [`LowPriCommand`] is. Nothing outside this crate
176/// constructs one, and it is deliberately not re-exported at the crate root.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub struct ChunkOutcome {
179    /// Rows the transaction actually wrote.
180    pub rows: usize,
181    /// How long the actor held the write lock for them.
182    pub held: std::time::Duration,
183}
184
185/// Smallest chunk the adaptive loop will fall to (0.12.0, W2).
186///
187/// # A floor is a deliberate, measured violation of [`CHUNK_BUDGET`]
188///
189/// Feedback alone converges to whatever size meets the budget, and on a
190/// populated `links` table that size keeps falling — per-row cost there grows
191/// with the table (D-059, D-142), so there is no size at which the *fixed* cost
192/// of a transaction stops dominating. Left unbounded the loop reaches chunks of
193/// one or two rows, where nearly all the work is `BEGIN`/`COMMIT` and the import
194/// no longer finishes.
195///
196/// 35 is measured, and **re-measured against the loop that uses it** — the
197/// difference matters, because the figure this constant shipped with was an
198/// extrapolation. `examples/chunk_matrix.rs -- converge` runs a 900-edge
199/// `bulk_import` into each of the four D-088 shapes at 8,000 edges and reports
200/// the actor's own per-transaction readings. A 35-row chunk costs **3.11–3.43 ms**
201/// across the four shapes, two sessions, excluding the run-up. The floor misses
202/// the 3 ms bound by 0.1–0.4 ms, not by the ~1.1 ms predicted from the sweep.
203///
204/// The miss is **steady state** — not a one-chunk transient on the way down —
205/// and the defense is the argument [`CHUNK_BUDGET`] is answerable to rather than
206/// the number itself: an interactive assertion arriving at the worst moment
207/// waits ~3.2 ms for the chunk in flight and then runs its own ≤ 5 ms write, so
208/// ~8.2 ms against a 16.7 ms frame.
209///
210/// What the same measurement says about the *size*: on this path at this
211/// population the loop goes `[90, 35, 35, …]` on all four shapes and never picks
212/// anything between. The proportional shrink from a 90-row chunk proposes ~31
213/// rows, which clamps here — so on the edge path the floor is not a safety net
214/// under the controller, it **is** the operating point, and this number is
215/// carrying more weight than a backstop normally would. Re-measure it, not the
216/// controller, when the edge path's per-row cost changes.
217const CHUNK_FLOOR: usize = 35;
218
219/// Size of the next chunk, from what the last one cost (0.12.0, W2).
220///
221/// Pure on purpose — no clock, no database, no actor — so the control law can be
222/// tested for the properties that matter without a fixture. Three regimes:
223///
224/// | last hold | response | why |
225/// |---|---|---|
226/// | over `budget` | shrink to `current · budget / held`, × 0.9 | back off *fast* from a bound already being exceeded; the 0.9 undershoots so the correction does not have to be repeated |
227/// | under `budget / 2` | grow by a quarter of `current`, at least one row | approach the bound *slowly*; the dead band above it stops a size that is merely comfortable from oscillating |
228/// | otherwise | hold | in band, and moving costs more than it buys |
229///
230/// The asymmetry is the whole design. Proportional shrinking converges from
231/// above in one or two steps, which matters because every step over budget is a
232/// latency miss a caller can feel; additive growth cannot overshoot by more than
233/// 25%, which matters because the ceiling is a throughput preference and not a
234/// bound.
235///
236/// `ceiling` is the path's [`chunk_rows`] constant, which is why those constants
237/// keep their values and their derivations: they are no longer the size, they
238/// are the largest size this will ever ask for. `floor` is [`CHUNK_FLOOR`] —
239/// see there for the budget it knowingly misses.
240///
241/// Never returns 0, at any input, including `held == 0` or `current == 0`.
242fn next_chunk_size(
243    current: usize,
244    held: std::time::Duration,
245    budget: std::time::Duration,
246    floor: usize,
247    ceiling: usize,
248) -> usize {
249    let held = held.as_nanos().max(1);
250    let budget_ns = budget.as_nanos().max(1);
251    let current = current.max(1);
252
253    let next = if held > budget_ns {
254        // Integer math, and the `max(1)` matters: a chunk 200× over budget
255        // would otherwise propose 0 and the loop would stop making progress.
256        let scaled = (current as u128) * budget_ns * 9 / (held * 10);
257        (scaled as usize).max(1)
258    } else if held * 2 < budget_ns {
259        // Saturating because `current` is a `usize` and this is the one branch
260        // that adds to it. Nothing sane reaches the boundary; the clamp below
261        // makes the answer correct anyway rather than a debug panic.
262        current.saturating_add((current / 4).max(1))
263    } else {
264        current
265    };
266
267    // Applied last and unconditionally, so a caller that passes a reversed pair
268    // gets the floor rather than a panic — and `max(1)` last of all, because a
269    // chunk of zero rows is the single answer no loop can make progress from.
270    next.clamp(floor.min(ceiling), ceiling).max(1)
271}
272
273/// The latency bound [`chunk_rows`] is derived from (§5.1.5, D-058).
274///
275/// This is the golden rule's actual content. §9 has carried it as a row count
276/// with a duration attached — "chunk commit, 500 rows ≤ 3 ms" — which reads as
277/// two requirements and is one: the duration is the requirement, and the row
278/// count is whatever satisfies it on a given path and machine.
279///
280/// 3 ms is §9's number, kept rather than renegotiated. What it buys, end to end:
281/// an interactive assertion arriving at the worst possible moment waits for the
282/// chunk in flight (≤ 3 ms, because the SQLite write lock is not preemptible —
283/// see [`HighPriCommand`]) and then runs its own write (≤ 5 ms, §9), so ≤ 8 ms
284/// worst case. That fits inside a 60 Hz frame with room, which is the standard
285/// this bound is ultimately answerable to.
286///
287/// # Some operations are exempt, and the exemption is a contract, not an oversight
288///
289/// This was recorded in three separate rustdoc notes and nowhere near the bound
290/// itself, which is where a reader looks for its scope (§8.6). Stated here, with
291/// Wave 3's measurements:
292///
293/// | Path | Bound | Why it cannot be chunked |
294/// |---|---|---|
295/// | [`Database::write_bulk_atomic`] | none — caller-sized `Vec` | D-014: the batch is *one act* under one stamp. Splitting it is the thing the method exists not to do |
296/// | [`Database::archive`] | measured **26.8 ms** for 2,000 archivable edges; see [`Database::archive_windowed`] | D-012: copy-then-delete must be atomic, or a crash between the phases duplicates or loses rows |
297/// | `rebuild_current` | measured **24.6 / 104 / 318 ms** at 4K / 16K / 40K rows in `links` (was "~50 s per 10M edges", which nothing had measured) | D-023: the window between `DELETE` and `INSERT` is the whole of current belief; a reader landing in it sees a graph with no edges and no error |
298/// | [`Database::rehydrate`] | unmeasured; a function of how many rows the caller named | D-012 backwards: the same copy-then-delete atomicity, in the other direction. **A row here since 0.12.9 only because it was previously invisible** — rehydration reported as `archive` and inherited its exemption without anyone deciding on it (W4.3, D-152) |
299/// | [`Database::checkpoint`] | a function of the WAL's size, which is a function of how long since the last checkpoint — not of anything the caller passes | It is not a transaction at all. `PRAGMA wal_checkpoint` copies frames back into the main file and there is no unit smaller than the frame it is already working in; the caller asked for exactly this, and the alternative to a long checkpoint is a WAL that keeps growing (0.12.13, W5.2, D-156) |
300///
301/// The `archive` figure is end-to-end through this method, so it **includes**
302/// the re-derivation `archive()` runs inside its transaction — but it does not
303/// attribute it, and until D-077 more than half of that re-derivation was an
304/// audit comparing `links_current` against the query that had just filled it.
305/// Note also which variable that cost scales with: `rebuild_within` reprojects
306/// **all of `links`**, so the archive's repair term grows with the *surviving*
307/// table and not with the batch being archived. A budget stated per "100K closed
308/// intervals" ([§9](../docs/architecture/s6-s10-flows-to-dependencies.md)) is
309/// therefore parameterised on the wrong quantity.
310///
311/// The first four are atomic **by contract**, which is why "cap the batch" and
312/// "add a third tier" were both considered and neither was taken: capping breaks the
313/// guarantee the operation exists to provide, and a third tier changes which
314/// caller waits without changing how long the lock is held. What was wrong was
315/// never the exemption — it was that the bound was stated as though it had none.
316///
317/// A caller who needs the latency bound and not the atomicity has
318/// [`Database::bulk_import`], which is the same write chunked at
319/// [`chunk_rows::EDGES`] and explicitly *not* atomic overall (D-011).
320///
321/// # One of them is no longer unbounded (T1.1, D-080)
322///
323/// `archive` was the worst of them, because its hold is a function of *how long
324/// since the last archive* rather than of anything the caller chose.
325/// [`Database::archive_windowed`] runs the same work as N sessions, each
326/// atomic, each its own actor turn. Measured on an 8,000-key fixture with four
327/// generations of superseded history: the longest single hold falls from
328/// **3.3 s to 0.77 s** at one-hour windows, for total wall time that is flat
329/// within this cycle's noise.
330///
331/// The same measurement at 2,000 keys goes the other way — the hold falls
332/// 260 ms → 117 ms while total time rises 260 ms → 671 ms — so windowing is a
333/// trade and not a free improvement. It pays when the backlog is large, which
334/// is when the unwindowed hold is a problem in the first place. `archive` is
335/// kept, not deprecated, for exactly that reason.
336pub const CHUNK_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
337
338/// Predicted hold above which [`Database::write_bulk_atomic`] warns (T1.3).
339///
340/// 250 ms is fifteen frames at 60 Hz: not a hitch, a visible freeze. It is well
341/// above [`CHUNK_BUDGET`] on purpose — this path is exempt from that bound by
342/// contract, so warning at 3 ms would fire on batches that are working exactly
343/// as designed and train the reader to filter the message out.
344pub const BULK_ATOMIC_WARN_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
345
346/// Roughly how long [`Database::write_bulk_atomic`] will hold the actor for
347/// this batch (T1.3, D-081).
348///
349/// # Three terms, because the cost is neither linear nor a function of size
350///
351/// T1.3 asks for "rows × measured per-row cost". That model is wrong twice over,
352/// and both corrections came out of measuring it.
353///
354/// First, the cost is not linear. `write_edges_atomic` opens with
355/// `reject_overlaps_within`, which compares **every pair** in the batch before a
356/// row is written. Second — and this is the one that matters — the quadratic
357/// term's constant depends on the batch's *shape*, not its size. The pairwise
358/// loop starts with an early `continue` on mismatched `(source, target,
359/// edge_type)`; pairs that share all three fall through to `Interval::new` and
360/// `overlaps`, which is **sixteen times** dearer per pair.
361///
362/// ```text
363/// hold ≈ 73 µs · rows  +  5.5 ns · mismatched pairs  +  86 ns · matching pairs
364/// ```
365///
366/// Two batches of 20,000 edges, measured on the same machine: one fanning out to
367/// distinct targets holds the actor for **2.5 s**, and one asserting 20,000
368/// corrections to a single relationship's history holds it for **18.6 s**. A
369/// size-only model is off by 7× between those two, in the direction that
370/// matters — it under-predicts the bad case. So this counts the matching pairs
371/// rather than guessing, with one `HashMap` pass over the batch. That pass is
372/// O(rows) against an operation about to spend milliseconds per row.
373///
374/// # What this is calibrated against, and where it will be wrong
375///
376/// libSQL 0.9.30, one machine, best of three, over 100–20,000 rows in both
377/// shapes; within 5% across that range except below ~500 rows, where fixed costs
378/// dominate and it over-predicts by 3× — harmless, since nothing that small can
379/// approach [`BULK_ATOMIC_WARN_HOLD`].
380///
381/// It is machine-specific and says nothing about disk. It exists to turn
382/// "uncapped" into an order of magnitude a caller can act on — the difference
383/// between 30 ms and 18 s — and should not be read more precisely than that.
384/// `examples/bulk_atomic_diag.rs` prints predicted against measured, so the
385/// model's drift is visible rather than assumed.
386pub fn estimated_bulk_hold(edges: &[EdgeAssertion]) -> std::time::Duration {
387    let rows = edges.len() as u64;
388    let all_pairs = rows.saturating_mul(rows.saturating_sub(1)) / 2;
389
390    // Pairs sharing all three key columns, which is exactly the set that reaches
391    // the guard's expensive path. Grouped rather than sorted: the batch is
392    // borrowed, and sorting would either clone it or reorder the caller's data.
393    let mut groups: std::collections::HashMap<(&str, &str, &str), u64> =
394        std::collections::HashMap::new();
395    for e in edges {
396        *groups
397            .entry((&e.source, &e.target, &e.edge_type))
398            .or_insert(0) += 1;
399    }
400    let matching: u64 = groups.values().map(|&g| g * (g - 1) / 2).sum();
401    let mismatched = all_pairs - matching;
402
403    // Nanoseconds throughout, saturating: a caller who passes a batch large
404    // enough to overflow this has a problem the arithmetic cannot express, and
405    // saturating to ~584 years still crosses every threshold above.
406    std::time::Duration::from_nanos(
407        (73_000u64.saturating_mul(rows))
408            .saturating_add(mismatched.saturating_mul(11) / 2)
409            .saturating_add(matching.saturating_mul(86)),
410    )
411}
412
413/// Most sessions [`Database::archive_windowed`] will run for one call (T1.1).
414///
415/// A limit exists because the session count is a function of *transaction-time
416/// span divided by window*, and both come from the caller — a one-second window
417/// over a decade of history is ten million actor turns, each opening a
418/// transaction and writing a horizon row. That is not a slow archive, it is a
419/// caller who meant something else.
420///
421/// 4,096 is chosen against the operation it bounds rather than against a clock:
422/// at the measured 26.8 ms for a session with work in it, a full run of this
423/// many is about two minutes of background writing, and the whole point of
424/// windowing is that those two minutes are interruptible. It is a refusal
425/// rather than a clamp — see [`DbError::ArchiveWindow`] for why.
426pub const MAX_ARCHIVE_SESSIONS: usize = 4_096;
427
428/// A concept assertion: the payload of an upsert.
429#[derive(Debug, Clone, PartialEq)]
430pub struct ConceptUpsert {
431    pub id: String,
432    pub title: String,
433    pub content: String,
434    pub embedding_model: Option<String>,
435    pub valid_from: String,
436    pub valid_to: String,
437    pub retired: bool,
438}
439
440impl ConceptUpsert {
441    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
442        Self {
443            id: id.into(),
444            title: title.into(),
445            content: String::new(),
446            embedding_model: None,
447            valid_from: String::new(),
448            valid_to: timestamp::OPEN_SENTINEL.to_string(),
449            retired: false,
450        }
451    }
452
453    pub fn content(mut self, content: impl Into<String>) -> Self {
454        self.content = content.into();
455        self
456    }
457
458    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
459        self.embedding_model = Some(model.into());
460        self
461    }
462
463    pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
464        self.valid_from = ts.into();
465        self
466    }
467
468    pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
469        self.valid_to = ts.into();
470        self
471    }
472
473    pub fn retired(mut self, retired: bool) -> Self {
474        self.retired = retired;
475        self
476    }
477
478    /// Put the timestamps in canonical form (D-029) before they cross the channel.
479    pub fn normalized(mut self) -> Result<Self> {
480        crate::util::ids::validate_id(&self.id)?;
481        self.valid_from = timestamp::normalize(&self.valid_from)?;
482        self.valid_to = timestamp::normalize(&self.valid_to)?;
483        Ok(self)
484    }
485}
486
487/// One derived analytics result for one concept (§5.4, D-041).
488///
489/// Not a `ConceptUpsert`. The distinction is the whole of D-041: a concept
490/// upsert is a statement about the world and belongs in the ledger, while an
491/// annotation is a function of an algorithm applied to a graph and belongs in
492/// `analytics_annotations`, which carries no log trigger. Writing one as the
493/// other overwrote the concept's `content` with the label and recorded every
494/// analytics rerun as a fresh version of the world.
495#[derive(Debug, Clone, PartialEq, Eq)]
496pub struct Annotation {
497    pub concept_id: String,
498    /// Namespaced by convention, e.g. `louvain.community`, `kcore.shell`.
499    pub label: String,
500    /// JSON-encoded payload. Opaque to this crate.
501    pub value: String,
502}
503
504impl Annotation {
505    pub fn new(
506        concept_id: impl Into<String>,
507        label: impl Into<String>,
508        value: impl Into<String>,
509    ) -> Self {
510        Self {
511            concept_id: concept_id.into(),
512            label: label.into(),
513            value: value.into(),
514        }
515    }
516}
517
518/// Commands sent to the Write Actor on the high-priority channel (UI-driven work).
519pub enum HighPriCommand {
520    AssertEdge {
521        edge: EdgeAssertion,
522        responder: oneshot::Sender<Result<()>>,
523    },
524    RetireEdge {
525        source: String,
526        target: String,
527        edge_type: String,
528        valid_from: String,
529        valid_to: String,
530        responder: oneshot::Sender<Result<()>>,
531    },
532    UpsertConcept {
533        concept: ConceptUpsert,
534        responder: oneshot::Sender<Result<()>>,
535    },
536    WriteBulkAtomic {
537        edges: Vec<EdgeAssertion>,
538        responder: oneshot::Sender<Result<usize>>,
539    },
540    RebuildCurrent {
541        responder: oneshot::Sender<Result<RebuildReport>>,
542    },
543    /// Create a model's embedding table and its DiskANN index (D-037, D-048).
544    ///
545    /// High priority despite being setup work: it is one small transaction, and
546    /// every embedding write for the model blocks on it, so queueing it behind a
547    /// bulk job would stall the thing it gates.
548    RegisterModel {
549        model: ModelName,
550        dim: usize,
551        responder: oneshot::Sender<Result<()>>,
552    },
553    /// Move WAL frames back into the main database file (§4.5, F-30, D-156).
554    ///
555    /// High priority, and for once the reason is not latency: a caller asking
556    /// for a checkpoint is asking for it *now*, usually at the end of a bulk
557    /// load or before taking a copy of the file, and queueing it behind the
558    /// background work it was meant to follow inverts the intent. It is also
559    /// the only command here that is not a transaction.
560    Checkpoint {
561        responder: oneshot::Sender<Result<CheckpointReport>>,
562    },
563    Shutdown {
564        responder: oneshot::Sender<Result<()>>,
565    },
566}
567
568/// What `PRAGMA wal_checkpoint` returned (0.12.13, W5.2, D-156).
569///
570/// The three columns SQLite gives back, named, rather than `()` — a checkpoint
571/// that did nothing and a checkpoint that reclaimed a 400 MB WAL are the same
572/// `Ok(())`, and the difference is the entire reason a caller asked.
573#[derive(Debug, Clone, Copy, PartialEq, Eq)]
574pub struct CheckpointReport {
575    /// `true` when SQLite could not complete the requested mode because a
576    /// reader or writer was in the way.
577    ///
578    /// **This is not an error, and it is not ignorable.** `TRUNCATE` waits for
579    /// readers only as long as `busy_timeout` allows; past that it gives up and
580    /// says so, having possibly still copied frames. A caller checkpointing
581    /// before copying the file away must read this, because a busy checkpoint
582    /// means the main file is not self-contained yet.
583    pub busy: bool,
584    /// Frames left in the WAL at the end. `0` when the checkpoint completed,
585    /// since the mode run is `TRUNCATE`.
586    pub log_frames: u64,
587    /// Frames moved back into the database file.
588    ///
589    /// Read from a `FULL` pass rather than from the `TRUNCATE` — see
590    /// `run_checkpoint` for why a truncating checkpoint cannot report this
591    /// number itself.
592    pub checkpointed_frames: u64,
593}
594
595impl CheckpointReport {
596    /// The WAL was fully reclaimed: nothing blocked, and nothing is left.
597    pub fn is_complete(&self) -> bool {
598        !self.busy && self.log_frames == 0
599    }
600}
601
602/// Commands sent to the Write Actor on the low-priority channel (background work).
603pub enum LowPriCommand {
604    /// One chunk of **concepts** — a ledger write, logged and versioned.
605    WriteConceptsChunk {
606        chunk: Vec<ConceptUpsert>,
607        responder: oneshot::Sender<Result<ChunkOutcome>>,
608    },
609    /// One chunk of **derived annotations** — off-ledger, no log trigger (D-041).
610    ///
611    /// The pair is named apart deliberately: this variant was `WriteAnalyticsChunk`
612    /// beside a `WriteAnnotationsChunk` that carried concepts, which is the
613    /// crossing D-075 undid.
614    WriteAnalyticsChunk {
615        chunk: Vec<Annotation>,
616        responder: oneshot::Sender<Result<ChunkOutcome>>,
617    },
618    /// One chunk of vectors for one model (§5.9, D-048).
619    ///
620    /// Low priority: embedding is bulk derived work and must never preempt an
621    /// interactive assertion.
622    UpsertEmbeddingChunk {
623        model: ModelName,
624        chunk: Vec<(String, Vec<f32>)>,
625        responder: oneshot::Sender<Result<ChunkOutcome>>,
626    },
627    BulkImportChunk {
628        chunk: Vec<EdgeAssertion>,
629        responder: oneshot::Sender<Result<ChunkOutcome>>,
630    },
631    Archive {
632        cutoff: String,
633        archive_path: PathBuf,
634        responder: oneshot::Sender<Result<ArchiveReport>>,
635    },
636    /// Move named concepts back out of the cold file (0.9.0, C3).
637    ///
638    /// Low priority for the same reason `Archive` is: it is bulk physical
639    /// movement with no latency bound, and it holds the write lock for its whole
640    /// transaction.
641    Rehydrate {
642        ids: Vec<String>,
643        archive_path: PathBuf,
644        responder: oneshot::Sender<Result<RehydrateReport>>,
645    },
646    /// Reconstruct the FTS index from `concepts` (§5.9, D-036, D-051).
647    ///
648    /// Low priority: it is maintenance on a derivative table, and a search index
649    /// that is a few seconds stale is a smaller cost than an interactive write
650    /// that waits behind a full reindex.
651    RebuildFts {
652        responder: oneshot::Sender<Result<()>>,
653    },
654    /// Refresh or top up the query planner's statistics (0.12.4, D-149).
655    ///
656    /// Low priority, and not a close call: statistics being a few seconds stale
657    /// costs a plan that was already the plan a moment ago, where preempting an
658    /// interactive assertion costs a caller their latency bound. It is a write —
659    /// it writes `sqlite_stat1` — so it takes the write lock like anything else,
660    /// and `PRAGMA analysis_limit` in `configure` is what keeps the hold a
661    /// function of the index count instead of the table size.
662    Analyze {
663        /// `true` runs `PRAGMA optimize`, which re-analyses only what SQLite
664        /// believes has gone stale; `false` runs `ANALYZE` unconditionally.
665        incremental: bool,
666        responder: oneshot::Sender<Result<()>>,
667    },
668    /// One step of a chunked shadow rebuild (§5.8, T1.2, D-082).
669    ///
670    /// Low priority, and one command per step rather than one per rebuild: the
671    /// whole value of building beside the live table is that the actor returns
672    /// here between chunks. See [`Database::rebuild_current_chunked`].
673    ShadowRebuild {
674        step: crate::integrity::ShadowStep,
675        responder: oneshot::Sender<Result<crate::integrity::ShadowOutcome>>,
676    },
677}
678
679enum LoopCtl {
680    Continue,
681    Break,
682}
683
684/// Primary database handle for Macrame bitemporal ledger.
685pub struct Database {
686    db: libsql::Database,
687    /// The file this handle opened, kept so [`Database::diagnostic_conn`] can
688    /// open it again under different flags (T5.1, D-091). `archive_path` and
689    /// `snapshots_dir` are derived from it and were previously the only trace
690    /// of it on the struct.
691    path: PathBuf,
692    read_conn: libsql::Connection,
693    highpri_tx: mpsc::Sender<HighPriCommand>,
694    lowpri_tx: mpsc::Sender<LowPriCommand>,
695    clock: Arc<dyn Clock>,
696    archive_path: PathBuf,
697    snapshots_dir: PathBuf,
698    schema_version: u32,
699    /// Kept so [`Database::diagnostic_conn`] can configure the connections it
700    /// mints the same way `open()` configured the internal readers (0.12.16,
701    /// W5.5, D-159). Before that split, each one ran with SQLite's defaults.
702    reader_cache_size: Option<i32>,
703    writer: Option<tokio::task::JoinHandle<Result<()>>>,
704    /// Stops the snapshot cadence. Dropping it stops the task too, which is what
705    /// keeps a `Database` that is dropped rather than closed from leaving a task
706    /// running against a connection whose database is going away.
707    cadence_stop: Option<tokio::sync::watch::Sender<bool>>,
708    cadence: Option<tokio::task::JoinHandle<()>>,
709    /// Set by [`Database::close`]. Read only by [`Drop`], which warns when it is
710    /// still false — see that impl for why the omission is worth a warning.
711    closed: bool,
712    /// Shared with the actor (T1.4, T1.2). Held here rather than behind
713    /// `#[cfg(feature = "metrics")]` so `open_inner` has one shape; with the
714    /// feature off the metrics half is a zero-sized type and only
715    /// [`Database::metrics`] is gated — which is also why the field is unread in
716    /// the default build: the actor holds the other `Arc` and does the writing.
717    #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
718    shared: Arc<ActorShared>,
719}
720
721/// What the snapshot cadence should do, for [`Tuning::cadence`].
722///
723/// # Why this is not `Option<SnapshotCadence>`
724///
725/// [`Database::open_with_cadence`] takes `Option<SnapshotCadence>`, where `None`
726/// means *no cadence at all*. Carrying that field into [`Tuning`] unchanged
727/// would have made it the one field in the struct whose `None` is a request to
728/// change the behaviour rather than a request to leave it alone — and since
729/// `Tuning` derives `Default`, `open_tuned(path, Tuning::default())` would then
730/// have silently disabled snapshots, while `open(path)` runs them. Two calls
731/// that read as synonyms, one of which stops writing anchors.
732///
733/// So the tri-state is written out. `Default` is the default cadence, matching
734/// [`Database::open`]; `Disabled` is `open_with_cadence(path, None)`, and has to
735/// be asked for by name.
736#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
737#[non_exhaustive]
738pub enum CadencePolicy {
739    /// [`SnapshotCadence::default`], as [`Database::open`] uses.
740    #[default]
741    Default,
742    /// No cadence task. `close()` is then the only thing that writes an anchor
743    /// (§5.5, D-053).
744    Disabled,
745    /// An explicit cadence.
746    Every(SnapshotCadence),
747}
748
749impl CadencePolicy {
750    /// Collapse to the `Option` the open path has always taken.
751    fn resolve(self) -> Option<SnapshotCadence> {
752        match self {
753            Self::Default => Some(SnapshotCadence::default()),
754            Self::Disabled => None,
755            Self::Every(cadence) => Some(cadence),
756        }
757    }
758}
759
760/// When SQLite should checkpoint the WAL on its own, for
761/// [`Tuning::wal_autocheckpoint`] (0.12.14, W5.3, D-157).
762///
763/// # Why this is not `Option<u32>`
764///
765/// The same reason [`CadencePolicy`] is not `Option<SnapshotCadence>`, and the
766/// plan for this wave specified `Option<u32>` here too. In a struct that derives
767/// `Default`, a field whose `None` means *turn the mechanism off* is a field
768/// that turns the mechanism off for everyone who did not mention it. Absence
769/// means "leave it alone" everywhere in [`Tuning`], and disabling the automatic
770/// checkpointer — which is not safe without an explicit
771/// [`Database::checkpoint`] to replace it — has to be asked for by name.
772///
773/// **The default does not change.** 1,000 pages is SQLite's default and stays
774/// SQLite's default; F-30 is a control-loop perturbation, not a correctness bug,
775/// and changing a default is a behaviour change for every existing caller.
776///
777/// # What disabling it actually buys, measured (0.12.14, W5.3, D-157)
778///
779/// F-30 says the automatic checkpointer is an unbudgeted hold *inside* 0.12.0's
780/// adaptive chunk controller: a checkpoint firing during a chunk transaction is
781/// charged to that chunk, and since D-146 made the measured hold the input to
782/// `next_chunk_size`, the controller shrinks in response to work the chunk did
783/// not do. Three rounds, 6,000 concepts of 1 KB each through `write_concepts`,
784/// release build:
785///
786/// | | longest chunk hold | mean | chunks | over budget | wall |
787/// |---|---|---|---|---|---|
788/// | autocheckpoint on (default) | **9.3–10.3 ms** | 2.40–2.44 ms | 125–130 | 24–28 | 304–321 ms |
789/// | autocheckpoint off | **4.50 ms** | 2.08–2.20 ms | 142–153 | 18–27 | 298–339 ms |
790///
791/// **The tail is the finding, and it is real and reproducible.** The longest
792/// hold roughly halves, and the >10 ms histogram bucket is populated only with
793/// the checkpointer on — that bucket is the checkpoint, landing inside somebody
794/// else's transaction and being charged to it. Every round agrees.
795///
796/// **What it does not buy is a calmer controller.** `over_budget` overlaps
797/// between the arms, and total wall time is the same within noise. The
798/// controller works near the budget boundary either way, because
799/// [D-090](../docs/architecture/s13-decision-register.md)'s ~0.8 ms
800/// per-transaction floor and the convergence cost do not go anywhere. So the
801/// honest statement is that disabling autocheckpoint removes an outlier, not an
802/// oscillation.
803///
804/// **And the cost is deferred, not removed.** The explicit
805/// [`Database::checkpoint`] at the end of the same fixture moved **8,400–9,100
806/// frames in 41–45 ms** with the checkpointer off, against **~860 frames in
807/// 5.5–6.2 ms** with it on. That is the whole trade in one line: the same work,
808/// moved out of the latency-bounded path and into one hold the caller chose the
809/// moment for. It is a good trade for a bulk importer and a bad one for an
810/// interactive process, which is why this is a knob and not a new default.
811#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
812#[non_exhaustive]
813pub enum WalCheckpointPolicy {
814    /// SQLite's own default: checkpoint once the WAL passes 1,000 pages.
815    #[default]
816    Default,
817    /// No automatic checkpointing.
818    ///
819    /// **Only correct if you call [`Database::checkpoint`] yourself.** Without
820    /// one, the WAL grows for the life of the process and the database file is
821    /// never brought up to date.
822    Disabled,
823    /// Checkpoint once the WAL passes this many pages.
824    ///
825    /// `0` is not special-cased to [`Self::Disabled`] even though SQLite treats
826    /// it that way, because a caller who computed a threshold and got zero has
827    /// a bug, and inheriting SQLite's overload would turn it into a silently
828    /// unbounded WAL.
829    EveryPages(u32),
830}
831
832impl WalCheckpointPolicy {
833    /// The pragma to run, or `None` to leave the connection at SQLite's
834    /// default.
835    fn pragma(self) -> Option<String> {
836        match self {
837            Self::Default => None,
838            Self::Disabled => Some("PRAGMA wal_autocheckpoint = 0".to_string()),
839            Self::EveryPages(pages) => Some(format!("PRAGMA wal_autocheckpoint = {pages}")),
840        }
841    }
842}
843
844/// Everything [`Database::open_tuned`] can be told, in one growable struct
845/// (0.12.12, W5.1, D-155).
846///
847/// # Why a struct rather than a fourth constructor
848///
849/// There were three — [`Database::open`], [`Database::open_with_cadence`],
850/// [`Database::open_with_clock`] — and each new knob added one more, with the
851/// combinatorics of the ones before it. 0.13.0 alone wanted three knobs
852/// (`wal_autocheckpoint`, and a page cache each for the writer and the
853/// readers), which is the point at which the naming stops being possible.
854///
855/// **`Default` plus functional update is the whole design.** They make a new
856/// knob an additive change: callers construct with `..Default::default()` and
857/// keep compiling, and the fields that arrive after them are the ones they did
858/// not ask about. That is not a hypothetical — W5.1 ships this struct with two
859/// fields, and W5.3/W5.4 add the three tuning knobs to it without touching a
860/// caller.
861///
862/// # Why this is *not* `#[non_exhaustive]`
863///
864/// The plan for this wave specified `#[non_exhaustive]` alongside `Default`,
865/// on the usual reasoning that the attribute is what makes a struct growable.
866/// It does not compile: a `#[non_exhaustive]` **struct** cannot be built with
867/// literal syntax outside its own crate *at all*, and the functional-update
868/// form is literal syntax, so `Tuning { cadence, ..Default::default() }` is
869/// `E0639` for every external caller — the exact expression the attribute was
870/// added to protect. (The rule differs from `#[non_exhaustive]` on an enum,
871/// which only forces a wildcard arm; [`CadencePolicy`] keeps it for that
872/// reason.) The two ways to have both are a builder with setters, or plain
873/// `Default` — and `Default` is chosen because the field-literal form is the
874/// legible one, and because the growth this needs to survive is *additive*
875/// fields, which `..Default::default()` already absorbs.
876///
877/// The cost is real and worth stating: a caller who writes an exhaustive
878/// literal, with no `..Default::default()`, breaks when a field is added. That
879/// is a compile error at the call site with an obvious fix, not a silent
880/// behaviour change, and it is the price of the readable form.
881///
882/// # The three constructors stay
883///
884/// They delegate here and are not deprecated. `open(path)` is the right call for
885/// most callers and should not acquire a warning for being the common case; the
886/// consolidation is about where the *next* knob goes, not about moving anyone.
887///
888/// ```no_run
889/// # use macrame::prelude::*;
890/// # async fn f() -> macrame::Result<()> {
891/// let db = Database::open_tuned(
892///     "graph.db",
893///     Tuning {
894///         cadence: CadencePolicy::Disabled,
895///         ..Default::default()
896///     },
897/// )
898/// .await?;
899/// # Ok(()) }
900/// ```
901#[derive(Clone, Default)]
902pub struct Tuning {
903    /// What the snapshot cadence should do. Defaults to
904    /// [`SnapshotCadence::default`], as [`Database::open`] does.
905    pub cadence: CadencePolicy,
906    /// A clock to stamp `recorded_at` with, for tests (§5.1.2, D-062). `None`
907    /// is [`SystemClock`]. Floored against the database exactly as
908    /// [`Database::open_with_clock`] describes — read that before injecting
909    /// one against a non-empty file.
910    pub clock: Option<Arc<dyn Clock>>,
911    /// When SQLite checkpoints the WAL on its own (0.12.14, W5.3, F-30).
912    ///
913    /// Applied to the **write connection**, which is the only connection in
914    /// this crate that commits, and therefore the only one whose autocheckpoint
915    /// setting can ever fire. Pair [`WalCheckpointPolicy::Disabled`] with an
916    /// explicit [`Database::checkpoint`] or the WAL grows without bound.
917    pub wal_autocheckpoint: WalCheckpointPolicy,
918    /// Page cache for the **write** connection, as SQLite's `cache_size`
919    /// (0.12.15, W5.4).
920    ///
921    /// `None` leaves SQLite's default of −2000, which is −2000 *kibibytes*, or
922    /// 2 MB. **Negative values are KiB and positive values are pages** — that
923    /// is SQLite's convention and it is preserved rather than smoothed over,
924    /// because a caller who knows the pragma should not have to discover that
925    /// this crate redefined it. `Some(-64_000)` is 64 MB; `Some(64_000)` is
926    /// 64,000 pages, which at the 4 KiB page size this crate gets is 256 MB.
927    ///
928    /// The writer wants a large cache: it is one connection, it holds the write
929    /// lock while it works, and every page it has to re-read from disk is time
930    /// no other writer can use.
931    ///
932    /// # Unlike the two above, `None` here is not a policy enum
933    ///
934    /// Because SQLite's default is a *value* rather than a mechanism. Absence
935    /// still means "leave it alone" — it just happens that leaving this alone
936    /// is expressible as not running a pragma, where leaving the automatic
937    /// checkpointer alone required saying which of two things "alone" meant.
938    pub writer_cache_size: Option<i32>,
939    /// Page cache for every **read-only** connection: the shared
940    /// [`Database::read_conn`], the snapshot cadence's own connection, and
941    /// (since W5.5) each [`Database::diagnostic_conn`] (0.12.15, W5.4).
942    ///
943    /// Same units as [`Self::writer_cache_size`], and the same `None`.
944    ///
945    /// Split from the writer's because the profiles are opposite and one number
946    /// cannot serve both. There is exactly one writer and it is long-lived, so
947    /// its cache is a fixed cost paid once. Read-only connections are plural —
948    /// `diagnostic_conn` mints a new one per call — so a large value here is
949    /// multiplied by however many a caller opens, and the R15 hazard that
950    /// method documents is about concurrent opens. A single shared number
951    /// therefore has to be small enough for the multiplied case, which is the
952    /// wrong size for the one connection that holds the write lock.
953    pub reader_cache_size: Option<i32>,
954}
955
956// `Clock` is not `Debug` — it is a behavioural trait with two methods and
957// requiring `Debug` of every implementor to print a handle here would be the
958// tail wagging the dog. So the field is reported as present-or-absent, which is
959// the only part of it a reader of a `Tuning` dump can act on.
960impl Tuning {
961    /// The `Option<SnapshotCadence>` the three older constructors take, mapped
962    /// onto the tri-state. `None` there means *disabled*, which is why
963    /// [`CadencePolicy`] exists — see its docs.
964    fn from_legacy(cadence: Option<SnapshotCadence>, clock: Option<Arc<dyn Clock>>) -> Self {
965        Self {
966            cadence: match cadence {
967                Some(cadence) => CadencePolicy::Every(cadence),
968                None => CadencePolicy::Disabled,
969            },
970            clock,
971            wal_autocheckpoint: WalCheckpointPolicy::default(),
972            writer_cache_size: None,
973            reader_cache_size: None,
974        }
975    }
976}
977
978impl std::fmt::Debug for Tuning {
979    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
980        f.debug_struct("Tuning")
981            .field("cadence", &self.cadence)
982            .field("clock", &self.clock.as_ref().map(|_| "<injected>"))
983            .field("wal_autocheckpoint", &self.wal_autocheckpoint)
984            .field("writer_cache_size", &self.writer_cache_size)
985            .field("reader_cache_size", &self.reader_cache_size)
986            .finish()
987    }
988}
989
990impl Database {
991    /// Open a database file at `path`, configuring pragmas, running migrations, and spawning the Write Actor.
992    ///
993    /// The snapshot cadence runs with [`SnapshotCadence::default`]. Use
994    /// [`Database::open_with_cadence`] to tune or disable it.
995    pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
996        Self::open_with_cadence(path, Some(SnapshotCadence::default())).await
997    }
998
999    /// Open with an explicit snapshot cadence, or `None` to run without one
1000    /// (§5.5, D-053).
1001    ///
1002    /// `None` restores the pre-0.5.5 behaviour, where `close()` is the only
1003    /// thing that ever writes an anchor. That is the right setting for a
1004    /// short-lived process that will not accumulate a delta worth bounding, and
1005    /// for tests that assert on the contents of the snapshot directory.
1006    pub async fn open_with_cadence(
1007        path: impl AsRef<Path>,
1008        cadence: Option<SnapshotCadence>,
1009    ) -> Result<Self> {
1010        Self::open_inner(path.as_ref(), Tuning::from_legacy(cadence, None)).await
1011    }
1012
1013    /// Open with an injected clock (§5.1.2, **defect K**, D-062).
1014    ///
1015    /// The reason this exists is testing: `recorded_at` is the transaction-time
1016    /// axis, and until now every test that wanted to assert on one had to either
1017    /// avoid it or drive a raw connection, because `open()` hardcoded
1018    /// [`SystemClock`]. `FakeClock` has been public and constructed in the test
1019    /// harness since 0.5.2 with nothing to inject it into — the compiler warned
1020    /// about the dead field on every build for three releases.
1021    ///
1022    /// **The clock is floored against the database before the actor starts.**
1023    /// [`Clock::raise_floor`] is called with the newest `recorded_at` in the
1024    /// ledger, so an injected clock cannot issue a stamp below what is already
1025    /// stored — which would abort the next concept write on
1026    /// `trg_concepts_monotonic_ra` rather than merely being odd. This is the
1027    /// step whose absence kept the defect open: the obvious implementation
1028    /// (take an `Arc<dyn Clock>`, use it) produces a `Database` that fails on
1029    /// its first write against any non-empty file.
1030    ///
1031    /// On a fresh database there is no floor, so an injected `FakeClock` issues
1032    /// exactly the stamps it was given.
1033    pub async fn open_with_clock(
1034        path: impl AsRef<Path>,
1035        cadence: Option<SnapshotCadence>,
1036        clock: Arc<dyn Clock>,
1037    ) -> Result<Self> {
1038        Self::open_inner(path.as_ref(), Tuning::from_legacy(cadence, Some(clock))).await
1039    }
1040
1041    /// Open with an explicit [`Tuning`] (0.12.12, W5.1, D-155).
1042    ///
1043    /// The consolidated form of the three constructors above, and the one that
1044    /// grows: every knob 0.13.0 adds arrives as a field here rather than as a
1045    /// fourth `open_*`. See [`Tuning`] for why the struct is
1046    /// `#[non_exhaustive]` and why that makes the growth additive.
1047    pub async fn open_tuned(path: impl AsRef<Path>, tuning: Tuning) -> Result<Self> {
1048        Self::open_inner(path.as_ref(), tuning).await
1049    }
1050
1051    async fn open_inner(path: &Path, tuning: Tuning) -> Result<Self> {
1052        let Tuning {
1053            cadence,
1054            clock: injected,
1055            wal_autocheckpoint,
1056            writer_cache_size,
1057            reader_cache_size,
1058        } = tuning;
1059        let cadence = cadence.resolve();
1060        let db = libsql::Builder::new_local(path).build().await?;
1061        let write_conn = configure(db.connect()?, writer_cache_size).await?;
1062        // The writer is the only connection that commits, so it is the only one
1063        // whose `wal_autocheckpoint` can ever fire. Setting it on the readers
1064        // would be a pragma with no path to running (0.12.14, W5.3, D-157).
1065        if let Some(pragma) = wal_autocheckpoint.pragma() {
1066            let _ = write_conn.query(&pragma, ()).await?;
1067        }
1068        let read_conn = configure(db.connect()?, reader_cache_size).await?;
1069
1070        // PRAGMA query_only = ON on reader connection (§5.1.2)
1071        read_conn.execute("PRAGMA query_only = ON", ()).await?;
1072
1073        let migration = migrations::run(&write_conn).await?;
1074
1075        let (highpri_tx, highpri_rx) = mpsc::channel(256);
1076        let (lowpri_tx, lowpri_rx) = mpsc::channel(64);
1077
1078        // Floored after `migrations::run`, so the tables the floor is read from
1079        // are guaranteed to exist.
1080        let clock: Arc<dyn Clock> = match injected {
1081            Some(clock) => {
1082                if let Some(floor) = crate::util::clock::recorded_at_floor(&read_conn).await? {
1083                    clock.raise_floor(floor);
1084                }
1085                clock
1086            }
1087            None => Arc::new(SystemClock::new(&read_conn).await?),
1088        };
1089        let shared = Arc::new(ActorShared::default());
1090        let writer = tokio::spawn(run_writer_actor(
1091            write_conn,
1092            Arc::clone(&clock),
1093            highpri_rx,
1094            lowpri_rx,
1095            Arc::clone(&shared),
1096        ));
1097
1098        let archive_path = derive_archive_path(path);
1099        let snapshots_dir = derive_snapshots_dir(path);
1100
1101        // **The cadence gets its own connection (Wave 4.1).** It used to share
1102        // `read_conn`, on the reasoning that `libsql::Connection` is an
1103        // Arc-backed handle and R15 makes every extra local connection a cost worth
1104        // not paying for nothing. The cost it was not paying for turned out to be
1105        // real: `reconstruct` brackets a fold with `ATTACH cold … DETACH cold`,
1106        // that region is per-connection state, and it is not synchronised. Two
1107        // folds on one connection can therefore interleave so that one DETACHes
1108        // the handle the other is mid-fold on.
1109        //
1110        // Recorded in §8.5 as a hazard rather than a defect because it **did not
1111        // reproduce**: 200 concurrent reconstructions against a 1 ms cadence with
1112        // an archive present produced zero errors, since the cadence anchors at
1113        // `MAX(recorded_at)` and so almost always takes the hot path. Narrow, and
1114        // real — a write landing between `log_head` and the fold opens it.
1115        //
1116        // Separate connections remove the interleaving rather than ordering it,
1117        // which is why this is preferred to a mutex around the region: there is
1118        // no shared state left to race on, and nothing to remember to hold. The
1119        // R15 objection does not apply — that fault is about *concurrent* opens,
1120        // and this is one more sequential open during `open()`.
1121        let (cadence_stop, cadence) = match cadence {
1122            Some(cadence) => {
1123                let cadence_conn = configure(db.connect()?, reader_cache_size).await?;
1124                cadence_conn.execute("PRAGMA query_only = ON", ()).await?;
1125                let (tx, rx) = tokio::sync::watch::channel(false);
1126                let handle = tokio::spawn(snapshot::run_cadence(
1127                    cadence_conn,
1128                    snapshots_dir.clone(),
1129                    archive_path.clone(),
1130                    cadence,
1131                    rx,
1132                ));
1133                (Some(tx), Some(handle))
1134            }
1135            None => (None, None),
1136        };
1137
1138        let handle = Self {
1139            db,
1140            path: path.to_path_buf(),
1141            read_conn,
1142            highpri_tx,
1143            lowpri_tx,
1144            clock,
1145            archive_path,
1146            snapshots_dir,
1147            schema_version: migrations::current_version(),
1148            reader_cache_size,
1149            writer: Some(writer),
1150            cadence_stop,
1151            cadence,
1152            closed: false,
1153            shared,
1154        };
1155
1156        // **Re-anchor after a migration (Wave 4.4).**
1157        //
1158        // D-043 makes a `SCHEMA_VERSION` bump invalidate every snapshot on disk,
1159        // which is correct — a snapshot is a serialised `MaterializedState` and a
1160        // schema change can change what that means. What was missing is the other
1161        // half: nothing wrote a replacement, so the first `reconstruct` after an
1162        // upgrade skipped every file as incompatible and folded from genesis. On
1163        // a database with a large log that is the difference between reading one
1164        // snapshot and folding the whole history, and the only trace was a
1165        // `warn!` per skipped file.
1166        //
1167        // Written here rather than left to the cadence because the cadence fires
1168        // on log *growth* (D-053): an upgraded database that is then read but not
1169        // written would never re-anchor at all.
1170        //
1171        // Failure is logged, not returned. A missing anchor costs time and no
1172        // information — snapshots are derivative under Doctrine VI — so refusing
1173        // to open a database because its optimisation could not be rebuilt would
1174        // trade a real capability for a performance one.
1175        //
1176        // Gated on the cadence being enabled, as well as on an actual upgrade:
1177        // `open_with_cadence(None)` means *this handle writes no snapshots except
1178        // at close()*, and a one-off write at open would contradict that for a
1179        // caller who asked for the quiet mode precisely to control when files
1180        // appear. They still get an anchor from `close()`.
1181        if migration.upgraded() && handle.cadence.is_some() {
1182            let ts = handle.clock.now();
1183            let archive = handle
1184                .archive_path
1185                .exists()
1186                .then_some(handle.archive_path.as_path());
1187            match snapshot::write_final(&handle.read_conn, &handle.snapshots_dir, &ts, archive)
1188                .await
1189            {
1190                Ok(path) => tracing::info!(
1191                    "schema moved v{} -> v{}; re-anchored snapshots at {:?}",
1192                    migration.from,
1193                    migration.to,
1194                    path
1195                ),
1196                Err(e) => tracing::warn!(
1197                    "schema moved v{} -> v{} but the re-anchor failed: {e}. \
1198                     Reconstruction stays correct and folds from genesis until the \
1199                     cadence writes one.",
1200                    migration.from,
1201                    migration.to
1202                ),
1203            }
1204        }
1205
1206        Ok(handle)
1207    }
1208
1209    /// Read connection handle for queries, traversals, and folds.
1210    pub fn read_conn(&self) -> &libsql::Connection {
1211        &self.read_conn
1212    }
1213
1214    /// The file this handle opened.
1215    pub fn path(&self) -> &Path {
1216        &self.path
1217    }
1218
1219    /// A **new, independently owned, OS-level read-only** connection to this
1220    /// database, for diagnostics (§4.7, T5.1, D-091).
1221    ///
1222    /// # Why this exists when `read_conn()` already does
1223    ///
1224    /// Two different things, and the difference is the point:
1225    ///
1226    /// * `read_conn()` returns a shared `&Connection` carrying
1227    ///   `PRAGMA query_only = ON`. That pragma is **per-connection and
1228    ///   reversible by its holder in one statement**, so it is a guardrail
1229    ///   against accident, not a capability boundary. And because the reference
1230    ///   is shared, a caller who runs a long reporting query on it is competing
1231    ///   with every traversal and fold in the process.
1232    /// * This returns a connection opened with `SQLITE_OPEN_READ_ONLY`, which is
1233    ///   enforced by the engine below the pragma layer, and it is the caller's
1234    ///   own.
1235    ///
1236    /// **Measured on libSQL 0.9.30 rather than assumed**
1237    /// (`examples/readonly_open_probe.rs`), against a live WAL database with the
1238    /// write actor running:
1239    ///
1240    /// | | `read_conn()` | `diagnostic_conn()` |
1241    /// |---|---|---|
1242    /// | `SELECT`, `EXPLAIN QUERY PLAN` | allowed | allowed |
1243    /// | `INSERT` | refused | refused |
1244    /// | `PRAGMA query_only = OFF` | **allowed** | allowed |
1245    /// | `INSERT` after that | **allowed** | **refused** |
1246    /// | `ATTACH` an existing file | allowed | allowed |
1247    /// | `INSERT` into the attachment | refused¹ | **refused** |
1248    /// | `ATTACH` a path that does not exist | — | refused (`SQLITE_CANTOPEN`) |
1249    ///
1250    /// The third and fourth rows are the whole difference: turning the pragma
1251    /// off restores writes on `read_conn()` and does not here. That is what
1252    /// "boundary rather than guardrail" means, and it is now a number rather
1253    /// than a claim.
1254    ///
1255    /// ¹ On `read_conn()` that refusal is `query_only` — the same reversible
1256    /// thing as row 2. On `diagnostic_conn()` it is the open flags, and the
1257    /// probe runs it *after* `query_only = OFF` so that the pragma cannot be
1258    /// what is doing the work.
1259    ///
1260    /// # `ATTACH` is permitted, and does not widen the write boundary
1261    ///
1262    /// Checked because `diagnostic_query` (Python) is the only arbitrary-SQL
1263    /// surface this crate exposes, and an attachment is a second `open` whose
1264    /// flags it does not obviously inherit. It does inherit them: the
1265    /// attachment is read-only, and a nonexistent path is `SQLITE_CANTOPEN`
1266    /// rather than a new file, because `SQLITE_OPEN_CREATE` is dropped for the
1267    /// attachment as it is for `main`. So `SQLITE_OPEN_READ_ONLY` bounds the
1268    /// **connection**, not just the one file it names (0.10.0, W4.3).
1269    ///
1270    /// What it does widen is *reading*: an `ATTACH` can name any file the
1271    /// process can open, so this connection is a read surface over the
1272    /// filesystem, not over this database. That is a property of arbitrary SQL
1273    /// rather than of the flags, and it is unchanged by them.
1274    ///
1275    /// # One way this is *more* permissive, which is worth knowing
1276    ///
1277    /// `CREATE TEMP TABLE` **succeeds** here and is refused by `read_conn()`.
1278    /// Temp tables live in a separate temporary database that is writable
1279    /// regardless of how the main one was opened, whereas `query_only` refuses
1280    /// them outright — which is the mechanism [D-050] measured when it removed
1281    /// `TwoPhaseTempTable` for returning `SQLITE_READONLY (8)` on the read
1282    /// connection. So the stronger boundary is not uniformly stronger, and a
1283    /// strategy that needs a temp table has a connection it could run on. That
1284    /// is recorded, not acted on: D-050 removed the strategy for two reasons and
1285    /// this addresses one of them.
1286    ///
1287    /// # Calling this concurrently is R15's shape
1288    ///
1289    /// **This is the one method on `Database` that opens the file.** Everything
1290    /// else runs on connections established once, at `open`. Each call here is
1291    /// a fresh `libsql::Builder::…build()`, so *N* threads calling it at once
1292    /// are *N* concurrent opens — which is exactly the pattern behind
1293    /// [R15](https://github.com/opticsWolf/Macrame#known-risks), the upstream
1294    /// libSQL access violation (`0xC0000005`) that `examples/r15_soak.rs`
1295    /// reproduces and `RUST_TEST_THREADS=1` exists to avoid in the suite.
1296    ///
1297    /// **This is measured, not inferred.** 48 threads sharing one handle and
1298    /// calling only this method: 7 bad runs in 18 — two access violations and
1299    /// five *returned* SQLite errors (`database is locked`, `bad parameter or
1300    /// other API misuse`). With the calls serialised, 0 in 18
1301    /// (`tests_py/probes/r15_diagnostic_path.py`). The returned-error mode is
1302    /// the one to watch for: it looks like a fact about the database, on the
1303    /// method a caller reaches for when they already doubt the typed answer.
1304    ///
1305    /// **Bound this yourself if you call it from more than one thread.** One
1306    /// outstanding open at a time is enough; a mutex around the call costs
1307    /// nothing on a diagnostic path. This method does not do it for you on
1308    /// purpose: serialising behind a lock the caller cannot see would
1309    /// contradict the thing above it — that the connection is *the caller's
1310    /// own* — and it would put a hidden queue in front of the one surface whose
1311    /// job is to answer questions when the typed path is already suspect. The
1312    /// Python binding does bound it, because it wraps this in a method a caller
1313    /// cannot see into (`PyDatabase::diagnostic_rows`); a Rust caller can.
1314    ///
1315    /// # Errors
1316    ///
1317    /// The file must already exist. `SQLITE_OPEN_READ_ONLY` drops
1318    /// `SQLITE_OPEN_CREATE` with it, so a missing file is `SQLITE_CANTOPEN`
1319    /// rather than a fresh empty database — which is the right failure, and is
1320    /// surfaced as a typed error rather than as libSQL's error 14.
1321    pub async fn diagnostic_conn(&self) -> Result<libsql::Connection> {
1322        let fail = |reason: String| DbError::DiagnosticConn {
1323            path: self.path.display().to_string(),
1324            reason,
1325        };
1326        if !self.path.exists() {
1327            return Err(fail(
1328                "the file does not exist, and a read-only open cannot create it".to_string(),
1329            ));
1330        }
1331        let db = libsql::Builder::new_local(&self.path)
1332            .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY)
1333            .build()
1334            .await
1335            .map_err(|e| fail(e.to_string()))?;
1336        let conn = db.connect().map_err(|e| fail(e.to_string()))?;
1337        // Configured since 0.12.16 (W5.5, D-159). Until then this connection
1338        // ran with SQLite's defaults while every other connection in the
1339        // process ran with the crate's — most consequentially a `busy_timeout`
1340        // of 0 against everyone else's 5 s, on the one surface whose job is to
1341        // answer questions when the typed path is already suspect. Only the
1342        // common half: `SQLITE_OPEN_READ_ONLY` cannot set `journal_mode`, and
1343        // the rest govern writes this connection cannot make.
1344        configure_common(&conn, self.reader_cache_size).await?;
1345        Ok(conn)
1346    }
1347
1348    /// Cross-check the snapshot chain against a fold from genesis (§5.5, T5.3,
1349    /// D-092).
1350    ///
1351    /// `write_final` composes onto the previous snapshot, so snapshot *n* is
1352    /// derived from snapshot *n−1* and nothing in the chain ever folds the whole
1353    /// log. An error at any link propagates forward forever and every read
1354    /// agrees with it, because every read descends from it. This is the check
1355    /// that would notice.
1356    ///
1357    /// # When to run it
1358    ///
1359    /// **Not on a schedule this crate chooses.** A genesis fold is precisely the
1360    /// cost snapshots exist to avoid, so running it periodically by default
1361    /// would give every application the bill snapshots were bought to remove —
1362    /// on a database whose log is large enough for snapshots to matter, which is
1363    /// the only kind where this is worth doing. The plan calls it a scheduling
1364    /// problem and it is the caller's schedule: an idle period, a nightly job,
1365    /// or once per *N* anchors, chosen against a log size this crate cannot see.
1366    ///
1367    /// The cadence is deliberately left alone for the same reason — it runs on a
1368    /// connection shared with nothing and a fold there would compete with
1369    /// interactive reads at a moment nobody chose.
1370    ///
1371    /// # It reports; it does not repair
1372    ///
1373    /// A divergence means the snapshots are a wrong **cache**, not that the
1374    /// ledger is corrupt: [Doctrine VI] makes them disposable, so deleting
1375    /// [`Self::snapshots_dir`] restores correctness and costs only speed.
1376    /// Rewriting the file here would destroy the evidence that composition has a
1377    /// defect, which is the only thing this can tell you that you did not
1378    /// already know.
1379    ///
1380    /// Pair it with the actor counters ([`Self::metrics`], D-079) so a
1381    /// divergence found by a scheduled run is visible beside the write latency
1382    /// of the period that produced it.
1383    ///
1384    /// [Doctrine VI]: ../../docs/architecture/s0-s3-foundations.md#doctrine-vi
1385    pub async fn verify_snapshot_chain(&self, ts: &str) -> Result<crate::temporal::ChainCheck> {
1386        let archive = self
1387            .archive_path
1388            .exists()
1389            .then_some(self.archive_path.as_path());
1390        crate::temporal::verify_snapshot_chain(&self.read_conn, ts, archive, &self.snapshots_dir)
1391            .await
1392    }
1393
1394    /// The clock every write is stamped with (§5.1.1).
1395    pub fn clock(&self) -> &Arc<dyn Clock> {
1396        &self.clock
1397    }
1398
1399    /// Schema version this handle opened against.
1400    pub fn schema_version(&self) -> u32 {
1401        self.schema_version
1402    }
1403
1404    /// Cold database path, derived by convention from the main file.
1405    pub fn archive_path(&self) -> &Path {
1406        &self.archive_path
1407    }
1408
1409    /// Snapshot directory, derived by convention from the main file.
1410    pub fn snapshots_dir(&self) -> &Path {
1411        &self.snapshots_dir
1412    }
1413
1414    /// What the write actor has done since this handle was opened (T1.4, D-079).
1415    ///
1416    /// Requires the `metrics` feature. The counters are per-handle and start at
1417    /// zero on `open()` — they are not read from the database, because the thing
1418    /// being measured is *this process's* actor and merging two processes'
1419    /// histograms would produce a number about neither.
1420    ///
1421    /// The intended first question is [`crate::metrics::MetricsSnapshot::budget_violations`]:
1422    ///
1423    /// ```no_run
1424    /// # async fn f(db: &macrame::Database) {
1425    /// # #[cfg(feature = "metrics")] {
1426    /// for k in db.metrics().budget_violations() {
1427    ///     eprintln!("{} broke the 3 ms bound {} times", k.kind, k.over_budget);
1428    /// }
1429    /// # }
1430    /// # }
1431    /// ```
1432    ///
1433    /// Reading this does not stop the actor — see
1434    /// [`crate::metrics::ActorMetrics::snapshot`] for what that costs in
1435    /// consistency, and why the trade goes that way.
1436    #[cfg(feature = "metrics")]
1437    pub fn metrics(&self) -> crate::metrics::MetricsSnapshot {
1438        self.shared.metrics.snapshot()
1439    }
1440
1441    /// The underlying libSQL database, for callers that need their own connection.
1442    ///
1443    /// # Actor containment is a convention above this line, not a guarantee
1444    ///
1445    /// **Kept public, and the honest statement of what that costs (Wave 4.3).**
1446    /// §5.1 says the write actor is the sole writer, and two mechanisms make that
1447    /// true of the handle: every write method goes through a channel, and
1448    /// [`Self::read_conn`] carries `PRAGMA query_only = ON`. **Nothing protects a
1449    /// connection obtained from here.** A caller can open one, write to `links`
1450    /// directly, and the actor will not know — the triggers still fire and the
1451    /// ledger stays internally consistent, but the single-writer property that
1452    /// [`crate::CHUNK_BUDGET`]'s latency argument rests on is gone, and so is the
1453    /// serialisation the overlap guard (D-060) relies on.
1454    ///
1455    /// This is the same shape as the limit stated in §4.2 for that guard, and it
1456    /// is one fact rather than two: **the storage layer permits what this API
1457    /// refuses.** Making it private would not change that — the database file is
1458    /// reachable by any SQLite client on the machine — it would only remove the
1459    /// supported way to do the thing, which is how escape hatches become
1460    /// `unsafe`-adjacent folklore.
1461    ///
1462    /// The free functions [`crate::register_model`] and
1463    /// [`crate::upsert_embedding`] take a bare connection for the same reason and
1464    /// carry the same caveat; prefer [`Self::register_model`] and
1465    /// [`Self::upsert_embeddings`], which go through the actor.
1466    ///
1467    /// # The legitimate-use list is now one item long (T5.1, D-091)
1468    ///
1469    /// It used to read: `EXPLAIN QUERY PLAN` and other diagnostics, read-only
1470    /// reporting queries wanting their own connection rather than sharing the
1471    /// reader, and provoking a guard in a test. The first two are exactly what
1472    /// [`Self::diagnostic_conn`] now does, and it does them behind an OS-level
1473    /// read-only open rather than on a handle that can write. **Use that.**
1474    ///
1475    /// What is left is the one use that genuinely requires write access through
1476    /// a connection the actor does not own: *provoking a guard* — writing the
1477    /// state §4.7 says the storage layer permits and this API refuses, so a test
1478    /// can assert the gap is still where the document says it is. That is the
1479    /// only thing this crate's own suite uses it for.
1480    ///
1481    /// # Why `#[doc(hidden)]` and not a `raw-access` feature
1482    ///
1483    /// T5.1 offers either. The feature is the stronger declaration — it shows up
1484    /// in the consumer's `Cargo.toml`, where a reviewer sees it — and it was
1485    /// **not** taken, for a reason specific to what uses this:
1486    ///
1487    /// Cargo features are additive and cannot be *required* by a test target
1488    /// except through `required-features`, which makes a plain `cargo test`
1489    /// **skip** that binary silently. The binaries that call this are
1490    /// `storage_boundary_tests` and `wave1_regression_tests` — the §4.7
1491    /// tripwires, whose entire job is to fail when a documented gap moves. Gating
1492    /// them behind a feature would mean the ordinary `cargo test` stopped running
1493    /// the tests that enforce the section this item is about, to make a
1494    /// declaration about a hatch. That trade is the wrong way round, and it is
1495    /// the same failure the project already names: a suite that quietly does less
1496    /// than it appears to.
1497    ///
1498    /// So the hatch stays reachable and stops being *discoverable*: it is absent
1499    /// from the docs, and the documented path for every non-write use is
1500    /// [`Self::diagnostic_conn`]. [D-068] is unchanged — removing it would buy
1501    /// the appearance of a guarantee, since the file is reachable by any SQLite
1502    /// client on the machine.
1503    ///
1504    /// [D-068]: ../../docs/architecture/s13-decision-register.md#d-068
1505    // convention (D-068/D-091): `raw()` is #[doc(hidden)] and is NOT exposed by
1506    // any binding. Everything above this line is invisible on docs.rs and
1507    // invisible to a contributor reading the Python surface list, which is where
1508    // the decision to expose it would actually be taken — hence this sentinel and
1509    // its twin in `bindings/python/src/lib.rs` (0.10.0, W4.10). The documented
1510    // path for every non-write use is `diagnostic_conn`.
1511    #[doc(hidden)]
1512    pub fn raw(&self) -> &libsql::Database {
1513        &self.db
1514    }
1515
1516    // -- write surface (§5.1, Appendix A) --
1517    //
1518    // Every method here validates and canonicalises before the value crosses the
1519    // channel, so a bad edge type or a second-precision timestamp is a typed
1520    // error at the call site rather than an engine `CHECK` failure surfacing
1521    // from the far side of an actor with no context attached.
1522    //
1523    // NOTE (§5.1.8, D-028): awaiting one of these waits on a Rust channel, not
1524    // in SQLite, so `busy_timeout` does not bound it. During an in-flight
1525    // `rebuild_current` or `archive` the caller stalls for that transaction's
1526    // duration. Wrap in `tokio::time::timeout` if you need a bound — but a
1527    // timeout is not a cancellation: the command stays queued and commits when
1528    // the actor reaches it.
1529
1530    /// Assert an edge (Doctrine III: a new row, never an update).
1531    ///
1532    /// # One row costs a transaction, so N rows cost N transactions
1533    ///
1534    /// This is the correct method for a caller who genuinely has one edge, and
1535    /// it is the wrong one in a loop. Each call is its own transaction and pays
1536    /// the ~0.8 ms per-transaction floor (D-090) whole, so a thousand edges
1537    /// asserted one at a time spend roughly **0.8 s in transaction overhead
1538    /// alone** — before any of the work — and mint a thousand distinct
1539    /// `recorded_at` stamps for what the caller probably means as one act.
1540    ///
1541    /// There are two bulk forms and the difference between them is the one to
1542    /// get right:
1543    ///
1544    /// - [`Self::bulk_import`] is **chunked** against [`CHUNK_BUDGET`] and
1545    ///   atomic per chunk. It amortises the transaction floor across the batch
1546    ///   while still yielding to interactive work at every chunk boundary. This
1547    ///   is the one a loop should almost always become.
1548    /// - [`Self::write_bulk_atomic`] is one transaction under one stamp and is
1549    ///   **the one write with no latency bound** — the hold is a function of
1550    ///   `edges.len()`, tabulated in its own docs, and is time every other
1551    ///   writer spends waiting. Reach for it when the batch is genuinely one
1552    ///   act that must not be observable half-applied, not for speed.
1553    ///
1554    /// The choice is the caller's and neither form is deprecated. Doctrine III
1555    /// makes "one act, one stamp" a semantic claim rather than a performance
1556    /// one, and only the caller knows whether their thousand edges are one act.
1557    pub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()> {
1558        let edge = edge.normalized()?;
1559        self.high(|responder| HighPriCommand::AssertEdge { edge, responder })
1560            .await
1561    }
1562
1563    /// Close an open interval by asserting its replacement (Doctrine III).
1564    pub async fn retire_edge(
1565        &self,
1566        source: impl Into<String>,
1567        target: impl Into<String>,
1568        edge_type: impl Into<String>,
1569        valid_from: &str,
1570        valid_to: &str,
1571    ) -> Result<()> {
1572        let edge_type = edge_type.into();
1573        crate::graph::edge::validate_edge_type(&edge_type)?;
1574        let valid_from = timestamp::normalize(valid_from)?;
1575        let valid_to = timestamp::normalize(valid_to)?;
1576        let (source, target) = (source.into(), target.into());
1577
1578        self.high(|responder| HighPriCommand::RetireEdge {
1579            source,
1580            target,
1581            edge_type,
1582            valid_from,
1583            valid_to,
1584            responder,
1585        })
1586        .await
1587    }
1588
1589    /// Insert or update a concept.
1590    ///
1591    /// # One row costs a transaction
1592    ///
1593    /// The same trade [`Self::assert_edge`] describes, for the same reason and
1594    /// with the same ~0.8 ms floor (D-090): correct for one concept, wrong in a
1595    /// loop. [`Self::write_concepts`] takes a `Vec` and commits it as one
1596    /// transaction under one stamp.
1597    ///
1598    /// There is no atomic-across-chunks concept path and none is needed to make
1599    /// the choice: `write_concepts` is chunked against [`CHUNK_BUDGET`] and
1600    /// atomic per chunk, so a large `Vec` is cooperative rather than a stall.
1601    /// The responsiveness argument for writing one row at a time therefore does
1602    /// not apply — the bulk form already yields at every chunk boundary.
1603    pub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()> {
1604        let concept = concept.normalized()?;
1605        self.high(|responder| HighPriCommand::UpsertConcept { concept, responder })
1606            .await
1607    }
1608
1609    /// Assert many edges in one transaction under one stamp (D-014).
1610    ///
1611    /// # This is the one write with no latency bound, and here is what it costs
1612    ///
1613    /// The batch is one act under one `recorded_at`, so it cannot be chunked —
1614    /// splitting it is the thing this method exists not to do. That makes the
1615    /// actor's hold a function of `edges.len()`, and until now the only
1616    /// statement of that anywhere was the prose "uncapped" in
1617    /// [`CHUNK_BUDGET`]'s table. A caller who stalls every other writer for
1618    /// eight seconds should have been able to predict it from the signature.
1619    ///
1620    /// Measured on libSQL 0.9.30 (T1.3, D-081), holding the actor for:
1621    ///
1622    /// | rows | hold |
1623    /// |---|---|
1624    /// | 500 | ~34 ms |
1625    /// | 2,000 | ~155 ms |
1626    /// | 10,000 | ~1.0 s |
1627    /// | 20,000 | ~2.6 s |
1628    ///
1629    /// [`estimated_bulk_hold`] is that curve as a function, and this method
1630    /// emits a `tracing::warn!` when it predicts more than
1631    /// [`BULK_ATOMIC_WARN_HOLD`]. **The estimate is a shape, not a promise** —
1632    /// see [`estimated_bulk_hold`] for what it is calibrated against and where
1633    /// it will be wrong.
1634    ///
1635    /// A caller who needs the latency bound and not the atomicity wants
1636    /// [`Self::bulk_import`], which is the same write chunked and explicitly not
1637    /// atomic overall (D-011).
1638    pub async fn write_bulk_atomic(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1639        let estimate = estimated_bulk_hold(&edges);
1640        if estimate > BULK_ATOMIC_WARN_HOLD {
1641            // Warned here rather than in the actor, and before the send: this is
1642            // the caller's own task, so the log line lands with their span
1643            // attached and names the call site that chose the batch size. By the
1644            // time the actor has it, the only context left is "a large batch".
1645            tracing::warn!(
1646                rows = edges.len(),
1647                estimated_hold_ms = estimate.as_millis() as u64,
1648                "write_bulk_atomic will hold the write actor for roughly \
1649                 {estimate:?} — it is atomic by contract (D-014) and cannot be \
1650                 chunked. Every other writer waits that long. Use bulk_import \
1651                 if the batch does not need to be all-or-nothing."
1652            );
1653        }
1654
1655        let edges = normalize_all(edges)?;
1656        self.high(|responder| HighPriCommand::WriteBulkAtomic { edges, responder })
1657            .await
1658    }
1659
1660    /// Move the WAL back into the main database file (§4.5, F-30, 0.12.13,
1661    /// W5.2, D-156).
1662    ///
1663    /// Runs `PRAGMA wal_checkpoint(FULL)` and then `(TRUNCATE)` on the write
1664    /// connection, as one actor turn, and returns what SQLite reported. **Read
1665    /// [`CheckpointReport::busy`]** — a checkpoint that could not run is an
1666    /// `Ok` whose WAL is still there.
1667    ///
1668    /// Two passes rather than one because **a truncating checkpoint cannot
1669    /// report its own work**: the counts describe the WAL *after* the
1670    /// operation, and after a truncation there is nothing left to describe, so
1671    /// `TRUNCATE` alone answers `busy=0, log=0, checkpointed=0` on success —
1672    /// indistinguishable from having done nothing. `FULL` supplies the frame
1673    /// count and `TRUNCATE` resets the file; `busy` is the union of the two.
1674    ///
1675    /// # When a caller needs this
1676    ///
1677    /// Three cases, and only three:
1678    ///
1679    /// - **Before copying the database file elsewhere.** In WAL mode the `.db`
1680    ///   file alone is not the database; recent commits live in the `-wal`. A
1681    ///   complete checkpoint is what makes the main file self-contained.
1682    /// - **At the end of a bulk load that turned the automatic checkpointer
1683    ///   off.** That is the pairing this method exists for — see
1684    ///   [`Tuning::wal_autocheckpoint`]. Disabling autocheckpoint without
1685    ///   calling this leaves a WAL that grows for the life of the process.
1686    /// - **Before a long idle period**, to give back the disk.
1687    ///
1688    /// Nobody else should call it on a timer. SQLite checkpoints automatically
1689    /// every 1,000 pages and that default is not changed by this method
1690    /// existing; a periodic explicit checkpoint on top of it buys nothing and
1691    /// takes the write lock to do so.
1692    ///
1693    /// # It takes the write lock, and it is budget-exempt
1694    ///
1695    /// The hold is a function of how many frames have accumulated, which is a
1696    /// function of how long since the last checkpoint — not of anything passed
1697    /// in. It is on [`CHUNK_BUDGET`]'s exemption table for that reason, and it
1698    /// is the one entry there that is not a transaction: there is no smaller
1699    /// unit to chunk into, because the operation *is* the copy.
1700    pub async fn checkpoint(&self) -> Result<CheckpointReport> {
1701        self.high(|responder| HighPriCommand::Checkpoint { responder })
1702            .await
1703    }
1704
1705    /// Rebuild `links_current` from `links` and verify zero drift (§5.8).
1706    ///
1707    /// One transaction holding the write lock for its whole duration, because
1708    /// [D-023] will not let the `DELETE` and the `INSERT` be split: a reader
1709    /// landing between them would see a graph with no edges and no error.
1710    /// [`Self::rebuild_current_chunked`] is the same result with a different
1711    /// latency profile, and is what a populated database wants.
1712    ///
1713    /// The report's `drift_after` is the audit run inside the same transaction,
1714    /// so a repair that did not converge is reported by the call that made it
1715    /// rather than by the next one to look.
1716    ///
1717    /// [D-023]: ../docs/architecture/s13-decision-register.md#d-023
1718    pub async fn rebuild_current(&self) -> Result<RebuildReport> {
1719        self.high(|responder| HighPriCommand::RebuildCurrent { responder })
1720            .await
1721    }
1722
1723    /// Rebuild `links_current` beside itself, in chunks (§5.8, T1.2, D-082).
1724    ///
1725    /// Same result as [`Self::rebuild_current`], different latency profile.
1726    /// `rebuild_current` is one transaction holding the write lock for its whole
1727    /// duration, because D-023 will not let the `DELETE` and the `INSERT` be
1728    /// split: a reader landing between them sees a graph with no edges and no
1729    /// error. This builds the replacement in a shadow table instead — the live
1730    /// table stays live and trigger-maintained throughout — and swaps it in at
1731    /// the end.
1732    ///
1733    /// Each step is its own actor turn, so an interactive assertion can jump the
1734    /// queue between chunks. That is the whole of the improvement, and it is why
1735    /// the loop is here rather than inside the actor's arm (the same reasoning
1736    /// as [`Self::archive_windowed`] and [`Self::bulk_import`]).
1737    ///
1738    /// # What the swap still costs
1739    ///
1740    /// Not microseconds. Index names are global and SQLite has no `ALTER INDEX
1741    /// … RENAME`, so the shadow cannot be built carrying `links_current`'s index
1742    /// names while `links_current` still holds them — and building it under
1743    /// other names would leave the table permanently indexed under names absent
1744    /// from [`CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES), so the next
1745    /// migration would create a second copy of each.
1746    /// `DROP TABLE` frees the names, so the swap transaction is where
1747    /// the three indexes get built. What the chunking moves off the lock is the
1748    /// **projection** — the window function over all of `links` — which is the
1749    /// O(E log E) term.
1750    ///
1751    /// # When this returns an error rather than a repair
1752    ///
1753    /// [`DbError::RebuildInterrupted`] means an archive committed while the
1754    /// shadow was being built. Its deletions are invisible to a catch-up pass
1755    /// keyed on `recorded_at` — a deleted row has no `recorded_at` left to find
1756    /// it by — so the work is discarded rather than swapped in. `links_current`
1757    /// is untouched and the call can simply be retried.
1758    ///
1759    /// Use [`Self::rebuild_current`] when the repair must be one atomic act, or
1760    /// when nothing else is contending for the actor and the extra turns are
1761    /// pure overhead.
1762    pub async fn rebuild_current_chunked(&self) -> Result<RebuildReport> {
1763        use crate::integrity::{ShadowOutcome, ShadowStep};
1764
1765        // Each `else` arm is unreachable: the actor maps each step to its own
1766        // outcome variant. Written as a refutable pattern rather than an
1767        // `unwrap` so that adding a step cannot turn a mismatch into a panic on
1768        // the write path — and `WriterDroppedResponder` is the honest name for
1769        // "the actor answered with something this cannot use".
1770        let ShadowOutcome::Started { build_start, epoch } =
1771            self.shadow_step(ShadowStep::Begin).await?
1772        else {
1773            return Err(DbError::WriterDroppedResponder);
1774        };
1775
1776        let mut after: Option<String> = None;
1777        loop {
1778            let ShadowOutcome::Filled { last } = self
1779                .shadow_step(ShadowStep::Fill {
1780                    after: after.take(),
1781                })
1782                .await?
1783            else {
1784                return Err(DbError::WriterDroppedResponder);
1785            };
1786            match last {
1787                Some(last) => after = Some(last),
1788                None => break,
1789            }
1790        }
1791
1792        let ShadowOutcome::Swapped { rows } = self
1793            .shadow_step(ShadowStep::Swap { build_start, epoch })
1794            .await?
1795        else {
1796            return Err(DbError::WriterDroppedResponder);
1797        };
1798
1799        Ok(RebuildReport {
1800            rows_rebuilt: rows,
1801            // Not audited. The chunked path's whole argument is that the
1802            // expensive work happens off the lock, and `audit_current` is two
1803            // `EXCEPT` passes over the projection — the cost D-077 removed from
1804            // the archive for the same reason. A caller who wants the check has
1805            // `audit_current` on the read connection, where it costs nobody the
1806            // write lock.
1807            drift_after: 0,
1808        })
1809    }
1810
1811    /// Run one step of a chunked rebuild, for a caller doing its own scheduling.
1812    ///
1813    /// [`Self::rebuild_current_chunked`] is this in a loop and is what almost
1814    /// everyone wants. This exists because that loop offers no seam: it drives
1815    /// `Begin`, then `Fill` to exhaustion, then `Swap`, and a caller who needs to
1816    /// do something *between* steps — pace them against a frame budget, abandon
1817    /// a rebuild that has run long enough, or provoke the archive interlock in a
1818    /// test — cannot get in.
1819    ///
1820    /// The obligation that comes with it: `epoch` from
1821    /// [`ShadowOutcome::Started`](crate::integrity::ShadowOutcome) must be handed
1822    /// back to [`ShadowStep::Swap`](crate::integrity::ShadowStep), or the
1823    /// archive interlock is defeated and a stale projection can be swapped in.
1824    /// The looping version cannot get that wrong; this one can.
1825    pub async fn shadow_step(
1826        &self,
1827        step: crate::integrity::ShadowStep,
1828    ) -> Result<crate::integrity::ShadowOutcome> {
1829        self.low(|responder| LowPriCommand::ShadowRebuild { step, responder })
1830            .await
1831    }
1832
1833    /// Import edges on the background channel, chunked (D-011).
1834    ///
1835    /// Atomic *per chunk*, not overall: a failure partway leaves earlier chunks
1836    /// committed. That is the tradeoff [`chunk_rows`] documents — use
1837    /// [`Database::write_bulk_atomic`] when the batch must be all-or-nothing.
1838    ///
1839    /// Chunked adaptively, at most [`chunk_rows::EDGES`] rows at a time: that
1840    /// constant is where the loop starts and the largest chunk it will send, and
1841    /// each chunk's measured hold sizes the next against [`CHUNK_BUDGET`]. It is
1842    /// also faster in total than the larger chunks this used through 0.5.5
1843    /// (D-058).
1844    ///
1845    /// A consequence worth planning for: the chunk boundaries — and so the
1846    /// `recorded_at` stamps this import writes — depend on how fast the machine
1847    /// was, not only on how many edges were passed (§5.1.6).
1848    pub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1849        let edges = normalize_all(edges)?;
1850        self.low_chunked(edges, chunk_rows::EDGES, |chunk, responder| {
1851            LowPriCommand::BulkImportChunk { chunk, responder }
1852        })
1853        .await
1854    }
1855
1856    /// Upsert many **concepts** on the background channel, chunked (D-011).
1857    ///
1858    /// This is the bulk concept path, and every row it writes is a ledger write:
1859    /// it versions the concept and lands in `transaction_log`. Derived analytics
1860    /// output does not belong here — see
1861    /// [`Database::write_analytics_annotations`] and D-041.
1862    ///
1863    /// Called `write_annotations` through 0.5.6, from when the two writes were
1864    /// one call. D-041 split them and the name stayed on the wrong one for three
1865    /// releases, so the crate had a `write_annotations` that wrote concepts
1866    /// sitting beside a `write_analytics_annotations` that wrote annotations
1867    /// (D-075).
1868    pub async fn write_concepts(&self, concepts: Vec<ConceptUpsert>) -> Result<usize> {
1869        let concepts: Vec<ConceptUpsert> = concepts
1870            .into_iter()
1871            .map(ConceptUpsert::normalized)
1872            .collect::<Result<_>>()?;
1873        self.low_chunked(concepts, chunk_rows::CONCEPTS, |chunk, responder| {
1874            LowPriCommand::WriteConceptsChunk { chunk, responder }
1875        })
1876        .await
1877    }
1878
1879    /// State as believed at `ts` (§5.5, D-026, D-049).
1880    ///
1881    /// A read: it runs on `read_conn` and never touches the Write Actor, so a
1882    /// reconstruction and a full-speed write-back do not slow each other.
1883    ///
1884    /// Prefer this to calling [`crate::temporal::reconstruct`] directly. The
1885    /// free function takes the archive path and the snapshot directory as
1886    /// arguments, and a caller who passes `None` for the second gets a correct
1887    /// answer that folds the whole log every time — the composition is opt-in
1888    /// at that layer and easy to leave off by accident. Here both come from the
1889    /// handle, so the fast path is the default one.
1890    pub async fn reconstruct(&self, ts: &str) -> Result<crate::temporal::MaterializedState> {
1891        let ts = timestamp::normalize(ts)?;
1892        crate::temporal::reconstruct(
1893            &self.read_conn,
1894            &ts,
1895            Some(&self.archive_path),
1896            Some(&self.snapshots_dir),
1897        )
1898        .await
1899    }
1900
1901    /// Create a model's embedding table and DiskANN index (§5.9, D-048).
1902    ///
1903    /// Idempotent: registering a model that already exists at the same
1904    /// dimension succeeds, and at a different dimension fails with
1905    /// [`DbError::DimMismatch`] naming both, rather than no-opping through
1906    /// `IF NOT EXISTS` and leaving the caller believing the dimension they
1907    /// asked for is the one in force.
1908    ///
1909    /// This issues DDL, which everywhere else in the crate is the migration
1910    /// runner's exclusive business (D-032). The exception is bounded and
1911    /// deliberate: a model's table is created once, by an explicit call, and
1912    /// the alternative — a caller-supplied write connection — is the very thing
1913    /// the Write Actor exists to make impossible.
1914    ///
1915    /// # Latency
1916    ///
1917    /// One small transaction, but it queues like any other write: see §5.1.8.
1918    pub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()> {
1919        let model = model.clone();
1920        self.high(|responder| HighPriCommand::RegisterModel {
1921            model,
1922            dim,
1923            responder,
1924        })
1925        .await
1926    }
1927
1928    /// Store or replace vectors for `model`, chunked (§5.9, D-011, D-048).
1929    ///
1930    /// The write path for embeddings. Before 0.5.4 there was none:
1931    /// [`crate::vector::upsert_embedding`] takes a raw connection, `read_conn`
1932    /// is `query_only`, and the write connection lives inside the actor — so an
1933    /// application could search vectors it had no way to store.
1934    ///
1935    /// Low priority and chunked at [`chunk_rows::EMBEDDINGS`], because embedding
1936    /// is bulk derived work: a 50,000-vector backfill must yield to an
1937    /// interactive assertion at every chunk boundary. That constant is the
1938    /// smallest of the four by a wide margin — DiskANN index maintenance makes an
1939    /// embedding the most expensive row in the system (D-058). Atomic per chunk, not overall, which
1940    /// is the same trade [`Database::bulk_import`] makes and is safer here than
1941    /// there — an embedding is derived (Doctrine VII), so a partially written
1942    /// batch is recoverable by re-embedding.
1943    ///
1944    /// Fails with [`DbError::ModelNotRegistered`] if `model` has no table, and
1945    /// [`DbError::DimMismatch`] if a vector's length is not the declared
1946    /// dimension. The dimension is read from the schema once per chunk (D-037):
1947    /// the crate keeps no registry of its own to fall out of date.
1948    pub async fn upsert_embeddings(
1949        &self,
1950        model: &ModelName,
1951        rows: Vec<(String, Vec<f32>)>,
1952    ) -> Result<usize> {
1953        self.low_chunked(rows, chunk_rows::EMBEDDINGS, |chunk, responder| {
1954            LowPriCommand::UpsertEmbeddingChunk {
1955                model: model.clone(),
1956                chunk,
1957                responder,
1958            }
1959        })
1960        .await
1961    }
1962
1963    /// Reconstruct the concept-text search index from the ledger (§5.9, D-036).
1964    ///
1965    /// The FTS index is derivative: D-036 promises every derivative table can be
1966    /// rebuilt from the ledger tables, and this is that promise made callable
1967    /// for `concepts_fts`. Needed after a restore that skipped the shadow
1968    /// tables, or if the index is ever suspected of drifting from the text —
1969    /// and, as a matter of policy, cheaper to run than to reason about.
1970    ///
1971    /// The work is `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')`,
1972    /// which is FTS5's own operation over the content table, so this is not a
1973    /// second implementation of the sync triggers that could disagree with them.
1974    pub async fn rebuild_fts(&self) -> Result<()> {
1975        self.low(|responder| LowPriCommand::RebuildFts { responder })
1976            .await
1977    }
1978
1979    /// Refresh the query planner's statistics (0.12.4, [D-149]).
1980    ///
1981    /// Runs `ANALYZE`, which writes `sqlite_stat1`. **Before 0.12.4 nothing in
1982    /// this crate ever did**, so the planner costed every query against SQLite's
1983    /// built-in defaults — assume ~1M rows, assume each bound equality column
1984    /// divides by ten. That estimate is structural: it depends on how many
1985    /// columns a query binds, not on what the table contains.
1986    ///
1987    /// Which is this schema's own worst defect restated. D-042, D-059 and D-064
1988    /// are three occasions where *a covering index captured a query because it
1989    /// contained the columns, not because it discriminated*, and two of the four
1990    /// declared indices lead on the same column. Statistics are what let the
1991    /// planner tell them apart by measurement instead of by shape.
1992    ///
1993    /// # Cost, and why it is bounded
1994    ///
1995    /// This is a write and it takes the write lock. `PRAGMA analysis_limit`
1996    /// (set per connection, see [`ddl::ANALYSIS_LIMIT`]) caps the rows examined
1997    /// per index. It is scheduled as low-priority work and will not preempt an
1998    /// interactive assertion.
1999    ///
2000    /// **The bound is a constant factor, not an independence** (0.12.23,
2001    /// D-166). This rustdoc said the hold "scales with the number of indices —
2002    /// four — and not with the size of `links_current`", which is measurably
2003    /// wrong: the pragma is worth 3–4× and what remains still grows with the
2004    /// table. Measured, `examples/analyze_hold.rs`: **5.26 ms at 10,000 edges,
2005    /// 19.1 ms at 40,000**, against a 3 ms [`crate::CHUNK_BUDGET`].
2006    ///
2007    /// So this call **misses the budget by ~6× on a moderately sized ledger**,
2008    /// and [`crate::metrics::CommandKind::Analyze`] is deliberately not among
2009    /// the budget-exempt kinds — `metrics().budget_violations()` names it. That
2010    /// is the honest position: the work is low priority and preemptible between
2011    /// commands, but it is one indivisible statement and cannot be chunked, so
2012    /// the hold is what it is. Prefer [`optimize`], which does nothing when
2013    /// nothing has moved.
2014    ///
2015    /// # When to call it
2016    ///
2017    /// After a bulk import, and after anything that changes a table's shape by
2018    /// an order of magnitude. Prefer [`optimize`] for routine upkeep: it does
2019    /// nothing when nothing has moved, and this does the work unconditionally.
2020    ///
2021    /// Statistics are derived state in the sense Doctrine VI means it — deleting
2022    /// `sqlite_stat1` costs plan quality and no information, and this call
2023    /// rebuilds it.
2024    ///
2025    /// [D-149]: ../docs/architecture/s13-decision-register.md#d-149
2026    /// [`ddl::ANALYSIS_LIMIT`]: crate::schema::ddl::ANALYSIS_LIMIT
2027    /// [`optimize`]: Database::optimize
2028    pub async fn analyze(&self) -> Result<()> {
2029        self.low(|responder| LowPriCommand::Analyze {
2030            incremental: false,
2031            responder,
2032        })
2033        .await
2034    }
2035
2036    /// Re-analyse only what has gone stale (0.12.4, [D-149]).
2037    ///
2038    /// `PRAGMA optimize`. SQLite tracks how far each table has drifted since its
2039    /// last analysis and re-analyses only where it believes the statistics no
2040    /// longer hold — so this is a no-op on an idle database and the full cost of
2041    /// [`analyze`] on one that has changed completely.
2042    ///
2043    /// That property is the whole point: it is safe to call on a schedule, where
2044    /// [`analyze`] is not. `close()` runs it, so a process that opens, works and
2045    /// closes keeps its statistics current without anybody arranging it.
2046    ///
2047    /// [D-149]: ../docs/architecture/s13-decision-register.md#d-149
2048    /// [`analyze`]: Database::analyze
2049    pub async fn optimize(&self) -> Result<()> {
2050        self.low(|responder| LowPriCommand::Analyze {
2051            incremental: true,
2052            responder,
2053        })
2054        .await
2055    }
2056
2057    // **There is deliberately no `verify_fts()` (§5.9, D-071).**
2058    //
2059    // `rebuild_fts` is the repair with no way to ask whether it is needed, and
2060    // Wave 5 set out to add the missing half. FTS5 offers `'integrity-check'`,
2061    // which looked like exactly the engine-provided answer this crate prefers.
2062    // It is not: on libSQL 0.9.30 it verifies the index's *internal* consistency
2063    // and not its agreement with the content table. Measured — after
2064    // `'delete-all'` the index matches nothing where it matched ten rows, and
2065    // both `'integrity-check'` and `'integrity-check', 0` still report success.
2066    //
2067    // A `verify_fts()` on that footing would answer "healthy" for an empty
2068    // index, which is worse than having no method at all: it is the shape of
2069    // defect AC, a function that looks like it checks something and does not.
2070    // `an_emptied_fts_index_still_passes_integrity_check` pins the limitation so
2071    // that if a later libSQL fixes it, the test fails and says so.
2072
2073    /// Write derived analytics results on the background channel, chunked
2074    /// (§5.4, D-041).
2075    ///
2076    /// Rows go to `analytics_annotations`, which has no log trigger, so nothing
2077    /// written here reaches `transaction_log` and nothing here versions a
2078    /// concept. Rerunning an algorithm replaces the previous pass rather than
2079    /// recording that the world changed.
2080    ///
2081    /// Low priority and chunked at up to [`chunk_rows::ANNOTATIONS`] — the
2082    /// largest ceiling of the four, because this is the only bulk table carrying
2083    /// no triggers at all
2084    /// and its rows are correspondingly cheap (D-058) — so a 50,000-label Louvain
2085    /// save yields to interactive writes at every chunk boundary and carries the
2086    /// per-chunk fidelity boundary of §5.1.6 — a partially written pass is
2087    /// recoverable by rerunning, which is the property that makes derived state
2088    /// safe to write this way and assertions not.
2089    pub async fn write_analytics_annotations(&self, annotations: Vec<Annotation>) -> Result<usize> {
2090        self.low_chunked(annotations, chunk_rows::ANNOTATIONS, |chunk, responder| {
2091            LowPriCommand::WriteAnalyticsChunk { chunk, responder }
2092        })
2093        .await
2094    }
2095
2096    /// Move closed intervals and superseded log rows older than `cutoff` to the
2097    /// cold database (§5.7, D-012).
2098    pub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport> {
2099        let cutoff = timestamp::normalize(cutoff)?;
2100        let archive_path = self.archive_path.clone();
2101        self.low(|responder| LowPriCommand::Archive {
2102            cutoff,
2103            archive_path,
2104            responder,
2105        })
2106        .await
2107    }
2108
2109    /// Move the named concepts back from the cold database into the hot tables
2110    /// (§2.3, C3).
2111    ///
2112    /// Rehydration is a **physical move back, not a write**: it mints no
2113    /// transaction-time facts and is invisible to both clocks. An id that is not
2114    /// in the cold file is skipped rather than being an error — the caller
2115    /// generally has a list from a cold-side query, and a partially-stale list is
2116    /// the normal case rather than a mistake. The report says how many actually
2117    /// moved.
2118    ///
2119    /// See [`RehydrateReport::rowids_reassigned`] for the one way a rehydrated
2120    /// row can differ from the row that was archived.
2121    pub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport> {
2122        let ids: Vec<String> = ids.iter().map(|s| (*s).to_string()).collect();
2123        let archive_path = self.archive_path.clone();
2124        self.low(|responder| LowPriCommand::Rehydrate {
2125            ids,
2126            archive_path,
2127            responder,
2128        })
2129        .await
2130    }
2131
2132    /// Archive up to `cutoff` as a sequence of sessions, each covering at most
2133    /// `window` of **transaction** time (T1.1, D-080).
2134    ///
2135    /// `archive(cutoff)` is one transaction whose size is set by how long it has
2136    /// been since the last one, which makes it the least bounded of the three
2137    /// operations exempt from [`CHUNK_BUDGET`] — its hold is a function of
2138    /// operational history rather than of anything a caller chose. This runs the
2139    /// same work as *N* complete sessions, each with its own marker, horizon row
2140    /// and rebuild, and returns one [`ArchiveReport`] per session in order.
2141    ///
2142    /// # D-012 is satisfied per session, and that is what it requires
2143    ///
2144    /// The atomicity D-012 demands is that copy-then-delete never be split — a
2145    /// crash between the phases duplicates or loses rows. *N* small sessions
2146    /// satisfy that exactly as one large one does. The obligation windowing adds
2147    /// is that a partial run leave a coherent intermediate state, which it does:
2148    /// each session commits a valid horizon, so a failure at window *k* leaves a
2149    /// database archived up to boundary *k−1* and nothing in between. **The
2150    /// sequence is not atomic and does not claim to be** — on error, the reports
2151    /// for the sessions that did commit are lost with it, but their effect is
2152    /// not, and re-running with the same `cutoff` completes the job.
2153    ///
2154    /// # Each session is its own actor turn, and that is the entire point
2155    ///
2156    /// This loop lives here, on the handle, rather than inside the actor's
2157    /// `Archive` arm. Putting it there would have produced *N* small
2158    /// transactions inside **one** hold, which shrinks the transaction and
2159    /// changes the latency not at all: the actor is single-threaded, so nothing
2160    /// else writes until its turn returns regardless of how many `COMMIT`s the
2161    /// turn contains. Sending *N* commands returns the actor to its `select!`
2162    /// between sessions, which is where an interactive assertion gets to jump
2163    /// the queue — and it is high-priority, so it does.
2164    ///
2165    /// The same reasoning is why [`Self::bulk_import`] chunks here and not
2166    /// there, and it is the trap T1.2 names for `CREATE TABLE … AS SELECT`.
2167    ///
2168    /// # Choosing a window
2169    ///
2170    /// The bound is on *transaction* time, so the session count is set by how
2171    /// far back the hot file goes, not by how much it holds. A window is
2172    /// rejected rather than clamped if it would need more than
2173    /// [`MAX_ARCHIVE_SESSIONS`] sessions — see [`DbError::ArchiveWindow`].
2174    ///
2175    /// Windows containing nothing archivable are cheap but not free: each still
2176    /// opens a transaction and writes a horizon row. What they no longer do is
2177    /// re-project `links_current`, which `archive_session` now skips when its
2178    /// `DELETE` removed no rows — without that, windowing costs *more* in total
2179    /// than not windowing, because the repair term scales with the surviving
2180    /// table and not with the batch (D-077).
2181    pub async fn archive_windowed(
2182        &self,
2183        cutoff: &str,
2184        window: std::time::Duration,
2185    ) -> Result<Vec<ArchiveReport>> {
2186        let cutoff = timestamp::normalize(cutoff)?;
2187        let boundaries = self.archive_boundaries(&cutoff, window).await?;
2188
2189        let mut reports = Vec::with_capacity(boundaries.len());
2190        for boundary in boundaries {
2191            let archive_path = self.archive_path.clone();
2192            reports.push(
2193                self.low(|responder| LowPriCommand::Archive {
2194                    cutoff: boundary,
2195                    archive_path,
2196                    responder,
2197                })
2198                .await?,
2199            );
2200        }
2201        Ok(reports)
2202    }
2203
2204    /// The cutoffs [`Self::archive_windowed`] will run, ascending, ending at
2205    /// `cutoff` exactly.
2206    ///
2207    /// Read on `read_conn`, not on the actor: this is two `MIN`s and the actor
2208    /// has no reason to hold its lock for them.
2209    ///
2210    /// The lower end comes from the data rather than from the clock. Stepping
2211    /// from some fixed epoch would make the session count a function of the
2212    /// calendar — a database opened yesterday would still be asked to archive
2213    /// 1970 — whereas the oldest `recorded_at` actually present is the earliest
2214    /// boundary that can contain anything.
2215    async fn archive_boundaries(
2216        &self,
2217        cutoff: &str,
2218        window: std::time::Duration,
2219    ) -> Result<Vec<String>> {
2220        // A single session at `cutoff` is exactly `archive(cutoff)`, and it is
2221        // the right answer for an empty hot file: it still writes the horizon
2222        // row, so windowed and unwindowed runs leave the same observable state.
2223        let Some(oldest) = self.oldest_hot_stamp(cutoff).await? else {
2224            return Ok(vec![cutoff.to_string()]);
2225        };
2226
2227        let start = timestamp::parse(&oldest)?;
2228        let end = timestamp::parse(cutoff)?;
2229        let Ok(span) = end.duration_since(start) else {
2230            // Everything in the hot file is at or after the cutoff, so there is
2231            // nothing in range to divide.
2232            return Ok(vec![cutoff.to_string()]);
2233        };
2234
2235        if window.is_zero() {
2236            return Err(DbError::ArchiveWindow {
2237                window,
2238                reason: "a zero-length window never advances past the first boundary".into(),
2239            });
2240        }
2241
2242        // `div_ceil` on nanos: a span of 90 minutes in 60-minute windows is two
2243        // sessions, not one. `as_nanos` is u128, so neither the division nor the
2244        // span can overflow for any timestamp this crate can store.
2245        let sessions = span.as_nanos().div_ceil(window.as_nanos());
2246        if sessions > MAX_ARCHIVE_SESSIONS as u128 {
2247            return Err(DbError::ArchiveWindow {
2248                window,
2249                reason: format!(
2250                    "a span of {span:?} would need {sessions} sessions (limit \
2251                     {MAX_ARCHIVE_SESSIONS}); widen the window"
2252                ),
2253            });
2254        }
2255
2256        let mut boundaries = Vec::with_capacity(sessions as usize);
2257        for k in 1..sessions {
2258            boundaries.push(timestamp::format(start + window * k as u32));
2259        }
2260        // The last boundary is `cutoff` itself and not `start + n*window`, which
2261        // would overshoot and archive rows the caller excluded.
2262        boundaries.push(cutoff.to_string());
2263        Ok(boundaries)
2264    }
2265
2266    /// Oldest `recorded_at` below `cutoff` in either hot table, or `None`.
2267    async fn oldest_hot_stamp(&self, cutoff: &str) -> Result<Option<String>> {
2268        let mut oldest: Option<String> = None;
2269        for table in ["links", "transaction_log"] {
2270            let found: Option<String> = self
2271                .read_conn
2272                .query(
2273                    &format!("SELECT MIN(recorded_at) FROM {table} WHERE recorded_at < ?1"),
2274                    libsql::params![cutoff],
2275                )
2276                .await?
2277                .next()
2278                .await?
2279                .and_then(|row| row.get(0).ok());
2280            if let Some(found) = found {
2281                if oldest.as_ref().is_none_or(|o| found < *o) {
2282                    oldest = Some(found);
2283                }
2284            }
2285        }
2286        Ok(oldest)
2287    }
2288
2289    /// Send a high-priority command and wait for its answer.
2290    ///
2291    /// The two error mappings here are the whole reason this helper exists.
2292    /// `send` failing means the actor is gone — `WriterUnavailable`. The
2293    /// responder being dropped without an answer means the actor took the
2294    /// command and never replied — `WriterDroppedResponder`, which is a bug in
2295    /// the actor rather than a condition the caller can retry. Both variants
2296    /// existed in `error.rs` from 0.4.5 and neither was ever constructed, so a
2297    /// dead actor and a hung one were both just a caller waiting forever.
2298    async fn high<T>(
2299        &self,
2300        make: impl FnOnce(oneshot::Sender<Result<T>>) -> HighPriCommand,
2301    ) -> Result<T> {
2302        let (tx, rx) = oneshot::channel();
2303        self.highpri_tx
2304            .send(make(tx))
2305            .await
2306            .map_err(|_| DbError::WriterUnavailable)?;
2307        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
2308    }
2309
2310    /// Send each chunk in turn and sum the counts — the shape all four bulk
2311    /// paths share (T3.4, D-086).
2312    ///
2313    /// # This is sequential on purpose, and the purpose is a measurement
2314    ///
2315    /// T3.4 proposed pipelining: send *k* chunks ahead so the actor never finds
2316    /// an empty queue. The reasoning is that awaiting each chunk before building
2317    /// the next leaves the actor idle for a channel round trip every time, which
2318    /// on a 1M-edge import is ~11,000 idle gaps.
2319    ///
2320    /// Both halves of that are true and the conclusion does not follow. The gaps
2321    /// are real; they are also **four orders of magnitude smaller than the work
2322    /// they interrupt**. A tokio mpsc hop is sub-microsecond and a chunk takes
2323    /// 13–21 ms. Implemented and swept at depths 1, 2, 4, 8 and 16 over 20K and
2324    /// 100K edges: every cell landed within 1% of sequential, in both directions
2325    /// — see `examples/pipeline_diag.rs`, which is kept precisely so this is not
2326    /// re-proposed from the same reasoning.
2327    ///
2328    /// So the pipelining was removed and the deduplication kept. It was not free
2329    /// to hold: with chunks in flight, a failure at chunk `i` no longer leaves a
2330    /// **prefix** committed, because `i+1 ..= i+k-1` were already sent and commit
2331    /// anyway. D-011 promises "earlier chunks committed", and paying for that
2332    /// with a weaker recovery story in exchange for nothing measurable is the
2333    /// wrong trade.
2334    ///
2335    /// Sending stops at the first error, so what commits is exactly the prefix
2336    /// before the failure.
2337    /// # The size is now measured, not assumed (0.12.0, W3)
2338    ///
2339    /// Until 0.11.0 the caller pre-split into `chunks(chunk_rows::WHATEVER)` and
2340    /// this loop sent what it was given. That made the constant *the* size, and
2341    /// D-143 is the record of a constant fitted at one population being wrong at
2342    /// another: all four D-088 shapes agreed the largest in-budget edge chunk was
2343    /// **20** against a shipped 90, and 20 would itself have been wrong at 80,000
2344    /// edges, because per-row cost on that path grows with `links_current`.
2345    ///
2346    /// No row count can bound a duration on such a path, so the loop stopped
2347    /// trying to pick one ahead of time. `ceiling` — still the path's
2348    /// [`chunk_rows`] constant, with its derivation intact — is now the largest
2349    /// size this will ever ask for, and each chunk's measured hold chooses the
2350    /// next through `next_chunk_size`.
2351    ///
2352    /// **Feedback, not preemption.** The chunk in flight always commits in full;
2353    /// the SQLite write lock is not preemptible, so nothing here can shorten a
2354    /// transaction already running. A batch of one chunk gets no protection at
2355    /// all, and convergence costs one or two chunks — which is the price of the
2356    /// bound being a duration rather than a promise.
2357    ///
2358    /// The last chunk's outcome is discarded, there being no next chunk to size.
2359    async fn low_chunked<T>(
2360        &self,
2361        items: Vec<T>,
2362        ceiling: usize,
2363        make: impl Fn(Vec<T>, oneshot::Sender<Result<ChunkOutcome>>) -> LowPriCommand,
2364    ) -> Result<usize> {
2365        let mut items = items.into_iter();
2366        let mut size = ceiling.max(1);
2367        let mut written = 0usize;
2368        loop {
2369            let chunk: Vec<T> = items.by_ref().take(size).collect();
2370            if chunk.is_empty() {
2371                return Ok(written);
2372            }
2373            let (tx, rx) = oneshot::channel();
2374            self.lowpri_tx
2375                .send(make(chunk, tx))
2376                .await
2377                .map_err(|_| DbError::WriterUnavailable)?;
2378            let outcome = rx.await.map_err(|_| DbError::WriterDroppedResponder)??;
2379            written += outcome.rows;
2380            size = next_chunk_size(size, outcome.held, CHUNK_BUDGET, CHUNK_FLOOR, ceiling);
2381        }
2382    }
2383
2384    async fn low<T>(
2385        &self,
2386        make: impl FnOnce(oneshot::Sender<Result<T>>) -> LowPriCommand,
2387    ) -> Result<T> {
2388        let (tx, rx) = oneshot::channel();
2389        self.lowpri_tx
2390            .send(make(tx))
2391            .await
2392            .map_err(|_| DbError::WriterUnavailable)?;
2393        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
2394    }
2395
2396    /// Clean shutdown: stop the Write Actor, then write the final snapshot (§5.1.7).
2397    ///
2398    /// Order matters. The snapshot is taken *after* the actor has stopped and
2399    /// been joined, so no write can land between the fold and the file — the
2400    /// anchor it records is the last thing that happened, not the last thing
2401    /// that happened to be visible.
2402    ///
2403    /// A failed snapshot is reported rather than swallowed. It is not a
2404    /// durability loss — the ledger is in the WAL and the log replays without
2405    /// it — but it means the next open starts from an older anchor, and a caller
2406    /// that never hears about it cannot know why startup got slower.
2407    ///
2408    /// **The cadence stops first (§5.5, D-053).** Both it and `write_final` end
2409    /// by running retention over the snapshot directory, and retention deletes
2410    /// files. Letting them overlap would mean one pass enumerating the directory
2411    /// while the other removes from it — not a correctness problem for the
2412    /// ledger, which is why the ordering is stated rather than locked, but a
2413    /// source of spurious warnings and of a final anchor that could be deleted
2414    /// by a cleanup that started before it existed. Stopping the cadence, then
2415    /// the actor, then taking the snapshot leaves exactly one writer at each
2416    /// step.
2417    pub async fn close(mut self) -> Result<()> {
2418        if let Some(stop) = self.cadence_stop.take() {
2419            let _ = stop.send(true);
2420        }
2421        if let Some(handle) = self.cadence.take() {
2422            let _ = handle.await;
2423        }
2424
2425        // Top up the planner's statistics while the actor is still alive to do
2426        // it (0.12.4, D-149). `PRAGMA optimize` re-analyses only what SQLite
2427        // believes has gone stale, so on a database that did nothing this costs
2428        // nothing, and on one that was just bulk-loaded it is the difference
2429        // between the next process planning on measurements and planning on
2430        // built-in guesses.
2431        //
2432        // **Deliberately not fatal.** A failure here costs plan quality on the
2433        // next open and nothing else — no ledger state depends on it — and
2434        // `close()` is where a caller learns whether their *writes* survived.
2435        // Turning a stale-statistics problem into a failed close would bury that
2436        // answer under a much less important one.
2437        if let Err(e) = self.optimize().await {
2438            tracing::warn!(
2439                "PRAGMA optimize failed during close(): {e}. Statistics may be \
2440                 stale for the next process; call analyze() to rebuild them. \
2441                 Nothing else is affected."
2442            );
2443        }
2444
2445        let (tx, rx) = oneshot::channel();
2446        let _ = self
2447            .highpri_tx
2448            .send(HighPriCommand::Shutdown { responder: tx })
2449            .await;
2450        let _ = rx.await;
2451
2452        // **The writer's `Result` is propagated, not discarded (Wave 4.2).**
2453        // It used to be `let _ = handle.await`, so an actor that had panicked or
2454        // returned an error closed "successfully" and the caller's last chance to
2455        // learn that the write path had died was spent silently. A `JoinError`
2456        // here means the actor panicked; the inner `Result` is whatever it
2457        // returned.
2458        //
2459        // Ordered before the final snapshot on purpose: a snapshot written after
2460        // a failed writer records a state the caller has no reason to trust, and
2461        // returning the writer's error while also having written that file is
2462        // worse than not writing it.
2463        if let Some(handle) = self.writer.take() {
2464            match handle.await {
2465                Ok(res) => res?,
2466                Err(e) => {
2467                    return Err(DbError::WriterStopped(format!(
2468                        "the write actor did not exit cleanly: {e}"
2469                    )))
2470                }
2471            }
2472        }
2473
2474        let ts = self.clock.now();
2475        let archive = self
2476            .archive_path
2477            .exists()
2478            .then_some(self.archive_path.as_path());
2479        snapshot::write_final(&self.read_conn, &self.snapshots_dir, &ts, archive).await?;
2480
2481        // Marks the handle closed so `Drop` knows not to complain.
2482        self.closed = true;
2483        Ok(())
2484    }
2485}
2486
2487/// Notes a missed `close()` at `warn!`, and deliberately does **not** assert.
2488///
2489/// **§7.3 offered option B — document `close()` as mandatory and `debug_assert`
2490/// in `Drop` — and Wave 4.2 implemented it, measured the consequence, and
2491/// reduced it to a warning.** The assert fired on roughly thirty tests on its
2492/// first run. That is the signal it was built to produce, and the right reading
2493/// of it was not "thirty tests are wrong".
2494///
2495/// What dropping actually costs is one final snapshot. Nothing else: every
2496/// public write method awaits its responder, so by the time a caller *can* drop
2497/// the handle, every write it issued has already committed; and the cadence stops
2498/// on its own, because `cadence_stop` is a `watch::Sender` whose drop signals the
2499/// task. A snapshot is derivative state under Doctrine VI — disposable,
2500/// reconstructible, and never the only copy of anything. Losing one makes the
2501/// next `reconstruct` fold from an older anchor, which is **slower, not wrong**.
2502///
2503/// A `debug_assert` aborts a test run. Spending that on a performance loss, in a
2504/// project whose own notes say a suite that fails for reasons unrelated to the
2505/// code under test trains people to ignore red, is the wrong trade — and paying
2506/// it in thirty places would have made `close()` look mandatory by ceremony
2507/// rather than by consequence. `close()` remains the right thing to call, and
2508/// the two reasons to call it are now stated where they can be acted on: the
2509/// snapshot, and the writer's `Result`, which only `close()` can return.
2510///
2511/// Option A ("abort the actor and log") stays rejected, for the reason it was
2512/// rejected twice before: `Drop` cannot await, so it cannot drain, and cleanup
2513/// that cannot clean up is worse than none — it looks like cleanup.
2514impl Drop for Database {
2515    fn drop(&mut self) {
2516        if !self.closed {
2517            tracing::warn!(
2518                "Database dropped without close(): the final snapshot was not written, \
2519                 so the next reconstruct folds from an older anchor, and the write \
2520                 actor's exit status was not checked. Prefer close().await."
2521            );
2522        }
2523    }
2524}
2525
2526fn normalize_all(edges: Vec<EdgeAssertion>) -> Result<Vec<EdgeAssertion>> {
2527    edges.into_iter().map(EdgeAssertion::normalized).collect()
2528}
2529
2530/// One `FULL` checkpoint for the numbers, then a `TRUNCATE` for the file.
2531///
2532/// # Why `TRUNCATE` and not a mode parameter
2533///
2534/// The four SQLite modes are not four things a caller of *this* crate wants.
2535/// `PASSIVE` is what the automatic checkpointer already runs on its own, so an
2536/// explicit `PASSIVE` asks for something that was going to happen anyway;
2537/// `RESTART` and `FULL` differ from `TRUNCATE` only in whether the WAL file is
2538/// left at its high-water size. The reason W5.2 exists is
2539/// [`Tuning::wal_autocheckpoint`] — a bulk importer turns the automatic
2540/// checkpointer off and calls this once at the end — and what that caller wants
2541/// is the WAL *gone*, not smaller than it was. So the mode is fixed and decided
2542/// here rather than pushed to the caller as a choice they would have to read
2543/// SQLite's documentation to make. If a mode ever needs selecting, that is an
2544/// additive method, not a change to this one.
2545///
2546/// # Why it is two pragmas, which is not the obvious implementation
2547///
2548/// **A successful `TRUNCATE` reports `busy=0, log=0, checkpointed=0`** — the
2549/// counts describe the WAL *after* the operation, and after a truncation there
2550/// is no WAL to describe. Measured, not inferred: on a 387-frame WAL, `PASSIVE`
2551/// returns `0, 387, 387` and `TRUNCATE` on the same file returns `0, 0, 0`. So
2552/// the single-pragma implementation returns a [`CheckpointReport`] whose two
2553/// counts are structurally zero on success, which makes the whole struct a
2554/// less useful `bool`.
2555///
2556/// `FULL` copies every frame back and reports what it moved; the `TRUNCATE`
2557/// that follows finds nothing left to copy and resets the file. The second pass
2558/// is close to free for exactly that reason — it is a file operation, not a
2559/// second copy. `busy` is the **union**: a checkpoint that was blocked in
2560/// either phase did not fully happen, and a caller about to copy the database
2561/// file elsewhere needs the pessimistic answer.
2562///
2563/// # They return rows, so they go through `query()`
2564///
2565/// The same libsql constraint the pragmas in `configure` document: `execute()`
2566/// rejects any statement that yields rows, and these yield the row that is the
2567/// entire point.
2568async fn run_checkpoint(conn: &libsql::Connection) -> Result<CheckpointReport> {
2569    // The columns are `busy, log, checkpointed`. SQLite reports -1 for the two
2570    // counts when the checkpoint could not run; clamped to 0 rather than
2571    // surfaced as a signed count, because `busy` already carries "this did not
2572    // happen" and a negative frame count is not a quantity anyone can use.
2573    //
2574    // A database not in WAL mode returns no row at all. `configure` puts every
2575    // connection this crate opens into WAL, so that is unreachable here — but a
2576    // zeroed report is a better failure than a panic if it stops being.
2577    async fn one(conn: &libsql::Connection, sql: &str) -> Result<(bool, u64, u64)> {
2578        let mut rows = conn.query(sql, ()).await?;
2579        let Some(row) = rows.next().await? else {
2580            return Ok((false, 0, 0));
2581        };
2582        let field = |i: i32| -> u64 { row.get::<i64>(i).unwrap_or(0).max(0) as u64 };
2583        Ok((row.get::<i64>(0).unwrap_or(0) != 0, field(1), field(2)))
2584    }
2585
2586    let (full_busy, _, moved) = one(conn, "PRAGMA wal_checkpoint(FULL)").await?;
2587    let (trunc_busy, log_frames, _) = one(conn, "PRAGMA wal_checkpoint(TRUNCATE)").await?;
2588
2589    Ok(CheckpointReport {
2590        busy: full_busy || trunc_busy,
2591        log_frames,
2592        checkpointed_frames: moved,
2593    })
2594}
2595
2596/// Pragmas that mean something on **any** connection, including one opened
2597/// `SQLITE_OPEN_READ_ONLY` (0.12.16, W5.5, D-159).
2598///
2599/// Both of these are per-connection state that a reader is subject to just as a
2600/// writer is. `busy_timeout` is the one that made this a finding:
2601/// [`Database::diagnostic_conn`] ran with SQLite's default of **0** — return
2602/// `SQLITE_BUSY` immediately — while every other connection in the process
2603/// waited 5 s, so the one surface whose job is to answer questions when the
2604/// typed path is already suspect was also the one most likely to fail with
2605/// "database is locked" under exactly the contention that prompted the
2606/// question.
2607async fn configure_common(conn: &libsql::Connection, cache_size: Option<i32>) -> Result<()> {
2608    // NOTE: `busy_timeout` returns its resulting value as a row, and libsql's
2609    // `execute()` rejects any statement that yields rows ("Execute returned
2610    // rows"). It must be issued through `query()`.
2611    let _ = conn.query("PRAGMA busy_timeout = 5000", ()).await?;
2612    // Per-connection, and split writer from reader since 0.12.15 (W5.4,
2613    // D-158). `None` runs no pragma at all rather than restating SQLite's
2614    // default, so the default remains SQLite's to change.
2615    if let Some(pages) = cache_size {
2616        conn.execute(&format!("PRAGMA cache_size = {pages}"), ())
2617            .await?;
2618    }
2619    Ok(())
2620}
2621
2622/// Pragmas that only mean anything where writes can happen (0.12.16, W5.5).
2623///
2624/// Not run on [`Database::diagnostic_conn`], and the reason is not tidiness:
2625/// `journal_mode = WAL` is a change to the *database file*, which a connection
2626/// opened `SQLITE_OPEN_READ_ONLY` cannot make. The rest —
2627/// `synchronous`, `foreign_keys`, `recursive_triggers`, and the `ANALYZE`
2628/// bound — govern how writes behave, and a connection that cannot write is not
2629/// governed by them.
2630///
2631/// The write connection and the two internal readers all still get these. The
2632/// internal readers are opened from the same read-write `libsql::Database`, so
2633/// the pragmas apply; leaving them out would be a behaviour change made for
2634/// symmetry, which is not a reason.
2635async fn configure_writable(conn: &libsql::Connection) -> Result<()> {
2636    // Returns its resulting value as a row — see the note in `configure_common`.
2637    let _ = conn.query("PRAGMA journal_mode = WAL", ()).await?;
2638    conn.execute("PRAGMA synchronous = NORMAL", ()).await?;
2639    conn.execute("PRAGMA foreign_keys = ON", ()).await?;
2640    conn.execute("PRAGMA recursive_triggers = OFF", ()).await?;
2641    // Bounds every `ANALYZE` this connection will ever run, explicit or
2642    // triggered by `PRAGMA optimize` (D-149). Set here rather than around the
2643    // call sites so the scheduled path is bounded too — that is the half that
2644    // runs with nobody watching. Returns the previous limit as a row, so it goes
2645    // through `query()` for the reason the note above gives.
2646    let _ = conn.query(crate::schema::ddl::ANALYSIS_LIMIT, ()).await?;
2647    Ok(())
2648}
2649
2650/// Full pragma configuration, for a connection that can write.
2651async fn configure(
2652    conn: libsql::Connection,
2653    cache_size: Option<i32>,
2654) -> Result<libsql::Connection> {
2655    configure_writable(&conn).await?;
2656    configure_common(&conn, cache_size).await?;
2657    Ok(conn)
2658}
2659
2660/// Helper to derive the snapshot directory by convention: foo.db -> foo_snapshots/
2661fn derive_snapshots_dir(path: &Path) -> PathBuf {
2662    let mut dir = path.to_path_buf();
2663    let stem = path
2664        .file_stem()
2665        .and_then(|s| s.to_str())
2666        .unwrap_or("macrame");
2667    dir.set_file_name(format!("{stem}_snapshots"));
2668    dir
2669}
2670
2671/// Helper to derive archive database path by convention: foo.db -> foo_archive.db
2672fn derive_archive_path(path: &Path) -> PathBuf {
2673    let mut archive = path.to_path_buf();
2674    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
2675        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("db");
2676        archive.set_file_name(format!("{stem}_archive.{ext}"));
2677    } else {
2678        archive.set_extension("archive.db");
2679    }
2680    archive
2681}
2682
2683/// Dedicated Write Actor event loop prioritizing high-priority UI requests over low-priority background work.
2684///
2685/// # The turn is the unit, not the statement (T1.4)
2686///
2687/// One iteration of this loop is one *hold*: the actor is single-threaded and
2688/// the SQLite write lock is not preemptible, so from the moment a command starts
2689/// executing until it returns, nothing else writes. That is the quantity
2690/// [`CHUNK_BUDGET`] bounds, and so it is the quantity
2691/// [`crate::metrics::ActorMetrics`] measures — deliberately around the whole
2692/// `execute` call rather than inside it. Timing the SQL alone would have
2693/// reported a bound that held while callers waited.
2694///
2695/// Queue depth is sampled *before* the `select!`, so it is the backlog the turn
2696/// found on arrival rather than the one it left behind.
2697///
2698/// # `biased` has no floor, and since 0.12.10 that is measured (W4.4, D-153)
2699///
2700/// `biased` makes the arms poll in declaration order, so high-priority work is
2701/// taken whenever any is ready. Nothing bounds how long that can continue:
2702/// sustained interactive traffic can hold the low tier off indefinitely, and
2703/// through 0.12.9 nothing in the crate could say whether it ever did.
2704/// `record_priority_choice` counts the turns where the choice went against
2705/// queued low-priority work, and the longest unbroken run of them, which is the
2706/// half that distinguishes "prioritised" from "starved".
2707///
2708/// **No forced yield is added here.** Whether one is needed is the question the
2709/// counter answers, and adding a policy now would be fixing a bound nobody has
2710/// observed being hit — the same mistake D-124 was retracted for.
2711async fn run_writer_actor(
2712    conn: libsql::Connection,
2713    clock: Arc<dyn Clock>,
2714    mut highpri_rx: mpsc::Receiver<HighPriCommand>,
2715    mut lowpri_rx: mpsc::Receiver<LowPriCommand>,
2716    shared: Arc<ActorShared>,
2717) -> Result<()> {
2718    loop {
2719        // Read once and reused by both the depth sample and the starvation
2720        // counter, so the two cannot disagree about what was queued when this
2721        // turn went looking (W4.4, D-153).
2722        let low_queued = lowpri_rx.len();
2723        shared.metrics.record_turn(highpri_rx.len(), low_queued);
2724
2725        let ctl = tokio::select! {
2726            biased;
2727            Some(cmd) = highpri_rx.recv() => {
2728                shared.metrics.record_priority_choice(true, low_queued);
2729                let turn = Turn::start(cmd.kind(), &shared);
2730                cmd.execute(&conn, &*clock, &turn).await
2731            }
2732            Some(cmd) = lowpri_rx.recv() => {
2733                shared.metrics.record_priority_choice(false, low_queued);
2734                let turn = Turn::start(cmd.kind(), &shared);
2735                cmd.execute(&conn, &*clock, &turn).await
2736            }
2737            else => LoopCtl::Break,
2738        };
2739        if matches!(ctl, LoopCtl::Break) {
2740            break;
2741        }
2742    }
2743    Ok(())
2744}
2745
2746/// One command's hold: the timer, its label, and the counters it reports to.
2747///
2748/// # The hold is recorded *before* the caller is answered, and it has to be
2749///
2750/// The obvious placement — time the whole `execute` call from the loop — is
2751/// wrong in a way that only shows up under test. Every arm of `execute` ends by
2752/// sending on a `oneshot`, which wakes the waiting caller; the actor then
2753/// returns to the loop and records. Those are two tasks, so a caller that awaits
2754/// its own write and immediately reads [`Database::metrics`] can be scheduled
2755/// first and see a turn count that does not include the write it just did.
2756///
2757/// Not a correctness bug in the ledger, and it would never have been noticed in
2758/// production — a dashboard sampling every few seconds cannot see the window.
2759/// It makes every test and diagnostic of the counters flaky, which is worse: the
2760/// instrumentation would have been *believed* while being wrong exactly when
2761/// someone tried to check it. `examples/bulk_atomic_diag.rs` was the thing that
2762/// caught it, reporting a 20,000-row batch as a 0 ms hold.
2763///
2764/// So `answer` records and then sends, in that order, and the ordering is the
2765/// method's whole reason to exist. What it costs is that the `oneshot::send`
2766/// itself falls outside the measurement, which is a few nanoseconds against a
2767/// turn measured in microseconds at best.
2768struct Turn<'a> {
2769    kind: crate::metrics::CommandKind,
2770    timer: crate::metrics::HoldTimer,
2771    shared: &'a ActorShared,
2772}
2773
2774/// State the actor owns and a `Turn` needs to reach.
2775///
2776/// `archive_epoch` is here rather than in [`crate::metrics::ActorMetrics`]
2777/// because it is **not** a metric: T1.2's shadow rebuild reads it to decide
2778/// whether its work is still valid, so it has to be present in every build, not
2779/// only under the `metrics` feature. Counting archives happens to be what both
2780/// want; only one of them is allowed to be compiled out.
2781#[derive(Default)]
2782struct ActorShared {
2783    metrics: crate::metrics::ActorMetrics,
2784    archive_epoch: std::sync::atomic::AtomicU64,
2785}
2786
2787impl<'a> Turn<'a> {
2788    fn start(kind: crate::metrics::CommandKind, shared: &'a ActorShared) -> Self {
2789        Self {
2790            kind,
2791            timer: crate::metrics::HoldTimer::start(),
2792            shared,
2793        }
2794    }
2795
2796    fn epoch(&self) -> u64 {
2797        self.shared
2798            .archive_epoch
2799            .load(std::sync::atomic::Ordering::Relaxed)
2800    }
2801
2802    /// Record that an archive session committed.
2803    ///
2804    /// Bumped on **success only**: a failed archive rolls back, so it deletes
2805    /// nothing and invalidates no shadow build.
2806    fn archive_committed(&self) {
2807        self.shared
2808            .archive_epoch
2809            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2810    }
2811
2812    /// Close the hold and hand the result back. Never the other way round.
2813    ///
2814    /// The `let _ =` on the send is deliberate and predates this: a caller that
2815    /// dropped its receiver — `tokio::time::timeout` around a write, which
2816    /// [`Database`]'s write surface explicitly documents — is not an actor
2817    /// error, and the command committed regardless.
2818    fn answer<T>(&self, responder: oneshot::Sender<Result<T>>, res: Result<T>) {
2819        self.shared
2820            .metrics
2821            .record_hold(self.kind, self.timer.elapsed());
2822        let _ = responder.send(res);
2823    }
2824
2825    /// [`answer`](Self::answer) for a chunk: the same reading, handed back to the
2826    /// caller as well as recorded (0.12.0, W1).
2827    ///
2828    /// One `elapsed()` serves both, so the duration the chunk loop sizes against
2829    /// is *the same number* the histogram shows — a controller and a dashboard
2830    /// disagreeing about what a chunk cost would be a bad way to spend a
2831    /// debugging session.
2832    ///
2833    /// The record-then-send ordering documented on [`Turn`] is preserved, and
2834    /// matters here for the same reason: the send wakes the caller, which may be
2835    /// scheduled before this method returns.
2836    fn answer_chunk(&self, responder: oneshot::Sender<Result<ChunkOutcome>>, res: Result<usize>) {
2837        let held = self.timer.elapsed();
2838        self.shared.metrics.record_hold(self.kind, held);
2839        let _ = responder.send(res.map(|rows| ChunkOutcome { rows, held }));
2840    }
2841}
2842
2843const INSERT_LINK: &str = "INSERT INTO links \
2844     (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
2845     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)";
2846
2847/// Shared by the single-concept write and the chunked one, so the two paths
2848/// cannot drift into upserting different column sets — and so the chunk has a
2849/// statement text it can prepare once (D-056).
2850const UPSERT_CONCEPT: &str = "INSERT INTO concepts \
2851     (id, title, content, embedding_model, valid_from, valid_to, recorded_at, retired) \
2852     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
2853     ON CONFLICT(id) DO UPDATE SET \
2854         title = excluded.title, \
2855         content = excluded.content, \
2856         embedding_model = excluded.embedding_model, \
2857         valid_from = excluded.valid_from, \
2858         valid_to = excluded.valid_to, \
2859         recorded_at = excluded.recorded_at, \
2860         retired = excluded.retired";
2861
2862/// The parameter row for [`UPSERT_CONCEPT`], in one place for the same reason.
2863fn concept_params<'a>(concept: &'a ConceptUpsert, stamp: &'a str) -> [libsql::Value; 8] {
2864    [
2865        concept.id.as_str().into(),
2866        concept.title.as_str().into(),
2867        concept.content.as_str().into(),
2868        concept
2869            .embedding_model
2870            .as_deref()
2871            .map_or(libsql::Value::Null, Into::into),
2872        concept.valid_from.as_str().into(),
2873        concept.valid_to.as_str().into(),
2874        stamp.into(),
2875        (concept.retired as i64).into(),
2876    ]
2877}
2878
2879impl HighPriCommand {
2880    /// The metrics label for this variant (T1.4).
2881    ///
2882    /// Exhaustive for the same reason `execute` is: a new variant that silently
2883    /// borrowed another's label would attribute its holds to the wrong command,
2884    /// and the one question the counters exist to answer is *which* command
2885    /// broke the budget.
2886    fn kind(&self) -> crate::metrics::CommandKind {
2887        use crate::metrics::CommandKind as K;
2888        match self {
2889            HighPriCommand::AssertEdge { .. } => K::AssertEdge,
2890            HighPriCommand::RetireEdge { .. } => K::RetireEdge,
2891            HighPriCommand::UpsertConcept { .. } => K::UpsertConcept,
2892            HighPriCommand::WriteBulkAtomic { .. } => K::WriteBulkAtomic,
2893            HighPriCommand::RebuildCurrent { .. } => K::RebuildCurrent,
2894            HighPriCommand::RegisterModel { .. } => K::RegisterModel,
2895            HighPriCommand::Checkpoint { .. } => K::Checkpoint,
2896            HighPriCommand::Shutdown { .. } => K::Shutdown,
2897        }
2898    }
2899
2900    /// Run one command and answer its caller.
2901    ///
2902    /// Deliberately exhaustive — there is no `_` arm. The 0.4.5–0.5.4 actor
2903    /// matched `Shutdown` and `AssertEdge` and sent everything else to
2904    /// `_ => LoopCtl::Continue`, which **dropped the responder**: the caller's
2905    /// `rx.await` resolved to a `RecvError` that no code mapped, so four of six
2906    /// commands were indistinguishable from a hung database. An exhaustive match
2907    /// makes that failure a compile error instead of a runtime silence, which is
2908    /// why adding a variant should break this function.
2909    async fn execute(
2910        self,
2911        conn: &libsql::Connection,
2912        clock: &dyn Clock,
2913        turn: &Turn<'_>,
2914    ) -> LoopCtl {
2915        match self {
2916            HighPriCommand::Shutdown { responder } => {
2917                turn.answer(responder, Ok(()));
2918                return LoopCtl::Break;
2919            }
2920            HighPriCommand::Checkpoint { responder } => {
2921                let res = run_checkpoint(conn).await;
2922                turn.answer(responder, res);
2923            }
2924            HighPriCommand::AssertEdge { edge, responder } => {
2925                let stamp = clock.now();
2926                if let Err(e) = reject_overlapping_interval(conn, &edge).await {
2927                    turn.answer(responder, Err(e));
2928                    return LoopCtl::Continue;
2929                }
2930                let res = match conn
2931                    .execute(
2932                        INSERT_LINK,
2933                        libsql::params![
2934                            edge.source.as_str(),
2935                            edge.target.as_str(),
2936                            edge.edge_type.as_str(),
2937                            edge.valid_from.as_str(),
2938                            edge.valid_to.as_str(),
2939                            edge.weight,
2940                            edge.properties.as_str(),
2941                            stamp.as_str()
2942                        ],
2943                    )
2944                    .await
2945                {
2946                    Ok(_) => Ok(()),
2947                    Err(e) => Err(classify(
2948                        conn,
2949                        e,
2950                        WriteOp::Edge {
2951                            source_id: &edge.source,
2952                            target_id: &edge.target,
2953                            edge_type: &edge.edge_type,
2954                        },
2955                    )
2956                    .await),
2957                };
2958                turn.answer(responder, res);
2959            }
2960            HighPriCommand::RetireEdge {
2961                source,
2962                target,
2963                edge_type,
2964                valid_from,
2965                valid_to,
2966                responder,
2967            } => {
2968                let stamp = clock.now();
2969                let res = retire_edge(
2970                    conn,
2971                    &source,
2972                    &target,
2973                    &edge_type,
2974                    &valid_from,
2975                    &valid_to,
2976                    &stamp,
2977                )
2978                .await;
2979                turn.answer(responder, res);
2980            }
2981            HighPriCommand::UpsertConcept { concept, responder } => {
2982                let stamp = clock.now();
2983                let res = upsert_concept(conn, &concept, &stamp).await;
2984                turn.answer(responder, res);
2985            }
2986            HighPriCommand::WriteBulkAtomic { edges, responder } => {
2987                // One stamp for the whole batch (D-014): the rows were asserted
2988                // by one act, and giving them different transaction times would
2989                // invent an ordering the caller never expressed.
2990                let stamp = clock.now();
2991                let res = write_edges_atomic(conn, &edges, &stamp).await;
2992                turn.answer(responder, res);
2993            }
2994            HighPriCommand::RebuildCurrent { responder } => {
2995                turn.answer(responder, rebuild_current(conn).await);
2996            }
2997            HighPriCommand::RegisterModel {
2998                model,
2999                dim,
3000                responder,
3001            } => {
3002                turn.answer(
3003                    responder,
3004                    crate::vector::register_model(conn, &model, dim).await,
3005                );
3006            }
3007        }
3008        LoopCtl::Continue
3009    }
3010}
3011
3012impl LowPriCommand {
3013    /// The metrics label for this variant (T1.4). See [`HighPriCommand::kind`].
3014    fn kind(&self) -> crate::metrics::CommandKind {
3015        use crate::metrics::CommandKind as K;
3016        match self {
3017            LowPriCommand::WriteConceptsChunk { .. } => K::WriteConceptsChunk,
3018            LowPriCommand::WriteAnalyticsChunk { .. } => K::WriteAnalyticsChunk,
3019            LowPriCommand::UpsertEmbeddingChunk { .. } => K::UpsertEmbeddingChunk,
3020            LowPriCommand::BulkImportChunk { .. } => K::BulkImportChunk,
3021            LowPriCommand::Archive { .. } => K::Archive,
3022            // Its own counter since 0.12.9 (W4.3, D-152). It reported as
3023            // `K::Archive` from 0.9.0 to 0.12.8 — the budget really is shared,
3024            // but attribution is not budget, and an operator reading a long
3025            // `archive` hold could not tell whether anything had been archived.
3026            // What kept it folded was that a `CommandKind` variant was a
3027            // breaking addition; `#[non_exhaustive]` (W4.2) removed that.
3028            LowPriCommand::Rehydrate { .. } => K::Rehydrate,
3029            LowPriCommand::RebuildFts { .. } => K::RebuildFts,
3030            LowPriCommand::Analyze { .. } => K::Analyze,
3031            LowPriCommand::ShadowRebuild { .. } => K::ShadowRebuild,
3032        }
3033    }
3034
3035    /// Run one background command and answer its caller.
3036    ///
3037    /// Also exhaustive. The pre-0.5.4 version was a single `LoopCtl::Continue`
3038    /// for *every* variant — every background write silently discarded, its
3039    /// caller waiting forever.
3040    async fn execute(
3041        self,
3042        conn: &libsql::Connection,
3043        clock: &dyn Clock,
3044        turn: &Turn<'_>,
3045    ) -> LoopCtl {
3046        match self {
3047            LowPriCommand::BulkImportChunk { chunk, responder } => {
3048                // A stamp per chunk, not per batch: the chunks commit
3049                // separately, so a shared stamp would claim a simultaneity the
3050                // storage does not have.
3051                let stamp = clock.now();
3052                turn.answer_chunk(responder, write_edges_atomic(conn, &chunk, &stamp).await);
3053            }
3054            LowPriCommand::WriteConceptsChunk { chunk, responder } => {
3055                let stamp = clock.now();
3056                turn.answer_chunk(responder, write_concepts_atomic(conn, &chunk, &stamp).await);
3057            }
3058            LowPriCommand::WriteAnalyticsChunk { chunk, responder } => {
3059                let stamp = clock.now();
3060                turn.answer_chunk(
3061                    responder,
3062                    write_annotations_atomic(conn, &chunk, &stamp).await,
3063                );
3064            }
3065            LowPriCommand::UpsertEmbeddingChunk {
3066                model,
3067                chunk,
3068                responder,
3069            } => {
3070                // No clock reading: an embedding carries no timestamp on either
3071                // axis. It is a derived artifact of a model applied to content
3072                // (Doctrine VII), and the ledger already records when the
3073                // content changed.
3074                turn.answer_chunk(
3075                    responder,
3076                    crate::vector::search::upsert_embedding_chunk(conn, &model, &chunk).await,
3077                );
3078            }
3079            LowPriCommand::Archive {
3080                cutoff,
3081                archive_path,
3082                responder,
3083            } => {
3084                // The archive *time*, not the cutoff. `archive_horizon` records
3085                // both and they are different facts — see `archive()` (Wave 4.5).
3086                let archived_at = clock.now();
3087                let res = archive(conn, &cutoff, &archived_at, &archive_path).await;
3088                // Before the answer, so a shadow rebuild that reads the epoch on
3089                // its next turn cannot miss an archive that has already deleted
3090                // rows out from under it (T1.2).
3091                if res.is_ok() {
3092                    turn.archive_committed();
3093                }
3094                turn.answer(responder, res);
3095            }
3096            LowPriCommand::Rehydrate {
3097                ids,
3098                archive_path,
3099                responder,
3100            } => {
3101                let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
3102                let res = rehydrate(conn, &refs, &archive_path).await;
3103                // Same reason as `Archive`: rehydration moves rows into `links`'
3104                // parent table, so a shadow rebuild in flight must see the epoch
3105                // move before the caller is answered (T1.2).
3106                if res.is_ok() {
3107                    turn.archive_committed();
3108                }
3109                turn.answer(responder, res);
3110            }
3111            LowPriCommand::ShadowRebuild { step, responder } => {
3112                use crate::integrity::{shadow, ShadowOutcome, ShadowStep};
3113                let res = match step {
3114                    ShadowStep::Begin => {
3115                        shadow::begin(conn)
3116                            .await
3117                            .map(|build_start| ShadowOutcome::Started {
3118                                build_start,
3119                                epoch: turn.epoch(),
3120                            })
3121                    }
3122                    ShadowStep::Fill { after } => shadow::fill_chunk(conn, after.as_deref())
3123                        .await
3124                        .map(|last| ShadowOutcome::Filled { last }),
3125                    ShadowStep::Swap { build_start, epoch } => {
3126                        shadow::swap(conn, &build_start, epoch, turn.epoch())
3127                            .await
3128                            .map(|rows| ShadowOutcome::Swapped { rows })
3129                    }
3130                };
3131                turn.answer(responder, res);
3132            }
3133            LowPriCommand::RebuildFts { responder } => {
3134                let res = conn
3135                    .execute(crate::schema::ddl::REBUILD_CONCEPTS_FTS, ())
3136                    .await
3137                    .map(|_| ())
3138                    .map_err(Into::into);
3139                turn.answer(responder, res);
3140            }
3141            LowPriCommand::Analyze {
3142                incremental,
3143                responder,
3144            } => {
3145                // Both go through `query()`, not `execute()`. `PRAGMA optimize`
3146                // yields rows, and libsql's `execute()` rejects any statement
3147                // that does ("Execute returned rows") — the same trap
3148                // `configure` documents. `ANALYZE` does not yield rows, but is
3149                // issued the same way so the two arms cannot drift into needing
3150                // different call shapes for no visible reason.
3151                let sql = if incremental {
3152                    crate::schema::ddl::OPTIMIZE
3153                } else {
3154                    crate::schema::ddl::ANALYZE
3155                };
3156                let res = conn.query(sql, ()).await.map(|_| ()).map_err(Into::into);
3157                turn.answer(responder, res);
3158            }
3159        }
3160        LoopCtl::Continue
3161    }
3162}
3163
3164/// Close an open interval by asserting its successor (Doctrine III).
3165///
3166/// Never an `UPDATE`. The replacement row copies weight and properties from
3167/// current belief and differs only in `valid_to` and `recorded_at`, so the
3168/// original assertion survives intact and `reconstruct` at an earlier instant
3169/// still sees the interval open — which is the entire point of a bitemporal
3170/// ledger.
3171async fn retire_edge(
3172    conn: &libsql::Connection,
3173    source: &str,
3174    target: &str,
3175    edge_type: &str,
3176    valid_from: &str,
3177    valid_to: &str,
3178    stamp: &str,
3179) -> Result<()> {
3180    let affected = conn
3181        .execute(
3182            "INSERT INTO links \
3183                 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
3184             SELECT source_id, target_id, edge_type, valid_from, ?5, weight, properties, ?6 \
3185             FROM links_current \
3186             WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 AND valid_from = ?4",
3187            libsql::params![source, target, edge_type, valid_from, valid_to, stamp],
3188        )
3189        .await
3190        .map_err(DbError::Engine)?;
3191
3192    if affected == 0 {
3193        return Err(DbError::NotFound(format!(
3194            "{source} -> {target} ({edge_type}) at {valid_from}"
3195        )));
3196    }
3197    Ok(())
3198}
3199
3200async fn upsert_concept(
3201    conn: &libsql::Connection,
3202    concept: &ConceptUpsert,
3203    stamp: &str,
3204) -> Result<()> {
3205    let res = conn
3206        .execute(UPSERT_CONCEPT, concept_params(concept, stamp))
3207        .await;
3208
3209    match res {
3210        Ok(_) => Ok(()),
3211        Err(e) => Err(classify(
3212            conn,
3213            e,
3214            WriteOp::Concept {
3215                id: &concept.id,
3216                recorded_at: stamp,
3217            },
3218        )
3219        .await),
3220    }
3221}
3222
3223/// Every recorded interval for one relationship key, for [`Interval::overlaps`]
3224/// to judge.
3225///
3226/// **Three equalities and nothing else, deliberately — and the "and nothing
3227/// else" was measured, not assumed.** The first version added
3228/// `AND valid_from < :new_valid_to`, a provably safe narrowing (overlap requires
3229/// `max(start) < min(end)`, so an interval starting at or after the new one's end
3230/// cannot overlap it). It cost **9.8 ms on a 90-edge chunk into a 2,000-edge
3231/// hub**, because it walked the planner straight into D-059's trap:
3232///
3233/// ```text
3234/// with the range:     SEARCH links_current USING COVERING INDEX
3235///                     idx_lc_traversal_cover (source_id=? AND valid_from<?)
3236/// without it:         SEARCH links_current USING COVERING INDEX
3237///                     idx_lc_open_interval (source_id=? AND target_id=? AND edge_type=?)
3238/// ```
3239///
3240/// `idx_lc_traversal_cover` leads on `(source_id, valid_from, …)` and contains
3241/// every column this query mentions, so with a `valid_from` range available it
3242/// wins as a covering index while binding **one** equality column — and the
3243/// guard scans the source's entire out-degree. That is the same shape as the
3244/// defect D-059 diagnosed in `trg_links_single_open`, reintroduced by an
3245/// optimisation, one wave after it was fixed.
3246///
3247/// Dropping the range makes the query a pure three-column point lookup that
3248/// `idx_lc_open_interval` serves exactly, and the rows it returns are the
3249/// intervals recorded for one `(source, target, edge_type)` — a version count,
3250/// not an out-degree. **A narrowing predicate is not free if it changes the
3251/// plan**, which is the general lesson and the reason this constant carries its
3252/// own `EXPLAIN` output.
3253const OVERLAP_CANDIDATES: &str = "SELECT valid_from, valid_to FROM links_current \
3254     WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 \
3255       AND valid_from <> ?4";
3256
3257/// Whether this pair is the storage layer's case rather than this guard's.
3258///
3259/// Two **open** intervals overlap — they share every instant from the later
3260/// start onwards — so a naive overlap check reports them, and reporting them
3261/// here would leave `DbError::SingleOpenViolation` constructible by nothing.
3262/// That variant is the more specific error, it is enforced by
3263/// `trg_links_single_open` rather than by this function, and its field names
3264/// were ratified in §1.2. Shadowing it with a general one would be defect Q's
3265/// shape reintroduced by a fix: a typed error that no code path can produce.
3266///
3267/// So the two guards partition the space rather than overlapping it. Both open
3268/// belongs to the trigger. Everything else — open against closed, closed against
3269/// closed — is unguarded at the storage layer and belongs here. That the split
3270/// is exactly the trigger's `WHEN` clause is not a coincidence; it is the
3271/// definition of what was missing.
3272fn defer_to_single_open(proposed: &Interval, existing: &Interval) -> bool {
3273    proposed.is_open() && existing.is_open()
3274}
3275
3276/// Refuse an assertion whose valid-time interval overlaps one already recorded
3277/// for the same `(source, target, edge_type)` — **defect AA, D-060**.
3278///
3279/// `trg_links_single_open` fires only `WHEN NEW.valid_to = '9999-…'`, so it
3280/// guards the open sentinel and nothing else. Two *closed* intervals that
3281/// overlap were accepted without complaint, and `query_as_of_edges` at an
3282/// instant inside both returned one relationship as two edges.
3283///
3284/// **This runs in the write actor, which is what makes it sound.** The obvious
3285/// place is `EdgeAssertion::normalized`, and it cannot go there — `normalized`
3286/// is a pure function with no connection, and doing the read at the API boundary
3287/// instead would leave a check-then-write race between the read and the actor's
3288/// insert. Inside the actor there is one writer by construction (D-014), and for
3289/// the batch paths this runs inside the same transaction as the insert, so the
3290/// window does not exist rather than being small.
3291///
3292/// **What it does not cover, and §4.2 now says so:** raw SQL against the same
3293/// file. The storage layer permits what this API refuses, which is the honest
3294/// cost of not putting the check in a trigger. The alternative was a second
3295/// index probe inside `trg_links_single_open` on every insert — on the path
3296/// D-059 has just finished making fast — for a guarantee that only holds against
3297/// callers who were going through the actor anyway.
3298///
3299/// `valid_from <> ?4` excludes the row being re-asserted. Re-assertion at the
3300/// same `valid_from` is Doctrine III's ordinary case — a new belief about the
3301/// same interval — and is settled by the primary key and the single-open
3302/// trigger, not here.
3303/// The single-assertion path prepares one statement for one check, which is what
3304/// `AssertEdge` needs; the batch path prepares once and calls
3305/// [`check_prepared`] per row.
3306async fn reject_overlapping_interval(
3307    conn: &libsql::Connection,
3308    edge: &EdgeAssertion,
3309) -> Result<()> {
3310    let stmt = conn.prepare(OVERLAP_CANDIDATES).await?;
3311    check_prepared(&stmt, edge).await
3312}
3313
3314/// The guard's body, against a statement the caller has already prepared.
3315///
3316/// **Split out because preparing per row was worth 10.4 ms on a 90-edge chunk**
3317/// (§8.8) — the same defect D-056 and D-057 diagnosed and fixed for
3318/// `INSERT_LINK`, reintroduced by the Wave 2 guard that was written beside it.
3319/// Measured with and without the guard, on a 2,000-edge hub: 8.65 ms → 19.25 ms,
3320/// and *identical* with and without `idx_lc_open_interval`, which is what
3321/// identified preparation rather than a scan as the cost. A guard that reads an
3322/// index correctly and prepares its statement 90 times is indistinguishable, at
3323/// the call site, from one that scans.
3324///
3325/// `reset()` between rows is not optional: libsql binds and steps without
3326/// resetting, so a reused statement must be returned to its initial state.
3327async fn check_prepared(stmt: &libsql::Statement, edge: &EdgeAssertion) -> Result<()> {
3328    let proposed = Interval::new(edge.valid_from.clone(), edge.valid_to.clone());
3329
3330    stmt.reset();
3331    let mut rows = stmt
3332        .query(libsql::params![
3333            edge.source.as_str(),
3334            edge.target.as_str(),
3335            edge.edge_type.as_str(),
3336            edge.valid_from.as_str()
3337        ])
3338        .await?;
3339
3340    while let Some(row) = rows.next().await? {
3341        let existing = Interval::new(row.get::<String>(0)?, row.get::<String>(1)?);
3342        if defer_to_single_open(&proposed, &existing) {
3343            continue;
3344        }
3345        if proposed.overlaps(&existing) {
3346            return Err(DbError::OverlappingInterval {
3347                overlap: Box::new(crate::error::Overlap {
3348                    source_id: edge.source.clone(),
3349                    target_id: edge.target.clone(),
3350                    edge_type: edge.edge_type.clone(),
3351                    valid_from: edge.valid_from.clone(),
3352                    valid_to: edge.valid_to.clone(),
3353                    existing_from: existing.valid_from,
3354                    existing_to: existing.valid_to,
3355                }),
3356            });
3357        }
3358    }
3359
3360    Ok(())
3361}
3362
3363/// The same guard applied *within* a batch, before any of it is written.
3364///
3365/// The database check cannot see rows that are not in the database yet, so a
3366/// batch carrying two overlapping intervals for one relationship would pass
3367/// every per-row check and commit the overlap in one transaction. Quadratic in
3368/// the batch, which is affordable because the chunk is bounded at
3369/// [`chunk_rows::EDGES`] = 90 and because the comparison is a pair of string
3370/// compares — and because grouping first means the inner loop only ever runs
3371/// over edges sharing a key, which is normally one.
3372fn reject_overlaps_within(edges: &[EdgeAssertion]) -> Result<()> {
3373    for (i, a) in edges.iter().enumerate() {
3374        let ia = Interval::new(a.valid_from.clone(), a.valid_to.clone());
3375        for b in &edges[i + 1..] {
3376            if a.source != b.source || a.target != b.target || a.edge_type != b.edge_type {
3377                continue;
3378            }
3379            // Identical valid_from is re-assertion within one batch: the last
3380            // writer wins by seq_id, as it does across batches. Not an overlap.
3381            if a.valid_from == b.valid_from {
3382                continue;
3383            }
3384            let ib = Interval::new(b.valid_from.clone(), b.valid_to.clone());
3385            // Both open is the trigger's case; it fires during the insert and
3386            // rolls the batch back with the more specific error.
3387            if defer_to_single_open(&ia, &ib) {
3388                continue;
3389            }
3390            if ia.overlaps(&ib) {
3391                return Err(DbError::OverlappingInterval {
3392                    overlap: Box::new(crate::error::Overlap {
3393                        source_id: a.source.clone(),
3394                        target_id: a.target.clone(),
3395                        edge_type: a.edge_type.clone(),
3396                        valid_from: a.valid_from.clone(),
3397                        valid_to: a.valid_to.clone(),
3398                        existing_from: ib.valid_from,
3399                        existing_to: ib.valid_to,
3400                    }),
3401                });
3402            }
3403        }
3404    }
3405    Ok(())
3406}
3407
3408/// Write every edge or none, under a single stamp.
3409///
3410/// **The statement is prepared once for the whole chunk (§9, D-056).** It used to
3411/// be `tx.execute(INSERT_LINK, …)` per row, which re-prepares on every call — and
3412/// `links` carries two triggers, so each preparation compiles their bodies along
3413/// with the insert.
3414///
3415/// Measured at 500 rows: **≈62 ms → ≈37 ms, a 41% saving.** Preparation was a
3416/// large cost and *not* the dominant one, which the first guess had it as. The
3417/// residual is the triggers themselves: the same 500 rows with
3418/// `trg_links_log_insert` and `trg_links_current_sync` dropped commit in **2.96
3419/// ms**, so trigger amplification is ~92% of what remains. There is no further
3420/// win available here without changing what the ledger records, and Doctrine IV
3421/// is what says it must be recorded. See D-056 for what that implies about §9's
3422/// ≤ 3 ms budget — briefly, 2.96 ms *is* the un-amplified figure, so the budget
3423/// appears to have been set without the amplification its own preamble says is
3424/// included.
3425///
3426/// `reset()` between rows is not optional: libsql's `execute` binds and steps
3427/// without resetting, so a reused statement must be returned to its initial state
3428/// or the second row steps a completed statement.
3429async fn write_edges_atomic(
3430    conn: &libsql::Connection,
3431    edges: &[EdgeAssertion],
3432    stamp: &str,
3433) -> Result<usize> {
3434    if edges.is_empty() {
3435        return Ok(0);
3436    }
3437
3438    // Before the transaction opens: a batch that contradicts itself is refused
3439    // without taking the write lock at all (D-060).
3440    reject_overlaps_within(edges)?;
3441
3442    let tx = conn
3443        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
3444        .await?;
3445
3446    // Inside the transaction, so the rows this checks against cannot change
3447    // between the check and the insert.
3448    // One preparation for the whole chunk, not one per row — see
3449    // `check_prepared`, and D-056 for the same lesson learned on `INSERT_LINK`.
3450    let guard = tx.prepare(OVERLAP_CANDIDATES).await?;
3451    for edge in edges {
3452        if let Err(e) = check_prepared(&guard, edge).await {
3453            // Released before the rollback: a live statement on the connection
3454            // is what makes SQLite refuse to end a transaction.
3455            drop(guard);
3456            let _ = tx.rollback().await;
3457            return Err(e);
3458        }
3459    }
3460    drop(guard);
3461
3462    let stmt = tx.prepare(INSERT_LINK).await?;
3463
3464    for edge in edges {
3465        stmt.reset();
3466        let res = stmt
3467            .execute(libsql::params![
3468                edge.source.as_str(),
3469                edge.target.as_str(),
3470                edge.edge_type.as_str(),
3471                edge.valid_from.as_str(),
3472                edge.valid_to.as_str(),
3473                edge.weight,
3474                edge.properties.as_str(),
3475                stamp
3476            ])
3477            .await;
3478
3479        if let Err(e) = res {
3480            let typed = classify(
3481                &tx,
3482                e,
3483                WriteOp::Edge {
3484                    source_id: &edge.source,
3485                    target_id: &edge.target,
3486                    edge_type: &edge.edge_type,
3487                },
3488            )
3489            .await;
3490            // Released before the rollback: a live statement on the connection
3491            // is exactly what makes SQLite refuse to end a transaction.
3492            drop(stmt);
3493            let _ = tx.rollback().await;
3494            return Err(typed);
3495        }
3496    }
3497
3498    drop(stmt);
3499    tx.commit().await?;
3500    Ok(edges.len())
3501}
3502
3503/// Write every concept or none, under a single stamp.
3504/// Upsert one chunk of derived annotations in a single transaction (D-041).
3505///
3506/// `stamp` is the actor's clock reading, exactly as for every other chunk — but
3507/// it lands in `computed_at`, not in a `recorded_at`, and the difference is not
3508/// cosmetic. `recorded_at` is the transaction-time axis and is subject to
3509/// Doctrine II and the monotonicity guard; `computed_at` is a note about when a
3510/// derivation last ran, on a table the ledger does not see. Rerunning an
3511/// algorithm therefore replaces the row and advances the note, rather than
3512/// versioning a concept the world did not change.
3513async fn write_annotations_atomic(
3514    conn: &libsql::Connection,
3515    annotations: &[Annotation],
3516    stamp: &str,
3517) -> Result<usize> {
3518    if annotations.is_empty() {
3519        return Ok(0);
3520    }
3521
3522    let tx = conn
3523        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
3524        .await?;
3525
3526    let stmt = tx
3527        .prepare(
3528            "INSERT INTO analytics_annotations (concept_id, label, value, computed_at) \
3529             VALUES (?1, ?2, ?3, ?4) \
3530             ON CONFLICT(concept_id, label) DO UPDATE SET \
3531                 value = excluded.value, computed_at = excluded.computed_at",
3532        )
3533        .await?;
3534
3535    for a in annotations {
3536        stmt.reset();
3537        let res = stmt
3538            .execute(libsql::params![
3539                a.concept_id.as_str(),
3540                a.label.as_str(),
3541                a.value.as_str(),
3542                stamp
3543            ])
3544            .await;
3545        if let Err(e) = res {
3546            drop(stmt);
3547            let _ = tx.rollback().await;
3548            return Err(DbError::Engine(e));
3549        }
3550    }
3551
3552    drop(stmt);
3553    tx.commit().await?;
3554    Ok(annotations.len())
3555}
3556
3557async fn write_concepts_atomic(
3558    conn: &libsql::Connection,
3559    concepts: &[ConceptUpsert],
3560    stamp: &str,
3561) -> Result<usize> {
3562    if concepts.is_empty() {
3563        return Ok(0);
3564    }
3565
3566    let tx = conn
3567        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
3568        .await?;
3569
3570    // Prepared once, like the edge chunk (D-056). This no longer routes through
3571    // [`upsert_concept`] — that function prepares per call by construction — but
3572    // it shares that function's statement text and parameter row, so the two
3573    // cannot upsert different columns.
3574    let stmt = tx.prepare(UPSERT_CONCEPT).await?;
3575
3576    for concept in concepts {
3577        stmt.reset();
3578        let res = stmt.execute(concept_params(concept, stamp)).await;
3579
3580        if let Err(e) = res {
3581            let typed = classify(
3582                &tx,
3583                e,
3584                WriteOp::Concept {
3585                    id: &concept.id,
3586                    recorded_at: stamp,
3587                },
3588            )
3589            .await;
3590            drop(stmt);
3591            let _ = tx.rollback().await;
3592            return Err(typed);
3593        }
3594    }
3595
3596    drop(stmt);
3597    tx.commit().await?;
3598    Ok(concepts.len())
3599}
3600
3601#[cfg(test)]
3602mod tests {
3603    use super::*;
3604
3605    fn edge(target: &str, micros: usize) -> EdgeAssertion {
3606        EdgeAssertion::new("src", target, "LINKS")
3607            .valid_from(format!("2026-01-01T00:00:00.{micros:06}Z"))
3608            .valid_to(format!("2026-01-01T00:00:00.{:06}Z", micros + 1))
3609    }
3610
3611    /// The estimate must depend on the batch's **shape**, not only its size.
3612    ///
3613    /// This is the correction T1.3's "rows × per-row cost" needed. Two batches
3614    /// of the same length whose measured holds differ by 7× must not be
3615    /// predicted identically, and the direction matters: a model that averages
3616    /// the two under-predicts the expensive shape, which is the only one anyone
3617    /// needs warning about.
3618    #[test]
3619    fn two_batches_of_one_size_are_not_predicted_alike() {
3620        const N: usize = 20_000;
3621        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
3622        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
3623
3624        let (a, b) = (estimated_bulk_hold(&fanout), estimated_bulk_hold(&history));
3625        assert!(
3626            b > a * 5,
3627            "the guard's expensive path is 16x dearer per pair and this batch \
3628             takes it on every pair, but the estimates are {a:?} and {b:?}"
3629        );
3630    }
3631
3632    /// Measured on libSQL 0.9.30: 2.5 s and 18.6 s for those two batches. The
3633    /// estimator tracked both within 5%, and this pins that it still does — a
3634    /// coefficient edited without re-measuring fails here.
3635    #[test]
3636    fn the_estimate_matches_what_was_measured() {
3637        const N: usize = 20_000;
3638        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
3639        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
3640
3641        for (batch, measured_ms, label) in
3642            [(fanout, 2_618u128, "fanout"), (history, 18_057, "history")]
3643        {
3644            let predicted = estimated_bulk_hold(&batch).as_millis();
3645            let ratio = predicted as f64 / measured_ms as f64;
3646            assert!(
3647                (0.8..1.25).contains(&ratio),
3648                "{label}: predicted {predicted} ms against a measured \
3649                 {measured_ms} ms ({ratio:.2}x). Re-run \
3650                 examples/bulk_atomic_diag.rs before changing the coefficients."
3651            );
3652        }
3653    }
3654
3655    /// An empty or single-edge batch has no pairs, and the arithmetic must not
3656    /// underflow computing it.
3657    #[test]
3658    fn a_batch_too_small_to_have_pairs_still_estimates() {
3659        assert_eq!(estimated_bulk_hold(&[]), std::time::Duration::ZERO);
3660        let one = [edge("t0", 0)];
3661        assert_eq!(
3662            estimated_bulk_hold(&one),
3663            std::time::Duration::from_nanos(73_000)
3664        );
3665    }
3666
3667    /// The warning threshold sits well above the bound this path is exempt from.
3668    ///
3669    /// Warning at `CHUNK_BUDGET` would fire on batches working exactly as
3670    /// designed — the exemption is a contract (D-014), not a failure — and a
3671    /// warning that fires on correct behaviour gets filtered out, taking the
3672    /// 18-second case with it.
3673    #[test]
3674    fn the_warning_threshold_is_not_the_chunk_budget() {
3675        assert!(BULK_ATOMIC_WARN_HOLD > CHUNK_BUDGET * 10);
3676    }
3677
3678    // -----------------------------------------------------------------------
3679    // next_chunk_size — the control law (0.12.0, W2)
3680    //
3681    // All of these run without a database, a clock or an actor, which is why
3682    // W2 comes before W3: the loop that will use this function can only be
3683    // tested against a real write, and the properties below cannot be observed
3684    // there without also observing the machine.
3685    // -----------------------------------------------------------------------
3686
3687    use std::time::Duration;
3688
3689    /// `next_chunk_size` with the shipped budget and floor.
3690    fn step_to(current: usize, held_ms: f64, ceiling: usize) -> usize {
3691        next_chunk_size(
3692            current,
3693            Duration::from_nanos((held_ms * 1_000_000.0) as u64),
3694            CHUNK_BUDGET,
3695            CHUNK_FLOOR,
3696            ceiling,
3697        )
3698    }
3699
3700    /// The edge path, which is every test here that does not say otherwise.
3701    fn step(current: usize, held_ms: f64) -> usize {
3702        step_to(current, held_ms, chunk_rows::EDGES)
3703    }
3704
3705    /// Iterate the law against a machine that costs `per_row_us` per row plus a
3706    /// fixed `overhead_ms` per transaction — the two-term model D-142 measured.
3707    fn converge(
3708        start: usize,
3709        per_row_us: f64,
3710        overhead_ms: f64,
3711        ceiling: usize,
3712        steps: usize,
3713    ) -> Vec<usize> {
3714        let mut size = start;
3715        (0..steps)
3716            .map(|_| {
3717                let held = overhead_ms + per_row_us * size as f64 / 1000.0;
3718                size = step_to(size, held, ceiling);
3719                size
3720            })
3721            .collect()
3722    }
3723
3724    /// The reason the shrink is proportional rather than a halving: at 4× over
3725    /// budget, halving needs three steps and every one of them is a latency
3726    /// miss a caller can feel.
3727    ///
3728    /// Run on the annotations path, because it is the only one whose ceiling
3729    /// leaves room to start far above a size that is reachable — on the edge
3730    /// path a 4× miss lands under [`CHUNK_FLOOR`], which is a different test.
3731    #[test]
3732    fn a_chunk_far_over_budget_converges_from_above_in_at_most_two_steps() {
3733        const CEILING: usize = chunk_rows::ANNOTATIONS;
3734        let (per_row_us, overhead_ms) = (20.0, 0.05);
3735        let held = |n: usize| overhead_ms + per_row_us * n as f64 / 1000.0;
3736        assert!(
3737            held(CEILING) > 4.0 * 3.0,
3738            "the start is not far over budget"
3739        );
3740
3741        let trace = converge(CEILING, per_row_us, overhead_ms, CEILING, 4);
3742        let first_in_budget = trace
3743            .iter()
3744            .position(|&n| held(n) <= 3.0)
3745            .expect("never reached the budget");
3746        assert!(
3747            first_in_budget <= 1,
3748            "took {} steps to get under budget: {trace:?}",
3749            first_in_budget + 1
3750        );
3751    }
3752
3753    /// Growth is additive, so a size that is merely comfortable cannot leap the
3754    /// ceiling — and cannot overshoot the budget by more than a quarter.
3755    #[test]
3756    fn growth_is_slow_and_shrinking_is_fast() {
3757        let grown = step(40, 1.0);
3758        assert!(
3759            (41..=50).contains(&grown),
3760            "40 rows at 1 ms should grow by about a quarter, got {grown}"
3761        );
3762        let shrunk = step(90, 9.0);
3763        assert!(
3764            shrunk <= 40,
3765            "90 rows at 3x the budget should shrink proportionally, got {shrunk}"
3766        );
3767    }
3768
3769    /// The dead band. Between `budget / 2` and `budget` the size is right and
3770    /// moving it only costs a re-measurement; without this the law oscillates
3771    /// across the bound forever.
3772    #[test]
3773    fn a_chunk_inside_the_band_is_left_alone() {
3774        for held_ms in [1.6, 2.0, 2.5, 2.9, 3.0] {
3775            assert_eq!(step(60, held_ms), 60, "moved at {held_ms} ms");
3776        }
3777        assert_ne!(step(60, 1.4), 60, "did not grow at well under half budget");
3778    }
3779
3780    /// Both clamps, and the floor's violation stated as a test rather than only
3781    /// as a comment: a populated table drives this to `CHUNK_FLOOR` and holds it
3782    /// there **over budget**, which is [`CHUNK_FLOOR`]'s documented trade.
3783    #[test]
3784    fn the_floor_and_the_ceiling_both_hold() {
3785        // 118 µs/row + 0.03 ms fixed — the populated arm, where 35 rows is
3786        // ~4.1 ms and no size in range meets the bound.
3787        let trace = converge(chunk_rows::EDGES, 118.0, 0.03, chunk_rows::EDGES, 8);
3788        assert!(
3789            trace.iter().all(|&n| n >= CHUNK_FLOOR),
3790            "fell through the floor: {trace:?}"
3791        );
3792        assert_eq!(*trace.last().unwrap(), CHUNK_FLOOR, "settled off the floor");
3793
3794        // A free machine cannot grow past the path's constant.
3795        let fast = converge(CHUNK_FLOOR, 1.0, 0.01, chunk_rows::EDGES, 40);
3796        assert_eq!(*fast.last().unwrap(), chunk_rows::EDGES);
3797        assert!(fast.iter().all(|&n| n <= chunk_rows::EDGES));
3798    }
3799
3800    /// Zero is the one answer that cannot be recovered from: a loop asked for
3801    /// chunks of no rows makes no progress and never finishes. Degenerate
3802    /// inputs included, since `held` is a measurement and measurements arrive
3803    /// from a machine under load.
3804    #[test]
3805    fn the_law_never_returns_zero() {
3806        let cases = [
3807            (0usize, Duration::ZERO),
3808            (0, Duration::from_secs(60)),
3809            (1, Duration::from_secs(60)),
3810            (90, Duration::from_secs(3600)),
3811            (usize::MAX, Duration::from_nanos(1)),
3812            (1, Duration::ZERO),
3813        ];
3814        for (current, held) in cases {
3815            for (floor, ceiling) in [(35, 90), (1, 1), (0, 0), (90, 35)] {
3816                let n = next_chunk_size(current, held, CHUNK_BUDGET, floor, ceiling);
3817                assert!(
3818                    n > 0,
3819                    "returned 0 for current={current}, held={held:?}, \
3820                     floor={floor}, ceiling={ceiling}"
3821                );
3822            }
3823        }
3824    }
3825
3826    /// A zero budget is not a configuration anyone should reach, but it is one
3827    /// division away from a panic, so it is pinned.
3828    #[test]
3829    fn a_zero_budget_shrinks_to_the_floor_rather_than_dividing_by_it() {
3830        assert_eq!(
3831            next_chunk_size(90, Duration::from_millis(1), Duration::ZERO, 35, 90),
3832            35
3833        );
3834    }
3835}