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.
85pub mod chunk_rows {
86    /// Edge assertions (`bulk_import`).
87    ///
88    /// Per-row cost on this path rises with the size of `links_current`, not
89    /// with the chunk (D-059) — so cutting the chunk buys latency and costs
90    /// throughput, ~11% for 1,000 edges. An earlier version of this comment
91    /// claimed it was 3.3× *faster*; that came from multiplying eleven copies of
92    /// a chunk measured into an empty database.
93    ///
94    /// **This size does not meet the 3 ms bound on a populated database.** 90
95    /// edges into an 8,000-edge table take **9.06 ms** — measured, two sessions
96    /// at 9.08 and 9.05, against an empty-table arm of 2.69 and 2.65 beside
97    /// them (D-136).
98    ///
99    /// The reason given here until 0.10.0 — that `trg_links_single_open`'s
100    /// `EXISTS` scans the whole out-degree, "a schema defect with a proven fix,
101    /// recorded in D-059 and not applied here" — described 0.5.5. The fix *was*
102    /// applied, as the `v5 → v6` rung, and took this from 47.7 ms to ~8 ms.
103    /// What survives is the miss: the bound is still exceeded ~3×. Its cause is
104    /// no longer unknown — D-142 attributes it to `trg_links_current_sync`, and
105    /// within that to secondary-index maintenance on `links_current` — and the
106    /// guard this comment used to blame contributes **no** growth at all.
107    ///
108    /// **The constant is unchanged, and that is now a measured decision**
109    /// (D-143). Re-derived against all four D-088 shapes at 8,000 edges, they
110    /// agree that the largest size meeting the bound is **20**. It stays at 90
111    /// because 20 is the same miss at a larger population — per-row cost grows
112    /// with `links_current`, so a constant fitted at 8,000 edges is wrong at
113    /// 80,000 — while the throughput cost of turning eleven chunks into fifty
114    /// is certain and immediate (D-058). The fix is not a row count: it is for
115    /// the chunk loop to stop on elapsed time, which is named for 0.12.0.
116    ///
117    /// D-134 retired the growth claim on the neighbouring *single-assertion*
118    /// path and did not measure this one; D-136 is why this line now carries a
119    /// measurement rather than a figure quoted from 0.5.6.
120    pub const EDGES: usize = 90;
121
122    /// Concept upserts (`write_concepts`).
123    ///
124    /// Linear at ~23 µs per row, so unlike [`EDGES`] this size *is* a genuine
125    /// throughput sacrifice: 1,000-row chunks ran at 23.6 µs per row against
126    /// ~35 µs here. Paid deliberately — a 1,000-row chunk takes 24 ms, eight
127    /// times the bound.
128    pub const CONCEPTS: usize = 70;
129
130    /// Analytics annotations (`write_analytics_annotations`).
131    ///
132    /// The one path where the old constant was nearly right, and the only bulk
133    /// table with no triggers at all: ~2.5 µs per row, linear, so the bound buys
134    /// a large chunk. 1,000 rows would be 3.5 ms — over, but only just.
135    pub const ANNOTATIONS: usize = 600;
136
137    /// Embedding vectors (`upsert_embeddings`).
138    ///
139    /// The smallest by a wide margin, because DiskANN index maintenance makes an
140    /// embedding the most expensive row in the system. That cost grows with the
141    /// **corpus**, not the chunk (D-059): a fixed 30-vector chunk costs 49 µs per
142    /// vector into an empty corpus and 224 µs into an 8,000-vector one. Graph
143    /// insertion getting dearer as the graph grows is what DiskANN is, so unlike
144    /// [`EDGES`] there is nothing here to fix — but it does mean this size buys
145    /// latency at some throughput, not for free.
146    pub const EMBEDDINGS: usize = 30;
147}
148
149/// The latency bound [`chunk_rows`] is derived from (§5.1.5, D-058).
150///
151/// This is the golden rule's actual content. §9 has carried it as a row count
152/// with a duration attached — "chunk commit, 500 rows ≤ 3 ms" — which reads as
153/// two requirements and is one: the duration is the requirement, and the row
154/// count is whatever satisfies it on a given path and machine.
155///
156/// 3 ms is §9's number, kept rather than renegotiated. What it buys, end to end:
157/// an interactive assertion arriving at the worst possible moment waits for the
158/// chunk in flight (≤ 3 ms, because the SQLite write lock is not preemptible —
159/// see [`HighPriCommand`]) and then runs its own write (≤ 5 ms, §9), so ≤ 8 ms
160/// worst case. That fits inside a 60 Hz frame with room, which is the standard
161/// this bound is ultimately answerable to.
162///
163/// # Three operations are exempt, and the exemption is a contract, not an oversight
164///
165/// This was recorded in three separate rustdoc notes and nowhere near the bound
166/// itself, which is where a reader looks for its scope (§8.6). Stated here, with
167/// Wave 3's measurements:
168///
169/// | Path | Bound | Why it cannot be chunked |
170/// |---|---|---|
171/// | [`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 |
172/// | [`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 |
173/// | `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 |
174///
175/// The `archive` figure is end-to-end through this method, so it **includes**
176/// the re-derivation `archive()` runs inside its transaction — but it does not
177/// attribute it, and until D-077 more than half of that re-derivation was an
178/// audit comparing `links_current` against the query that had just filled it.
179/// Note also which variable that cost scales with: `rebuild_within` reprojects
180/// **all of `links`**, so the archive's repair term grows with the *surviving*
181/// table and not with the batch being archived. A budget stated per "100K closed
182/// intervals" ([§9](../docs/architecture/s6-s10-flows-to-dependencies.md)) is
183/// therefore parameterised on the wrong quantity.
184///
185/// All three are atomic **by contract**, which is why "cap the batch" and "add a
186/// third tier" were both considered and neither was taken: capping breaks the
187/// guarantee the operation exists to provide, and a third tier changes which
188/// caller waits without changing how long the lock is held. What was wrong was
189/// never the exemption — it was that the bound was stated as though it had none.
190///
191/// A caller who needs the latency bound and not the atomicity has
192/// [`Database::bulk_import`], which is the same write chunked at
193/// [`chunk_rows::EDGES`] and explicitly *not* atomic overall (D-011).
194///
195/// # One of the three is no longer unbounded (T1.1, D-080)
196///
197/// `archive` was the worst of them, because its hold is a function of *how long
198/// since the last archive* rather than of anything the caller chose.
199/// [`Database::archive_windowed`] runs the same work as N sessions, each
200/// atomic, each its own actor turn. Measured on an 8,000-key fixture with four
201/// generations of superseded history: the longest single hold falls from
202/// **3.3 s to 0.77 s** at one-hour windows, for total wall time that is flat
203/// within this cycle's noise.
204///
205/// The same measurement at 2,000 keys goes the other way — the hold falls
206/// 260 ms → 117 ms while total time rises 260 ms → 671 ms — so windowing is a
207/// trade and not a free improvement. It pays when the backlog is large, which
208/// is when the unwindowed hold is a problem in the first place. `archive` is
209/// kept, not deprecated, for exactly that reason.
210pub const CHUNK_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
211
212/// Predicted hold above which [`Database::write_bulk_atomic`] warns (T1.3).
213///
214/// 250 ms is fifteen frames at 60 Hz: not a hitch, a visible freeze. It is well
215/// above [`CHUNK_BUDGET`] on purpose — this path is exempt from that bound by
216/// contract, so warning at 3 ms would fire on batches that are working exactly
217/// as designed and train the reader to filter the message out.
218pub const BULK_ATOMIC_WARN_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
219
220/// Roughly how long [`Database::write_bulk_atomic`] will hold the actor for
221/// this batch (T1.3, D-081).
222///
223/// # Three terms, because the cost is neither linear nor a function of size
224///
225/// T1.3 asks for "rows × measured per-row cost". That model is wrong twice over,
226/// and both corrections came out of measuring it.
227///
228/// First, the cost is not linear. `write_edges_atomic` opens with
229/// `reject_overlaps_within`, which compares **every pair** in the batch before a
230/// row is written. Second — and this is the one that matters — the quadratic
231/// term's constant depends on the batch's *shape*, not its size. The pairwise
232/// loop starts with an early `continue` on mismatched `(source, target,
233/// edge_type)`; pairs that share all three fall through to `Interval::new` and
234/// `overlaps`, which is **sixteen times** dearer per pair.
235///
236/// ```text
237/// hold ≈ 73 µs · rows  +  5.5 ns · mismatched pairs  +  86 ns · matching pairs
238/// ```
239///
240/// Two batches of 20,000 edges, measured on the same machine: one fanning out to
241/// distinct targets holds the actor for **2.5 s**, and one asserting 20,000
242/// corrections to a single relationship's history holds it for **18.6 s**. A
243/// size-only model is off by 7× between those two, in the direction that
244/// matters — it under-predicts the bad case. So this counts the matching pairs
245/// rather than guessing, with one `HashMap` pass over the batch. That pass is
246/// O(rows) against an operation about to spend milliseconds per row.
247///
248/// # What this is calibrated against, and where it will be wrong
249///
250/// libSQL 0.9.30, one machine, best of three, over 100–20,000 rows in both
251/// shapes; within 5% across that range except below ~500 rows, where fixed costs
252/// dominate and it over-predicts by 3× — harmless, since nothing that small can
253/// approach [`BULK_ATOMIC_WARN_HOLD`].
254///
255/// It is machine-specific and says nothing about disk. It exists to turn
256/// "uncapped" into an order of magnitude a caller can act on — the difference
257/// between 30 ms and 18 s — and should not be read more precisely than that.
258/// `examples/bulk_atomic_diag.rs` prints predicted against measured, so the
259/// model's drift is visible rather than assumed.
260pub fn estimated_bulk_hold(edges: &[EdgeAssertion]) -> std::time::Duration {
261    let rows = edges.len() as u64;
262    let all_pairs = rows.saturating_mul(rows.saturating_sub(1)) / 2;
263
264    // Pairs sharing all three key columns, which is exactly the set that reaches
265    // the guard's expensive path. Grouped rather than sorted: the batch is
266    // borrowed, and sorting would either clone it or reorder the caller's data.
267    let mut groups: std::collections::HashMap<(&str, &str, &str), u64> =
268        std::collections::HashMap::new();
269    for e in edges {
270        *groups
271            .entry((&e.source, &e.target, &e.edge_type))
272            .or_insert(0) += 1;
273    }
274    let matching: u64 = groups.values().map(|&g| g * (g - 1) / 2).sum();
275    let mismatched = all_pairs - matching;
276
277    // Nanoseconds throughout, saturating: a caller who passes a batch large
278    // enough to overflow this has a problem the arithmetic cannot express, and
279    // saturating to ~584 years still crosses every threshold above.
280    std::time::Duration::from_nanos(
281        (73_000u64.saturating_mul(rows))
282            .saturating_add(mismatched.saturating_mul(11) / 2)
283            .saturating_add(matching.saturating_mul(86)),
284    )
285}
286
287/// Most sessions [`Database::archive_windowed`] will run for one call (T1.1).
288///
289/// A limit exists because the session count is a function of *transaction-time
290/// span divided by window*, and both come from the caller — a one-second window
291/// over a decade of history is ten million actor turns, each opening a
292/// transaction and writing a horizon row. That is not a slow archive, it is a
293/// caller who meant something else.
294///
295/// 4,096 is chosen against the operation it bounds rather than against a clock:
296/// at the measured 26.8 ms for a session with work in it, a full run of this
297/// many is about two minutes of background writing, and the whole point of
298/// windowing is that those two minutes are interruptible. It is a refusal
299/// rather than a clamp — see [`DbError::ArchiveWindow`] for why.
300pub const MAX_ARCHIVE_SESSIONS: usize = 4_096;
301
302/// A concept assertion: the payload of an upsert.
303#[derive(Debug, Clone, PartialEq)]
304pub struct ConceptUpsert {
305    pub id: String,
306    pub title: String,
307    pub content: String,
308    pub embedding_model: Option<String>,
309    pub valid_from: String,
310    pub valid_to: String,
311    pub retired: bool,
312}
313
314impl ConceptUpsert {
315    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
316        Self {
317            id: id.into(),
318            title: title.into(),
319            content: String::new(),
320            embedding_model: None,
321            valid_from: String::new(),
322            valid_to: timestamp::OPEN_SENTINEL.to_string(),
323            retired: false,
324        }
325    }
326
327    pub fn content(mut self, content: impl Into<String>) -> Self {
328        self.content = content.into();
329        self
330    }
331
332    pub fn embedding_model(mut self, model: impl Into<String>) -> Self {
333        self.embedding_model = Some(model.into());
334        self
335    }
336
337    pub fn valid_from(mut self, ts: impl Into<String>) -> Self {
338        self.valid_from = ts.into();
339        self
340    }
341
342    pub fn valid_to(mut self, ts: impl Into<String>) -> Self {
343        self.valid_to = ts.into();
344        self
345    }
346
347    pub fn retired(mut self, retired: bool) -> Self {
348        self.retired = retired;
349        self
350    }
351
352    /// Put the timestamps in canonical form (D-029) before they cross the channel.
353    pub fn normalized(mut self) -> Result<Self> {
354        crate::util::ids::validate_id(&self.id)?;
355        self.valid_from = timestamp::normalize(&self.valid_from)?;
356        self.valid_to = timestamp::normalize(&self.valid_to)?;
357        Ok(self)
358    }
359}
360
361/// One derived analytics result for one concept (§5.4, D-041).
362///
363/// Not a `ConceptUpsert`. The distinction is the whole of D-041: a concept
364/// upsert is a statement about the world and belongs in the ledger, while an
365/// annotation is a function of an algorithm applied to a graph and belongs in
366/// `analytics_annotations`, which carries no log trigger. Writing one as the
367/// other overwrote the concept's `content` with the label and recorded every
368/// analytics rerun as a fresh version of the world.
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct Annotation {
371    pub concept_id: String,
372    /// Namespaced by convention, e.g. `louvain.community`, `kcore.shell`.
373    pub label: String,
374    /// JSON-encoded payload. Opaque to this crate.
375    pub value: String,
376}
377
378impl Annotation {
379    pub fn new(
380        concept_id: impl Into<String>,
381        label: impl Into<String>,
382        value: impl Into<String>,
383    ) -> Self {
384        Self {
385            concept_id: concept_id.into(),
386            label: label.into(),
387            value: value.into(),
388        }
389    }
390}
391
392/// Commands sent to the Write Actor on the high-priority channel (UI-driven work).
393pub enum HighPriCommand {
394    AssertEdge {
395        edge: EdgeAssertion,
396        responder: oneshot::Sender<Result<()>>,
397    },
398    RetireEdge {
399        source: String,
400        target: String,
401        edge_type: String,
402        valid_from: String,
403        valid_to: String,
404        responder: oneshot::Sender<Result<()>>,
405    },
406    UpsertConcept {
407        concept: ConceptUpsert,
408        responder: oneshot::Sender<Result<()>>,
409    },
410    WriteBulkAtomic {
411        edges: Vec<EdgeAssertion>,
412        responder: oneshot::Sender<Result<usize>>,
413    },
414    RebuildCurrent {
415        responder: oneshot::Sender<Result<RebuildReport>>,
416    },
417    /// Create a model's embedding table and its DiskANN index (D-037, D-048).
418    ///
419    /// High priority despite being setup work: it is one small transaction, and
420    /// every embedding write for the model blocks on it, so queueing it behind a
421    /// bulk job would stall the thing it gates.
422    RegisterModel {
423        model: ModelName,
424        dim: usize,
425        responder: oneshot::Sender<Result<()>>,
426    },
427    Shutdown {
428        responder: oneshot::Sender<Result<()>>,
429    },
430}
431
432/// Commands sent to the Write Actor on the low-priority channel (background work).
433pub enum LowPriCommand {
434    /// One chunk of **concepts** — a ledger write, logged and versioned.
435    WriteConceptsChunk {
436        chunk: Vec<ConceptUpsert>,
437        responder: oneshot::Sender<Result<usize>>,
438    },
439    /// One chunk of **derived annotations** — off-ledger, no log trigger (D-041).
440    ///
441    /// The pair is named apart deliberately: this variant was `WriteAnalyticsChunk`
442    /// beside a `WriteAnnotationsChunk` that carried concepts, which is the
443    /// crossing D-075 undid.
444    WriteAnalyticsChunk {
445        chunk: Vec<Annotation>,
446        responder: oneshot::Sender<Result<usize>>,
447    },
448    /// One chunk of vectors for one model (§5.9, D-048).
449    ///
450    /// Low priority: embedding is bulk derived work and must never preempt an
451    /// interactive assertion.
452    UpsertEmbeddingChunk {
453        model: ModelName,
454        chunk: Vec<(String, Vec<f32>)>,
455        responder: oneshot::Sender<Result<usize>>,
456    },
457    BulkImportChunk {
458        chunk: Vec<EdgeAssertion>,
459        responder: oneshot::Sender<Result<usize>>,
460    },
461    Archive {
462        cutoff: String,
463        archive_path: PathBuf,
464        responder: oneshot::Sender<Result<ArchiveReport>>,
465    },
466    /// Move named concepts back out of the cold file (0.9.0, C3).
467    ///
468    /// Low priority for the same reason `Archive` is: it is bulk physical
469    /// movement with no latency bound, and it holds the write lock for its whole
470    /// transaction.
471    Rehydrate {
472        ids: Vec<String>,
473        archive_path: PathBuf,
474        responder: oneshot::Sender<Result<RehydrateReport>>,
475    },
476    /// Reconstruct the FTS index from `concepts` (§5.9, D-036, D-051).
477    ///
478    /// Low priority: it is maintenance on a derivative table, and a search index
479    /// that is a few seconds stale is a smaller cost than an interactive write
480    /// that waits behind a full reindex.
481    RebuildFts {
482        responder: oneshot::Sender<Result<()>>,
483    },
484    /// One step of a chunked shadow rebuild (§5.8, T1.2, D-082).
485    ///
486    /// Low priority, and one command per step rather than one per rebuild: the
487    /// whole value of building beside the live table is that the actor returns
488    /// here between chunks. See [`Database::rebuild_current_chunked`].
489    ShadowRebuild {
490        step: crate::integrity::ShadowStep,
491        responder: oneshot::Sender<Result<crate::integrity::ShadowOutcome>>,
492    },
493}
494
495enum LoopCtl {
496    Continue,
497    Break,
498}
499
500/// Primary database handle for Macrame bitemporal ledger.
501pub struct Database {
502    db: libsql::Database,
503    /// The file this handle opened, kept so [`Database::diagnostic_conn`] can
504    /// open it again under different flags (T5.1, D-091). `archive_path` and
505    /// `snapshots_dir` are derived from it and were previously the only trace
506    /// of it on the struct.
507    path: PathBuf,
508    read_conn: libsql::Connection,
509    highpri_tx: mpsc::Sender<HighPriCommand>,
510    lowpri_tx: mpsc::Sender<LowPriCommand>,
511    clock: Arc<dyn Clock>,
512    archive_path: PathBuf,
513    snapshots_dir: PathBuf,
514    schema_version: u32,
515    writer: Option<tokio::task::JoinHandle<Result<()>>>,
516    /// Stops the snapshot cadence. Dropping it stops the task too, which is what
517    /// keeps a `Database` that is dropped rather than closed from leaving a task
518    /// running against a connection whose database is going away.
519    cadence_stop: Option<tokio::sync::watch::Sender<bool>>,
520    cadence: Option<tokio::task::JoinHandle<()>>,
521    /// Set by [`Database::close`]. Read only by [`Drop`], which warns when it is
522    /// still false — see that impl for why the omission is worth a warning.
523    closed: bool,
524    /// Shared with the actor (T1.4, T1.2). Held here rather than behind
525    /// `#[cfg(feature = "metrics")]` so `open_inner` has one shape; with the
526    /// feature off the metrics half is a zero-sized type and only
527    /// [`Database::metrics`] is gated — which is also why the field is unread in
528    /// the default build: the actor holds the other `Arc` and does the writing.
529    #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
530    shared: Arc<ActorShared>,
531}
532
533impl Database {
534    /// Open a database file at `path`, configuring pragmas, running migrations, and spawning the Write Actor.
535    ///
536    /// The snapshot cadence runs with [`SnapshotCadence::default`]. Use
537    /// [`Database::open_with_cadence`] to tune or disable it.
538    pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
539        Self::open_with_cadence(path, Some(SnapshotCadence::default())).await
540    }
541
542    /// Open with an explicit snapshot cadence, or `None` to run without one
543    /// (§5.5, D-053).
544    ///
545    /// `None` restores the pre-0.5.5 behaviour, where `close()` is the only
546    /// thing that ever writes an anchor. That is the right setting for a
547    /// short-lived process that will not accumulate a delta worth bounding, and
548    /// for tests that assert on the contents of the snapshot directory.
549    pub async fn open_with_cadence(
550        path: impl AsRef<Path>,
551        cadence: Option<SnapshotCadence>,
552    ) -> Result<Self> {
553        Self::open_inner(path.as_ref(), cadence, None).await
554    }
555
556    /// Open with an injected clock (§5.1.2, **defect K**, D-062).
557    ///
558    /// The reason this exists is testing: `recorded_at` is the transaction-time
559    /// axis, and until now every test that wanted to assert on one had to either
560    /// avoid it or drive a raw connection, because `open()` hardcoded
561    /// [`SystemClock`]. `FakeClock` has been public and constructed in the test
562    /// harness since 0.5.2 with nothing to inject it into — the compiler warned
563    /// about the dead field on every build for three releases.
564    ///
565    /// **The clock is floored against the database before the actor starts.**
566    /// [`Clock::raise_floor`] is called with the newest `recorded_at` in the
567    /// ledger, so an injected clock cannot issue a stamp below what is already
568    /// stored — which would abort the next concept write on
569    /// `trg_concepts_monotonic_ra` rather than merely being odd. This is the
570    /// step whose absence kept the defect open: the obvious implementation
571    /// (take an `Arc<dyn Clock>`, use it) produces a `Database` that fails on
572    /// its first write against any non-empty file.
573    ///
574    /// On a fresh database there is no floor, so an injected `FakeClock` issues
575    /// exactly the stamps it was given.
576    pub async fn open_with_clock(
577        path: impl AsRef<Path>,
578        cadence: Option<SnapshotCadence>,
579        clock: Arc<dyn Clock>,
580    ) -> Result<Self> {
581        Self::open_inner(path.as_ref(), cadence, Some(clock)).await
582    }
583
584    async fn open_inner(
585        path: &Path,
586        cadence: Option<SnapshotCadence>,
587        injected: Option<Arc<dyn Clock>>,
588    ) -> Result<Self> {
589        let db = libsql::Builder::new_local(path).build().await?;
590        let write_conn = configure(db.connect()?).await?;
591        let read_conn = configure(db.connect()?).await?;
592
593        // PRAGMA query_only = ON on reader connection (§5.1.2)
594        read_conn.execute("PRAGMA query_only = ON", ()).await?;
595
596        let migration = migrations::run(&write_conn).await?;
597
598        let (highpri_tx, highpri_rx) = mpsc::channel(256);
599        let (lowpri_tx, lowpri_rx) = mpsc::channel(64);
600
601        // Floored after `migrations::run`, so the tables the floor is read from
602        // are guaranteed to exist.
603        let clock: Arc<dyn Clock> = match injected {
604            Some(clock) => {
605                if let Some(floor) = crate::util::clock::recorded_at_floor(&read_conn).await? {
606                    clock.raise_floor(floor);
607                }
608                clock
609            }
610            None => Arc::new(SystemClock::new(&read_conn).await?),
611        };
612        let shared = Arc::new(ActorShared::default());
613        let writer = tokio::spawn(run_writer_actor(
614            write_conn,
615            Arc::clone(&clock),
616            highpri_rx,
617            lowpri_rx,
618            Arc::clone(&shared),
619        ));
620
621        let archive_path = derive_archive_path(path);
622        let snapshots_dir = derive_snapshots_dir(path);
623
624        // **The cadence gets its own connection (Wave 4.1).** It used to share
625        // `read_conn`, on the reasoning that `libsql::Connection` is an
626        // Arc-backed handle and R15 makes every extra local connection a cost worth
627        // not paying for nothing. The cost it was not paying for turned out to be
628        // real: `reconstruct` brackets a fold with `ATTACH cold … DETACH cold`,
629        // that region is per-connection state, and it is not synchronised. Two
630        // folds on one connection can therefore interleave so that one DETACHes
631        // the handle the other is mid-fold on.
632        //
633        // Recorded in §8.5 as a hazard rather than a defect because it **did not
634        // reproduce**: 200 concurrent reconstructions against a 1 ms cadence with
635        // an archive present produced zero errors, since the cadence anchors at
636        // `MAX(recorded_at)` and so almost always takes the hot path. Narrow, and
637        // real — a write landing between `log_head` and the fold opens it.
638        //
639        // Separate connections remove the interleaving rather than ordering it,
640        // which is why this is preferred to a mutex around the region: there is
641        // no shared state left to race on, and nothing to remember to hold. The
642        // R15 objection does not apply — that fault is about *concurrent* opens,
643        // and this is one more sequential open during `open()`.
644        let (cadence_stop, cadence) = match cadence {
645            Some(cadence) => {
646                let cadence_conn = configure(db.connect()?).await?;
647                cadence_conn.execute("PRAGMA query_only = ON", ()).await?;
648                let (tx, rx) = tokio::sync::watch::channel(false);
649                let handle = tokio::spawn(snapshot::run_cadence(
650                    cadence_conn,
651                    snapshots_dir.clone(),
652                    archive_path.clone(),
653                    cadence,
654                    rx,
655                ));
656                (Some(tx), Some(handle))
657            }
658            None => (None, None),
659        };
660
661        let handle = Self {
662            db,
663            path: path.to_path_buf(),
664            read_conn,
665            highpri_tx,
666            lowpri_tx,
667            clock,
668            archive_path,
669            snapshots_dir,
670            schema_version: migrations::current_version(),
671            writer: Some(writer),
672            cadence_stop,
673            cadence,
674            closed: false,
675            shared,
676        };
677
678        // **Re-anchor after a migration (Wave 4.4).**
679        //
680        // D-043 makes a `SCHEMA_VERSION` bump invalidate every snapshot on disk,
681        // which is correct — a snapshot is a serialised `MaterializedState` and a
682        // schema change can change what that means. What was missing is the other
683        // half: nothing wrote a replacement, so the first `reconstruct` after an
684        // upgrade skipped every file as incompatible and folded from genesis. On
685        // a database with a large log that is the difference between reading one
686        // snapshot and folding the whole history, and the only trace was a
687        // `warn!` per skipped file.
688        //
689        // Written here rather than left to the cadence because the cadence fires
690        // on log *growth* (D-053): an upgraded database that is then read but not
691        // written would never re-anchor at all.
692        //
693        // Failure is logged, not returned. A missing anchor costs time and no
694        // information — snapshots are derivative under Doctrine VI — so refusing
695        // to open a database because its optimisation could not be rebuilt would
696        // trade a real capability for a performance one.
697        //
698        // Gated on the cadence being enabled, as well as on an actual upgrade:
699        // `open_with_cadence(None)` means *this handle writes no snapshots except
700        // at close()*, and a one-off write at open would contradict that for a
701        // caller who asked for the quiet mode precisely to control when files
702        // appear. They still get an anchor from `close()`.
703        if migration.upgraded() && handle.cadence.is_some() {
704            let ts = handle.clock.now();
705            let archive = handle
706                .archive_path
707                .exists()
708                .then_some(handle.archive_path.as_path());
709            match snapshot::write_final(&handle.read_conn, &handle.snapshots_dir, &ts, archive)
710                .await
711            {
712                Ok(path) => tracing::info!(
713                    "schema moved v{} -> v{}; re-anchored snapshots at {:?}",
714                    migration.from,
715                    migration.to,
716                    path
717                ),
718                Err(e) => tracing::warn!(
719                    "schema moved v{} -> v{} but the re-anchor failed: {e}. \
720                     Reconstruction stays correct and folds from genesis until the \
721                     cadence writes one.",
722                    migration.from,
723                    migration.to
724                ),
725            }
726        }
727
728        Ok(handle)
729    }
730
731    /// Read connection handle for queries, traversals, and folds.
732    pub fn read_conn(&self) -> &libsql::Connection {
733        &self.read_conn
734    }
735
736    /// The file this handle opened.
737    pub fn path(&self) -> &Path {
738        &self.path
739    }
740
741    /// A **new, independently owned, OS-level read-only** connection to this
742    /// database, for diagnostics (§4.7, T5.1, D-091).
743    ///
744    /// # Why this exists when `read_conn()` already does
745    ///
746    /// Two different things, and the difference is the point:
747    ///
748    /// * `read_conn()` returns a shared `&Connection` carrying
749    ///   `PRAGMA query_only = ON`. That pragma is **per-connection and
750    ///   reversible by its holder in one statement**, so it is a guardrail
751    ///   against accident, not a capability boundary. And because the reference
752    ///   is shared, a caller who runs a long reporting query on it is competing
753    ///   with every traversal and fold in the process.
754    /// * This returns a connection opened with `SQLITE_OPEN_READ_ONLY`, which is
755    ///   enforced by the engine below the pragma layer, and it is the caller's
756    ///   own.
757    ///
758    /// **Measured on libSQL 0.9.30 rather than assumed**
759    /// (`examples/readonly_open_probe.rs`), against a live WAL database with the
760    /// write actor running:
761    ///
762    /// | | `read_conn()` | `diagnostic_conn()` |
763    /// |---|---|---|
764    /// | `SELECT`, `EXPLAIN QUERY PLAN` | allowed | allowed |
765    /// | `INSERT` | refused | refused |
766    /// | `PRAGMA query_only = OFF` | **allowed** | allowed |
767    /// | `INSERT` after that | **allowed** | **refused** |
768    /// | `ATTACH` an existing file | allowed | allowed |
769    /// | `INSERT` into the attachment | refused¹ | **refused** |
770    /// | `ATTACH` a path that does not exist | — | refused (`SQLITE_CANTOPEN`) |
771    ///
772    /// The third and fourth rows are the whole difference: turning the pragma
773    /// off restores writes on `read_conn()` and does not here. That is what
774    /// "boundary rather than guardrail" means, and it is now a number rather
775    /// than a claim.
776    ///
777    /// ¹ On `read_conn()` that refusal is `query_only` — the same reversible
778    /// thing as row 2. On `diagnostic_conn()` it is the open flags, and the
779    /// probe runs it *after* `query_only = OFF` so that the pragma cannot be
780    /// what is doing the work.
781    ///
782    /// # `ATTACH` is permitted, and does not widen the write boundary
783    ///
784    /// Checked because `diagnostic_query` (Python) is the only arbitrary-SQL
785    /// surface this crate exposes, and an attachment is a second `open` whose
786    /// flags it does not obviously inherit. It does inherit them: the
787    /// attachment is read-only, and a nonexistent path is `SQLITE_CANTOPEN`
788    /// rather than a new file, because `SQLITE_OPEN_CREATE` is dropped for the
789    /// attachment as it is for `main`. So `SQLITE_OPEN_READ_ONLY` bounds the
790    /// **connection**, not just the one file it names (0.10.0, W4.3).
791    ///
792    /// What it does widen is *reading*: an `ATTACH` can name any file the
793    /// process can open, so this connection is a read surface over the
794    /// filesystem, not over this database. That is a property of arbitrary SQL
795    /// rather than of the flags, and it is unchanged by them.
796    ///
797    /// # One way this is *more* permissive, which is worth knowing
798    ///
799    /// `CREATE TEMP TABLE` **succeeds** here and is refused by `read_conn()`.
800    /// Temp tables live in a separate temporary database that is writable
801    /// regardless of how the main one was opened, whereas `query_only` refuses
802    /// them outright — which is the mechanism [D-050] measured when it removed
803    /// `TwoPhaseTempTable` for returning `SQLITE_READONLY (8)` on the read
804    /// connection. So the stronger boundary is not uniformly stronger, and a
805    /// strategy that needs a temp table has a connection it could run on. That
806    /// is recorded, not acted on: D-050 removed the strategy for two reasons and
807    /// this addresses one of them.
808    ///
809    /// # Calling this concurrently is R15's shape
810    ///
811    /// **This is the one method on `Database` that opens the file.** Everything
812    /// else runs on connections established once, at `open`. Each call here is
813    /// a fresh `libsql::Builder::…build()`, so *N* threads calling it at once
814    /// are *N* concurrent opens — which is exactly the pattern behind
815    /// [R15](https://github.com/opticsWolf/Macrame#known-risks), the upstream
816    /// libSQL access violation (`0xC0000005`) that `examples/r15_soak.rs`
817    /// reproduces and `RUST_TEST_THREADS=1` exists to avoid in the suite.
818    ///
819    /// **This is measured, not inferred.** 48 threads sharing one handle and
820    /// calling only this method: 7 bad runs in 18 — two access violations and
821    /// five *returned* SQLite errors (`database is locked`, `bad parameter or
822    /// other API misuse`). With the calls serialised, 0 in 18
823    /// (`tests_py/probes/r15_diagnostic_path.py`). The returned-error mode is
824    /// the one to watch for: it looks like a fact about the database, on the
825    /// method a caller reaches for when they already doubt the typed answer.
826    ///
827    /// **Bound this yourself if you call it from more than one thread.** One
828    /// outstanding open at a time is enough; a mutex around the call costs
829    /// nothing on a diagnostic path. This method does not do it for you on
830    /// purpose: serialising behind a lock the caller cannot see would
831    /// contradict the thing above it — that the connection is *the caller's
832    /// own* — and it would put a hidden queue in front of the one surface whose
833    /// job is to answer questions when the typed path is already suspect. The
834    /// Python binding does bound it, because it wraps this in a method a caller
835    /// cannot see into (`PyDatabase::diagnostic_rows`); a Rust caller can.
836    ///
837    /// # Errors
838    ///
839    /// The file must already exist. `SQLITE_OPEN_READ_ONLY` drops
840    /// `SQLITE_OPEN_CREATE` with it, so a missing file is `SQLITE_CANTOPEN`
841    /// rather than a fresh empty database — which is the right failure, and is
842    /// surfaced as a typed error rather than as libSQL's error 14.
843    pub async fn diagnostic_conn(&self) -> Result<libsql::Connection> {
844        let fail = |reason: String| DbError::DiagnosticConn {
845            path: self.path.display().to_string(),
846            reason,
847        };
848        if !self.path.exists() {
849            return Err(fail(
850                "the file does not exist, and a read-only open cannot create it".to_string(),
851            ));
852        }
853        let db = libsql::Builder::new_local(&self.path)
854            .flags(libsql::OpenFlags::SQLITE_OPEN_READ_ONLY)
855            .build()
856            .await
857            .map_err(|e| fail(e.to_string()))?;
858        db.connect().map_err(|e| fail(e.to_string()))
859    }
860
861    /// Cross-check the snapshot chain against a fold from genesis (§5.5, T5.3,
862    /// D-092).
863    ///
864    /// `write_final` composes onto the previous snapshot, so snapshot *n* is
865    /// derived from snapshot *n−1* and nothing in the chain ever folds the whole
866    /// log. An error at any link propagates forward forever and every read
867    /// agrees with it, because every read descends from it. This is the check
868    /// that would notice.
869    ///
870    /// # When to run it
871    ///
872    /// **Not on a schedule this crate chooses.** A genesis fold is precisely the
873    /// cost snapshots exist to avoid, so running it periodically by default
874    /// would give every application the bill snapshots were bought to remove —
875    /// on a database whose log is large enough for snapshots to matter, which is
876    /// the only kind where this is worth doing. The plan calls it a scheduling
877    /// problem and it is the caller's schedule: an idle period, a nightly job,
878    /// or once per *N* anchors, chosen against a log size this crate cannot see.
879    ///
880    /// The cadence is deliberately left alone for the same reason — it runs on a
881    /// connection shared with nothing and a fold there would compete with
882    /// interactive reads at a moment nobody chose.
883    ///
884    /// # It reports; it does not repair
885    ///
886    /// A divergence means the snapshots are a wrong **cache**, not that the
887    /// ledger is corrupt: [Doctrine VI] makes them disposable, so deleting
888    /// [`Self::snapshots_dir`] restores correctness and costs only speed.
889    /// Rewriting the file here would destroy the evidence that composition has a
890    /// defect, which is the only thing this can tell you that you did not
891    /// already know.
892    ///
893    /// Pair it with the actor counters ([`Self::metrics`], D-079) so a
894    /// divergence found by a scheduled run is visible beside the write latency
895    /// of the period that produced it.
896    ///
897    /// [Doctrine VI]: ../../docs/architecture/s0-s3-foundations.md#doctrine-vi
898    pub async fn verify_snapshot_chain(&self, ts: &str) -> Result<crate::temporal::ChainCheck> {
899        let archive = self
900            .archive_path
901            .exists()
902            .then_some(self.archive_path.as_path());
903        crate::temporal::verify_snapshot_chain(&self.read_conn, ts, archive, &self.snapshots_dir)
904            .await
905    }
906
907    /// The clock every write is stamped with (§5.1.1).
908    pub fn clock(&self) -> &Arc<dyn Clock> {
909        &self.clock
910    }
911
912    /// Schema version this handle opened against.
913    pub fn schema_version(&self) -> u32 {
914        self.schema_version
915    }
916
917    /// Cold database path, derived by convention from the main file.
918    pub fn archive_path(&self) -> &Path {
919        &self.archive_path
920    }
921
922    /// Snapshot directory, derived by convention from the main file.
923    pub fn snapshots_dir(&self) -> &Path {
924        &self.snapshots_dir
925    }
926
927    /// What the write actor has done since this handle was opened (T1.4, D-079).
928    ///
929    /// Requires the `metrics` feature. The counters are per-handle and start at
930    /// zero on `open()` — they are not read from the database, because the thing
931    /// being measured is *this process's* actor and merging two processes'
932    /// histograms would produce a number about neither.
933    ///
934    /// The intended first question is [`crate::metrics::MetricsSnapshot::budget_violations`]:
935    ///
936    /// ```no_run
937    /// # async fn f(db: &macrame::Database) {
938    /// # #[cfg(feature = "metrics")] {
939    /// for k in db.metrics().budget_violations() {
940    ///     eprintln!("{} broke the 3 ms bound {} times", k.kind, k.over_budget);
941    /// }
942    /// # }
943    /// # }
944    /// ```
945    ///
946    /// Reading this does not stop the actor — see
947    /// [`crate::metrics::ActorMetrics::snapshot`] for what that costs in
948    /// consistency, and why the trade goes that way.
949    #[cfg(feature = "metrics")]
950    pub fn metrics(&self) -> crate::metrics::MetricsSnapshot {
951        self.shared.metrics.snapshot()
952    }
953
954    /// The underlying libSQL database, for callers that need their own connection.
955    ///
956    /// # Actor containment is a convention above this line, not a guarantee
957    ///
958    /// **Kept public, and the honest statement of what that costs (Wave 4.3).**
959    /// §5.1 says the write actor is the sole writer, and two mechanisms make that
960    /// true of the handle: every write method goes through a channel, and
961    /// [`Self::read_conn`] carries `PRAGMA query_only = ON`. **Nothing protects a
962    /// connection obtained from here.** A caller can open one, write to `links`
963    /// directly, and the actor will not know — the triggers still fire and the
964    /// ledger stays internally consistent, but the single-writer property that
965    /// [`crate::CHUNK_BUDGET`]'s latency argument rests on is gone, and so is the
966    /// serialisation the overlap guard (D-060) relies on.
967    ///
968    /// This is the same shape as the limit stated in §4.2 for that guard, and it
969    /// is one fact rather than two: **the storage layer permits what this API
970    /// refuses.** Making it private would not change that — the database file is
971    /// reachable by any SQLite client on the machine — it would only remove the
972    /// supported way to do the thing, which is how escape hatches become
973    /// `unsafe`-adjacent folklore.
974    ///
975    /// The free functions [`crate::register_model`] and
976    /// [`crate::upsert_embedding`] take a bare connection for the same reason and
977    /// carry the same caveat; prefer [`Self::register_model`] and
978    /// [`Self::upsert_embeddings`], which go through the actor.
979    ///
980    /// # The legitimate-use list is now one item long (T5.1, D-091)
981    ///
982    /// It used to read: `EXPLAIN QUERY PLAN` and other diagnostics, read-only
983    /// reporting queries wanting their own connection rather than sharing the
984    /// reader, and provoking a guard in a test. The first two are exactly what
985    /// [`Self::diagnostic_conn`] now does, and it does them behind an OS-level
986    /// read-only open rather than on a handle that can write. **Use that.**
987    ///
988    /// What is left is the one use that genuinely requires write access through
989    /// a connection the actor does not own: *provoking a guard* — writing the
990    /// state §4.7 says the storage layer permits and this API refuses, so a test
991    /// can assert the gap is still where the document says it is. That is the
992    /// only thing this crate's own suite uses it for.
993    ///
994    /// # Why `#[doc(hidden)]` and not a `raw-access` feature
995    ///
996    /// T5.1 offers either. The feature is the stronger declaration — it shows up
997    /// in the consumer's `Cargo.toml`, where a reviewer sees it — and it was
998    /// **not** taken, for a reason specific to what uses this:
999    ///
1000    /// Cargo features are additive and cannot be *required* by a test target
1001    /// except through `required-features`, which makes a plain `cargo test`
1002    /// **skip** that binary silently. The binaries that call this are
1003    /// `storage_boundary_tests` and `wave1_regression_tests` — the §4.7
1004    /// tripwires, whose entire job is to fail when a documented gap moves. Gating
1005    /// them behind a feature would mean the ordinary `cargo test` stopped running
1006    /// the tests that enforce the section this item is about, to make a
1007    /// declaration about a hatch. That trade is the wrong way round, and it is
1008    /// the same failure the project already names: a suite that quietly does less
1009    /// than it appears to.
1010    ///
1011    /// So the hatch stays reachable and stops being *discoverable*: it is absent
1012    /// from the docs, and the documented path for every non-write use is
1013    /// [`Self::diagnostic_conn`]. [D-068] is unchanged — removing it would buy
1014    /// the appearance of a guarantee, since the file is reachable by any SQLite
1015    /// client on the machine.
1016    ///
1017    /// [D-068]: ../../docs/architecture/s13-decision-register.md#d-068
1018    // convention (D-068/D-091): `raw()` is #[doc(hidden)] and is NOT exposed by
1019    // any binding. Everything above this line is invisible on docs.rs and
1020    // invisible to a contributor reading the Python surface list, which is where
1021    // the decision to expose it would actually be taken — hence this sentinel and
1022    // its twin in `bindings/python/src/lib.rs` (0.10.0, W4.10). The documented
1023    // path for every non-write use is `diagnostic_conn`.
1024    #[doc(hidden)]
1025    pub fn raw(&self) -> &libsql::Database {
1026        &self.db
1027    }
1028
1029    // -- write surface (§5.1, Appendix A) --
1030    //
1031    // Every method here validates and canonicalises before the value crosses the
1032    // channel, so a bad edge type or a second-precision timestamp is a typed
1033    // error at the call site rather than an engine `CHECK` failure surfacing
1034    // from the far side of an actor with no context attached.
1035    //
1036    // NOTE (§5.1.8, D-028): awaiting one of these waits on a Rust channel, not
1037    // in SQLite, so `busy_timeout` does not bound it. During an in-flight
1038    // `rebuild_current` or `archive` the caller stalls for that transaction's
1039    // duration. Wrap in `tokio::time::timeout` if you need a bound — but a
1040    // timeout is not a cancellation: the command stays queued and commits when
1041    // the actor reaches it.
1042
1043    /// Assert an edge (Doctrine III: a new row, never an update).
1044    pub async fn assert_edge(&self, edge: EdgeAssertion) -> Result<()> {
1045        let edge = edge.normalized()?;
1046        self.high(|responder| HighPriCommand::AssertEdge { edge, responder })
1047            .await
1048    }
1049
1050    /// Close an open interval by asserting its replacement (Doctrine III).
1051    pub async fn retire_edge(
1052        &self,
1053        source: impl Into<String>,
1054        target: impl Into<String>,
1055        edge_type: impl Into<String>,
1056        valid_from: &str,
1057        valid_to: &str,
1058    ) -> Result<()> {
1059        let edge_type = edge_type.into();
1060        crate::graph::edge::validate_edge_type(&edge_type)?;
1061        let valid_from = timestamp::normalize(valid_from)?;
1062        let valid_to = timestamp::normalize(valid_to)?;
1063        let (source, target) = (source.into(), target.into());
1064
1065        self.high(|responder| HighPriCommand::RetireEdge {
1066            source,
1067            target,
1068            edge_type,
1069            valid_from,
1070            valid_to,
1071            responder,
1072        })
1073        .await
1074    }
1075
1076    /// Insert or update a concept.
1077    pub async fn upsert_concept(&self, concept: ConceptUpsert) -> Result<()> {
1078        let concept = concept.normalized()?;
1079        self.high(|responder| HighPriCommand::UpsertConcept { concept, responder })
1080            .await
1081    }
1082
1083    /// Assert many edges in one transaction under one stamp (D-014).
1084    ///
1085    /// # This is the one write with no latency bound, and here is what it costs
1086    ///
1087    /// The batch is one act under one `recorded_at`, so it cannot be chunked —
1088    /// splitting it is the thing this method exists not to do. That makes the
1089    /// actor's hold a function of `edges.len()`, and until now the only
1090    /// statement of that anywhere was the prose "uncapped" in
1091    /// [`CHUNK_BUDGET`]'s table. A caller who stalls every other writer for
1092    /// eight seconds should have been able to predict it from the signature.
1093    ///
1094    /// Measured on libSQL 0.9.30 (T1.3, D-081), holding the actor for:
1095    ///
1096    /// | rows | hold |
1097    /// |---|---|
1098    /// | 500 | ~34 ms |
1099    /// | 2,000 | ~155 ms |
1100    /// | 10,000 | ~1.0 s |
1101    /// | 20,000 | ~2.6 s |
1102    ///
1103    /// [`estimated_bulk_hold`] is that curve as a function, and this method
1104    /// emits a `tracing::warn!` when it predicts more than
1105    /// [`BULK_ATOMIC_WARN_HOLD`]. **The estimate is a shape, not a promise** —
1106    /// see [`estimated_bulk_hold`] for what it is calibrated against and where
1107    /// it will be wrong.
1108    ///
1109    /// A caller who needs the latency bound and not the atomicity wants
1110    /// [`Self::bulk_import`], which is the same write chunked and explicitly not
1111    /// atomic overall (D-011).
1112    pub async fn write_bulk_atomic(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1113        let estimate = estimated_bulk_hold(&edges);
1114        if estimate > BULK_ATOMIC_WARN_HOLD {
1115            // Warned here rather than in the actor, and before the send: this is
1116            // the caller's own task, so the log line lands with their span
1117            // attached and names the call site that chose the batch size. By the
1118            // time the actor has it, the only context left is "a large batch".
1119            tracing::warn!(
1120                rows = edges.len(),
1121                estimated_hold_ms = estimate.as_millis() as u64,
1122                "write_bulk_atomic will hold the write actor for roughly \
1123                 {estimate:?} — it is atomic by contract (D-014) and cannot be \
1124                 chunked. Every other writer waits that long. Use bulk_import \
1125                 if the batch does not need to be all-or-nothing."
1126            );
1127        }
1128
1129        let edges = normalize_all(edges)?;
1130        self.high(|responder| HighPriCommand::WriteBulkAtomic { edges, responder })
1131            .await
1132    }
1133
1134    /// Rebuild `links_current` from `links` and verify zero drift (§5.8).
1135    pub async fn rebuild_current(&self) -> Result<RebuildReport> {
1136        self.high(|responder| HighPriCommand::RebuildCurrent { responder })
1137            .await
1138    }
1139
1140    /// Rebuild `links_current` beside itself, in chunks (§5.8, T1.2, D-082).
1141    ///
1142    /// Same result as [`Self::rebuild_current`], different latency profile.
1143    /// `rebuild_current` is one transaction holding the write lock for its whole
1144    /// duration, because D-023 will not let the `DELETE` and the `INSERT` be
1145    /// split: a reader landing between them sees a graph with no edges and no
1146    /// error. This builds the replacement in a shadow table instead — the live
1147    /// table stays live and trigger-maintained throughout — and swaps it in at
1148    /// the end.
1149    ///
1150    /// Each step is its own actor turn, so an interactive assertion can jump the
1151    /// queue between chunks. That is the whole of the improvement, and it is why
1152    /// the loop is here rather than inside the actor's arm (the same reasoning
1153    /// as [`Self::archive_windowed`] and [`Self::bulk_import`]).
1154    ///
1155    /// # What the swap still costs
1156    ///
1157    /// Not microseconds. Index names are global and SQLite has no `ALTER INDEX
1158    /// … RENAME`, so the shadow cannot be built carrying `links_current`'s index
1159    /// names while `links_current` still holds them — and building it under
1160    /// other names would leave the table permanently indexed under names absent
1161    /// from [`CREATE_INDICES`](crate::schema::ddl::CREATE_INDICES), so the next
1162    /// migration would create a second copy of each.
1163    /// `DROP TABLE` frees the names, so the swap transaction is where
1164    /// the three indexes get built. What the chunking moves off the lock is the
1165    /// **projection** — the window function over all of `links` — which is the
1166    /// O(E log E) term.
1167    ///
1168    /// # When this returns an error rather than a repair
1169    ///
1170    /// [`DbError::RebuildInterrupted`] means an archive committed while the
1171    /// shadow was being built. Its deletions are invisible to a catch-up pass
1172    /// keyed on `recorded_at` — a deleted row has no `recorded_at` left to find
1173    /// it by — so the work is discarded rather than swapped in. `links_current`
1174    /// is untouched and the call can simply be retried.
1175    ///
1176    /// Use [`Self::rebuild_current`] when the repair must be one atomic act, or
1177    /// when nothing else is contending for the actor and the extra turns are
1178    /// pure overhead.
1179    pub async fn rebuild_current_chunked(&self) -> Result<RebuildReport> {
1180        use crate::integrity::{ShadowOutcome, ShadowStep};
1181
1182        // Each `else` arm is unreachable: the actor maps each step to its own
1183        // outcome variant. Written as a refutable pattern rather than an
1184        // `unwrap` so that adding a step cannot turn a mismatch into a panic on
1185        // the write path — and `WriterDroppedResponder` is the honest name for
1186        // "the actor answered with something this cannot use".
1187        let ShadowOutcome::Started { build_start, epoch } =
1188            self.shadow_step(ShadowStep::Begin).await?
1189        else {
1190            return Err(DbError::WriterDroppedResponder);
1191        };
1192
1193        let mut after: Option<String> = None;
1194        loop {
1195            let ShadowOutcome::Filled { last } = self
1196                .shadow_step(ShadowStep::Fill {
1197                    after: after.take(),
1198                })
1199                .await?
1200            else {
1201                return Err(DbError::WriterDroppedResponder);
1202            };
1203            match last {
1204                Some(last) => after = Some(last),
1205                None => break,
1206            }
1207        }
1208
1209        let ShadowOutcome::Swapped { rows } = self
1210            .shadow_step(ShadowStep::Swap { build_start, epoch })
1211            .await?
1212        else {
1213            return Err(DbError::WriterDroppedResponder);
1214        };
1215
1216        Ok(RebuildReport {
1217            rows_rebuilt: rows,
1218            // Not audited. The chunked path's whole argument is that the
1219            // expensive work happens off the lock, and `audit_current` is two
1220            // `EXCEPT` passes over the projection — the cost D-077 removed from
1221            // the archive for the same reason. A caller who wants the check has
1222            // `audit_current` on the read connection, where it costs nobody the
1223            // write lock.
1224            drift_after: 0,
1225        })
1226    }
1227
1228    /// Run one step of a chunked rebuild, for a caller doing its own scheduling.
1229    ///
1230    /// [`Self::rebuild_current_chunked`] is this in a loop and is what almost
1231    /// everyone wants. This exists because that loop offers no seam: it drives
1232    /// `Begin`, then `Fill` to exhaustion, then `Swap`, and a caller who needs to
1233    /// do something *between* steps — pace them against a frame budget, abandon
1234    /// a rebuild that has run long enough, or provoke the archive interlock in a
1235    /// test — cannot get in.
1236    ///
1237    /// The obligation that comes with it: `epoch` from
1238    /// [`ShadowOutcome::Started`](crate::integrity::ShadowOutcome) must be handed
1239    /// back to [`ShadowStep::Swap`](crate::integrity::ShadowStep), or the
1240    /// archive interlock is defeated and a stale projection can be swapped in.
1241    /// The looping version cannot get that wrong; this one can.
1242    pub async fn shadow_step(
1243        &self,
1244        step: crate::integrity::ShadowStep,
1245    ) -> Result<crate::integrity::ShadowOutcome> {
1246        self.low(|responder| LowPriCommand::ShadowRebuild { step, responder })
1247            .await
1248    }
1249
1250    /// Import edges on the background channel, chunked (D-011).
1251    ///
1252    /// Atomic *per chunk*, not overall: a failure partway leaves earlier chunks
1253    /// committed. That is the tradeoff [`chunk_rows`] documents — use
1254    /// [`Database::write_bulk_atomic`] when the batch must be all-or-nothing.
1255    ///
1256    /// Chunked at [`chunk_rows::EDGES`], which is also faster in total than the
1257    /// larger chunks this used through 0.5.5 (D-058).
1258    pub async fn bulk_import(&self, edges: Vec<EdgeAssertion>) -> Result<usize> {
1259        let edges = normalize_all(edges)?;
1260        let chunks: Vec<_> = edges.chunks(chunk_rows::EDGES).map(<[_]>::to_vec).collect();
1261        self.low_chunked(chunks, |chunk, responder| LowPriCommand::BulkImportChunk {
1262            chunk,
1263            responder,
1264        })
1265        .await
1266    }
1267
1268    /// Upsert many **concepts** on the background channel, chunked (D-011).
1269    ///
1270    /// This is the bulk concept path, and every row it writes is a ledger write:
1271    /// it versions the concept and lands in `transaction_log`. Derived analytics
1272    /// output does not belong here — see
1273    /// [`Database::write_analytics_annotations`] and D-041.
1274    ///
1275    /// Called `write_annotations` through 0.5.6, from when the two writes were
1276    /// one call. D-041 split them and the name stayed on the wrong one for three
1277    /// releases, so the crate had a `write_annotations` that wrote concepts
1278    /// sitting beside a `write_analytics_annotations` that wrote annotations
1279    /// (D-075).
1280    pub async fn write_concepts(&self, concepts: Vec<ConceptUpsert>) -> Result<usize> {
1281        let concepts: Vec<ConceptUpsert> = concepts
1282            .into_iter()
1283            .map(ConceptUpsert::normalized)
1284            .collect::<Result<_>>()?;
1285        let chunks: Vec<_> = concepts
1286            .chunks(chunk_rows::CONCEPTS)
1287            .map(<[_]>::to_vec)
1288            .collect();
1289        self.low_chunked(chunks, |chunk, responder| {
1290            LowPriCommand::WriteConceptsChunk { chunk, responder }
1291        })
1292        .await
1293    }
1294
1295    /// State as believed at `ts` (§5.5, D-026, D-049).
1296    ///
1297    /// A read: it runs on `read_conn` and never touches the Write Actor, so a
1298    /// reconstruction and a full-speed write-back do not slow each other.
1299    ///
1300    /// Prefer this to calling [`crate::temporal::reconstruct`] directly. The
1301    /// free function takes the archive path and the snapshot directory as
1302    /// arguments, and a caller who passes `None` for the second gets a correct
1303    /// answer that folds the whole log every time — the composition is opt-in
1304    /// at that layer and easy to leave off by accident. Here both come from the
1305    /// handle, so the fast path is the default one.
1306    pub async fn reconstruct(&self, ts: &str) -> Result<crate::temporal::MaterializedState> {
1307        let ts = timestamp::normalize(ts)?;
1308        crate::temporal::reconstruct(
1309            &self.read_conn,
1310            &ts,
1311            Some(&self.archive_path),
1312            Some(&self.snapshots_dir),
1313        )
1314        .await
1315    }
1316
1317    /// Create a model's embedding table and DiskANN index (§5.9, D-048).
1318    ///
1319    /// Idempotent: registering a model that already exists at the same
1320    /// dimension succeeds, and at a different dimension fails with
1321    /// [`DbError::DimMismatch`] naming both, rather than no-opping through
1322    /// `IF NOT EXISTS` and leaving the caller believing the dimension they
1323    /// asked for is the one in force.
1324    ///
1325    /// This issues DDL, which everywhere else in the crate is the migration
1326    /// runner's exclusive business (D-032). The exception is bounded and
1327    /// deliberate: a model's table is created once, by an explicit call, and
1328    /// the alternative — a caller-supplied write connection — is the very thing
1329    /// the Write Actor exists to make impossible.
1330    ///
1331    /// # Latency
1332    ///
1333    /// One small transaction, but it queues like any other write: see §5.1.8.
1334    pub async fn register_model(&self, model: &ModelName, dim: usize) -> Result<()> {
1335        let model = model.clone();
1336        self.high(|responder| HighPriCommand::RegisterModel {
1337            model,
1338            dim,
1339            responder,
1340        })
1341        .await
1342    }
1343
1344    /// Store or replace vectors for `model`, chunked (§5.9, D-011, D-048).
1345    ///
1346    /// The write path for embeddings. Before 0.5.4 there was none:
1347    /// [`crate::vector::upsert_embedding`] takes a raw connection, `read_conn`
1348    /// is `query_only`, and the write connection lives inside the actor — so an
1349    /// application could search vectors it had no way to store.
1350    ///
1351    /// Low priority and chunked at [`chunk_rows::EMBEDDINGS`], because embedding
1352    /// is bulk derived work: a 50,000-vector backfill must yield to an
1353    /// interactive assertion at every chunk boundary. That constant is the
1354    /// smallest of the four by a wide margin — DiskANN index maintenance makes an
1355    /// embedding the most expensive row in the system (D-058). Atomic per chunk, not overall, which
1356    /// is the same trade [`Database::bulk_import`] makes and is safer here than
1357    /// there — an embedding is derived (Doctrine VII), so a partially written
1358    /// batch is recoverable by re-embedding.
1359    ///
1360    /// Fails with [`DbError::ModelNotRegistered`] if `model` has no table, and
1361    /// [`DbError::DimMismatch`] if a vector's length is not the declared
1362    /// dimension. The dimension is read from the schema once per chunk (D-037):
1363    /// the crate keeps no registry of its own to fall out of date.
1364    pub async fn upsert_embeddings(
1365        &self,
1366        model: &ModelName,
1367        rows: Vec<(String, Vec<f32>)>,
1368    ) -> Result<usize> {
1369        let chunks: Vec<_> = rows
1370            .chunks(chunk_rows::EMBEDDINGS)
1371            .map(<[_]>::to_vec)
1372            .collect();
1373        self.low_chunked(chunks, |chunk, responder| {
1374            LowPriCommand::UpsertEmbeddingChunk {
1375                model: model.clone(),
1376                chunk,
1377                responder,
1378            }
1379        })
1380        .await
1381    }
1382
1383    /// Reconstruct the concept-text search index from the ledger (§5.9, D-036).
1384    ///
1385    /// The FTS index is derivative: D-036 promises every derivative table can be
1386    /// rebuilt from the ledger tables, and this is that promise made callable
1387    /// for `concepts_fts`. Needed after a restore that skipped the shadow
1388    /// tables, or if the index is ever suspected of drifting from the text —
1389    /// and, as a matter of policy, cheaper to run than to reason about.
1390    ///
1391    /// The work is `INSERT INTO concepts_fts(concepts_fts) VALUES('rebuild')`,
1392    /// which is FTS5's own operation over the content table, so this is not a
1393    /// second implementation of the sync triggers that could disagree with them.
1394    pub async fn rebuild_fts(&self) -> Result<()> {
1395        self.low(|responder| LowPriCommand::RebuildFts { responder })
1396            .await
1397    }
1398
1399    // **There is deliberately no `verify_fts()` (§5.9, D-071).**
1400    //
1401    // `rebuild_fts` is the repair with no way to ask whether it is needed, and
1402    // Wave 5 set out to add the missing half. FTS5 offers `'integrity-check'`,
1403    // which looked like exactly the engine-provided answer this crate prefers.
1404    // It is not: on libSQL 0.9.30 it verifies the index's *internal* consistency
1405    // and not its agreement with the content table. Measured — after
1406    // `'delete-all'` the index matches nothing where it matched ten rows, and
1407    // both `'integrity-check'` and `'integrity-check', 0` still report success.
1408    //
1409    // A `verify_fts()` on that footing would answer "healthy" for an empty
1410    // index, which is worse than having no method at all: it is the shape of
1411    // defect AC, a function that looks like it checks something and does not.
1412    // `an_emptied_fts_index_still_passes_integrity_check` pins the limitation so
1413    // that if a later libSQL fixes it, the test fails and says so.
1414
1415    /// Write derived analytics results on the background channel, chunked
1416    /// (§5.4, D-041).
1417    ///
1418    /// Rows go to `analytics_annotations`, which has no log trigger, so nothing
1419    /// written here reaches `transaction_log` and nothing here versions a
1420    /// concept. Rerunning an algorithm replaces the previous pass rather than
1421    /// recording that the world changed.
1422    ///
1423    /// Low priority and chunked at [`chunk_rows::ANNOTATIONS`] — the largest of
1424    /// the four, because this is the only bulk table carrying no triggers at all
1425    /// and its rows are correspondingly cheap (D-058) — so a 50,000-label Louvain
1426    /// save yields to interactive writes at every chunk boundary and carries the
1427    /// per-chunk fidelity boundary of §5.1.6 — a partially written pass is
1428    /// recoverable by rerunning, which is the property that makes derived state
1429    /// safe to write this way and assertions not.
1430    pub async fn write_analytics_annotations(&self, annotations: Vec<Annotation>) -> Result<usize> {
1431        let chunks: Vec<_> = annotations
1432            .chunks(chunk_rows::ANNOTATIONS)
1433            .map(<[_]>::to_vec)
1434            .collect();
1435        self.low_chunked(chunks, |chunk, responder| {
1436            LowPriCommand::WriteAnalyticsChunk { chunk, responder }
1437        })
1438        .await
1439    }
1440
1441    /// Move closed intervals and superseded log rows older than `cutoff` to the
1442    /// cold database (§5.7, D-012).
1443    pub async fn archive(&self, cutoff: &str) -> Result<ArchiveReport> {
1444        let cutoff = timestamp::normalize(cutoff)?;
1445        let archive_path = self.archive_path.clone();
1446        self.low(|responder| LowPriCommand::Archive {
1447            cutoff,
1448            archive_path,
1449            responder,
1450        })
1451        .await
1452    }
1453
1454    /// Move the named concepts back from the cold database into the hot tables
1455    /// (§2.3, C3).
1456    ///
1457    /// Rehydration is a **physical move back, not a write**: it mints no
1458    /// transaction-time facts and is invisible to both clocks. An id that is not
1459    /// in the cold file is skipped rather than being an error — the caller
1460    /// generally has a list from a cold-side query, and a partially-stale list is
1461    /// the normal case rather than a mistake. The report says how many actually
1462    /// moved.
1463    ///
1464    /// See [`RehydrateReport::rowids_reassigned`] for the one way a rehydrated
1465    /// row can differ from the row that was archived.
1466    pub async fn rehydrate(&self, ids: &[&str]) -> Result<RehydrateReport> {
1467        let ids: Vec<String> = ids.iter().map(|s| (*s).to_string()).collect();
1468        let archive_path = self.archive_path.clone();
1469        self.low(|responder| LowPriCommand::Rehydrate {
1470            ids,
1471            archive_path,
1472            responder,
1473        })
1474        .await
1475    }
1476
1477    /// Archive up to `cutoff` as a sequence of sessions, each covering at most
1478    /// `window` of **transaction** time (T1.1, D-080).
1479    ///
1480    /// `archive(cutoff)` is one transaction whose size is set by how long it has
1481    /// been since the last one, which makes it the least bounded of the three
1482    /// operations exempt from [`CHUNK_BUDGET`] — its hold is a function of
1483    /// operational history rather than of anything a caller chose. This runs the
1484    /// same work as *N* complete sessions, each with its own marker, horizon row
1485    /// and rebuild, and returns one [`ArchiveReport`] per session in order.
1486    ///
1487    /// # D-012 is satisfied per session, and that is what it requires
1488    ///
1489    /// The atomicity D-012 demands is that copy-then-delete never be split — a
1490    /// crash between the phases duplicates or loses rows. *N* small sessions
1491    /// satisfy that exactly as one large one does. The obligation windowing adds
1492    /// is that a partial run leave a coherent intermediate state, which it does:
1493    /// each session commits a valid horizon, so a failure at window *k* leaves a
1494    /// database archived up to boundary *k−1* and nothing in between. **The
1495    /// sequence is not atomic and does not claim to be** — on error, the reports
1496    /// for the sessions that did commit are lost with it, but their effect is
1497    /// not, and re-running with the same `cutoff` completes the job.
1498    ///
1499    /// # Each session is its own actor turn, and that is the entire point
1500    ///
1501    /// This loop lives here, on the handle, rather than inside the actor's
1502    /// `Archive` arm. Putting it there would have produced *N* small
1503    /// transactions inside **one** hold, which shrinks the transaction and
1504    /// changes the latency not at all: the actor is single-threaded, so nothing
1505    /// else writes until its turn returns regardless of how many `COMMIT`s the
1506    /// turn contains. Sending *N* commands returns the actor to its `select!`
1507    /// between sessions, which is where an interactive assertion gets to jump
1508    /// the queue — and it is high-priority, so it does.
1509    ///
1510    /// The same reasoning is why [`Self::bulk_import`] chunks here and not
1511    /// there, and it is the trap T1.2 names for `CREATE TABLE … AS SELECT`.
1512    ///
1513    /// # Choosing a window
1514    ///
1515    /// The bound is on *transaction* time, so the session count is set by how
1516    /// far back the hot file goes, not by how much it holds. A window is
1517    /// rejected rather than clamped if it would need more than
1518    /// [`MAX_ARCHIVE_SESSIONS`] sessions — see [`DbError::ArchiveWindow`].
1519    ///
1520    /// Windows containing nothing archivable are cheap but not free: each still
1521    /// opens a transaction and writes a horizon row. What they no longer do is
1522    /// re-project `links_current`, which `archive_session` now skips when its
1523    /// `DELETE` removed no rows — without that, windowing costs *more* in total
1524    /// than not windowing, because the repair term scales with the surviving
1525    /// table and not with the batch (D-077).
1526    pub async fn archive_windowed(
1527        &self,
1528        cutoff: &str,
1529        window: std::time::Duration,
1530    ) -> Result<Vec<ArchiveReport>> {
1531        let cutoff = timestamp::normalize(cutoff)?;
1532        let boundaries = self.archive_boundaries(&cutoff, window).await?;
1533
1534        let mut reports = Vec::with_capacity(boundaries.len());
1535        for boundary in boundaries {
1536            let archive_path = self.archive_path.clone();
1537            reports.push(
1538                self.low(|responder| LowPriCommand::Archive {
1539                    cutoff: boundary,
1540                    archive_path,
1541                    responder,
1542                })
1543                .await?,
1544            );
1545        }
1546        Ok(reports)
1547    }
1548
1549    /// The cutoffs [`Self::archive_windowed`] will run, ascending, ending at
1550    /// `cutoff` exactly.
1551    ///
1552    /// Read on `read_conn`, not on the actor: this is two `MIN`s and the actor
1553    /// has no reason to hold its lock for them.
1554    ///
1555    /// The lower end comes from the data rather than from the clock. Stepping
1556    /// from some fixed epoch would make the session count a function of the
1557    /// calendar — a database opened yesterday would still be asked to archive
1558    /// 1970 — whereas the oldest `recorded_at` actually present is the earliest
1559    /// boundary that can contain anything.
1560    async fn archive_boundaries(
1561        &self,
1562        cutoff: &str,
1563        window: std::time::Duration,
1564    ) -> Result<Vec<String>> {
1565        // A single session at `cutoff` is exactly `archive(cutoff)`, and it is
1566        // the right answer for an empty hot file: it still writes the horizon
1567        // row, so windowed and unwindowed runs leave the same observable state.
1568        let Some(oldest) = self.oldest_hot_stamp(cutoff).await? else {
1569            return Ok(vec![cutoff.to_string()]);
1570        };
1571
1572        let start = timestamp::parse(&oldest)?;
1573        let end = timestamp::parse(cutoff)?;
1574        let Ok(span) = end.duration_since(start) else {
1575            // Everything in the hot file is at or after the cutoff, so there is
1576            // nothing in range to divide.
1577            return Ok(vec![cutoff.to_string()]);
1578        };
1579
1580        if window.is_zero() {
1581            return Err(DbError::ArchiveWindow {
1582                window,
1583                reason: "a zero-length window never advances past the first boundary".into(),
1584            });
1585        }
1586
1587        // `div_ceil` on nanos: a span of 90 minutes in 60-minute windows is two
1588        // sessions, not one. `as_nanos` is u128, so neither the division nor the
1589        // span can overflow for any timestamp this crate can store.
1590        let sessions = span.as_nanos().div_ceil(window.as_nanos());
1591        if sessions > MAX_ARCHIVE_SESSIONS as u128 {
1592            return Err(DbError::ArchiveWindow {
1593                window,
1594                reason: format!(
1595                    "a span of {span:?} would need {sessions} sessions (limit \
1596                     {MAX_ARCHIVE_SESSIONS}); widen the window"
1597                ),
1598            });
1599        }
1600
1601        let mut boundaries = Vec::with_capacity(sessions as usize);
1602        for k in 1..sessions {
1603            boundaries.push(timestamp::format(start + window * k as u32));
1604        }
1605        // The last boundary is `cutoff` itself and not `start + n*window`, which
1606        // would overshoot and archive rows the caller excluded.
1607        boundaries.push(cutoff.to_string());
1608        Ok(boundaries)
1609    }
1610
1611    /// Oldest `recorded_at` below `cutoff` in either hot table, or `None`.
1612    async fn oldest_hot_stamp(&self, cutoff: &str) -> Result<Option<String>> {
1613        let mut oldest: Option<String> = None;
1614        for table in ["links", "transaction_log"] {
1615            let found: Option<String> = self
1616                .read_conn
1617                .query(
1618                    &format!("SELECT MIN(recorded_at) FROM {table} WHERE recorded_at < ?1"),
1619                    libsql::params![cutoff],
1620                )
1621                .await?
1622                .next()
1623                .await?
1624                .and_then(|row| row.get(0).ok());
1625            if let Some(found) = found {
1626                if oldest.as_ref().is_none_or(|o| found < *o) {
1627                    oldest = Some(found);
1628                }
1629            }
1630        }
1631        Ok(oldest)
1632    }
1633
1634    /// Send a high-priority command and wait for its answer.
1635    ///
1636    /// The two error mappings here are the whole reason this helper exists.
1637    /// `send` failing means the actor is gone — `WriterUnavailable`. The
1638    /// responder being dropped without an answer means the actor took the
1639    /// command and never replied — `WriterDroppedResponder`, which is a bug in
1640    /// the actor rather than a condition the caller can retry. Both variants
1641    /// existed in `error.rs` from 0.4.5 and neither was ever constructed, so a
1642    /// dead actor and a hung one were both just a caller waiting forever.
1643    async fn high<T>(
1644        &self,
1645        make: impl FnOnce(oneshot::Sender<Result<T>>) -> HighPriCommand,
1646    ) -> Result<T> {
1647        let (tx, rx) = oneshot::channel();
1648        self.highpri_tx
1649            .send(make(tx))
1650            .await
1651            .map_err(|_| DbError::WriterUnavailable)?;
1652        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
1653    }
1654
1655    /// Send each chunk in turn and sum the counts — the shape all four bulk
1656    /// paths share (T3.4, D-086).
1657    ///
1658    /// # This is sequential on purpose, and the purpose is a measurement
1659    ///
1660    /// T3.4 proposed pipelining: send *k* chunks ahead so the actor never finds
1661    /// an empty queue. The reasoning is that awaiting each chunk before building
1662    /// the next leaves the actor idle for a channel round trip every time, which
1663    /// on a 1M-edge import is ~11,000 idle gaps.
1664    ///
1665    /// Both halves of that are true and the conclusion does not follow. The gaps
1666    /// are real; they are also **four orders of magnitude smaller than the work
1667    /// they interrupt**. A tokio mpsc hop is sub-microsecond and a chunk takes
1668    /// 13–21 ms. Implemented and swept at depths 1, 2, 4, 8 and 16 over 20K and
1669    /// 100K edges: every cell landed within 1% of sequential, in both directions
1670    /// — see `examples/pipeline_diag.rs`, which is kept precisely so this is not
1671    /// re-proposed from the same reasoning.
1672    ///
1673    /// So the pipelining was removed and the deduplication kept. It was not free
1674    /// to hold: with chunks in flight, a failure at chunk `i` no longer leaves a
1675    /// **prefix** committed, because `i+1 ..= i+k-1` were already sent and commit
1676    /// anyway. D-011 promises "earlier chunks committed", and paying for that
1677    /// with a weaker recovery story in exchange for nothing measurable is the
1678    /// wrong trade.
1679    ///
1680    /// Sending stops at the first error, so what commits is exactly the prefix
1681    /// before the failure.
1682    async fn low_chunked<C>(
1683        &self,
1684        chunks: Vec<C>,
1685        make: impl Fn(C, oneshot::Sender<Result<usize>>) -> LowPriCommand,
1686    ) -> Result<usize> {
1687        let mut written = 0usize;
1688        for chunk in chunks {
1689            let (tx, rx) = oneshot::channel();
1690            self.lowpri_tx
1691                .send(make(chunk, tx))
1692                .await
1693                .map_err(|_| DbError::WriterUnavailable)?;
1694            written += rx.await.map_err(|_| DbError::WriterDroppedResponder)??;
1695        }
1696        Ok(written)
1697    }
1698
1699    async fn low<T>(
1700        &self,
1701        make: impl FnOnce(oneshot::Sender<Result<T>>) -> LowPriCommand,
1702    ) -> Result<T> {
1703        let (tx, rx) = oneshot::channel();
1704        self.lowpri_tx
1705            .send(make(tx))
1706            .await
1707            .map_err(|_| DbError::WriterUnavailable)?;
1708        rx.await.map_err(|_| DbError::WriterDroppedResponder)?
1709    }
1710
1711    /// Clean shutdown: stop the Write Actor, then write the final snapshot (§5.1.7).
1712    ///
1713    /// Order matters. The snapshot is taken *after* the actor has stopped and
1714    /// been joined, so no write can land between the fold and the file — the
1715    /// anchor it records is the last thing that happened, not the last thing
1716    /// that happened to be visible.
1717    ///
1718    /// A failed snapshot is reported rather than swallowed. It is not a
1719    /// durability loss — the ledger is in the WAL and the log replays without
1720    /// it — but it means the next open starts from an older anchor, and a caller
1721    /// that never hears about it cannot know why startup got slower.
1722    ///
1723    /// **The cadence stops first (§5.5, D-053).** Both it and `write_final` end
1724    /// by running retention over the snapshot directory, and retention deletes
1725    /// files. Letting them overlap would mean one pass enumerating the directory
1726    /// while the other removes from it — not a correctness problem for the
1727    /// ledger, which is why the ordering is stated rather than locked, but a
1728    /// source of spurious warnings and of a final anchor that could be deleted
1729    /// by a cleanup that started before it existed. Stopping the cadence, then
1730    /// the actor, then taking the snapshot leaves exactly one writer at each
1731    /// step.
1732    pub async fn close(mut self) -> Result<()> {
1733        if let Some(stop) = self.cadence_stop.take() {
1734            let _ = stop.send(true);
1735        }
1736        if let Some(handle) = self.cadence.take() {
1737            let _ = handle.await;
1738        }
1739
1740        let (tx, rx) = oneshot::channel();
1741        let _ = self
1742            .highpri_tx
1743            .send(HighPriCommand::Shutdown { responder: tx })
1744            .await;
1745        let _ = rx.await;
1746
1747        // **The writer's `Result` is propagated, not discarded (Wave 4.2).**
1748        // It used to be `let _ = handle.await`, so an actor that had panicked or
1749        // returned an error closed "successfully" and the caller's last chance to
1750        // learn that the write path had died was spent silently. A `JoinError`
1751        // here means the actor panicked; the inner `Result` is whatever it
1752        // returned.
1753        //
1754        // Ordered before the final snapshot on purpose: a snapshot written after
1755        // a failed writer records a state the caller has no reason to trust, and
1756        // returning the writer's error while also having written that file is
1757        // worse than not writing it.
1758        if let Some(handle) = self.writer.take() {
1759            match handle.await {
1760                Ok(res) => res?,
1761                Err(e) => {
1762                    return Err(DbError::WriterStopped(format!(
1763                        "the write actor did not exit cleanly: {e}"
1764                    )))
1765                }
1766            }
1767        }
1768
1769        let ts = self.clock.now();
1770        let archive = self
1771            .archive_path
1772            .exists()
1773            .then_some(self.archive_path.as_path());
1774        snapshot::write_final(&self.read_conn, &self.snapshots_dir, &ts, archive).await?;
1775
1776        // Marks the handle closed so `Drop` knows not to complain.
1777        self.closed = true;
1778        Ok(())
1779    }
1780}
1781
1782/// Notes a missed `close()` at `warn!`, and deliberately does **not** assert.
1783///
1784/// **§7.3 offered option B — document `close()` as mandatory and `debug_assert`
1785/// in `Drop` — and Wave 4.2 implemented it, measured the consequence, and
1786/// reduced it to a warning.** The assert fired on roughly thirty tests on its
1787/// first run. That is the signal it was built to produce, and the right reading
1788/// of it was not "thirty tests are wrong".
1789///
1790/// What dropping actually costs is one final snapshot. Nothing else: every
1791/// public write method awaits its responder, so by the time a caller *can* drop
1792/// the handle, every write it issued has already committed; and the cadence stops
1793/// on its own, because `cadence_stop` is a `watch::Sender` whose drop signals the
1794/// task. A snapshot is derivative state under Doctrine VI — disposable,
1795/// reconstructible, and never the only copy of anything. Losing one makes the
1796/// next `reconstruct` fold from an older anchor, which is **slower, not wrong**.
1797///
1798/// A `debug_assert` aborts a test run. Spending that on a performance loss, in a
1799/// project whose own notes say a suite that fails for reasons unrelated to the
1800/// code under test trains people to ignore red, is the wrong trade — and paying
1801/// it in thirty places would have made `close()` look mandatory by ceremony
1802/// rather than by consequence. `close()` remains the right thing to call, and
1803/// the two reasons to call it are now stated where they can be acted on: the
1804/// snapshot, and the writer's `Result`, which only `close()` can return.
1805///
1806/// Option A ("abort the actor and log") stays rejected, for the reason it was
1807/// rejected twice before: `Drop` cannot await, so it cannot drain, and cleanup
1808/// that cannot clean up is worse than none — it looks like cleanup.
1809impl Drop for Database {
1810    fn drop(&mut self) {
1811        if !self.closed {
1812            tracing::warn!(
1813                "Database dropped without close(): the final snapshot was not written, \
1814                 so the next reconstruct folds from an older anchor, and the write \
1815                 actor's exit status was not checked. Prefer close().await."
1816            );
1817        }
1818    }
1819}
1820
1821fn normalize_all(edges: Vec<EdgeAssertion>) -> Result<Vec<EdgeAssertion>> {
1822    edges.into_iter().map(EdgeAssertion::normalized).collect()
1823}
1824
1825/// Identical pragma configuration on every connection.
1826async fn configure(conn: libsql::Connection) -> Result<libsql::Connection> {
1827    // NOTE: `journal_mode` and `busy_timeout` return their resulting value as a
1828    // row, and libsql's `execute()` rejects any statement that yields rows
1829    // ("Execute returned rows"). They must be issued through `query()`.
1830    let _ = conn.query("PRAGMA journal_mode = WAL", ()).await?;
1831    let _ = conn.query("PRAGMA busy_timeout = 5000", ()).await?;
1832    conn.execute("PRAGMA synchronous = NORMAL", ()).await?;
1833    conn.execute("PRAGMA foreign_keys = ON", ()).await?;
1834    conn.execute("PRAGMA recursive_triggers = OFF", ()).await?;
1835    Ok(conn)
1836}
1837
1838/// Helper to derive the snapshot directory by convention: foo.db -> foo_snapshots/
1839fn derive_snapshots_dir(path: &Path) -> PathBuf {
1840    let mut dir = path.to_path_buf();
1841    let stem = path
1842        .file_stem()
1843        .and_then(|s| s.to_str())
1844        .unwrap_or("macrame");
1845    dir.set_file_name(format!("{stem}_snapshots"));
1846    dir
1847}
1848
1849/// Helper to derive archive database path by convention: foo.db -> foo_archive.db
1850fn derive_archive_path(path: &Path) -> PathBuf {
1851    let mut archive = path.to_path_buf();
1852    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1853        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("db");
1854        archive.set_file_name(format!("{stem}_archive.{ext}"));
1855    } else {
1856        archive.set_extension("archive.db");
1857    }
1858    archive
1859}
1860
1861/// Dedicated Write Actor event loop prioritizing high-priority UI requests over low-priority background work.
1862///
1863/// # The turn is the unit, not the statement (T1.4)
1864///
1865/// One iteration of this loop is one *hold*: the actor is single-threaded and
1866/// the SQLite write lock is not preemptible, so from the moment a command starts
1867/// executing until it returns, nothing else writes. That is the quantity
1868/// [`CHUNK_BUDGET`] bounds, and so it is the quantity
1869/// [`crate::metrics::ActorMetrics`] measures — deliberately around the whole
1870/// `execute` call rather than inside it. Timing the SQL alone would have
1871/// reported a bound that held while callers waited.
1872///
1873/// Queue depth is sampled *before* the `select!`, so it is the backlog the turn
1874/// found on arrival rather than the one it left behind.
1875async fn run_writer_actor(
1876    conn: libsql::Connection,
1877    clock: Arc<dyn Clock>,
1878    mut highpri_rx: mpsc::Receiver<HighPriCommand>,
1879    mut lowpri_rx: mpsc::Receiver<LowPriCommand>,
1880    shared: Arc<ActorShared>,
1881) -> Result<()> {
1882    loop {
1883        shared
1884            .metrics
1885            .record_turn(highpri_rx.len(), lowpri_rx.len());
1886
1887        let ctl = tokio::select! {
1888            biased;
1889            Some(cmd) = highpri_rx.recv() => {
1890                let turn = Turn::start(cmd.kind(), &shared);
1891                cmd.execute(&conn, &*clock, &turn).await
1892            }
1893            Some(cmd) = lowpri_rx.recv() => {
1894                let turn = Turn::start(cmd.kind(), &shared);
1895                cmd.execute(&conn, &*clock, &turn).await
1896            }
1897            else => LoopCtl::Break,
1898        };
1899        if matches!(ctl, LoopCtl::Break) {
1900            break;
1901        }
1902    }
1903    Ok(())
1904}
1905
1906/// One command's hold: the timer, its label, and the counters it reports to.
1907///
1908/// # The hold is recorded *before* the caller is answered, and it has to be
1909///
1910/// The obvious placement — time the whole `execute` call from the loop — is
1911/// wrong in a way that only shows up under test. Every arm of `execute` ends by
1912/// sending on a `oneshot`, which wakes the waiting caller; the actor then
1913/// returns to the loop and records. Those are two tasks, so a caller that awaits
1914/// its own write and immediately reads [`Database::metrics`] can be scheduled
1915/// first and see a turn count that does not include the write it just did.
1916///
1917/// Not a correctness bug in the ledger, and it would never have been noticed in
1918/// production — a dashboard sampling every few seconds cannot see the window.
1919/// It makes every test and diagnostic of the counters flaky, which is worse: the
1920/// instrumentation would have been *believed* while being wrong exactly when
1921/// someone tried to check it. `examples/bulk_atomic_diag.rs` was the thing that
1922/// caught it, reporting a 20,000-row batch as a 0 ms hold.
1923///
1924/// So `answer` records and then sends, in that order, and the ordering is the
1925/// method's whole reason to exist. What it costs is that the `oneshot::send`
1926/// itself falls outside the measurement, which is a few nanoseconds against a
1927/// turn measured in microseconds at best.
1928struct Turn<'a> {
1929    kind: crate::metrics::CommandKind,
1930    timer: crate::metrics::HoldTimer,
1931    shared: &'a ActorShared,
1932}
1933
1934/// State the actor owns and a `Turn` needs to reach.
1935///
1936/// `archive_epoch` is here rather than in [`crate::metrics::ActorMetrics`]
1937/// because it is **not** a metric: T1.2's shadow rebuild reads it to decide
1938/// whether its work is still valid, so it has to be present in every build, not
1939/// only under the `metrics` feature. Counting archives happens to be what both
1940/// want; only one of them is allowed to be compiled out.
1941#[derive(Default)]
1942struct ActorShared {
1943    metrics: crate::metrics::ActorMetrics,
1944    archive_epoch: std::sync::atomic::AtomicU64,
1945}
1946
1947impl<'a> Turn<'a> {
1948    fn start(kind: crate::metrics::CommandKind, shared: &'a ActorShared) -> Self {
1949        Self {
1950            kind,
1951            timer: crate::metrics::HoldTimer::start(),
1952            shared,
1953        }
1954    }
1955
1956    fn epoch(&self) -> u64 {
1957        self.shared
1958            .archive_epoch
1959            .load(std::sync::atomic::Ordering::Relaxed)
1960    }
1961
1962    /// Record that an archive session committed.
1963    ///
1964    /// Bumped on **success only**: a failed archive rolls back, so it deletes
1965    /// nothing and invalidates no shadow build.
1966    fn archive_committed(&self) {
1967        self.shared
1968            .archive_epoch
1969            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1970    }
1971
1972    /// Close the hold and hand the result back. Never the other way round.
1973    ///
1974    /// The `let _ =` on the send is deliberate and predates this: a caller that
1975    /// dropped its receiver — `tokio::time::timeout` around a write, which
1976    /// [`Database`]'s write surface explicitly documents — is not an actor
1977    /// error, and the command committed regardless.
1978    fn answer<T>(&self, responder: oneshot::Sender<Result<T>>, res: Result<T>) {
1979        self.shared
1980            .metrics
1981            .record_hold(self.kind, self.timer.elapsed());
1982        let _ = responder.send(res);
1983    }
1984}
1985
1986const INSERT_LINK: &str = "INSERT INTO links \
1987     (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
1988     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)";
1989
1990/// Shared by the single-concept write and the chunked one, so the two paths
1991/// cannot drift into upserting different column sets — and so the chunk has a
1992/// statement text it can prepare once (D-056).
1993const UPSERT_CONCEPT: &str = "INSERT INTO concepts \
1994     (id, title, content, embedding_model, valid_from, valid_to, recorded_at, retired) \
1995     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
1996     ON CONFLICT(id) DO UPDATE SET \
1997         title = excluded.title, \
1998         content = excluded.content, \
1999         embedding_model = excluded.embedding_model, \
2000         valid_from = excluded.valid_from, \
2001         valid_to = excluded.valid_to, \
2002         recorded_at = excluded.recorded_at, \
2003         retired = excluded.retired";
2004
2005/// The parameter row for [`UPSERT_CONCEPT`], in one place for the same reason.
2006fn concept_params<'a>(concept: &'a ConceptUpsert, stamp: &'a str) -> [libsql::Value; 8] {
2007    [
2008        concept.id.as_str().into(),
2009        concept.title.as_str().into(),
2010        concept.content.as_str().into(),
2011        concept
2012            .embedding_model
2013            .as_deref()
2014            .map_or(libsql::Value::Null, Into::into),
2015        concept.valid_from.as_str().into(),
2016        concept.valid_to.as_str().into(),
2017        stamp.into(),
2018        (concept.retired as i64).into(),
2019    ]
2020}
2021
2022impl HighPriCommand {
2023    /// The metrics label for this variant (T1.4).
2024    ///
2025    /// Exhaustive for the same reason `execute` is: a new variant that silently
2026    /// borrowed another's label would attribute its holds to the wrong command,
2027    /// and the one question the counters exist to answer is *which* command
2028    /// broke the budget.
2029    fn kind(&self) -> crate::metrics::CommandKind {
2030        use crate::metrics::CommandKind as K;
2031        match self {
2032            HighPriCommand::AssertEdge { .. } => K::AssertEdge,
2033            HighPriCommand::RetireEdge { .. } => K::RetireEdge,
2034            HighPriCommand::UpsertConcept { .. } => K::UpsertConcept,
2035            HighPriCommand::WriteBulkAtomic { .. } => K::WriteBulkAtomic,
2036            HighPriCommand::RebuildCurrent { .. } => K::RebuildCurrent,
2037            HighPriCommand::RegisterModel { .. } => K::RegisterModel,
2038            HighPriCommand::Shutdown { .. } => K::Shutdown,
2039        }
2040    }
2041
2042    /// Run one command and answer its caller.
2043    ///
2044    /// Deliberately exhaustive — there is no `_` arm. The 0.4.5–0.5.4 actor
2045    /// matched `Shutdown` and `AssertEdge` and sent everything else to
2046    /// `_ => LoopCtl::Continue`, which **dropped the responder**: the caller's
2047    /// `rx.await` resolved to a `RecvError` that no code mapped, so four of six
2048    /// commands were indistinguishable from a hung database. An exhaustive match
2049    /// makes that failure a compile error instead of a runtime silence, which is
2050    /// why adding a variant should break this function.
2051    async fn execute(
2052        self,
2053        conn: &libsql::Connection,
2054        clock: &dyn Clock,
2055        turn: &Turn<'_>,
2056    ) -> LoopCtl {
2057        match self {
2058            HighPriCommand::Shutdown { responder } => {
2059                turn.answer(responder, Ok(()));
2060                return LoopCtl::Break;
2061            }
2062            HighPriCommand::AssertEdge { edge, responder } => {
2063                let stamp = clock.now();
2064                if let Err(e) = reject_overlapping_interval(conn, &edge).await {
2065                    turn.answer(responder, Err(e));
2066                    return LoopCtl::Continue;
2067                }
2068                let res = match conn
2069                    .execute(
2070                        INSERT_LINK,
2071                        libsql::params![
2072                            edge.source.as_str(),
2073                            edge.target.as_str(),
2074                            edge.edge_type.as_str(),
2075                            edge.valid_from.as_str(),
2076                            edge.valid_to.as_str(),
2077                            edge.weight,
2078                            edge.properties.as_str(),
2079                            stamp.as_str()
2080                        ],
2081                    )
2082                    .await
2083                {
2084                    Ok(_) => Ok(()),
2085                    Err(e) => Err(classify(
2086                        conn,
2087                        e,
2088                        WriteOp::Edge {
2089                            source_id: &edge.source,
2090                            target_id: &edge.target,
2091                            edge_type: &edge.edge_type,
2092                        },
2093                    )
2094                    .await),
2095                };
2096                turn.answer(responder, res);
2097            }
2098            HighPriCommand::RetireEdge {
2099                source,
2100                target,
2101                edge_type,
2102                valid_from,
2103                valid_to,
2104                responder,
2105            } => {
2106                let stamp = clock.now();
2107                let res = retire_edge(
2108                    conn,
2109                    &source,
2110                    &target,
2111                    &edge_type,
2112                    &valid_from,
2113                    &valid_to,
2114                    &stamp,
2115                )
2116                .await;
2117                turn.answer(responder, res);
2118            }
2119            HighPriCommand::UpsertConcept { concept, responder } => {
2120                let stamp = clock.now();
2121                let res = upsert_concept(conn, &concept, &stamp).await;
2122                turn.answer(responder, res);
2123            }
2124            HighPriCommand::WriteBulkAtomic { edges, responder } => {
2125                // One stamp for the whole batch (D-014): the rows were asserted
2126                // by one act, and giving them different transaction times would
2127                // invent an ordering the caller never expressed.
2128                let stamp = clock.now();
2129                let res = write_edges_atomic(conn, &edges, &stamp).await;
2130                turn.answer(responder, res);
2131            }
2132            HighPriCommand::RebuildCurrent { responder } => {
2133                turn.answer(responder, rebuild_current(conn).await);
2134            }
2135            HighPriCommand::RegisterModel {
2136                model,
2137                dim,
2138                responder,
2139            } => {
2140                turn.answer(
2141                    responder,
2142                    crate::vector::register_model(conn, &model, dim).await,
2143                );
2144            }
2145        }
2146        LoopCtl::Continue
2147    }
2148}
2149
2150impl LowPriCommand {
2151    /// The metrics label for this variant (T1.4). See [`HighPriCommand::kind`].
2152    fn kind(&self) -> crate::metrics::CommandKind {
2153        use crate::metrics::CommandKind as K;
2154        match self {
2155            LowPriCommand::WriteConceptsChunk { .. } => K::WriteConceptsChunk,
2156            LowPriCommand::WriteAnalyticsChunk { .. } => K::WriteAnalyticsChunk,
2157            LowPriCommand::UpsertEmbeddingChunk { .. } => K::UpsertEmbeddingChunk,
2158            LowPriCommand::BulkImportChunk { .. } => K::BulkImportChunk,
2159            LowPriCommand::Archive { .. } => K::Archive,
2160            // No counter of its own: rehydration is the archive path run
2161            // backwards and shares its budget, and a `CommandKind` variant is a
2162            // public enum addition (D-036 periphery, but still a break).
2163            LowPriCommand::Rehydrate { .. } => K::Archive,
2164            LowPriCommand::RebuildFts { .. } => K::RebuildFts,
2165            LowPriCommand::ShadowRebuild { .. } => K::ShadowRebuild,
2166        }
2167    }
2168
2169    /// Run one background command and answer its caller.
2170    ///
2171    /// Also exhaustive. The pre-0.5.4 version was a single `LoopCtl::Continue`
2172    /// for *every* variant — every background write silently discarded, its
2173    /// caller waiting forever.
2174    async fn execute(
2175        self,
2176        conn: &libsql::Connection,
2177        clock: &dyn Clock,
2178        turn: &Turn<'_>,
2179    ) -> LoopCtl {
2180        match self {
2181            LowPriCommand::BulkImportChunk { chunk, responder } => {
2182                // A stamp per chunk, not per batch: the chunks commit
2183                // separately, so a shared stamp would claim a simultaneity the
2184                // storage does not have.
2185                let stamp = clock.now();
2186                turn.answer(responder, write_edges_atomic(conn, &chunk, &stamp).await);
2187            }
2188            LowPriCommand::WriteConceptsChunk { chunk, responder } => {
2189                let stamp = clock.now();
2190                turn.answer(responder, write_concepts_atomic(conn, &chunk, &stamp).await);
2191            }
2192            LowPriCommand::WriteAnalyticsChunk { chunk, responder } => {
2193                let stamp = clock.now();
2194                turn.answer(
2195                    responder,
2196                    write_annotations_atomic(conn, &chunk, &stamp).await,
2197                );
2198            }
2199            LowPriCommand::UpsertEmbeddingChunk {
2200                model,
2201                chunk,
2202                responder,
2203            } => {
2204                // No clock reading: an embedding carries no timestamp on either
2205                // axis. It is a derived artifact of a model applied to content
2206                // (Doctrine VII), and the ledger already records when the
2207                // content changed.
2208                turn.answer(
2209                    responder,
2210                    crate::vector::search::upsert_embedding_chunk(conn, &model, &chunk).await,
2211                );
2212            }
2213            LowPriCommand::Archive {
2214                cutoff,
2215                archive_path,
2216                responder,
2217            } => {
2218                // The archive *time*, not the cutoff. `archive_horizon` records
2219                // both and they are different facts — see `archive()` (Wave 4.5).
2220                let archived_at = clock.now();
2221                let res = archive(conn, &cutoff, &archived_at, &archive_path).await;
2222                // Before the answer, so a shadow rebuild that reads the epoch on
2223                // its next turn cannot miss an archive that has already deleted
2224                // rows out from under it (T1.2).
2225                if res.is_ok() {
2226                    turn.archive_committed();
2227                }
2228                turn.answer(responder, res);
2229            }
2230            LowPriCommand::Rehydrate {
2231                ids,
2232                archive_path,
2233                responder,
2234            } => {
2235                let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2236                let res = rehydrate(conn, &refs, &archive_path).await;
2237                // Same reason as `Archive`: rehydration moves rows into `links`'
2238                // parent table, so a shadow rebuild in flight must see the epoch
2239                // move before the caller is answered (T1.2).
2240                if res.is_ok() {
2241                    turn.archive_committed();
2242                }
2243                turn.answer(responder, res);
2244            }
2245            LowPriCommand::ShadowRebuild { step, responder } => {
2246                use crate::integrity::{shadow, ShadowOutcome, ShadowStep};
2247                let res = match step {
2248                    ShadowStep::Begin => {
2249                        shadow::begin(conn)
2250                            .await
2251                            .map(|build_start| ShadowOutcome::Started {
2252                                build_start,
2253                                epoch: turn.epoch(),
2254                            })
2255                    }
2256                    ShadowStep::Fill { after } => shadow::fill_chunk(conn, after.as_deref())
2257                        .await
2258                        .map(|last| ShadowOutcome::Filled { last }),
2259                    ShadowStep::Swap { build_start, epoch } => {
2260                        shadow::swap(conn, &build_start, epoch, turn.epoch())
2261                            .await
2262                            .map(|rows| ShadowOutcome::Swapped { rows })
2263                    }
2264                };
2265                turn.answer(responder, res);
2266            }
2267            LowPriCommand::RebuildFts { responder } => {
2268                let res = conn
2269                    .execute(crate::schema::ddl::REBUILD_CONCEPTS_FTS, ())
2270                    .await
2271                    .map(|_| ())
2272                    .map_err(Into::into);
2273                turn.answer(responder, res);
2274            }
2275        }
2276        LoopCtl::Continue
2277    }
2278}
2279
2280/// Close an open interval by asserting its successor (Doctrine III).
2281///
2282/// Never an `UPDATE`. The replacement row copies weight and properties from
2283/// current belief and differs only in `valid_to` and `recorded_at`, so the
2284/// original assertion survives intact and `reconstruct` at an earlier instant
2285/// still sees the interval open — which is the entire point of a bitemporal
2286/// ledger.
2287async fn retire_edge(
2288    conn: &libsql::Connection,
2289    source: &str,
2290    target: &str,
2291    edge_type: &str,
2292    valid_from: &str,
2293    valid_to: &str,
2294    stamp: &str,
2295) -> Result<()> {
2296    let affected = conn
2297        .execute(
2298            "INSERT INTO links \
2299                 (source_id, target_id, edge_type, valid_from, valid_to, weight, properties, recorded_at) \
2300             SELECT source_id, target_id, edge_type, valid_from, ?5, weight, properties, ?6 \
2301             FROM links_current \
2302             WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 AND valid_from = ?4",
2303            libsql::params![source, target, edge_type, valid_from, valid_to, stamp],
2304        )
2305        .await
2306        .map_err(DbError::Engine)?;
2307
2308    if affected == 0 {
2309        return Err(DbError::NotFound(format!(
2310            "{source} -> {target} ({edge_type}) at {valid_from}"
2311        )));
2312    }
2313    Ok(())
2314}
2315
2316async fn upsert_concept(
2317    conn: &libsql::Connection,
2318    concept: &ConceptUpsert,
2319    stamp: &str,
2320) -> Result<()> {
2321    let res = conn
2322        .execute(UPSERT_CONCEPT, concept_params(concept, stamp))
2323        .await;
2324
2325    match res {
2326        Ok(_) => Ok(()),
2327        Err(e) => Err(classify(
2328            conn,
2329            e,
2330            WriteOp::Concept {
2331                id: &concept.id,
2332                recorded_at: stamp,
2333            },
2334        )
2335        .await),
2336    }
2337}
2338
2339/// Every recorded interval for one relationship key, for [`Interval::overlaps`]
2340/// to judge.
2341///
2342/// **Three equalities and nothing else, deliberately — and the "and nothing
2343/// else" was measured, not assumed.** The first version added
2344/// `AND valid_from < :new_valid_to`, a provably safe narrowing (overlap requires
2345/// `max(start) < min(end)`, so an interval starting at or after the new one's end
2346/// cannot overlap it). It cost **9.8 ms on a 90-edge chunk into a 2,000-edge
2347/// hub**, because it walked the planner straight into D-059's trap:
2348///
2349/// ```text
2350/// with the range:     SEARCH links_current USING COVERING INDEX
2351///                     idx_lc_traversal_cover (source_id=? AND valid_from<?)
2352/// without it:         SEARCH links_current USING COVERING INDEX
2353///                     idx_lc_open_interval (source_id=? AND target_id=? AND edge_type=?)
2354/// ```
2355///
2356/// `idx_lc_traversal_cover` leads on `(source_id, valid_from, …)` and contains
2357/// every column this query mentions, so with a `valid_from` range available it
2358/// wins as a covering index while binding **one** equality column — and the
2359/// guard scans the source's entire out-degree. That is the same shape as the
2360/// defect D-059 diagnosed in `trg_links_single_open`, reintroduced by an
2361/// optimisation, one wave after it was fixed.
2362///
2363/// Dropping the range makes the query a pure three-column point lookup that
2364/// `idx_lc_open_interval` serves exactly, and the rows it returns are the
2365/// intervals recorded for one `(source, target, edge_type)` — a version count,
2366/// not an out-degree. **A narrowing predicate is not free if it changes the
2367/// plan**, which is the general lesson and the reason this constant carries its
2368/// own `EXPLAIN` output.
2369const OVERLAP_CANDIDATES: &str = "SELECT valid_from, valid_to FROM links_current \
2370     WHERE source_id = ?1 AND target_id = ?2 AND edge_type = ?3 \
2371       AND valid_from <> ?4";
2372
2373/// Whether this pair is the storage layer's case rather than this guard's.
2374///
2375/// Two **open** intervals overlap — they share every instant from the later
2376/// start onwards — so a naive overlap check reports them, and reporting them
2377/// here would leave `DbError::SingleOpenViolation` constructible by nothing.
2378/// That variant is the more specific error, it is enforced by
2379/// `trg_links_single_open` rather than by this function, and its field names
2380/// were ratified in §1.2. Shadowing it with a general one would be defect Q's
2381/// shape reintroduced by a fix: a typed error that no code path can produce.
2382///
2383/// So the two guards partition the space rather than overlapping it. Both open
2384/// belongs to the trigger. Everything else — open against closed, closed against
2385/// closed — is unguarded at the storage layer and belongs here. That the split
2386/// is exactly the trigger's `WHEN` clause is not a coincidence; it is the
2387/// definition of what was missing.
2388fn defer_to_single_open(proposed: &Interval, existing: &Interval) -> bool {
2389    proposed.is_open() && existing.is_open()
2390}
2391
2392/// Refuse an assertion whose valid-time interval overlaps one already recorded
2393/// for the same `(source, target, edge_type)` — **defect AA, D-060**.
2394///
2395/// `trg_links_single_open` fires only `WHEN NEW.valid_to = '9999-…'`, so it
2396/// guards the open sentinel and nothing else. Two *closed* intervals that
2397/// overlap were accepted without complaint, and `query_as_of_edges` at an
2398/// instant inside both returned one relationship as two edges.
2399///
2400/// **This runs in the write actor, which is what makes it sound.** The obvious
2401/// place is `EdgeAssertion::normalized`, and it cannot go there — `normalized`
2402/// is a pure function with no connection, and doing the read at the API boundary
2403/// instead would leave a check-then-write race between the read and the actor's
2404/// insert. Inside the actor there is one writer by construction (D-014), and for
2405/// the batch paths this runs inside the same transaction as the insert, so the
2406/// window does not exist rather than being small.
2407///
2408/// **What it does not cover, and §4.2 now says so:** raw SQL against the same
2409/// file. The storage layer permits what this API refuses, which is the honest
2410/// cost of not putting the check in a trigger. The alternative was a second
2411/// index probe inside `trg_links_single_open` on every insert — on the path
2412/// D-059 has just finished making fast — for a guarantee that only holds against
2413/// callers who were going through the actor anyway.
2414///
2415/// `valid_from <> ?4` excludes the row being re-asserted. Re-assertion at the
2416/// same `valid_from` is Doctrine III's ordinary case — a new belief about the
2417/// same interval — and is settled by the primary key and the single-open
2418/// trigger, not here.
2419/// The single-assertion path prepares one statement for one check, which is what
2420/// `AssertEdge` needs; the batch path prepares once and calls
2421/// [`check_prepared`] per row.
2422async fn reject_overlapping_interval(
2423    conn: &libsql::Connection,
2424    edge: &EdgeAssertion,
2425) -> Result<()> {
2426    let stmt = conn.prepare(OVERLAP_CANDIDATES).await?;
2427    check_prepared(&stmt, edge).await
2428}
2429
2430/// The guard's body, against a statement the caller has already prepared.
2431///
2432/// **Split out because preparing per row was worth 10.4 ms on a 90-edge chunk**
2433/// (§8.8) — the same defect D-056 and D-057 diagnosed and fixed for
2434/// `INSERT_LINK`, reintroduced by the Wave 2 guard that was written beside it.
2435/// Measured with and without the guard, on a 2,000-edge hub: 8.65 ms → 19.25 ms,
2436/// and *identical* with and without `idx_lc_open_interval`, which is what
2437/// identified preparation rather than a scan as the cost. A guard that reads an
2438/// index correctly and prepares its statement 90 times is indistinguishable, at
2439/// the call site, from one that scans.
2440///
2441/// `reset()` between rows is not optional: libsql binds and steps without
2442/// resetting, so a reused statement must be returned to its initial state.
2443async fn check_prepared(stmt: &libsql::Statement, edge: &EdgeAssertion) -> Result<()> {
2444    let proposed = Interval::new(edge.valid_from.clone(), edge.valid_to.clone());
2445
2446    stmt.reset();
2447    let mut rows = stmt
2448        .query(libsql::params![
2449            edge.source.as_str(),
2450            edge.target.as_str(),
2451            edge.edge_type.as_str(),
2452            edge.valid_from.as_str()
2453        ])
2454        .await?;
2455
2456    while let Some(row) = rows.next().await? {
2457        let existing = Interval::new(row.get::<String>(0)?, row.get::<String>(1)?);
2458        if defer_to_single_open(&proposed, &existing) {
2459            continue;
2460        }
2461        if proposed.overlaps(&existing) {
2462            return Err(DbError::OverlappingInterval {
2463                overlap: Box::new(crate::error::Overlap {
2464                    source_id: edge.source.clone(),
2465                    target_id: edge.target.clone(),
2466                    edge_type: edge.edge_type.clone(),
2467                    valid_from: edge.valid_from.clone(),
2468                    valid_to: edge.valid_to.clone(),
2469                    existing_from: existing.valid_from,
2470                    existing_to: existing.valid_to,
2471                }),
2472            });
2473        }
2474    }
2475
2476    Ok(())
2477}
2478
2479/// The same guard applied *within* a batch, before any of it is written.
2480///
2481/// The database check cannot see rows that are not in the database yet, so a
2482/// batch carrying two overlapping intervals for one relationship would pass
2483/// every per-row check and commit the overlap in one transaction. Quadratic in
2484/// the batch, which is affordable because the chunk is bounded at
2485/// [`chunk_rows::EDGES`] = 90 and because the comparison is a pair of string
2486/// compares — and because grouping first means the inner loop only ever runs
2487/// over edges sharing a key, which is normally one.
2488fn reject_overlaps_within(edges: &[EdgeAssertion]) -> Result<()> {
2489    for (i, a) in edges.iter().enumerate() {
2490        let ia = Interval::new(a.valid_from.clone(), a.valid_to.clone());
2491        for b in &edges[i + 1..] {
2492            if a.source != b.source || a.target != b.target || a.edge_type != b.edge_type {
2493                continue;
2494            }
2495            // Identical valid_from is re-assertion within one batch: the last
2496            // writer wins by seq_id, as it does across batches. Not an overlap.
2497            if a.valid_from == b.valid_from {
2498                continue;
2499            }
2500            let ib = Interval::new(b.valid_from.clone(), b.valid_to.clone());
2501            // Both open is the trigger's case; it fires during the insert and
2502            // rolls the batch back with the more specific error.
2503            if defer_to_single_open(&ia, &ib) {
2504                continue;
2505            }
2506            if ia.overlaps(&ib) {
2507                return Err(DbError::OverlappingInterval {
2508                    overlap: Box::new(crate::error::Overlap {
2509                        source_id: a.source.clone(),
2510                        target_id: a.target.clone(),
2511                        edge_type: a.edge_type.clone(),
2512                        valid_from: a.valid_from.clone(),
2513                        valid_to: a.valid_to.clone(),
2514                        existing_from: ib.valid_from,
2515                        existing_to: ib.valid_to,
2516                    }),
2517                });
2518            }
2519        }
2520    }
2521    Ok(())
2522}
2523
2524/// Write every edge or none, under a single stamp.
2525///
2526/// **The statement is prepared once for the whole chunk (§9, D-056).** It used to
2527/// be `tx.execute(INSERT_LINK, …)` per row, which re-prepares on every call — and
2528/// `links` carries two triggers, so each preparation compiles their bodies along
2529/// with the insert.
2530///
2531/// Measured at 500 rows: **≈62 ms → ≈37 ms, a 41% saving.** Preparation was a
2532/// large cost and *not* the dominant one, which the first guess had it as. The
2533/// residual is the triggers themselves: the same 500 rows with
2534/// `trg_links_log_insert` and `trg_links_current_sync` dropped commit in **2.96
2535/// ms**, so trigger amplification is ~92% of what remains. There is no further
2536/// win available here without changing what the ledger records, and Doctrine IV
2537/// is what says it must be recorded. See D-056 for what that implies about §9's
2538/// ≤ 3 ms budget — briefly, 2.96 ms *is* the un-amplified figure, so the budget
2539/// appears to have been set without the amplification its own preamble says is
2540/// included.
2541///
2542/// `reset()` between rows is not optional: libsql's `execute` binds and steps
2543/// without resetting, so a reused statement must be returned to its initial state
2544/// or the second row steps a completed statement.
2545async fn write_edges_atomic(
2546    conn: &libsql::Connection,
2547    edges: &[EdgeAssertion],
2548    stamp: &str,
2549) -> Result<usize> {
2550    if edges.is_empty() {
2551        return Ok(0);
2552    }
2553
2554    // Before the transaction opens: a batch that contradicts itself is refused
2555    // without taking the write lock at all (D-060).
2556    reject_overlaps_within(edges)?;
2557
2558    let tx = conn
2559        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2560        .await?;
2561
2562    // Inside the transaction, so the rows this checks against cannot change
2563    // between the check and the insert.
2564    // One preparation for the whole chunk, not one per row — see
2565    // `check_prepared`, and D-056 for the same lesson learned on `INSERT_LINK`.
2566    let guard = tx.prepare(OVERLAP_CANDIDATES).await?;
2567    for edge in edges {
2568        if let Err(e) = check_prepared(&guard, edge).await {
2569            // Released before the rollback: a live statement on the connection
2570            // is what makes SQLite refuse to end a transaction.
2571            drop(guard);
2572            let _ = tx.rollback().await;
2573            return Err(e);
2574        }
2575    }
2576    drop(guard);
2577
2578    let stmt = tx.prepare(INSERT_LINK).await?;
2579
2580    for edge in edges {
2581        stmt.reset();
2582        let res = stmt
2583            .execute(libsql::params![
2584                edge.source.as_str(),
2585                edge.target.as_str(),
2586                edge.edge_type.as_str(),
2587                edge.valid_from.as_str(),
2588                edge.valid_to.as_str(),
2589                edge.weight,
2590                edge.properties.as_str(),
2591                stamp
2592            ])
2593            .await;
2594
2595        if let Err(e) = res {
2596            let typed = classify(
2597                &tx,
2598                e,
2599                WriteOp::Edge {
2600                    source_id: &edge.source,
2601                    target_id: &edge.target,
2602                    edge_type: &edge.edge_type,
2603                },
2604            )
2605            .await;
2606            // Released before the rollback: a live statement on the connection
2607            // is exactly what makes SQLite refuse to end a transaction.
2608            drop(stmt);
2609            let _ = tx.rollback().await;
2610            return Err(typed);
2611        }
2612    }
2613
2614    drop(stmt);
2615    tx.commit().await?;
2616    Ok(edges.len())
2617}
2618
2619/// Write every concept or none, under a single stamp.
2620/// Upsert one chunk of derived annotations in a single transaction (D-041).
2621///
2622/// `stamp` is the actor's clock reading, exactly as for every other chunk — but
2623/// it lands in `computed_at`, not in a `recorded_at`, and the difference is not
2624/// cosmetic. `recorded_at` is the transaction-time axis and is subject to
2625/// Doctrine II and the monotonicity guard; `computed_at` is a note about when a
2626/// derivation last ran, on a table the ledger does not see. Rerunning an
2627/// algorithm therefore replaces the row and advances the note, rather than
2628/// versioning a concept the world did not change.
2629async fn write_annotations_atomic(
2630    conn: &libsql::Connection,
2631    annotations: &[Annotation],
2632    stamp: &str,
2633) -> Result<usize> {
2634    if annotations.is_empty() {
2635        return Ok(0);
2636    }
2637
2638    let tx = conn
2639        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2640        .await?;
2641
2642    let stmt = tx
2643        .prepare(
2644            "INSERT INTO analytics_annotations (concept_id, label, value, computed_at) \
2645             VALUES (?1, ?2, ?3, ?4) \
2646             ON CONFLICT(concept_id, label) DO UPDATE SET \
2647                 value = excluded.value, computed_at = excluded.computed_at",
2648        )
2649        .await?;
2650
2651    for a in annotations {
2652        stmt.reset();
2653        let res = stmt
2654            .execute(libsql::params![
2655                a.concept_id.as_str(),
2656                a.label.as_str(),
2657                a.value.as_str(),
2658                stamp
2659            ])
2660            .await;
2661        if let Err(e) = res {
2662            drop(stmt);
2663            let _ = tx.rollback().await;
2664            return Err(DbError::Engine(e));
2665        }
2666    }
2667
2668    drop(stmt);
2669    tx.commit().await?;
2670    Ok(annotations.len())
2671}
2672
2673async fn write_concepts_atomic(
2674    conn: &libsql::Connection,
2675    concepts: &[ConceptUpsert],
2676    stamp: &str,
2677) -> Result<usize> {
2678    if concepts.is_empty() {
2679        return Ok(0);
2680    }
2681
2682    let tx = conn
2683        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
2684        .await?;
2685
2686    // Prepared once, like the edge chunk (D-056). This no longer routes through
2687    // [`upsert_concept`] — that function prepares per call by construction — but
2688    // it shares that function's statement text and parameter row, so the two
2689    // cannot upsert different columns.
2690    let stmt = tx.prepare(UPSERT_CONCEPT).await?;
2691
2692    for concept in concepts {
2693        stmt.reset();
2694        let res = stmt.execute(concept_params(concept, stamp)).await;
2695
2696        if let Err(e) = res {
2697            let typed = classify(
2698                &tx,
2699                e,
2700                WriteOp::Concept {
2701                    id: &concept.id,
2702                    recorded_at: stamp,
2703                },
2704            )
2705            .await;
2706            drop(stmt);
2707            let _ = tx.rollback().await;
2708            return Err(typed);
2709        }
2710    }
2711
2712    drop(stmt);
2713    tx.commit().await?;
2714    Ok(concepts.len())
2715}
2716
2717#[cfg(test)]
2718mod tests {
2719    use super::*;
2720
2721    fn edge(target: &str, micros: usize) -> EdgeAssertion {
2722        EdgeAssertion::new("src", target, "LINKS")
2723            .valid_from(format!("2026-01-01T00:00:00.{micros:06}Z"))
2724            .valid_to(format!("2026-01-01T00:00:00.{:06}Z", micros + 1))
2725    }
2726
2727    /// The estimate must depend on the batch's **shape**, not only its size.
2728    ///
2729    /// This is the correction T1.3's "rows × per-row cost" needed. Two batches
2730    /// of the same length whose measured holds differ by 7× must not be
2731    /// predicted identically, and the direction matters: a model that averages
2732    /// the two under-predicts the expensive shape, which is the only one anyone
2733    /// needs warning about.
2734    #[test]
2735    fn two_batches_of_one_size_are_not_predicted_alike() {
2736        const N: usize = 20_000;
2737        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
2738        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
2739
2740        let (a, b) = (estimated_bulk_hold(&fanout), estimated_bulk_hold(&history));
2741        assert!(
2742            b > a * 5,
2743            "the guard's expensive path is 16x dearer per pair and this batch \
2744             takes it on every pair, but the estimates are {a:?} and {b:?}"
2745        );
2746    }
2747
2748    /// Measured on libSQL 0.9.30: 2.5 s and 18.6 s for those two batches. The
2749    /// estimator tracked both within 5%, and this pins that it still does — a
2750    /// coefficient edited without re-measuring fails here.
2751    #[test]
2752    fn the_estimate_matches_what_was_measured() {
2753        const N: usize = 20_000;
2754        let fanout: Vec<_> = (0..N).map(|i| edge(&format!("t{i:07}"), i)).collect();
2755        let history: Vec<_> = (0..N).map(|i| edge("t0", i)).collect();
2756
2757        for (batch, measured_ms, label) in
2758            [(fanout, 2_618u128, "fanout"), (history, 18_057, "history")]
2759        {
2760            let predicted = estimated_bulk_hold(&batch).as_millis();
2761            let ratio = predicted as f64 / measured_ms as f64;
2762            assert!(
2763                (0.8..1.25).contains(&ratio),
2764                "{label}: predicted {predicted} ms against a measured \
2765                 {measured_ms} ms ({ratio:.2}x). Re-run \
2766                 examples/bulk_atomic_diag.rs before changing the coefficients."
2767            );
2768        }
2769    }
2770
2771    /// An empty or single-edge batch has no pairs, and the arithmetic must not
2772    /// underflow computing it.
2773    #[test]
2774    fn a_batch_too_small_to_have_pairs_still_estimates() {
2775        assert_eq!(estimated_bulk_hold(&[]), std::time::Duration::ZERO);
2776        let one = [edge("t0", 0)];
2777        assert_eq!(
2778            estimated_bulk_hold(&one),
2779            std::time::Duration::from_nanos(73_000)
2780        );
2781    }
2782
2783    /// The warning threshold sits well above the bound this path is exempt from.
2784    ///
2785    /// Warning at `CHUNK_BUDGET` would fire on batches working exactly as
2786    /// designed — the exemption is a contract (D-014), not a failure — and a
2787    /// warning that fires on correct behaviour gets filtered out, taking the
2788    /// 18-second case with it.
2789    #[test]
2790    fn the_warning_threshold_is_not_the_chunk_budget() {
2791        assert!(BULK_ATOMIC_WARN_HOLD > CHUNK_BUDGET * 10);
2792    }
2793}