Let a retired memory keep its vector and its place in the present

The second review round of p2-4 measured three behaviour defects behind the
as_of feature it had just accepted, and all three came from one mistake: the
code asked "is this memory current?" in three places and answered differently.

- A memory that has closed kept no vector at all, so the dense leg could only
  exclude an old fact, never recall it. Vectors now follow existence, not
  currency; `_allowed` decides who may see one.
- "Open right now" meant `valid_to IS NULL` in the view and in the words leg
  but `valid_to IS NULL OR valid_to > now` in the vector leg, so a supersede
  booked for next year hid a fact that is still true today. All three now read
  the same rule; schema v12 records the change and the upgrade drops the old
  view, since CREATE VIEW IF NOT EXISTS would have kept the old body.
- The dense as_of test was名不副实: dense is off by default, so it passed while
  the vector side had no coverage. It now turns dense on with a stand-in model
  and pins both `VectorIndex.search(as_of=…)` and the plumbing in retrieval.

Five mutations, each caught by exactly one test: the dense as_of branch, the
retrieval hand-off, the three vector reads, the words-leg currency, the view.

Record side: the round-2 line quoted a D-list I had misread out of the
reviewer's PASS tables (cf2de31 fixed those, and they were its precision notes,
not its findings), the stale line-number parenthetical, an acceptance grep that
scanned CHECKLIST's own quotations, and a Gaps.md tally that disagreed with its
own table (8 closed, 3 closed with reservations, 1 partial).

Gates: 411 passed; ruff, filesize, audit 33/2/6 clean; pyright 0 on the six
touched files; mypy cold cache Success on the same six; scripts/key_test.sh
exit 0 (6 PASS, 1 NOT MEASURED).
This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 22:13:11 -04:00
1 parent cf2de313dc
commit 92237c8f10
7 files changed
+156 -40

No files matched your search

+4 -4
View File
@@ -16,7 +16,7 @@ import sqlite3
from .storage import token_equivalent, visual_token_equivalent
from .text_index import index_text
SCHEMA_VERSION = 11
SCHEMA_VERSION = 12
ATOM_COLUMNS_V2 = """
atom_id TEXT PRIMARY KEY,
@@ -188,7 +188,7 @@ tokenize = 'unicode61'
VIEWS = """
CREATE VIEW IF NOT EXISTS v_atoms_current AS
SELECT * FROM atoms
WHERE valid_to IS NULL
WHERE (valid_to IS NULL OR valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
AND archived_at IS NULL
AND (valid_from IS NULL OR valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
AND (retention_until IS NULL OR retention_until >= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'));
@@ -328,8 +328,8 @@ def apply(connection: sqlite3.Connection) -> None:
for name, definition in _NEW_HOLDER_COLUMNS:
if name not in existing_holders:
connection.execute(f"ALTER TABLE holders ADD COLUMN {name} {definition}")
if version < 10:
# CREATE VIEW IF NOT EXISTS keeps the old body, and v10 changed which rows are current, so an upgraded store must be told to look again.
if version < 12:
# CREATE VIEW IF NOT EXISTS keeps the old body, and v10 and v12 each changed which rows are current, so an upgraded store must be told to look again.
connection.execute("DROP VIEW IF EXISTS v_atoms_current")
connection.executescript(VIEWS)
+2 -1
View File
@@ -68,7 +68,8 @@ def search_atoms(
WHERE atom_fts MATCH ?
AND (
(? IS NULL
AND a.valid_to IS NULL
AND (a.valid_to IS NULL
OR a.valid_to > strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
AND a.archived_at IS NULL
AND (a.valid_from IS NULL
OR a.valid_from <= strftime('%Y-%m-%dT%H:%M:%f+00:00', 'now'))
+16 -9
View File
@@ -4,7 +4,8 @@ The first version kept the vectors in `cache/dense_index.npy` plus a JSON list o
That pair had no owner: nothing noticed when an atom was added, changed or forgotten, so a
search answered from a snapshot of the past and `forget` had to delete the whole file. Here the
vectors sit in the same SQLite file as the atoms, in a `vec0` table the `sqlite-vec` extension
answers, and the only rule the index has is "one row per current atom, re-encode what changed".
answers, and the only rule the index has is "one row per atom that still exists and has words,
re-encode what changed".
"""
from __future__ import annotations
@@ -58,7 +59,7 @@ def load_extension(connection: sqlite3.Connection) -> None:
class VectorIndex:
"""One float32 vector per current atom: the writer keeps it, the reader asks it questions."""
"""One float32 vector per memory that has words: the writer keeps it, the reader asks it questions."""
def __init__(
self,
@@ -121,7 +122,9 @@ class VectorIndex:
"""Bring the index in step with memories as they are written.
Nothing on the search path rebuilds, so this is the half that keeps "written now,
findable now" true; a memory that has closed or been forgotten loses its vector here too.
findable now" true. A forgotten memory loses its vector here; a memory that has merely
closed keeps one, because a question about the past still has to reach it — the currency
rule in `_allowed` decides who may see it, not the presence of a vector.
"""
wanted = [atom_id for atom_id in atom_ids if atom_id]
if not wanted or not self.settings.dense_embeddings_enabled:
@@ -382,12 +385,16 @@ class VectorIndex:
return removed
def _current(self, connection: sqlite3.Connection, atom_ids: list[str]) -> list[tuple[str, Any]]:
"""The written memories that still deserve a vector: open, and with words."""
"""The written memories that still deserve a vector: ones whose row exists, with words.
Currency is not asked here on purpose. A superseded memory keeps its vector so `as_of`
can answer from it; the search path is where the window gets applied.
"""
from .row_codec import row_to_atom
rows = connection.execute(
"""
SELECT * FROM v_atoms_current
SELECT * FROM atoms
WHERE atom_id IN (SELECT value FROM json_each(?)) AND text IS NOT NULL AND text != ''
""",
(to_json(atom_ids),),
@@ -425,7 +432,7 @@ class VectorIndex:
)
def _stale(self, connection: sqlite3.Connection) -> list[tuple[str, Any]]:
"""Current memories the index does not hold, or holds from an older version of the memory.
"""Memories the index does not hold, or holds from an older version of the memory.
A memory with no words is the visual index's job, so it never reaches this table. The
stored stamps are copied into a temp table first: a vec0 table has no index on atom_id,
@@ -441,7 +448,7 @@ class VectorIndex:
)
rows = connection.execute(
"""
SELECT a.* FROM v_atoms_current AS a
SELECT a.* FROM atoms AS a
LEFT JOIN dense_seen AS d ON d.atom_id = a.atom_id
WHERE a.text IS NOT NULL AND a.text != ''
AND (d.atom_id IS NULL OR d.stamp IS NULL OR d.stamp != atom_stamp(a.ingested_at, a.text))
@@ -457,14 +464,14 @@ def atom_stamp(ingested_at: str, text: str | None) -> str:
def expected_vectors(database: Database) -> int:
"""Memories this index is meant to hold: the open ones that have words."""
"""Memories this index is meant to hold: the ones whose row exists and has words."""
with database.connect() as connection:
return _expected(connection)
def _expected(connection: sqlite3.Connection) -> int:
row = connection.execute(
"SELECT count(*) AS n FROM v_atoms_current WHERE text IS NOT NULL AND text != ''"
"SELECT count(*) AS n FROM atoms WHERE text IS NOT NULL AND text != ''"
).fetchone()
return int(row["n"])