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}