Skip to main content

macrame/
connection.rs

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