Skip to main content

rto_graph/
memory.rs

1//! Episodic agent memory — a separate artifact store, never a graph fact.
2//!
3//! What a session *learned* — a lesson, an approach that was tried and failed, a
4//! decision, a recurring failure pattern, a task outcome — has **no generating
5//! function**. Re-run extraction over the same tree a thousand times and none of
6//! it comes back, because it was never in the tree: it is the residue of work,
7//! not a property of source. So it is not a `derived` fact. It was also not
8//! deliberately written into a reviewed file, so it is not `authored` either
9//! (ADR-0013, issue #288).
10//!
11//! It lives here instead: its own table, its own retrieval surface, and never in
12//! `nodes`/`edges`. Three consequences are load-bearing, and all three are
13//! asserted by tests rather than assumed:
14//!
15//! - [`crate::Store::export_factset`] — and therefore the published
16//!   [`crate::GraphArtifact`] — stays a pure function of the tree **across every
17//!   memory write**, because nothing in this module writes a node or an edge.
18//! - No record acquires the `authored` relevance boost that [`crate::search`]
19//!   applies. At this stage the guarantee is structural and total: memory does
20//!   not enter [`crate::search`] **at all**, through any channel.
21//! - Records survive [`crate::Store::rebuild`], following the `imports`
22//!   precedent — `rebuild` deletes only `edges` and `nodes`, and what cannot be
23//!   re-derived must not be destroyed by a re-derivation.
24//!
25//! Nothing here adds a [`crate::Provenance`] variant, and no memory write may
26//! invalidate the content-addressed fact cache: memory is not extraction output,
27//! so it is not part of the extraction identity `EXTRACT_VERSION` belongs to.
28//! That is asserted as a property — a full spread of memory writes leaves the
29//! recorded extraction identity and every cached fact set untouched, and the next
30//! `sync` is still a no-op — by `memory_writes_do_not_invalidate_the_fact_cache`
31//! in `tests/sync.rs`, where the cache it is about lives.
32//!
33//! # Two tiers, opposite rules
34//!
35//! ADR-0013 describes two tiers with **opposite rules**, because they have
36//! opposite recovery costs, and both live here in separate tables:
37//!
38//! | | [`MemoryRecord`] — episodic | [`CacheEntry`] — transient |
39//! |---|---|---|
40//! | Table | `agent_memory` (migration 11) | `agent_cache` (migration 13) |
41//! | Re-derivable | **no** — there is no generating function | **yes**, by definition |
42//! | Bounded | never | by a byte budget ([`DEFAULT_CACHE_BUDGET_BYTES`]) |
43//! | Removed by | an explicit [`crate::Store::forget_memory`], and nothing else | that, or a sweep |
44//! | Cost of losing one | the knowledge, permanently | some cycles |
45//!
46//! **The rule: re-derivable ⇒ evictable; episodic ⇒ never silently evicted.**
47//! Bounding the episodic tier would be data loss wearing cache management's
48//! clothes; leaving the cache tier unbounded is the growth ADR-0013 exists to
49//! stop. What licenses the asymmetry is that `build_context` is *proven* to
50//! reconstruct identically (`context.rs` asserts `built == cached`), so evicting a
51//! cache entry costs cycles and never information.
52//!
53//! [`crate::Store::sweep_agent_cache`] is the only thing here that deletes without
54//! being asked, and it **cannot** reach the episodic tier: that table has no
55//! `bytes`, no `last_used` and no `hits`, so there is no column for a capacity
56//! policy to grip it by. The separation is structural rather than careful.
57//!
58//! # Anchoring: a node key and a blob, never a span
59//!
60//! A record may anchor to a point in the graph. The anchor is the pair
61//! `(anchor_key, anchor_blob)`, captured when the record is written, and a span
62//! is deliberately **not** part of it: a span is byte offsets and shifts on any
63//! edit above it, so a record anchored by span would read as stale after an
64//! unrelated import was added twenty lines up. A node key plus the blob hash the
65//! node carried at capture time is stable under that edit and moves only when the
66//! thing itself moves.
67//!
68//! On read — never on write, and never stored — the pair is checked against the
69//! current graph, yielding an [`AnchorState`]:
70//!
71//! | Recorded | In the graph now | State |
72//! |---|---|---|
73//! | no anchor | — | [`AnchorState::Unanchored`] |
74//! | a key | no such node | [`AnchorState::Vanished`] |
75//! | a key + blob | node present, same blob | [`AnchorState::Valid`] |
76//! | a key + blob | node present, different blob | [`AnchorState::Drifted`] |
77//! | a key, no blob | node present | [`AnchorState::Unverifiable`] |
78//!
79//! **Drift marks; it never prunes.** The authored layer drops links to vanished
80//! symbols; memory must not, because *a lesson about deleted code is often the
81//! most valuable thing in the store* — "we removed this because the retry loop
82//! double-counted" is worth more once the retry loop is gone, not less. This is a
83//! deliberate departure from the house pruning rule, and it is the main reason
84//! memory cannot live in the graph.
85//!
86//! # The anchor is the scope test
87//!
88//! The store is shared across branches, worktrees and clones, so the obvious
89//! question is whether a lesson learned on a feature branch is valid on `main`.
90//! The rule (ADR-0013 §*Scope*) is:
91//!
92//! > A lesson learned on a feature branch is valid on `main` **only if the
93//! > relevant association is merged to `main` in the same format** — if not, then
94//! > no.
95//!
96//! And that is not new machinery: it is [`AnchorState`], which this module
97//! already computes. Validity is **not a property of the branch that wrote the
98//! record**. It is whether the anchor resolves in the tree being looked at:
99//!
100//! - anchor resolves with a matching blob ⇒ the association is here *in the same
101//!   format* ⇒ the record applies, whichever branch wrote it;
102//! - drifted, vanished or unmeasurable ⇒ not merged, or merged in a different
103//!   form ⇒ it does not apply *to this tree*. Kept and marked, never pruned.
104//!
105//! "Is this valid on `main`?" is answered by resolving the anchor against
106//! `main`'s graph, and the identical mechanism answers it on any branch, worktree
107//! or clone, with **no branch bookkeeping at all**. [`AnchorState::applies`] is
108//! that predicate; note that it consults neither the scope, nor `created_at`, nor
109//! the record's position in the sequence.
110//!
111//! **"In the same format" means the blob matches**, deliberately strictly: even a
112//! pure reformat breaks the association. That fails toward *marked drifted*
113//! rather than silently applying a lesson to code that has moved on, which is the
114//! error worth avoiding.
115//!
116//! A record with **no anchor at all** is a general lesson about the repository
117//! ("CI is Ubuntu-only") and is repo-wide: it applies everywhere, because it
118//! never claimed to be about a particular piece of code. That is a different
119//! thing from an anchor that failed to resolve, and the two are separate
120//! [`AnchorState`] values with opposite answers so they can never be confused.
121//!
122//! ## What `scope` is, and is not
123//!
124//! `scope` is a **coarse namespace** — which repo or project a record belongs to,
125//! in a multi-repo workspace. It is **not a branch label**, and nothing keys off
126//! it beyond an exact-match filter: no isolation, no inheritance, no merging.
127//! Branch applicability is the anchor's job, above, and giving `scope` a second
128//! job would create two answers to one question.
129//!
130//! # Supersession, recorded and not guessed
131//!
132//! New knowledge overruling old is expressed **explicitly**, by pointing the old
133//! record's [`MemoryRecord::superseded_by`] at the new one's id. A superseded
134//! record drops out of live listing **immediately, regardless of age**, and the
135//! chain stays auditable because nothing is deleted.
136//!
137//! This is the live analogue of [`crate::EdgeKind::Supersedes`], which exists in
138//! the enum but is produced by nothing. Per the standing decision it stays
139//! **inside the artifact store and never becomes a graph edge**.
140//!
141//! # Recall: ranked at retrieval, stored nowhere
142//!
143//! [`crate::Store::recall_memory`] ranks the live records by
144//!
145//! ```text
146//! score = base_confidence × anchor_penalty × decay(current_generation − row.generation)
147//! ```
148//!
149//! and every one of those terms is computed **on the read** and written to no
150//! column. A stored score that decayed would have to be rewritten on every read
151//! and would be wrong in between, so recall would depend on when you last looked
152//! — the one kind of non-determinism this project keeps out of the graph, and
153//! there is no reason to let it in through the side door.
154//!
155//! The order of the terms is the depreciation model, in order: **evidence first,
156//! clock last.**
157//!
158//! 1. **Supersession is not in the formula at all.** A superseded record is
159//!    excluded in SQL, by a recorded pointer with no clock in it, so it leaves
160//!    recall the moment its successor is written — immediately, regardless of age,
161//!    and regardless of how well it would otherwise have scored.
162//! 2. **[`anchor_penalty`] dominates**, and it is built on [`AnchorState`] and
163//!    nothing else. There is deliberately no branch term and no scope term: the
164//!    anchor *is* the scope test, and a second rule would give two answers to one
165//!    question.
166//! 3. **[`Decay`] is last**, and defaults to [`Decay::None`] — no age term, and
167//!    therefore byte-identical recall for a fixed store and a fixed tree. Pricing
168//!    age at all is opt-in.
169//!
170//! Nothing in that list can remove a record. Drift demotes, decay ranks to zero
171//! at worst, and only [`crate::Store::forget_memory`] deletes.
172//!
173//! # Ordering is a generation, not a clock
174//!
175//! `id` is `INTEGER PRIMARY KEY AUTOINCREMENT` and is the ordering key.
176//! [`MemoryRecord::created_at`] is written for humans and **never read**, exactly
177//! as `imports.imported_at` behaves. The store is per-repo and shared across
178//! worktrees and branches, so concurrent checkouts produce non-monotone
179//! wall-clock, and `SQLite`'s `datetime('now')` is second-granular and ties on
180//! intra-second writes. Ranking on either would make results non-deterministic
181//! for a fixed repo state. [`MemoryRecord::superseded_at`] is the same kind of
182//! value — display, never policy.
183//!
184//! # Privacy
185//!
186//! The store lives in `.git/roteiro/` beside `graph.db`: per-clone, never
187//! committed, never pushed. That placement is not cosmetic. Extraction redacts
188//! secret-looking config values *before* persistence because the graph is
189//! exportable; memory has **no such chokepoint**, because it records prose an
190//! agent wrote, which can contain pasted tokens, stack traces or customer names.
191//! [`crate::Store::forget_memory`] is the reclamation path, and it is the only
192//! one.
193//!
194//! @rto:0013
195
196use rusqlite::{Connection, OptionalExtension, params};
197use serde::{Deserialize, Serialize};
198
199use crate::query::window;
200use crate::store::StoreError;
201
202/// Stable schema tag on [`MemoryListing`], so a programmatic consumer can depend
203/// on the shape.
204pub const MEMORY_SCHEMA: &str = "roteiro.memory/v1";
205
206/// The scope recorded when a caller names none.
207///
208/// `scope` is a **coarse namespace** — which repo or project a record belongs to
209/// in a multi-repo workspace (ADR-0008) — and it is **explicitly not a branch
210/// label**. Nothing keys off it beyond an exact-match filter in
211/// [`MemoryFilter::scope`]: no isolation, no inheritance, no merging.
212///
213/// Whether a record applies to the tree in front of you is decided by its
214/// **anchor**, not its scope — see [`AnchorState::applies`] and the module docs.
215/// Giving `scope` that second job would create two answers to one question, and
216/// the branch-shaped one would be wrong: a lesson does not become false because
217/// the branch that learned it was deleted.
218pub const DEFAULT_MEMORY_SCOPE: &str = "repo";
219
220/// Longest permitted memory body, in bytes. Generous, because a body is prose —
221/// a failure write-up with a stack trace in it is a legitimate memory. Anything
222/// past this is a file being pasted into a database, not a lesson.
223pub const MAX_MEMORY_BODY: usize = 64 * 1024;
224
225/// Longest permitted scope, in bytes. A scope is a short label — a branch name,
226/// a worktree id, a project — not a sentence.
227pub const MAX_MEMORY_SCOPE: usize = 128;
228
229/// Errors raised when writing or forgetting a memory record.
230#[derive(Debug, thiserror::Error)]
231pub enum MemoryError {
232    /// The underlying store failed.
233    #[error(transparent)]
234    Store(#[from] StoreError),
235    /// A scope was empty, over-long, or carried a control character or
236    /// surrounding whitespace.
237    #[error(
238        "invalid scope {0:?} (expected 1 to {MAX_MEMORY_SCOPE} bytes, no control characters, no surrounding whitespace)"
239    )]
240    InvalidScope(String),
241    /// A body was empty, whitespace-only, or longer than [`MAX_MEMORY_BODY`].
242    #[error("invalid body: {0}")]
243    InvalidBody(String),
244    /// A confidence was offered that is not a probability.
245    #[error("invalid confidence {0}: expected a finite number in [0.0, 1.0]")]
246    InvalidConfidence(f64),
247    /// A record was named that is not in the store.
248    #[error("no memory record with id {0}")]
249    NotFound(i64),
250    /// A record was named as superseded that another record has already
251    /// superseded. The chain stays a chain: re-pointing it would orphan the
252    /// successor already recorded.
253    #[error("memory record {id} is already superseded by {by}")]
254    AlreadySuperseded {
255        /// The record that was to be superseded.
256        id: i64,
257        /// The successor already on record.
258        by: i64,
259    },
260    /// A stored row could not be interpreted (database corruption).
261    #[error("corrupt memory record: {0}")]
262    Corrupt(String),
263    /// [`CACHE_BUDGET_ENV`] was set to something that is not a budget. Refused
264    /// rather than ignored: running the default under a name that says otherwise
265    /// is how an operator ends up believing in a bound that was never applied.
266    #[error(
267        "invalid {CACHE_BUDGET_ENV}={0:?}: expected a whole number of megabytes (the default is \
268         {default} MB)",
269        default = DEFAULT_CACHE_BUDGET_BYTES / (1024 * 1024)
270    )]
271    InvalidBudget(String),
272}
273
274impl From<rusqlite::Error> for MemoryError {
275    fn from(err: rusqlite::Error) -> Self {
276        Self::Store(StoreError::Sqlite(err))
277    }
278}
279
280/// What kind of knowledge a record holds.
281///
282/// A **closed** vocabulary, enforced by the schema as well as by this type,
283/// following the same rule the `analysis_runs` runner and isolation tokens live
284/// under: a value outside the known set is a corrupt write, not a new feature.
285/// The five names are ADR-0013's own list of what episodic memory is for. Free
286/// text was the alternative and was declined — `lesson`, `Lesson` and `lessons`
287/// would be three different kinds, none of them findable by a filter, and a
288/// vocabulary that cannot be filtered cannot later be ranked.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
290#[serde(rename_all = "lowercase")]
291pub enum MemoryKind {
292    /// Something established that a later session should not have to re-derive.
293    Lesson,
294    /// An approach that was tried and did not work, and why.
295    Attempt,
296    /// A choice made, so it is not silently remade.
297    Decision,
298    /// A failure mode seen more than once.
299    Pattern,
300    /// How a task actually ended.
301    Outcome,
302}
303
304impl MemoryKind {
305    /// Every kind, in declaration order — the vocabulary the CLI advertises.
306    pub const ALL: [Self; 5] = [
307        Self::Lesson,
308        Self::Attempt,
309        Self::Decision,
310        Self::Pattern,
311        Self::Outcome,
312    ];
313
314    /// Stable string token used in the `SQLite` store and in `--json` output.
315    #[must_use]
316    pub fn as_str(self) -> &'static str {
317        match self {
318            Self::Lesson => "lesson",
319            Self::Attempt => "attempt",
320            Self::Decision => "decision",
321            Self::Pattern => "pattern",
322            Self::Outcome => "outcome",
323        }
324    }
325
326    /// Parse a kind from its stable token; `None` for an unrecognised value (a
327    /// corrupt row, or a typo on the command line).
328    #[must_use]
329    pub fn from_token(s: &str) -> Option<Self> {
330        Self::ALL.into_iter().find(|k| k.as_str() == s)
331    }
332}
333
334impl std::fmt::Display for MemoryKind {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.write_str(self.as_str())
337    }
338}
339
340impl std::str::FromStr for MemoryKind {
341    type Err = String;
342
343    fn from_str(s: &str) -> Result<Self, Self::Err> {
344        Self::from_token(s).ok_or_else(|| {
345            let known = Self::ALL.map(Self::as_str).join(", ");
346            format!("unknown memory kind {s:?} (expected one of: {known})")
347        })
348    }
349}
350
351/// What a record's anchor is worth *right now*, computed on every read against
352/// the current graph and **never stored**.
353///
354/// A stored verdict would have to be rewritten on every sync — and would be
355/// wrong in between — which is the same reason ADR-0013 keeps decay out of the
356/// table. None of these states deletes anything: see the module docs for why a
357/// record about vanished code is kept and marked rather than pruned.
358///
359/// **This is also the scope test.** [`AnchorState::applies`] is what decides
360/// whether a record applies to the tree in front of you — see the module docs.
361/// The two "no useful anchor" situations are deliberately *separate* states with
362/// opposite answers, because conflating them is the mistake that would make the
363/// rule meaningless:
364///
365/// - [`AnchorState::Unanchored`] — **nothing was ever anchored**. A general
366///   lesson about the repository, which applies everywhere.
367/// - [`AnchorState::Vanished`] / [`AnchorState::Drifted`] /
368///   [`AnchorState::Unverifiable`] — **an anchor was recorded and did not
369///   resolve here**. The association is not present in this tree in the same
370///   form, so the record does not apply to it.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "lowercase")]
373pub enum AnchorState {
374    /// **No anchor was ever recorded** — a general lesson about the repository
375    /// ("CI is Ubuntu-only"), tied to nothing in particular and therefore true
376    /// wherever the repository is. Applies.
377    ///
378    /// Not to be confused with an anchor that failed to resolve: this record
379    /// never claimed to be about a specific piece of code, so there is nothing
380    /// for a tree to disagree with.
381    Unanchored,
382    /// The anchored node is present and carries the blob captured at write time:
383    /// the association is in this tree **in the same format**. Applies.
384    Valid,
385    /// The anchored node is present but carries a **different** blob: the code
386    /// changed underneath the record. It may still be right; it is no longer
387    /// evidence about what is there now, and it does not apply to this tree.
388    Drifted,
389    /// The anchored node is **gone** from the graph. The most interesting state,
390    /// and the one the authored layer would have pruned. Does not apply here —
391    /// and is kept anyway, because a lesson about deleted code is often the most
392    /// valuable one.
393    Vanished,
394    /// The anchored node is present, but no blob was captured (or the node
395    /// carries none), so *the blob cannot be compared either way*. Reported
396    /// honestly rather than folded into [`AnchorState::Valid`], which would claim
397    /// a check that never happened.
398    ///
399    /// **Does not apply**, by the same strictness that makes [`AnchorState::
400    /// Drifted`] not apply: the rule is that the association is present *in the
401    /// same format*, and an unmeasurable blob cannot demonstrate that. Failing
402    /// toward *marked* is the whole point — the alternative silently applies a
403    /// lesson to code nobody checked.
404    Unverifiable,
405}
406
407impl AnchorState {
408    /// Stable string token used in `--json` output.
409    #[must_use]
410    pub fn as_str(self) -> &'static str {
411        match self {
412            Self::Unanchored => "unanchored",
413            Self::Valid => "valid",
414            Self::Drifted => "drifted",
415            Self::Vanished => "vanished",
416            Self::Unverifiable => "unverifiable",
417        }
418    }
419
420    /// **Whether this record applies to the tree it was just resolved against.**
421    ///
422    /// The whole scope rule, in one predicate: a record applies when it is
423    /// anchored to nothing (a general lesson) or when its anchor resolves here
424    /// with the same blob. Everything else — vanished, drifted, unmeasurable —
425    /// means the association is not present in this tree in the same format, so
426    /// the record does not apply *here*. It is still stored, still listed, and
427    /// still applies wherever its anchor does resolve.
428    ///
429    /// Note what this predicate does **not** consult: the branch the record was
430    /// written on, its `created_at`, its scope, or its position in the sequence.
431    /// None of those is available to it, which is the point — applicability is a
432    /// question about the tree, asked fresh every read.
433    #[must_use]
434    pub fn applies(self) -> bool {
435        matches!(self, Self::Unanchored | Self::Valid)
436    }
437
438    /// Whether the anchored code has moved out from under this record — the
439    /// evidence-first signal ADR-0013 depreciates on. **Never a delete
440    /// condition**; a stale record is kept, marked, and (in a later stage) ranked
441    /// lower.
442    ///
443    /// Narrower than the negation of [`AnchorState::applies`]: staleness means
444    /// *the code moved*, which [`AnchorState::Unverifiable`] does not claim —
445    /// nothing was measured there. A record can fail to apply without anything
446    /// having gone stale.
447    #[must_use]
448    pub fn is_stale(self) -> bool {
449        matches!(self, Self::Drifted | Self::Vanished)
450    }
451}
452
453impl std::fmt::Display for AnchorState {
454    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        f.write_str(self.as_str())
456    }
457}
458
459/// Where a record is anchored, as captured when it was written.
460///
461/// `blob` and `path` are the **evidence at capture time**, not a live view: they
462/// are what the node carried then, which is precisely what makes a later
463/// comparison meaningful.
464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
465pub struct MemoryAnchor {
466    /// The node key this record is about.
467    pub key: String,
468    /// The node's `blob_hash` when the record was written, if it had one. Half of
469    /// the stable pair; without it drift cannot be detected.
470    pub blob: Option<String>,
471    /// The node's path when the record was written. Evidence for a human reader;
472    /// never part of the drift check, because a path is not an identity.
473    pub path: Option<String>,
474}
475
476/// One stored memory record.
477#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
478pub struct MemoryRecord {
479    /// The monotonic generation and identity. `AUTOINCREMENT`, so an id is never
480    /// reused after a [`crate::Store::forget_memory`].
481    pub id: i64,
482    /// The recorded namespace — which repo or project this belongs to. **Not a
483    /// branch label**; see [`DEFAULT_MEMORY_SCOPE`].
484    pub scope: String,
485    /// What kind of knowledge this is.
486    pub kind: MemoryKind,
487    /// Where it is anchored, if anywhere.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub anchor: Option<MemoryAnchor>,
490    /// What that anchor is worth against the **current** graph. Computed on read;
491    /// no column holds it.
492    pub anchor_state: AnchorState,
493    /// **Whether this record applies to the tree it was just read against** —
494    /// [`AnchorState::applies`] for the state above, surfaced as its own field so
495    /// a programmatic consumer gets the scope rule without having to re-implement
496    /// it from the state token.
497    ///
498    /// Like `anchor_state`, computed on every read and stored in no column. A
499    /// `false` here is never a reason to delete anything: the record still applies
500    /// wherever its anchor does resolve.
501    pub applies: bool,
502    /// The prose. Unredacted by construction — see the module docs on privacy.
503    pub body: String,
504    /// The writer's own confidence, when it offered one. **Not** the score an
505    /// `inferred` edge carries, and never readable as one: no memory record is a
506    /// graph fact.
507    pub confidence: Option<f64>,
508    /// The `sync_state` tree id when the record was written — the repo-state
509    /// witness, so a reader can tell which state of the world the writer saw.
510    pub tree: Option<String>,
511    /// `SQLite`'s `datetime('now')` at write time. **Written for humans and never
512    /// read**, exactly as `imports.imported_at` is; no ordering or policy depends
513    /// on it.
514    pub created_at: String,
515    /// The record that overruled this one, if any. Live listing excludes any
516    /// record with a successor, immediately and regardless of age.
517    pub superseded_by: Option<i64>,
518    /// When that happened. Display only, on the same terms as `created_at`.
519    pub superseded_at: Option<String>,
520}
521
522impl MemoryRecord {
523    /// Whether this record is live — nothing has superseded it.
524    #[must_use]
525    pub fn is_live(&self) -> bool {
526        self.superseded_by.is_none()
527    }
528}
529
530/// The values [`crate::Store::record_memory`] writes.
531///
532/// `anchor` is a **node key**; the blob and path stored alongside it are captured
533/// by the store from the graph at write time, so there is exactly one place that
534/// decides what an anchor's evidence is. `tree` is captured the same way.
535#[derive(Debug, Clone, Copy)]
536pub struct MemoryWrite<'a> {
537    /// The scope to record.
538    pub scope: &'a str,
539    /// What kind of knowledge this is.
540    pub kind: MemoryKind,
541    /// The node key to anchor to, if any. A key naming no node is **accepted**,
542    /// and reads back as [`AnchorState::Vanished`]: recording a lesson about code
543    /// that is already gone is a legitimate — often the most valuable — thing to
544    /// do, and refusing it would be the prune rule wearing a different hat.
545    pub anchor: Option<&'a str>,
546    /// The prose.
547    pub body: &'a str,
548    /// The writer's own confidence, if it has one.
549    pub confidence: Option<f64>,
550    /// The record this one overrules, if any. Supersession is recorded here,
551    /// explicitly, at the moment the successor is written — never inferred later
552    /// from age.
553    pub supersedes: Option<i64>,
554}
555
556impl MemoryWrite<'_> {
557    /// Validate a write, refusing a record that could not be stored or recalled.
558    ///
559    /// # Errors
560    /// Returns [`MemoryError::InvalidScope`], [`MemoryError::InvalidBody`] or
561    /// [`MemoryError::InvalidConfidence`], each naming what was actually wrong.
562    pub fn validate(&self) -> Result<(), MemoryError> {
563        if self.scope.is_empty()
564            || self.scope.len() > MAX_MEMORY_SCOPE
565            || self.scope.trim() != self.scope
566            || self.scope.chars().any(char::is_control)
567        {
568            return Err(MemoryError::InvalidScope(self.scope.to_owned()));
569        }
570        if self.body.trim().is_empty() {
571            return Err(MemoryError::InvalidBody(
572                "it is empty or only whitespace".to_owned(),
573            ));
574        }
575        if self.body.len() > MAX_MEMORY_BODY {
576            return Err(MemoryError::InvalidBody(format!(
577                "it is {} bytes, over the {MAX_MEMORY_BODY}-byte limit",
578                self.body.len()
579            )));
580        }
581        if let Some(confidence) = self.confidence
582            && !(confidence.is_finite() && (0.0..=1.0).contains(&confidence))
583        {
584            return Err(MemoryError::InvalidConfidence(confidence));
585        }
586        Ok(())
587    }
588}
589
590/// A narrowing filter for [`crate::Store::memory_records`].
591///
592/// [`MemoryFilter::default`] is **live records only, newest generation first, no
593/// limit** — the listing an agent actually wants, with superseded knowledge
594/// already gone.
595#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
596pub struct MemoryFilter<'a> {
597    /// Only records recorded in this scope, matched exactly.
598    pub scope: Option<&'a str>,
599    /// Only records of this kind.
600    pub kind: Option<MemoryKind>,
601    /// Only records anchored to this node key.
602    pub anchor_key: Option<&'a str>,
603    /// Also return records another record has superseded. Off by default: a
604    /// superseded record drops out of live listing immediately, and the chain is
605    /// kept for audit rather than for reading.
606    pub include_superseded: bool,
607    /// At most this many records (the newest generations). `None` for all of
608    /// them — and so is `Some(0)`, which is [`window`]'s reading of `0` holding
609    /// on the one list surface in this module that cannot call it, because the
610    /// cut happens in SQL. Sharing the *rule* is the point; `LIMIT 0` returning
611    /// nothing would be the same divergence issue #447 was filed for, one
612    /// function away.
613    pub limit: Option<usize>,
614}
615
616/// A listing of memory records, with the counts that make it legible.
617#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
618pub struct MemoryListing {
619    /// Stable schema tag ([`MEMORY_SCHEMA`]).
620    pub schema: &'static str,
621    /// The matching records, newest generation first.
622    pub records: Vec<MemoryRecord>,
623    /// Live records in the whole store, ignoring the filter — so a filtered
624    /// listing that returns nothing is legible as *nothing matched* rather than
625    /// *nothing is stored*.
626    pub live: u64,
627    /// Superseded records in the whole store. Never zero once knowledge has been
628    /// overruled: nothing is deleted by supersession.
629    pub superseded: u64,
630}
631
632// --- Recall: ranking computed at retrieval time, and stored nowhere ----------
633
634/// Stable schema tag on [`Recall`].
635pub const RECALL_SCHEMA: &str = "roteiro.recall/v1";
636
637/// The `base_confidence` used for a record whose writer offered none — the
638/// **midpoint** of the range a writer can state.
639///
640/// Not `1.0`, which would let every record that claimed nothing outrank every
641/// record that honestly claimed `0.9`, and so would price honesty. Not `0.0`,
642/// which would make the common case (the CLI writes no confidence unless asked)
643/// unrecallable. At the midpoint, stating a high confidence promotes a record and
644/// stating a low one demotes it, both *relative to silence*, which is the only
645/// behaviour that makes the field worth filling in.
646pub const DEFAULT_BASE_CONFIDENCE: f64 = 0.5;
647
648/// Default span for [`Decay::Linear`], in generations — one generation per record
649/// written, never a second of wall-clock.
650pub const DEFAULT_DECAY_SPAN: u64 = 200;
651
652/// Default half-life for [`Decay::Exponential`], in generations.
653pub const DEFAULT_HALF_LIFE: u64 = 50;
654
655/// How a record's age is priced into its recall score.
656///
657/// The age term is the **last** term, deliberately: ADR-0013 depreciates by
658/// evidence first and clock last, so an anchor that no longer resolves and an
659/// explicit supersession both outrank age. Age is the tiebreak between records
660/// that are otherwise equally valid.
661///
662/// **Age is measured in generations, not time.** A generation is one written
663/// record ([`MemoryRecord::id`], `AUTOINCREMENT`), so "old" means *a lot has been
664/// learned since*, not *a while has passed*. That is what makes it skew-proof: the
665/// store is shared across worktrees and branches, where wall-clock is not
666/// monotone and `datetime('now')` ties on intra-second writes.
667///
668/// **The factor is computed on every read and never stored.** A stored score that
669/// ticked down would rewrite the store on every read and would be wrong in
670/// between, making recall depend on when you last looked.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
672#[serde(rename_all = "lowercase")]
673pub enum Decay {
674    /// No age term at all: every record's factor is exactly `1.0`.
675    ///
676    /// **This is the reproducible mode, and it is the default.** With no age term
677    /// the score depends only on what is stored and on the tree the anchors are
678    /// resolved against, so the same store and the same tree recall the same
679    /// records in the same order with the same scores — byte-identically, across
680    /// runs and across machines. Every other mode is a deliberate trade of that
681    /// property for recency.
682    None,
683    /// Falls linearly to zero over `span` generations.
684    ///
685    /// A record older than `span` scores `0.0` in the age term and therefore sorts
686    /// last — it is **still returned and still labelled**. Decay ranks; it never
687    /// filters and never deletes.
688    Linear {
689        /// Generations over which the factor reaches zero. Clamped to at least 1.
690        span: u64,
691    },
692    /// Halves every `half_life` generations, and never reaches zero.
693    Exponential {
694        /// Generations per halving. Clamped to at least 1.
695        half_life: u64,
696    },
697}
698
699impl Default for Decay {
700    /// [`Decay::None`] — the reproducible answer is the default answer, on the
701    /// same terms as [`crate::SearchOptions`] defaulting generated content off.
702    fn default() -> Self {
703        Self::None
704    }
705}
706
707impl Decay {
708    /// The age factor for a record `age` generations old, always in `[0.0, 1.0]`.
709    ///
710    /// A pure function of `(self, age)`: no clock, no store state, no I/O.
711    #[must_use]
712    pub fn factor(self, age: u64) -> f64 {
713        match self {
714            Self::None => 1.0,
715            // `max(1)` rather than a divide-by-zero: a span of zero is a caller
716            // asking for "everything old at once", and the honest reading of that
717            // is a one-generation span, not NaN.
718            Self::Linear { span } => {
719                let span = span.max(1);
720                #[expect(
721                    clippy::cast_precision_loss,
722                    reason = "generation counts are small; the ratio is a ranking weight"
723                )]
724                let ratio = age as f64 / span as f64;
725                (1.0 - ratio).max(0.0)
726            }
727            Self::Exponential { half_life } => {
728                let half_life = half_life.max(1);
729                #[expect(
730                    clippy::cast_precision_loss,
731                    reason = "generation counts are small; the ratio is a ranking weight"
732                )]
733                let ratio = age as f64 / half_life as f64;
734                0.5_f64.powf(ratio)
735            }
736        }
737    }
738
739    /// Stable token naming the mode, without its parameter.
740    #[must_use]
741    pub fn as_str(self) -> &'static str {
742        match self {
743            Self::None => "none",
744            Self::Linear { .. } => "linear",
745            Self::Exponential { .. } => "exponential",
746        }
747    }
748
749    /// Whether this mode guarantees reproducible recall — true only for
750    /// [`Decay::None`].
751    #[must_use]
752    pub fn is_reproducible(self) -> bool {
753        matches!(self, Self::None)
754    }
755}
756
757impl std::fmt::Display for Decay {
758    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
759        match self {
760            Self::None => f.write_str("none"),
761            Self::Linear { span } => write!(f, "linear:{span}"),
762            Self::Exponential { half_life } => write!(f, "exponential:{half_life}"),
763        }
764    }
765}
766
767impl std::str::FromStr for Decay {
768    type Err = String;
769
770    /// `none` | `linear[:span]` | `exponential[:half-life]`.
771    fn from_str(s: &str) -> Result<Self, Self::Err> {
772        let (mode, param) = match s.split_once(':') {
773            Some((mode, param)) => {
774                let n = param.parse::<u64>().map_err(|_| {
775                    format!("decay parameter {param:?} is not a whole number of generations")
776                })?;
777                (mode, Some(n))
778            }
779            None => (s, None),
780        };
781        match mode {
782            "none" => {
783                if param.is_some() {
784                    return Err("decay `none` takes no parameter: it has no age term".to_owned());
785                }
786                Ok(Self::None)
787            }
788            "linear" => Ok(Self::Linear {
789                span: param.unwrap_or(DEFAULT_DECAY_SPAN),
790            }),
791            "exponential" => Ok(Self::Exponential {
792                half_life: param.unwrap_or(DEFAULT_HALF_LIFE),
793            }),
794            other => Err(format!(
795                "unknown decay mode {other:?} (expected one of: none, linear[:span], \
796                 exponential[:half-life])"
797            )),
798        }
799    }
800}
801
802/// What a record's anchor is worth as a **ranking multiplier**, in `[0.0, 1.0]`.
803///
804/// This is the whole of ADR-0013's `anchor_penalty`, and it is built on
805/// [`AnchorState`] and on nothing else — no branch term, no scope term. `scope` is
806/// a namespace and the anchor is the validity test; a second rule would give two
807/// answers to one question.
808///
809/// Two properties are load-bearing and are asserted by tests rather than left to
810/// the reader:
811///
812/// - **Nothing is zero.** Anchor drift demotes; it never deletes and never
813///   silences. A record about deleted code still comes back, ranked lower and
814///   labelled — that is the whole reason memory cannot live in the graph, whose
815///   authored layer prunes links to vanished symbols.
816/// - **Every state that [`AnchorState::applies`] ranks above every state that does
817///   not.** The applicability rule and the ranking cannot disagree.
818///
819/// The ordering *within* the two groups is a judgement, and it is this one:
820///
821/// | State | Penalty | Why |
822/// |---|---|---|
823/// | [`AnchorState::Valid`] | `1.00` | the association is in this tree in the same format — the strongest evidence there is |
824/// | [`AnchorState::Unanchored`] | `0.90` | true wherever the repository is, but it never claimed to be about *this* code |
825/// | [`AnchorState::Unverifiable`] | `0.50` | the node is here and the blob could not be compared: nothing was measured either way |
826/// | [`AnchorState::Vanished`] | `0.35` | the thing is gone — history, and often the most valuable record in the store |
827/// | [`AnchorState::Drifted`] | `0.25` | the code moved *underneath a key that still resolves*, so this is the one state that can actively mislead about code someone is looking at now |
828///
829/// Drifted below vanished is the deliberate part. A vanished record can mislead
830/// nobody — the code it describes is not there to be confused with anything — while
831/// a drifted one sits under a live key describing a version of it that no longer
832/// exists. Ranking vanished lowest would also punish exactly the records ADR-0013
833/// says are worth keeping most.
834#[must_use]
835pub fn anchor_penalty(state: AnchorState) -> f64 {
836    match state {
837        AnchorState::Valid => 1.0,
838        AnchorState::Unanchored => 0.90,
839        AnchorState::Unverifiable => 0.50,
840        AnchorState::Vanished => 0.35,
841        AnchorState::Drifted => 0.25,
842    }
843}
844
845/// How to recall.
846///
847/// [`RecallOptions::default`] is **every live record, ranked, with no age term** —
848/// the reproducible answer.
849#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
850pub struct RecallOptions<'a> {
851    /// Only records recorded in this namespace, matched exactly.
852    pub scope: Option<&'a str>,
853    /// Only records of this kind.
854    pub kind: Option<MemoryKind>,
855    /// Only records anchored to this node key.
856    pub anchor_key: Option<&'a str>,
857    /// Every whitespace-separated token must appear in the record's body, its
858    /// anchor key or its anchor path (case-insensitively). A **filter, not a
859    /// scorer**: the ranking formula has no lexical term, so which records come
860    /// back can depend on the query while how they are ranked cannot.
861    pub query: Option<&'a str>,
862    /// How age is priced in. Defaults to [`Decay::None`] — reproducible recall.
863    pub decay: Decay,
864    /// Drop records that do not apply to this tree. **Off by default**: an
865    /// unanchored or drifted record is demoted and labelled, not withheld, and a
866    /// lesson about deleted code is often the one worth reading.
867    pub applicable_only: bool,
868    /// At most this many records, applied **after** ranking so a limit returns the
869    /// best matches rather than the newest ones.
870    ///
871    /// **`None` and `Some(0)` are the same request: every record.** `0` is
872    /// unlimited here because [`window`] is the one place that decides what
873    /// `limit` means in this crate, and that is what it decides (issue #375).
874    /// Recall used to read `Some(0)` as *no records* — the third implementation
875    /// of one parameter that `window`'s doc warned about, and issue #447.
876    pub limit: Option<usize>,
877}
878
879/// One recalled record and the arithmetic that ranked it.
880///
881/// Every term is reported, not just the product: a ranking an agent cannot take
882/// apart is a ranking it has to trust, and the whole point of depreciating by
883/// evidence is that the evidence can be inspected.
884#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
885pub struct Recalled {
886    /// `base_confidence × anchor_penalty × decay_factor`, in `[0.0, 1.0]`.
887    /// **Computed here and stored in no column.**
888    pub score: f64,
889    /// The writer's stated confidence, or [`DEFAULT_BASE_CONFIDENCE`] when it
890    /// stated none.
891    pub base_confidence: f64,
892    /// [`anchor_penalty`] for this record's [`AnchorState`] against the current
893    /// tree.
894    pub anchor_penalty: f64,
895    /// [`Decay::factor`] for this record's age.
896    pub decay_factor: f64,
897    /// Generations between this record and the newest one in the store. `0` for
898    /// the newest record itself.
899    pub age: u64,
900    /// The record, with its anchor state and applicability resolved against the
901    /// tree this recall ran on.
902    pub record: MemoryRecord,
903}
904
905/// A ranked recall, with the state it was computed against.
906#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
907pub struct Recall {
908    /// Stable schema tag ([`RECALL_SCHEMA`]).
909    pub schema: &'static str,
910    /// The generation this recall was computed at — the newest record's id.
911    /// Reported because every `age` is relative to it.
912    pub generation: i64,
913    /// The decay mode used.
914    pub decay: Decay,
915    /// Whether that mode guarantees reproducible recall
916    /// ([`Decay::is_reproducible`]). Surfaced so a consumer that is depending on
917    /// reproducibility does not have to infer it from the mode token.
918    pub reproducible: bool,
919    /// The ranked records, best score first, ties broken by newest generation.
920    pub results: Vec<Recalled>,
921    /// Live records in the whole store, ignoring the options.
922    pub live: u64,
923    /// Superseded records in the whole store. **None of them is in `results`**:
924    /// supersession drops a record out of recall immediately and regardless of
925    /// age, because the test is a recorded pointer and not a clock.
926    pub superseded: u64,
927}
928
929/// What one [`crate::Store::forget_memory`] removed.
930#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
931pub struct MemoryForgotten {
932    /// The record that was deleted.
933    pub id: i64,
934    /// Records that were superseded **by** the deleted one and are therefore live
935    /// again.
936    ///
937    /// Forgetting a successor destroys the only recorded reason its predecessor
938    /// was dropped from live listing. Leaving the predecessor superseded would
939    /// make it invisible on the strength of a record that no longer exists —
940    /// supersession by ghost, which is precisely the inferred-not-recorded
941    /// failure the explicit pointer exists to prevent. So the pointer is cleared
942    /// and the predecessor returns, reported here rather than silently.
943    pub restored: Vec<i64>,
944}
945
946// --- Tier 2: the bounded, evictable cache ------------------------------------
947
948/// Stable schema tag on [`CacheSweep`] and [`CacheStats`].
949pub const CACHE_SCHEMA: &str = "roteiro.cache/v1";
950
951/// The default byte budget for the cache tier: **256 MB**, and raisable.
952///
953/// The number is a measurement, not a guess. On this repository `.git/roteiro/`
954/// is 49 MB against a 91 MB `.git` — the sidecar is already ~54% of the
955/// repository it describes — so a cache tier is not a new cost category, it is a
956/// bound on one that is currently unbounded in every direction. 256 MB is small
957/// against `.git`, trivial against a model store, and large enough that an
958/// ordinary session never evicts.
959///
960/// Erring small is deliberate and cheap: everything in this tier is re-derivable,
961/// and `build_context` is *proven* to reconstruct identically, so eviction costs
962/// cycles and never information. Erring large only costs disk. Neither error is
963/// expensive, which is why this is a default rather than a policy.
964pub const DEFAULT_CACHE_BUDGET_BYTES: u64 = 256 * 1024 * 1024;
965
966/// Environment variable that raises or lowers [`DEFAULT_CACHE_BUDGET_BYTES`], in
967/// **whole megabytes** — so a large repository can hold more without a rebuild.
968pub const CACHE_BUDGET_ENV: &str = "ROTEIRO_CACHE_BUDGET_MB";
969
970/// The configured cache budget in bytes: [`CACHE_BUDGET_ENV`] megabytes if it is
971/// set, otherwise [`DEFAULT_CACHE_BUDGET_BYTES`].
972///
973/// A value that cannot be read is an **error, not a fallback**. Silently ignoring
974/// it would run the default under a name that says otherwise, and an operator who
975/// asked for a bound has to be told the ask did not land.
976///
977/// # Errors
978/// Returns [`MemoryError::InvalidBudget`] if the variable is set to something
979/// that is not a whole number of megabytes, or to a number of megabytes that does
980/// not fit in bytes.
981pub fn cache_budget_bytes() -> Result<u64, MemoryError> {
982    let Some(raw) = std::env::var_os(CACHE_BUDGET_ENV) else {
983        return Ok(DEFAULT_CACHE_BUDGET_BYTES);
984    };
985    let raw = raw.to_string_lossy().into_owned();
986    let megabytes: u64 = raw
987        .trim()
988        .parse()
989        .map_err(|_| MemoryError::InvalidBudget(raw.clone()))?;
990    megabytes
991        .checked_mul(1024 * 1024)
992        .ok_or(MemoryError::InvalidBudget(raw))
993}
994
995/// The values [`crate::Store::agent_cache_put`] writes.
996///
997/// `anchor` is a **node key**; the blob stored beside it is captured by the store
998/// from the graph at write time, exactly as [`MemoryWrite`]'s is, so there is one
999/// place that decides what an anchor's evidence is.
1000#[derive(Debug, Clone, Copy)]
1001pub struct CacheWrite<'a> {
1002    /// The cache key. Content-addressed by the caller; nothing here interprets it.
1003    pub key: &'a str,
1004    /// The freshness witness. A reader compares it with the fingerprint the
1005    /// current graph yields and treats a mismatch as a miss — capacity eviction is
1006    /// a separate, orthogonal policy.
1007    pub fingerprint: &'a str,
1008    /// The cached payload.
1009    pub json: &'a str,
1010    /// The node key this entry is derived from, if any. Supplies the
1011    /// `anchor_valid` half of the eviction order.
1012    pub anchor: Option<&'a str>,
1013}
1014
1015/// One entry in the cache tier, with its anchor resolved against the current
1016/// graph.
1017#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1018pub struct CacheEntry {
1019    /// The cache key.
1020    pub key: String,
1021    /// The freshness witness stored with the payload.
1022    pub fingerprint: String,
1023    /// The cached payload.
1024    pub json: String,
1025    /// The entry's payload size — what the byte budget is spent on.
1026    pub bytes: u64,
1027    /// The sweep generation this entry was written in.
1028    pub generation: i64,
1029    /// The access tick it was last read or written at. A logical counter, never a
1030    /// clock.
1031    pub last_used: i64,
1032    /// How many times it has been read back.
1033    pub hits: u64,
1034    /// The node key it is derived from, if any.
1035    #[serde(skip_serializing_if = "Option::is_none")]
1036    pub anchor: Option<String>,
1037    /// What that anchor is worth against the **current** graph. Computed on read;
1038    /// no column holds it.
1039    pub anchor_state: AnchorState,
1040}
1041
1042/// What the cache tier currently holds, against what it is allowed to hold.
1043#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1044pub struct CacheStats {
1045    /// Stable schema tag ([`CACHE_SCHEMA`]).
1046    pub schema: &'static str,
1047    /// Entries in the tier.
1048    pub entries: u64,
1049    /// Bytes they occupy.
1050    pub bytes: u64,
1051    /// The budget those bytes are measured against.
1052    pub budget_bytes: u64,
1053    /// The current sweep generation.
1054    pub generation: i64,
1055}
1056
1057/// What one sweep of the cache tier did.
1058///
1059/// Reported in full — including what was **kept** and whether the tier is still
1060/// over budget — because a sweep that silently declined to free anything and a
1061/// sweep that had nothing to free look identical from the outside, and they mean
1062/// opposite things.
1063#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1064pub struct CacheSweep {
1065    /// Stable schema tag ([`CACHE_SCHEMA`]).
1066    pub schema: &'static str,
1067    /// The budget this sweep enforced.
1068    pub budget_bytes: u64,
1069    /// Entries considered.
1070    pub scanned: u64,
1071    /// Entries that could not be evicted: written in the current generation with
1072    /// a valid anchor, or the most-recently-used entry, which is always kept.
1073    pub pinned: u64,
1074    /// Entries deleted.
1075    pub evicted: u64,
1076    /// Bytes their deletion freed.
1077    pub freed_bytes: u64,
1078    /// Bytes still held after the sweep.
1079    pub retained_bytes: u64,
1080    /// **Whether the tier is still over budget** after the sweep — which happens
1081    /// when what remains is all pinned. Reported rather than hidden: a bound that
1082    /// silently fails to bind is worse than one that says so.
1083    pub over_budget: bool,
1084    /// The generation the tier advanced to. Entries written before it are
1085    /// evictable by the next sweep.
1086    pub generation: i64,
1087}
1088
1089// --- Persistence. Free helpers over a `Connection` (a `Transaction` derefs to
1090// one), mirroring the findings and media stores. Every statement here touches
1091// `agent_memory` and reads `nodes` for anchor evidence; nothing in this module
1092// ever *writes* `nodes` or `edges`. ---
1093
1094/// Columns of `agent_memory` plus the joined anchor evidence, in the order
1095/// [`record_from_row`] decodes them.
1096const RECORD_COLS: &str = "m.id, m.scope, m.kind, m.anchor_key, m.anchor_blob, m.anchor_path, \
1097     m.body, m.confidence, m.tree, m.created_at, m.superseded_by, m.superseded_at, \
1098     n.key, n.blob_hash";
1099
1100/// The `LEFT JOIN` that resolves an anchor against the **current** graph. Left,
1101/// not inner: a record whose anchor vanished must still come back, marked.
1102const RECORD_FROM: &str = " FROM agent_memory m LEFT JOIN nodes n ON n.key = m.anchor_key";
1103
1104/// Write one record, returning its new id (its generation).
1105///
1106/// Anchor evidence and the repo-state witness are captured here, from the graph,
1107/// so a caller cannot record an anchor blob that was never on the node.
1108pub(crate) fn record(conn: &Connection, write: &MemoryWrite<'_>) -> Result<i64, MemoryError> {
1109    write.validate()?;
1110
1111    // Supersession is resolved *before* the insert, so a bad reference costs
1112    // nothing: the caller gets an error and the store is untouched.
1113    if let Some(target) = write.supersedes {
1114        let existing: Option<Option<i64>> = conn
1115            .query_row(
1116                "SELECT superseded_by FROM agent_memory WHERE id = ?1",
1117                [target],
1118                |r| r.get(0),
1119            )
1120            .optional()?;
1121        match existing {
1122            None => return Err(MemoryError::NotFound(target)),
1123            Some(Some(by)) => return Err(MemoryError::AlreadySuperseded { id: target, by }),
1124            Some(None) => {}
1125        }
1126    }
1127
1128    // Anchor evidence, captured from the graph as it stands. A key that names no
1129    // node stores the key alone — the record is kept and reads back as
1130    // `Vanished`, never refused.
1131    let anchor: Option<(Option<String>, Option<String>)> = match write.anchor {
1132        Some(key) => Some(
1133            conn.query_row(
1134                "SELECT blob_hash, path FROM nodes WHERE key = ?1",
1135                [key],
1136                |r| Ok((r.get(0)?, r.get(1)?)),
1137            )
1138            .optional()?
1139            .unwrap_or((None, None)),
1140        ),
1141        None => None,
1142    };
1143    // The repo-state witness. `sync_state` is a single row that may not exist yet
1144    // in a store that has never synced, which is a legitimate state to record a
1145    // memory from.
1146    let tree: Option<String> = conn
1147        .query_row("SELECT tree FROM sync_state WHERE id = 0", [], |r| r.get(0))
1148        .optional()?;
1149
1150    conn.execute(
1151        "INSERT INTO agent_memory (
1152             scope, kind, anchor_key, anchor_blob, anchor_path, body, confidence, tree
1153         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1154        params![
1155            write.scope,
1156            write.kind.as_str(),
1157            write.anchor,
1158            anchor.as_ref().and_then(|(blob, _)| blob.as_deref()),
1159            anchor.as_ref().and_then(|(_, path)| path.as_deref()),
1160            write.body,
1161            write.confidence,
1162            tree,
1163        ],
1164    )?;
1165    let id = conn.last_insert_rowid();
1166
1167    // The supersession itself: an explicit pointer from the overruled record to
1168    // this one, plus the moment for a human reader. The moment is never read —
1169    // `superseded_by` alone decides what is live.
1170    if let Some(target) = write.supersedes {
1171        conn.execute(
1172            "UPDATE agent_memory
1173                SET superseded_by = ?1, superseded_at = datetime('now')
1174              WHERE id = ?2",
1175            params![id, target],
1176        )?;
1177    }
1178    Ok(id)
1179}
1180
1181/// Records matching `filter`, newest generation first.
1182pub(crate) fn records(
1183    conn: &Connection,
1184    filter: &MemoryFilter<'_>,
1185) -> Result<Vec<MemoryRecord>, StoreError> {
1186    let mut where_parts: Vec<&str> = Vec::new();
1187    let mut bound: Vec<String> = Vec::new();
1188    if let Some(scope) = filter.scope {
1189        where_parts.push("m.scope = ?");
1190        bound.push(scope.to_owned());
1191    }
1192    if let Some(kind) = filter.kind {
1193        where_parts.push("m.kind = ?");
1194        bound.push(kind.as_str().to_owned());
1195    }
1196    if let Some(key) = filter.anchor_key {
1197        where_parts.push("m.anchor_key = ?");
1198        bound.push(key.to_owned());
1199    }
1200    // The whole of "superseded records drop out of live listing immediately,
1201    // regardless of age": one clause on a recorded pointer, and no clock in it.
1202    if !filter.include_superseded {
1203        where_parts.push("m.superseded_by IS NULL");
1204    }
1205    let clause = if where_parts.is_empty() {
1206        String::new()
1207    } else {
1208        format!(" WHERE {}", where_parts.join(" AND "))
1209    };
1210    // `id DESC` is newest-generation-first. It is an `AUTOINCREMENT` integer, not
1211    // a timestamp, so this ordering is total and skew-proof across worktrees.
1212    // `Some(0)` is unlimited, exactly as `window` reads it — expressed as the
1213    // absence of a clause because SQL's `LIMIT 0` means the opposite. This is
1214    // the contract translated into SQL, not a second opinion about it.
1215    let limit = match filter.limit {
1216        Some(n) if n > 0 => format!(" LIMIT {n}"),
1217        _ => String::new(),
1218    };
1219    let sql = format!("SELECT {RECORD_COLS}{RECORD_FROM}{clause} ORDER BY m.id DESC{limit}");
1220    let mut stmt = conn.prepare(&sql)?;
1221    let mut rows = stmt.query(rusqlite::params_from_iter(bound))?;
1222    let mut out = Vec::new();
1223    while let Some(row) = rows.next()? {
1224        out.push(record_from_row(row)?);
1225    }
1226    Ok(out)
1227}
1228
1229/// The generation recall is computed against: the newest record's id, or `0` in
1230/// an empty store.
1231///
1232/// Read from the store rather than counted, because `AUTOINCREMENT` ids are not
1233/// dense — forgetting records leaves gaps, and a gap is still a generation that
1234/// happened.
1235pub(crate) fn generation(conn: &Connection) -> Result<i64, StoreError> {
1236    Ok(
1237        conn.query_row("SELECT COALESCE(MAX(id), 0) FROM agent_memory", [], |r| {
1238            r.get(0)
1239        })?,
1240    )
1241}
1242
1243/// Rank the live records, computing every term at retrieval time.
1244///
1245/// Three things this deliberately does not do, each of which would break
1246/// something ADR-0013 promises:
1247///
1248/// * **It writes nothing.** No score, no hit counter, no touch. Recall over an
1249///   unchanged store and an unchanged tree is therefore idempotent, which is what
1250///   makes `decay = none` byte-identical across runs.
1251/// * **It never sees a superseded record.** They are excluded in SQL, by a
1252///   recorded pointer with no clock in it, so a superseded record leaves recall
1253///   the moment its successor is written regardless of its age or score.
1254/// * **It consults no branch and no clock.** Applicability is
1255///   [`AnchorState::applies`], resolved against the tree in front of you.
1256pub(crate) fn recall(
1257    conn: &Connection,
1258    opts: &RecallOptions<'_>,
1259) -> Result<Vec<Recalled>, StoreError> {
1260    let generation = generation(conn)?;
1261    // No SQL limit: the limit is applied after ranking, so it returns the best
1262    // matches rather than the newest ones.
1263    let rows = records(
1264        conn,
1265        &MemoryFilter {
1266            scope: opts.scope,
1267            kind: opts.kind,
1268            anchor_key: opts.anchor_key,
1269            include_superseded: false,
1270            limit: None,
1271        },
1272    )?;
1273
1274    let query = opts.query.map(|q| q.trim().to_lowercase());
1275    let tokens: Vec<&str> = query
1276        .as_deref()
1277        .map(|q| q.split("::").flat_map(str::split_whitespace).collect())
1278        .unwrap_or_default();
1279
1280    let mut out: Vec<Recalled> = Vec::new();
1281    for record in rows {
1282        if opts.applicable_only && !record.applies {
1283            continue;
1284        }
1285        if !tokens.is_empty() && !matches_tokens(&record, &tokens) {
1286            continue;
1287        }
1288        let base_confidence = record.confidence.unwrap_or(DEFAULT_BASE_CONFIDENCE);
1289        let anchor_penalty = anchor_penalty(record.anchor_state);
1290        // `saturating_sub`: a record can never be newer than the newest one, but
1291        // an underflow here would be a silently enormous age rather than an error.
1292        let age = u64::try_from(generation.saturating_sub(record.id)).unwrap_or(0);
1293        let decay_factor = opts.decay.factor(age);
1294        out.push(Recalled {
1295            score: base_confidence * anchor_penalty * decay_factor,
1296            base_confidence,
1297            anchor_penalty,
1298            decay_factor,
1299            age,
1300            record,
1301        });
1302    }
1303    // `total_cmp`, not `partial_cmp`: every term is finite by construction, and a
1304    // comparator that can return `None` is one that can silently stop sorting.
1305    // Ties break by newest generation, so the order is total and reproducible.
1306    out.sort_by(|a, b| {
1307        b.score
1308            .total_cmp(&a.score)
1309            .then_with(|| b.record.id.cmp(&a.record.id))
1310    });
1311    // The one definition of `limit`, not a fourth reading of it: `0` is
1312    // unlimited, so `None` and `Some(0)` both ask for every record (issues #375,
1313    // #447). Offset `0` — recall has no paging parameter to offer, which is the
1314    // same call `query::search_memory` already makes for the same reason. A
1315    // `limit`-shaped offset is not worth inventing for a lens with no pages.
1316    window(&mut out, 0, opts.limit.unwrap_or(0));
1317    Ok(out)
1318}
1319
1320/// Whether every token appears in the record's body, anchor key or anchor path.
1321///
1322/// The anchor is searchable so a symbol name recalls what was learned about it;
1323/// `scope` is not, because it is a namespace with an exact-match filter of its own
1324/// and matching it loosely here would be the second applicability rule ADR-0013
1325/// refuses.
1326fn matches_tokens(record: &MemoryRecord, tokens: &[&str]) -> bool {
1327    let body = record.body.to_lowercase();
1328    let anchor_key = record
1329        .anchor
1330        .as_ref()
1331        .map(|a| a.key.to_lowercase())
1332        .unwrap_or_default();
1333    let anchor_path = record
1334        .anchor
1335        .as_ref()
1336        .and_then(|a| a.path.as_deref())
1337        .unwrap_or_default()
1338        .to_lowercase();
1339    tokens
1340        .iter()
1341        .all(|t| body.contains(t) || anchor_key.contains(t) || anchor_path.contains(t))
1342}
1343
1344/// One record by id, or `None` if it is not there.
1345pub(crate) fn get(conn: &Connection, id: i64) -> Result<Option<MemoryRecord>, StoreError> {
1346    let sql = format!("SELECT {RECORD_COLS}{RECORD_FROM} WHERE m.id = ?1");
1347    conn.query_row(&sql, [id], |row| Ok(record_from_row(row)))
1348        .optional()?
1349        .transpose()
1350}
1351
1352/// Delete one record, restoring anything it had superseded. `None` if there was
1353/// no such record.
1354pub(crate) fn forget(conn: &Connection, id: i64) -> Result<Option<MemoryForgotten>, StoreError> {
1355    let present: Option<i64> = conn
1356        .query_row("SELECT id FROM agent_memory WHERE id = ?1", [id], |r| {
1357            r.get(0)
1358        })
1359        .optional()?;
1360    if present.is_none() {
1361        return Ok(None);
1362    }
1363    // Whatever this record superseded, read before the pointers are cleared.
1364    let restored: Vec<i64> = {
1365        let mut stmt =
1366            conn.prepare("SELECT id FROM agent_memory WHERE superseded_by = ?1 ORDER BY id")?;
1367        let mut rows = stmt.query([id])?;
1368        let mut out = Vec::new();
1369        while let Some(row) = rows.next()? {
1370            out.push(row.get::<_, i64>(0)?);
1371        }
1372        out
1373    };
1374    // Clear them first: `superseded_by` is a foreign key, so the delete below
1375    // would be refused while any row still points here. Clearing rather than
1376    // cascading is the deliberate part — see `MemoryForgotten::restored`.
1377    conn.execute(
1378        "UPDATE agent_memory SET superseded_by = NULL, superseded_at = NULL
1379          WHERE superseded_by = ?1",
1380        [id],
1381    )?;
1382    conn.execute("DELETE FROM agent_memory WHERE id = ?1", [id])?;
1383    Ok(Some(MemoryForgotten { id, restored }))
1384}
1385
1386/// How many records are stored, split live / superseded.
1387pub(crate) fn counts(conn: &Connection) -> Result<(u64, u64), StoreError> {
1388    let (live, superseded): (i64, i64) = conn.query_row(
1389        "SELECT COALESCE(SUM(superseded_by IS NULL), 0), COALESCE(SUM(superseded_by IS NOT NULL), 0)
1390           FROM agent_memory",
1391        [],
1392        |r| Ok((r.get(0)?, r.get(1)?)),
1393    )?;
1394    Ok((
1395        u64::try_from(live).unwrap_or(0),
1396        u64::try_from(superseded).unwrap_or(0),
1397    ))
1398}
1399
1400// --- Tier 2 persistence: the bounded cache and its sweep ---------------------
1401
1402/// Advance the access tick and return the new value.
1403///
1404/// The durable equivalent of `ModelCache`'s position in its `Vec`: strictly
1405/// increasing, unique per access, and **not a clock** — the store is shared across
1406/// worktrees, where wall-clock is not monotone and second granularity ties.
1407fn next_tick(conn: &Connection) -> Result<i64, StoreError> {
1408    Ok(conn.query_row(
1409        "UPDATE agent_cache_clock SET ticks = ticks + 1 WHERE id = 0 RETURNING ticks",
1410        [],
1411        |r| r.get(0),
1412    )?)
1413}
1414
1415/// The current sweep generation.
1416fn cache_generation(conn: &Connection) -> Result<i64, StoreError> {
1417    Ok(conn.query_row(
1418        "SELECT generation FROM agent_cache_clock WHERE id = 0",
1419        [],
1420        |r| r.get(0),
1421    )?)
1422}
1423
1424/// Write (or replace) one cache entry.
1425///
1426/// `bytes` is the payload's own size, computed here so the sweep can total and
1427/// order the tier without reading every `json` in it. `hits` survives a
1428/// replacement: the counter is about how often this *key* is worth having, and the
1429/// value under it is re-derivable by definition.
1430pub(crate) fn cache_put(conn: &Connection, write: &CacheWrite<'_>) -> Result<(), StoreError> {
1431    let bytes = u64::try_from(write.key.len() + write.fingerprint.len() + write.json.len())
1432        .unwrap_or(u64::MAX);
1433    // Anchor evidence, captured from the graph as it stands — the same capture
1434    // the episodic tier does, so the two agree about what an anchor was worth.
1435    let anchor_blob: Option<String> = match write.anchor {
1436        Some(key) => conn
1437            .query_row("SELECT blob_hash FROM nodes WHERE key = ?1", [key], |r| {
1438                r.get(0)
1439            })
1440            .optional()?
1441            .flatten(),
1442        None => None,
1443    };
1444    let tick = next_tick(conn)?;
1445    let generation = cache_generation(conn)?;
1446    conn.execute(
1447        "INSERT INTO agent_cache
1448             (key, fingerprint, json, bytes, generation, last_used, hits, anchor_key, anchor_blob)
1449         VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, ?7, ?8)
1450         ON CONFLICT(key) DO UPDATE SET
1451             fingerprint = excluded.fingerprint,
1452             json        = excluded.json,
1453             bytes       = excluded.bytes,
1454             generation  = excluded.generation,
1455             last_used   = excluded.last_used,
1456             anchor_key  = excluded.anchor_key,
1457             anchor_blob = excluded.anchor_blob",
1458        params![
1459            write.key,
1460            write.fingerprint,
1461            write.json,
1462            i64::try_from(bytes).unwrap_or(i64::MAX),
1463            generation,
1464            tick,
1465            write.anchor,
1466            anchor_blob,
1467        ],
1468    )?;
1469    Ok(())
1470}
1471
1472/// Columns of `agent_cache` plus the joined anchor evidence, in the order
1473/// [`cache_entry_from_row`] decodes them.
1474///
1475/// The **full** row, payload included — for the paths that actually hand an entry
1476/// back to a caller. The sweep deliberately does not use this; see [`SWEEP_COLS`].
1477const CACHE_COLS: &str = "c.key, c.fingerprint, c.json, c.bytes, c.generation, c.last_used, \
1478     c.hits, c.anchor_key, c.anchor_blob, n.key, n.blob_hash";
1479
1480/// The columns the **sweep** needs, which is every column eviction is decided by
1481/// and **not one byte of payload**.
1482///
1483/// This is the whole reason `agent_cache.bytes` exists. A sweep has to order and
1484/// total the tier, and if it did that by measuring payloads it would have to read
1485/// them — so a maintenance pass over a full tier would pull up to the entire
1486/// budget into memory, and the budget now defaults to 256 MB. Storing the size at
1487/// write time means the sweep can do its arithmetic from a handful of integers.
1488///
1489/// So: `json`, `fingerprint` and `hits` are absent, and their absence is the
1490/// point. `hits` is not a policy input either — eviction orders by
1491/// `(anchor_valid, last_used)`, never by popularity — so selecting it would be
1492/// reading a column the policy is not allowed to consult.
1493/// `the_sweep_query_names_no_payload_column` fails if any of the three comes back.
1494const SWEEP_COLS: &str = "c.key, c.bytes, c.generation, c.last_used, c.anchor_key, \
1495     c.anchor_blob, n.key, n.blob_hash";
1496
1497/// The `LEFT JOIN` resolving a cache entry's anchor against the current graph.
1498///
1499/// **Shared by both column sets above**, which is what stops the sweep and the
1500/// inspection path from disagreeing about which rows exist or how an anchor
1501/// resolves. Neither adds a `WHERE`, so both see exactly the tier; both decode the
1502/// anchor through [`resolve_anchor`]. `the_sweep_and_the_full_read_agree_row_for_row`
1503/// pins that they do.
1504const CACHE_FROM: &str = " FROM agent_cache c LEFT JOIN nodes n ON n.key = c.anchor_key";
1505
1506/// Read one entry back, **recording the access**: `hits` increments and
1507/// `last_used` advances.
1508///
1509/// This is the one read in this module that writes, and the write is the cache's
1510/// own bookkeeping — `hits` and `last_used` exist to be moved by exactly this, and
1511/// a hit counter nothing increments is a column that lies. It touches nothing
1512/// outside `agent_cache`, so the *ranked* read this module is really about
1513/// ([`recall`]) stays free of it and stays reproducible.
1514pub(crate) fn cache_get(conn: &Connection, key: &str) -> Result<Option<CacheEntry>, StoreError> {
1515    let sql = format!("SELECT {CACHE_COLS}{CACHE_FROM} WHERE c.key = ?1");
1516    let entry = conn
1517        .query_row(&sql, [key], |row| Ok(cache_entry_from_row(row)))
1518        .optional()?
1519        .transpose()?;
1520    if entry.is_some() {
1521        let tick = next_tick(conn)?;
1522        conn.execute(
1523            "UPDATE agent_cache SET hits = hits + 1, last_used = ?1 WHERE key = ?2",
1524            params![tick, key],
1525        )?;
1526    }
1527    Ok(entry)
1528}
1529
1530/// Every entry, ordered by key. Does **not** record an access — this is the
1531/// inspection path (stats, the sweep, tests), and inspecting a cache is not using
1532/// it.
1533pub(crate) fn cache_entries(conn: &Connection) -> Result<Vec<CacheEntry>, StoreError> {
1534    let sql = format!("SELECT {CACHE_COLS}{CACHE_FROM} ORDER BY c.key");
1535    let mut stmt = conn.prepare(&sql)?;
1536    let mut rows = stmt.query([])?;
1537    let mut out = Vec::new();
1538    while let Some(row) = rows.next()? {
1539        out.push(cache_entry_from_row(row)?);
1540    }
1541    Ok(out)
1542}
1543
1544/// One entry as the **sweep** sees it: what eviction is decided by, and nothing
1545/// else.
1546///
1547/// Deliberately not a [`CacheEntry`]. A `CacheEntry` carries the payload, and a
1548/// sweep that held one per row would defeat the purpose of storing `bytes` at
1549/// all — the type is the guard, because there is no field here for a payload to
1550/// arrive in.
1551#[derive(Debug, Clone)]
1552struct SweepRow {
1553    key: String,
1554    /// The size recorded at write time. **The authority for every byte the sweep
1555    /// totals or compares** — never `length(json)`, which is what reading the
1556    /// payload would amount to.
1557    bytes: u64,
1558    generation: i64,
1559    last_used: i64,
1560    anchor_state: AnchorState,
1561}
1562
1563/// Every entry, as [`SweepRow`]s — the narrow read the sweep runs.
1564///
1565/// Same table and same join as [`cache_entries`] ([`CACHE_FROM`]), same anchor
1566/// rule ([`resolve_anchor`]), neither with a `WHERE`: the two cannot disagree
1567/// about which rows exist or what an anchor is worth. They differ in exactly one
1568/// respect, which is that this one does not read the payload.
1569fn sweep_rows(conn: &Connection) -> Result<Vec<SweepRow>, StoreError> {
1570    let sql = format!("SELECT {SWEEP_COLS}{CACHE_FROM} ORDER BY c.key");
1571    let mut stmt = conn.prepare(&sql)?;
1572    let mut rows = stmt.query([])?;
1573    let mut out = Vec::new();
1574    while let Some(row) = rows.next()? {
1575        let bytes: i64 = row.get(1)?;
1576        let anchor_key: Option<String> = row.get(4)?;
1577        let anchor_blob: Option<String> = row.get(5)?;
1578        let node_key: Option<String> = row.get(6)?;
1579        let node_blob: Option<String> = row.get(7)?;
1580        out.push(SweepRow {
1581            key: row.get(0)?,
1582            bytes: u64::try_from(bytes).unwrap_or(0),
1583            generation: row.get(2)?,
1584            last_used: row.get(3)?,
1585            anchor_state: resolve_anchor(
1586                anchor_key.as_deref(),
1587                anchor_blob.as_deref(),
1588                node_key.as_deref(),
1589                node_blob.as_deref(),
1590            ),
1591        });
1592    }
1593    Ok(out)
1594}
1595
1596/// Delete one entry, returning whether there was one.
1597pub(crate) fn cache_forget(conn: &Connection, key: &str) -> Result<bool, StoreError> {
1598    Ok(conn.execute("DELETE FROM agent_cache WHERE key = ?1", [key])? > 0)
1599}
1600
1601/// What the tier holds against `budget_bytes`.
1602pub(crate) fn cache_stats(conn: &Connection, budget_bytes: u64) -> Result<CacheStats, StoreError> {
1603    let (entries, bytes): (i64, i64) = conn.query_row(
1604        "SELECT COUNT(*), COALESCE(SUM(bytes), 0) FROM agent_cache",
1605        [],
1606        |r| Ok((r.get(0)?, r.get(1)?)),
1607    )?;
1608    Ok(CacheStats {
1609        schema: CACHE_SCHEMA,
1610        entries: u64::try_from(entries).unwrap_or(0),
1611        bytes: u64::try_from(bytes).unwrap_or(0),
1612        budget_bytes,
1613        generation: cache_generation(conn)?,
1614    })
1615}
1616
1617/// **How many of the evictable entries must go**, given their sizes in eviction
1618/// order (first to go first), the bytes held by entries that cannot be evicted,
1619/// and the budget.
1620///
1621/// A pure function, and a deliberate port of `rto-llama`'s `lru_evict_count`
1622/// (`llama.rs:120-137`, pinned by `budget_evicts_oldest_until_it_fits`) rather
1623/// than a new policy: drop the oldest until the remainder fits. The one structural
1624/// difference is where "always keep the most-recently-used entry" lives — there it
1625/// is a `len - evict > 1` guard, here the caller has already moved that entry into
1626/// `pinned_bytes`, because this tier pins other rows too and one rule for all of
1627/// them is simpler than two.
1628///
1629/// Consequently this **can** return short of the budget: when everything left is
1630/// pinned, the tier stays over. That is the ADR's rule, not a bug — a session's
1631/// own just-written work is not thrown away by the maintenance pass behind it —
1632/// and [`CacheSweep::over_budget`] says so out loud.
1633fn evict_count(evictable_lru_first: &[u64], pinned_bytes: u64, budget_bytes: u64) -> usize {
1634    let mut total: u64 = pinned_bytes.saturating_add(evictable_lru_first.iter().sum());
1635    let mut evict = 0;
1636    while evict < evictable_lru_first.len() && total > budget_bytes {
1637        total = total.saturating_sub(evictable_lru_first[evict]);
1638        evict += 1;
1639    }
1640    evict
1641}
1642
1643/// Sweep the cache tier down to `budget_bytes`, oldest-first on
1644/// `(anchor_valid ASC, last_used ASC)`, and advance the generation.
1645///
1646/// **Nothing episodic is reachable from here.** Every statement names
1647/// `agent_cache`; `agent_memory` has no `bytes`, no `last_used` and no `hits`, so
1648/// there is no column for this policy to grip it by even by mistake. That is the
1649/// two-tier split doing its job: re-derivable ⇒ evictable, episodic ⇒ never
1650/// silently evicted.
1651///
1652/// Three classes of entry are never evicted:
1653///
1654/// * anything in the episodic tier, as above;
1655/// * an entry written in the **current generation** whose anchor still applies —
1656///   the session's own work, which the maintenance pass that follows it must not
1657///   undo;
1658/// * the **most-recently-used** entry, always, even if it alone exceeds the budget
1659///   — `ModelCache`'s rule, for `ModelCache`'s reason: what was just asked for has
1660///   to be there.
1661///
1662/// **This reads no payloads.** It runs the narrow [`sweep_rows`] query, so
1663/// deciding what to evict costs a few integers per entry rather than the tier's
1664/// contents — which is what `agent_cache.bytes` is *for*, and what makes a
1665/// maintenance pass over a full 256 MB tier affordable.
1666pub(crate) fn cache_sweep(conn: &Connection, budget_bytes: u64) -> Result<CacheSweep, StoreError> {
1667    let generation = cache_generation(conn)?;
1668    let entries = sweep_rows(conn)?;
1669    let scanned = u64::try_from(entries.len()).unwrap_or(u64::MAX);
1670
1671    // The most-recently-used entry, by the tick counter: always kept.
1672    let mru = entries.iter().max_by_key(|e| e.last_used).map(|e| &e.key);
1673
1674    let mut pinned_bytes: u64 = 0;
1675    let mut candidates: Vec<&SweepRow> = Vec::new();
1676    for entry in &entries {
1677        let is_mru = mru.is_some_and(|k| *k == entry.key);
1678        let own_work = entry.generation >= generation && entry.anchor_state.applies();
1679        if is_mru || own_work {
1680            pinned_bytes = pinned_bytes.saturating_add(entry.bytes);
1681        } else {
1682            candidates.push(entry);
1683        }
1684    }
1685    // The eviction order, exactly as ADR-0013 states it: entries whose anchor no
1686    // longer applies go before entries that still describe this tree, and within
1687    // each group the least recently used goes first. `key` breaks the last tie so
1688    // a sweep is deterministic even on a store where two entries somehow share a
1689    // tick.
1690    candidates.sort_by(|a, b| {
1691        a.anchor_state
1692            .applies()
1693            .cmp(&b.anchor_state.applies())
1694            .then_with(|| a.last_used.cmp(&b.last_used))
1695            .then_with(|| a.key.cmp(&b.key))
1696    });
1697
1698    let sizes: Vec<u64> = candidates.iter().map(|e| e.bytes).collect();
1699    let evict = evict_count(&sizes, pinned_bytes, budget_bytes);
1700    let mut freed_bytes: u64 = 0;
1701    for entry in candidates.iter().take(evict) {
1702        conn.execute("DELETE FROM agent_cache WHERE key = ?1", [&entry.key])?;
1703        freed_bytes = freed_bytes.saturating_add(entry.bytes);
1704    }
1705
1706    let held: u64 = entries.iter().map(|e| e.bytes).sum();
1707    let retained_bytes = held.saturating_sub(freed_bytes);
1708    // The generation advances *after* the sweep, so what this session wrote is
1709    // pinned for this pass and evictable by the next one. Without the advance the
1710    // pin would be permanent and the budget would never bind.
1711    let generation: i64 = conn.query_row(
1712        "UPDATE agent_cache_clock SET generation = generation + 1 WHERE id = 0
1713         RETURNING generation",
1714        [],
1715        |r| r.get(0),
1716    )?;
1717    Ok(CacheSweep {
1718        schema: CACHE_SCHEMA,
1719        budget_bytes,
1720        scanned,
1721        pinned: u64::try_from(entries.len().saturating_sub(candidates.len())).unwrap_or(0),
1722        evicted: u64::try_from(evict).unwrap_or(0),
1723        freed_bytes,
1724        retained_bytes,
1725        over_budget: retained_bytes > budget_bytes,
1726        generation,
1727    })
1728}
1729
1730/// Decode an `agent_cache` row joined against `nodes`.
1731fn cache_entry_from_row(row: &rusqlite::Row<'_>) -> Result<CacheEntry, StoreError> {
1732    let anchor_key: Option<String> = row.get(7)?;
1733    let anchor_blob: Option<String> = row.get(8)?;
1734    let node_key: Option<String> = row.get(9)?;
1735    let node_blob: Option<String> = row.get(10)?;
1736    let bytes: i64 = row.get(3)?;
1737    let hits: i64 = row.get(6)?;
1738    Ok(CacheEntry {
1739        key: row.get(0)?,
1740        fingerprint: row.get(1)?,
1741        json: row.get(2)?,
1742        bytes: u64::try_from(bytes).unwrap_or(0),
1743        generation: row.get(4)?,
1744        last_used: row.get(5)?,
1745        hits: u64::try_from(hits).unwrap_or(0),
1746        // The same rule the episodic tier reads by — one implementation, so the
1747        // two tiers can never disagree about what an anchor is worth.
1748        anchor_state: resolve_anchor(
1749            anchor_key.as_deref(),
1750            anchor_blob.as_deref(),
1751            node_key.as_deref(),
1752            node_blob.as_deref(),
1753        ),
1754        anchor: anchor_key,
1755    })
1756}
1757
1758/// **The anchor rule, in one place.** What a recorded anchor is worth against the
1759/// node the `LEFT JOIN` resolved it to — `node_key` of `None` meaning there is no
1760/// such node now, which is the vanished case rather than a missing column.
1761///
1762/// Both tiers call this and neither has a copy: the episodic tier ranks on it and
1763/// the cache tier orders eviction by it, and two implementations of one rule is
1764/// how they would come to disagree about what "applies here" means.
1765fn resolve_anchor(
1766    anchor_key: Option<&str>,
1767    anchor_blob: Option<&str>,
1768    node_key: Option<&str>,
1769    node_blob: Option<&str>,
1770) -> AnchorState {
1771    match (anchor_key, node_key) {
1772        (None, _) => AnchorState::Unanchored,
1773        (Some(_), None) => AnchorState::Vanished,
1774        (Some(_), Some(_)) => match (anchor_blob, node_blob) {
1775            // Both halves of the stable pair are present, so drift is a real
1776            // comparison rather than an assumption.
1777            (Some(captured), Some(current)) if captured == current => AnchorState::Valid,
1778            (Some(_), Some(_)) => AnchorState::Drifted,
1779            // One side has no blob: the node is there, but nothing can be
1780            // concluded about the code under it. Said plainly rather than
1781            // rounded up to `Valid`.
1782            _ => AnchorState::Unverifiable,
1783        },
1784    }
1785}
1786
1787/// Decode an `agent_memory` row joined against `nodes`.
1788fn record_from_row(row: &rusqlite::Row<'_>) -> Result<MemoryRecord, StoreError> {
1789    let kind_token: String = row.get(2)?;
1790    let kind = MemoryKind::from_token(&kind_token)
1791        .ok_or_else(|| StoreError::Corrupt(format!("unknown memory kind: {kind_token}")))?;
1792    let anchor_key: Option<String> = row.get(3)?;
1793    let anchor_blob: Option<String> = row.get(4)?;
1794    // Whether the anchored node exists *now*, from the LEFT JOIN: `NULL` means no
1795    // such node, which is the vanished case rather than a missing column.
1796    let node_key: Option<String> = row.get(12)?;
1797    let node_blob: Option<String> = row.get(13)?;
1798
1799    let anchor_state = resolve_anchor(
1800        anchor_key.as_deref(),
1801        anchor_blob.as_deref(),
1802        node_key.as_deref(),
1803        node_blob.as_deref(),
1804    );
1805    let anchor = anchor_key.map(|key| MemoryAnchor {
1806        key,
1807        blob: anchor_blob,
1808        path: row.get(5).unwrap_or(None),
1809    });
1810    Ok(MemoryRecord {
1811        id: row.get(0)?,
1812        scope: row.get(1)?,
1813        kind,
1814        anchor,
1815        anchor_state,
1816        // Derived from the state, in one place, rather than recomputed by every
1817        // consumer — the scope rule has exactly one implementation.
1818        applies: anchor_state.applies(),
1819        body: row.get(6)?,
1820        confidence: row.get(7)?,
1821        tree: row.get(8)?,
1822        created_at: row.get(9)?,
1823        superseded_by: row.get(10)?,
1824        superseded_at: row.get(11)?,
1825    })
1826}
1827
1828#[cfg(test)]
1829mod tests {
1830    use super::{
1831        AnchorState, CACHE_COLS, DEFAULT_DECAY_SPAN, DEFAULT_HALF_LIFE, DEFAULT_MEMORY_SCOPE,
1832        Decay, MAX_MEMORY_BODY, MAX_MEMORY_SCOPE, MemoryKind, MemoryWrite, SWEEP_COLS,
1833        anchor_penalty, cache_entries, cache_sweep, evict_count, sweep_rows,
1834    };
1835
1836    fn write(body: &str) -> MemoryWrite<'_> {
1837        MemoryWrite {
1838            scope: DEFAULT_MEMORY_SCOPE,
1839            kind: MemoryKind::Lesson,
1840            anchor: None,
1841            body,
1842            confidence: None,
1843            supersedes: None,
1844        }
1845    }
1846
1847    // `agent_memory_does_not_bump_the_extraction_version` stood here. The
1848    // invariant it was named for — *memory must not invalidate the fact cache* —
1849    // now lives in `tests/sync.rs` as
1850    // `memory_writes_do_not_invalidate_the_fact_cache`, stated as a property of
1851    // memory writes rather than as an equality on `EXTRACT_VERSION`. The history
1852    // that argues for the change is recorded on that test.
1853    //
1854    // In short: `EXTRACT_VERSION` is global, so pinning its value here asserted
1855    // the whole crate's extraction work rather than memory's share of it, and it
1856    // could not catch what it was named for — a memory write that really did
1857    // reach extraction surfaces as stale cached facts, not as an unexpected
1858    // number.
1859
1860    #[test]
1861    fn kind_tokens_round_trip_and_reject_the_unknown() {
1862        for kind in MemoryKind::ALL {
1863            assert_eq!(MemoryKind::from_token(kind.as_str()), Some(kind));
1864            assert_eq!(kind.to_string(), kind.as_str());
1865        }
1866        assert_eq!(MemoryKind::from_token("note"), None);
1867        assert_eq!(MemoryKind::from_token("Lesson"), None);
1868        let err = "note".parse::<MemoryKind>().expect_err("unknown kind");
1869        assert!(
1870            err.contains("lesson"),
1871            "the error lists the vocabulary: {err}"
1872        );
1873    }
1874
1875    #[test]
1876    fn anchor_state_tokens_and_staleness() {
1877        assert!(AnchorState::Drifted.is_stale());
1878        assert!(AnchorState::Vanished.is_stale());
1879        // The three that are *not* stale, said explicitly: `Unverifiable` in
1880        // particular must not be treated as drift — nothing was measured.
1881        for state in [
1882            AnchorState::Unanchored,
1883            AnchorState::Valid,
1884            AnchorState::Unverifiable,
1885        ] {
1886            assert!(!state.is_stale(), "{state} must not read as stale");
1887        }
1888        assert_eq!(AnchorState::Vanished.to_string(), "vanished");
1889    }
1890
1891    #[test]
1892    fn validation_names_what_was_actually_wrong() {
1893        write("a real lesson").validate().expect("the good case");
1894
1895        let over_long = "x".repeat(MAX_MEMORY_BODY + 1);
1896        for (case, w) in [
1897            ("empty body", write("")),
1898            ("whitespace body", write("   \n\t ")),
1899            ("over-long body", write(&over_long)),
1900        ] {
1901            assert!(w.validate().is_err(), "{case} must be refused");
1902        }
1903
1904        let long_scope = "s".repeat(MAX_MEMORY_SCOPE + 1);
1905        for scope in ["", " repo", "repo ", "re\npo", &long_scope] {
1906            let w = MemoryWrite {
1907                scope,
1908                ..write("body")
1909            };
1910            assert!(w.validate().is_err(), "scope {scope:?} must be refused");
1911        }
1912
1913        for confidence in [Some(0.0), Some(1.0), Some(0.5), None] {
1914            let w = MemoryWrite {
1915                confidence,
1916                ..write("body")
1917            };
1918            w.validate().expect("a probability is fine");
1919        }
1920        for confidence in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
1921            let w = MemoryWrite {
1922                confidence: Some(confidence),
1923                ..write("body")
1924            };
1925            assert!(
1926                w.validate().is_err(),
1927                "{confidence} is not a probability and must be refused"
1928            );
1929        }
1930    }
1931
1932    // --- Ranking: the two pure functions the recall score is built from --------
1933
1934    /// **`none` has no age term at all.** This is the property the whole
1935    /// reproducibility claim rests on: if the factor varied with age under `none`,
1936    /// recall would depend on how much had been written since, and "byte-identical
1937    /// across runs for a fixed repo state" would be false the moment anything else
1938    /// was recorded.
1939    #[test]
1940    fn decay_none_is_exactly_one_at_every_age() {
1941        for age in [0, 1, 7, 1_000, u64::from(u32::MAX)] {
1942            assert!(
1943                (Decay::None.factor(age) - 1.0).abs() < f64::EPSILON,
1944                "none must not price age at all, but age {age} moved it",
1945            );
1946        }
1947        assert!(Decay::None.is_reproducible());
1948        assert!(!Decay::Linear { span: 10 }.is_reproducible());
1949        assert!(!Decay::Exponential { half_life: 10 }.is_reproducible());
1950    }
1951
1952    /// Both age modes start at `1.0`, never leave `[0.0, 1.0]`, and never increase
1953    /// with age. Monotonicity is the part worth pinning: a decay that rose
1954    /// anywhere would rank an older record above a newer identical one.
1955    #[test]
1956    fn decay_modes_start_at_one_and_never_rise() {
1957        for decay in [
1958            Decay::Linear { span: 8 },
1959            Decay::Linear {
1960                span: DEFAULT_DECAY_SPAN,
1961            },
1962            Decay::Exponential { half_life: 4 },
1963            Decay::Exponential {
1964                half_life: DEFAULT_HALF_LIFE,
1965            },
1966        ] {
1967            assert!(
1968                (decay.factor(0) - 1.0).abs() < f64::EPSILON,
1969                "{decay} must not discount the newest record",
1970            );
1971            let mut previous = f64::INFINITY;
1972            for age in 0..64_u64 {
1973                let f = decay.factor(age);
1974                assert!((0.0..=1.0).contains(&f), "{decay} at age {age} gave {f}");
1975                assert!(f <= previous, "{decay} rose at age {age}");
1976                previous = f;
1977            }
1978        }
1979        // The shapes themselves, at the points that name them.
1980        assert!((Decay::Linear { span: 10 }.factor(5) - 0.5).abs() < 1e-12);
1981        assert!((Decay::Exponential { half_life: 10 }.factor(10) - 0.5).abs() < 1e-12);
1982        assert!(
1983            (Decay::Exponential { half_life: 10 }.factor(20) - 0.25).abs() < 1e-12,
1984            "two half-lives is a quarter",
1985        );
1986    }
1987
1988    /// Linear reaches zero and stays there; exponential never does. Both are
1989    /// **rankings, not filters** — a zero factor sorts a record last and returns
1990    /// it, which is asserted where recall is (`tests/agent_memory.rs`).
1991    #[test]
1992    fn linear_bottoms_out_and_exponential_does_not() {
1993        let linear = Decay::Linear { span: 10 };
1994        assert!(linear.factor(10).abs() < f64::EPSILON);
1995        assert!(
1996            linear.factor(10_000).abs() < f64::EPSILON,
1997            "and stays there"
1998        );
1999        let exponential = Decay::Exponential { half_life: 10 };
2000        assert!(
2001            exponential.factor(10_000) > 0.0,
2002            "an exponential is never quite zero",
2003        );
2004        // A degenerate parameter is clamped rather than dividing by zero.
2005        assert!((Decay::Linear { span: 0 }.factor(0) - 1.0).abs() < f64::EPSILON);
2006        assert!(Decay::Linear { span: 0 }.factor(1).abs() < f64::EPSILON);
2007        assert!((Decay::Exponential { half_life: 0 }.factor(0) - 1.0).abs() < f64::EPSILON);
2008    }
2009
2010    #[test]
2011    fn decay_tokens_round_trip_and_reject_the_unknown() {
2012        for decay in [
2013            Decay::None,
2014            Decay::Linear { span: 7 },
2015            Decay::Exponential { half_life: 9 },
2016        ] {
2017            assert_eq!(
2018                decay.to_string().parse::<Decay>(),
2019                Ok(decay),
2020                "{decay} must round-trip through its token",
2021            );
2022        }
2023        assert_eq!(
2024            "linear".parse::<Decay>(),
2025            Ok(Decay::Linear {
2026                span: DEFAULT_DECAY_SPAN
2027            }),
2028            "a bare mode takes its documented default span",
2029        );
2030        assert_eq!(
2031            "exponential".parse::<Decay>(),
2032            Ok(Decay::Exponential {
2033                half_life: DEFAULT_HALF_LIFE
2034            })
2035        );
2036        assert_eq!(Decay::default(), Decay::None, "reproducible by default");
2037        for bad in ["clock", "none:5", "linear:soon", ""] {
2038            assert!(bad.parse::<Decay>().is_err(), "{bad:?} must be refused");
2039        }
2040    }
2041
2042    /// **The ranking and the applicability rule cannot disagree.** Every state
2043    /// that [`AnchorState::applies`] must outrank every state that does not, and
2044    /// **nothing may be zero** — drift demotes, it never deletes, and a penalty of
2045    /// zero is deletion wearing a ranking's clothes.
2046    #[test]
2047    fn anchor_penalty_demotes_without_ever_silencing() {
2048        let states = [
2049            AnchorState::Unanchored,
2050            AnchorState::Valid,
2051            AnchorState::Drifted,
2052            AnchorState::Vanished,
2053            AnchorState::Unverifiable,
2054        ];
2055        for state in states {
2056            let p = anchor_penalty(state);
2057            assert!(p > 0.0, "{state} was silenced, not demoted");
2058            assert!(p <= 1.0, "{state} scored above the maximum");
2059        }
2060        let worst_applying = states
2061            .into_iter()
2062            .filter(|s| s.applies())
2063            .map(anchor_penalty)
2064            .fold(f64::INFINITY, f64::min);
2065        let best_not_applying = states
2066            .into_iter()
2067            .filter(|s| !s.applies())
2068            .map(anchor_penalty)
2069            .fold(0.0, f64::max);
2070        assert!(
2071            worst_applying > best_not_applying,
2072            "a record that applies here must outrank every record that does not \
2073             ({worst_applying} vs {best_not_applying})",
2074        );
2075        // Drifted is the one state that can mislead about code still under its
2076        // key, so it — not vanished — is ranked lowest. A lesson about deleted
2077        // code is often the most valuable record in the store.
2078        assert!(
2079            anchor_penalty(AnchorState::Vanished) > anchor_penalty(AnchorState::Drifted),
2080            "a record about deleted code must not be the most demoted of all",
2081        );
2082    }
2083
2084    // --- The eviction policy, as a pure function ------------------------------
2085
2086    /// **Parity with the policy this ports.** `rto-llama`'s `lru_evict_count`
2087    /// (`llama.rs:120-137`) is pinned by `tests::budget_evicts_oldest_until_it_
2088    /// fits`, and these are that test's cases restated on this signature: three
2089    /// 100-byte entries, the newest of which is pinned by the caller as
2090    /// `pinned_bytes` rather than by a `len - evict > 1` guard.
2091    ///
2092    /// Stated as parity on purpose. The value of porting an existing policy
2093    /// instead of inventing one is entirely lost if the port quietly behaves
2094    /// differently, so the numbers are the same numbers.
2095    #[test]
2096    fn eviction_matches_the_model_cache_policy_it_ports() {
2097        // `lru_evict_count(&[100, 100, 100], 250) == 1`
2098        assert_eq!(evict_count(&[100, 100], 100, 250), 1);
2099        // `… == 2` at a budget of zero: everything goes but the pinned entry.
2100        assert_eq!(evict_count(&[100, 100], 100, 0), 2);
2101        // `… == 0` when it already fits.
2102        assert_eq!(evict_count(&[100, 100], 100, 1000), 0);
2103        // Exactly at the budget is not over it.
2104        assert_eq!(evict_count(&[100, 100], 100, 300), 0);
2105    }
2106
2107    /// **Always keep at least one entry, even one that alone blows the budget.**
2108    /// `ModelCache`'s rule, for `ModelCache`'s reason: what was just asked for has
2109    /// to be there. Here the caller pins it, so this function's job is only to
2110    /// never evict what it was not given.
2111    #[test]
2112    fn nothing_evictable_means_nothing_evicted_however_small_the_budget() {
2113        assert_eq!(evict_count(&[], 500, 10), 0, "the sole entry survives");
2114        assert_eq!(evict_count(&[], 0, 0), 0, "an empty tier sweeps to nothing");
2115        assert_eq!(
2116            evict_count(&[100], 500, 10),
2117            1,
2118            "and everything else still goes",
2119        );
2120    }
2121
2122    /// Eviction stops the moment the remainder fits — it does not keep going to
2123    /// make room it was not asked for. A cache that over-evicts pays the recompute
2124    /// cost of entries it had no reason to drop.
2125    #[test]
2126    fn eviction_stops_as_soon_as_the_remainder_fits() {
2127        // Three evictable entries of 10, 20 and 30 in eviction order, plus 40
2128        // pinned: 100 bytes held. At a budget of 70, dropping the 10 leaves 90 and
2129        // dropping the 20 leaves 70 — which fits, so the 30 stays put.
2130        assert_eq!(evict_count(&[10, 20, 30], 40, 70), 2);
2131        assert_eq!(evict_count(&[10, 20, 30], 40, 90), 1);
2132        assert_eq!(evict_count(&[10, 20, 30], 40, 100), 0, "it already fits");
2133        // Down at the pinned set's own size, everything evictable goes — and no
2134        // further, because there is nothing further to go.
2135        assert_eq!(evict_count(&[10, 20, 30], 40, 40), 3);
2136    }
2137
2138    /// Pinned bytes count against the budget even though they cannot be freed, so
2139    /// a tier full of pinned entries evicts everything else and then legitimately
2140    /// stays over — which `CacheSweep::over_budget` is there to say.
2141    #[test]
2142    fn pinned_bytes_are_counted_but_never_freed() {
2143        assert_eq!(
2144            evict_count(&[10, 10], 1000, 100),
2145            2,
2146            "everything evictable goes when the pinned set alone exceeds the budget",
2147        );
2148    }
2149
2150    // --- The sweep reads sizes, never payloads -------------------------------
2151
2152    /// A store with the cache schema applied and the clock advanced past the
2153    /// generation test rows are written in, so nothing is pinned as "this
2154    /// session's own work" and the byte policy is what decides.
2155    fn cache_store() -> rusqlite::Connection {
2156        let mut conn = rusqlite::Connection::open_in_memory().expect("open");
2157        crate::migrations::apply(&mut conn).expect("apply");
2158        conn.execute("UPDATE agent_cache_clock SET generation = 5", [])
2159            .expect("advance the clock");
2160        conn
2161    }
2162
2163    /// Insert one entry with the stored size given **independently of the
2164    /// payload**, which is the divergence
2165    /// `the_sweep_totals_the_bytes_column_and_never_the_payload` turns on.
2166    fn raw_put(conn: &rusqlite::Connection, key: &str, bytes: i64, payload: usize, last_used: i64) {
2167        conn.execute(
2168            "INSERT INTO agent_cache (key, fingerprint, json, bytes, generation, last_used, hits)
2169             VALUES (?1, 'fp', ?2, ?3, 0, ?4, 0)",
2170            rusqlite::params![key, "x".repeat(payload), bytes, last_used],
2171        )
2172        .expect("insert");
2173    }
2174
2175    /// **The sweep query names no payload column.** The direct guard on the
2176    /// regression this test exists for: re-adding `c.json` to the sweep's SELECT
2177    /// makes it red.
2178    ///
2179    /// Stated on the query text rather than on behaviour, deliberately and with
2180    /// its limits understood. `SELECT key, json` and `SELECT key` return the same
2181    /// *answers* — the difference is only how much `SQLite` materialises on the way,
2182    /// which no assertion over results can see. The thing that would actually
2183    /// regress here is the column list, so the column list is what is pinned.
2184    ///
2185    /// `hits` is absent for a second reason worth keeping separate: it is not a
2186    /// policy input at all. Eviction orders by `(anchor_valid, last_used)` and
2187    /// never by popularity, so selecting `hits` would hand the sweep a column it
2188    /// is not allowed to consult.
2189    #[test]
2190    fn the_sweep_query_names_no_payload_column() {
2191        for forbidden in ["json", "fingerprint", "hits"] {
2192            assert!(
2193                !SWEEP_COLS.contains(forbidden),
2194                "the sweep must not read {forbidden}: it decides by the stored size, \
2195                 and reading payloads to decide what to evict is what `bytes` exists \
2196                 to avoid — on a full tier that is the whole budget in memory",
2197            );
2198        }
2199        // And it does still select everything eviction is decided by, so the
2200        // assertion above cannot be satisfied by selecting too little.
2201        for required in [
2202            "c.key",
2203            "c.bytes",
2204            "c.generation",
2205            "c.last_used",
2206            "c.anchor_key",
2207            "c.anchor_blob",
2208        ] {
2209            assert!(SWEEP_COLS.contains(required), "the sweep needs {required}");
2210        }
2211        // The full read is the one that *may* carry the payload — otherwise the
2212        // check above could be met by emptying both.
2213        assert!(
2214            CACHE_COLS.contains("c.json"),
2215            "the inspection path returns it"
2216        );
2217    }
2218
2219    /// **The two reads cannot disagree.** They share the table, the join and the
2220    /// anchor rule, and neither adds a `WHERE`, so the sweep sees exactly the rows
2221    /// the inspection path sees — with exactly the same sizes and anchor verdicts.
2222    ///
2223    /// The failure this prevents is a sweep that evicts by one view of the tier
2224    /// while every report describes another: an entry missing from one side would
2225    /// be evicted without being counted, or counted without being evictable.
2226    #[test]
2227    fn the_sweep_and_the_full_read_agree_row_for_row() {
2228        let conn = cache_store();
2229        conn.execute(
2230            "INSERT INTO nodes (key, kind, name, blob_hash) VALUES ('sym:a', 'fn', 'a', 'blob1')",
2231            [],
2232        )
2233        .expect("node");
2234        // One of every anchor shape, so the shared `resolve_anchor` is exercised
2235        // rather than just the easy case.
2236        raw_put(&conn, "unanchored", 10, 4, 1);
2237        conn.execute(
2238            "INSERT INTO agent_cache
2239                 (key, fingerprint, json, bytes, generation, last_used, hits, anchor_key, anchor_blob)
2240             VALUES ('valid', 'fp', '{}', 20, 0, 2, 0, 'sym:a', 'blob1'),
2241                    ('drifted', 'fp', '{}', 30, 0, 3, 0, 'sym:a', 'blob-old'),
2242                    ('vanished', 'fp', '{}', 40, 0, 4, 0, 'sym:gone', 'blob1'),
2243                    ('unverifiable', 'fp', '{}', 50, 0, 5, 0, 'sym:a', NULL)",
2244            [],
2245        )
2246        .expect("anchored entries");
2247
2248        let narrow = sweep_rows(&conn).expect("sweep rows");
2249        let full = cache_entries(&conn).expect("entries");
2250        assert_eq!(narrow.len(), full.len(), "the same rows, or one is blind");
2251        assert_eq!(narrow.len(), 5);
2252        for (n, f) in narrow.iter().zip(full.iter()) {
2253            assert_eq!(n.key, f.key, "same order, same rows");
2254            assert_eq!(n.bytes, f.bytes, "{}: the size must not differ", n.key);
2255            assert_eq!(n.generation, f.generation, "{}", n.key);
2256            assert_eq!(n.last_used, f.last_used, "{}", n.key);
2257            assert_eq!(
2258                n.anchor_state, f.anchor_state,
2259                "{}: both must resolve the anchor identically",
2260                n.key,
2261            );
2262        }
2263        // Every anchor shape really was covered, so the agreement above is not
2264        // agreement about one trivial case.
2265        let states: Vec<AnchorState> = narrow.iter().map(|r| r.anchor_state).collect();
2266        for expected in [
2267            AnchorState::Unanchored,
2268            AnchorState::Valid,
2269            AnchorState::Drifted,
2270            AnchorState::Vanished,
2271            AnchorState::Unverifiable,
2272        ] {
2273            assert!(states.contains(&expected), "{expected} was not exercised");
2274        }
2275    }
2276
2277    /// **The `bytes` column is the authority, not the payload's length.**
2278    ///
2279    /// The behavioural half of the guard above. Two entries are written whose
2280    /// stored size and actual payload disagree in opposite directions, so the two
2281    /// possible implementations reach *different eviction sets* rather than merely
2282    /// different arithmetic:
2283    ///
2284    /// | | `heavy` | `light` | held | evicted at budget 500 |
2285    /// |---|---|---|---|---|
2286    /// | by the `bytes` column | 1000 | 10 | 1010 | `heavy` only |
2287    /// | by payload length | 1 | 1000 | 1001 | `heavy` **and** `light` |
2288    ///
2289    /// So a sweep that measured payloads would take `light` too, and this test
2290    /// says which one ran.
2291    #[test]
2292    fn the_sweep_totals_the_bytes_column_and_never_the_payload() {
2293        let conn = cache_store();
2294        raw_put(&conn, "heavy", 1000, 1, 1);
2295        raw_put(&conn, "light", 10, 1000, 2);
2296        raw_put(&conn, "mru", 0, 0, 3);
2297
2298        let swept = cache_sweep(&conn, 500).expect("sweep");
2299
2300        assert_eq!(
2301            swept.evicted, 1,
2302            "a payload-measuring sweep would have taken `light` as well",
2303        );
2304        assert_eq!(
2305            swept.freed_bytes, 1000,
2306            "the freed total is the stored size, not the 1 byte `heavy` holds",
2307        );
2308        assert_eq!(
2309            swept.retained_bytes, 10,
2310            "and what remains is counted the same way",
2311        );
2312        let survivors: Vec<String> = sweep_rows(&conn)
2313            .expect("rows")
2314            .into_iter()
2315            .map(|r| r.key)
2316            .collect();
2317        assert_eq!(survivors, vec!["light".to_owned(), "mru".to_owned()]);
2318    }
2319}