Files
ely-eye/backend/src/ely_eye/holder_store.py
T
ZacharyZhang-NY dfc6cd4c28 Give the system a directory: ask who knows, then ask them
A memory system that only answers from its own store is a filing cabinet,
not a partner. The transactive rule the project is named for — not knowing
is fine when you know who does — needed a holder registry, a directory
query, and a referral that never leaks the text it may not read.

- schema v5 adds `holders` (person/agent/service, declared topics, contact).
  A holder with memories exists whether or not anybody registered them; the
  registry adds reachability, it does not grant existence.
- `who_knows` ranks holders by their own memories and is deliberately NOT
  filtered by visibility: "she knows" is the answer. The answer is filtered.
- `ask` searches the asker's visible scope first (hop 0), then walks the
  directory with a visited set and a hop budget. Three terminal states:
  answered, referred, no_holder_knows. Nobody knowing is a result, not an error.
- Confidence is ordinal, not calibrated: relative strength, coverage, and
  declared topics. Every hit carries the raw numbers and the basis lines that
  produced the score, so a ranking can be argued with.
- Faces: HTTP (/api/holders, /api/who_knows, /api/ask), CLI (holders,
  holder-set, holder-rm, who-knows, ask), MCP (who_knows, ask, list_holders).

Four bugs found by running the thing, not by reading it:
- FTS5 refuses bm25() inside MAX(); the aggregate uses FTS5's own `rank` column.
- A CJK question is longer than the sentence that answers it, so the whole-run
  phrase matched nothing. The query now ORs adjacent bigram phrases; adjacency
  is kept, equal length is not. Latin path untouched, baseline unaffected.
- The hop budget counted the asker as a hop, so max_hops=1 asked nobody.
- A second, stricter readability test in _answered() disagreed with the SQL
  visibility clause and hid a memory the asker was allowed to read. The SQL
  clause is the only authority now.

A method named `list` shadowed the builtin inside its own class and made
mypy reject the return annotation; renamed rather than cast around it.

Proof: 155 pytest, 12 Go tests, ruff and mypy clean on touched files, pyright
ledger unchanged at 62, and scripts/mcp_write_smoke.sh drives two principals
on one SQLite file — the husband asks through MCP, gets `referred` with his
wife's contact channel, and her private sentence appears nowhere in the reply.
2026-10-07 17:20:45 -04:00

174 lines
6.9 KiB
Python

"""The holder registry and the directory query: who holds a memory, and who knows a topic.
A transactive memory system stores the directory, not the content: "I do not know where the
keys are, I know my wife knows, and I can find my wife." That requires a holder entity the
store can answer questions about, and it requires the answer to be a pointer with a reason,
never a guess.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime
from typing import cast
from .db import Database
from .row_codec import from_json, to_json
from .schemas import utc_now
from .text_index import fts_query
from .transactive_schemas import Holder, HolderKind
@dataclass(frozen=True)
class WhoKnowsCount:
"""One holder's evidence for one query, straight from the index."""
holder_id: str
matched: int
top_score: float
latest_time: str | None
class HolderStore:
"""CRUD for registered holders plus the raw counts who_knows is built from."""
def __init__(self, database: Database) -> None:
self.database = database
def upsert(self, holder: Holder) -> Holder:
"""Insert or replace a registry entry, keeping the original creation time."""
now = utc_now().isoformat()
with self.database.connect() as connection:
existing = connection.execute(
"SELECT created_at FROM holders WHERE holder_id = ?", (holder.holder_id,)
).fetchone()
created_at = str(existing["created_at"]) if existing is not None else now
stored = holder.model_copy(update={"created_at": _moment(created_at), "updated_at": _moment(now)})
connection.execute(
"""
INSERT INTO holders (holder_id, name, kind, knows_about_json, contact_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(holder_id) DO UPDATE SET
name = excluded.name,
kind = excluded.kind,
knows_about_json = excluded.knows_about_json,
contact_json = excluded.contact_json,
updated_at = excluded.updated_at
""",
(
stored.holder_id,
stored.name,
stored.kind,
to_json(stored.knows_about),
to_json(stored.contact),
stored.created_at.isoformat(),
stored.updated_at.isoformat(),
),
)
return stored
def get(self, holder_id: str) -> Holder | None:
with self.database.connect() as connection:
row = connection.execute("SELECT * FROM holders WHERE holder_id = ?", (holder_id,)).fetchone()
return None if row is None else _row_to_holder(row)
def list_holders(self) -> list[Holder]:
with self.database.connect() as connection:
rows = connection.execute("SELECT * FROM holders ORDER BY holder_id").fetchall()
return [_row_to_holder(row) for row in rows]
def delete(self, holder_id: str) -> None:
"""Remove a registry entry. Refuses while that holder still owns memories: forgetting a
phone number must not silently orphan the memories it points to."""
holding = self.atom_count(holder_id)
if holding:
raise ValueError(
f"holder {holder_id!r} still holds {holding} memories; forget them before deleting the holder"
)
with self.database.connect() as connection:
connection.execute("DELETE FROM holders WHERE holder_id = ?", (holder_id,))
def atom_count(self, holder_id: str) -> int:
with self.database.connect() as connection:
row = connection.execute(
"SELECT COUNT(*) AS n FROM v_atoms_current WHERE holder_id = ?", (holder_id,)
).fetchone()
return int(row["n"])
def known_holders(self) -> list[str]:
"""Every holder with at least one open memory, registered or not.
A person who has memories is a holder whether or not anybody registered them; the
registry adds declared topics and contact details, it does not grant existence.
"""
with self.database.connect() as connection:
rows = connection.execute(
"SELECT DISTINCT holder_id FROM v_atoms_current WHERE holder_id <> '' ORDER BY holder_id"
).fetchall()
return [str(row["holder_id"]) for row in rows]
def who_knows_counts(self, query: str, limit: int) -> list[WhoKnowsCount]:
"""Rank holders by how well their own memories answer the query.
The directory is deliberately unfiltered by visibility: the whole point of transactive
memory is knowing that someone knows, even when their memory is private to them.
`rank` is FTS5's own bm25 column, and it is the only way to aggregate the score: SQLite
refuses bm25() inside MAX(), and refuses it in a subquery or window over a match.
"""
with self.database.connect() as connection:
rows = connection.execute(
"""
SELECT a.holder_id,
COUNT(*) AS matched,
-MAX(rank) AS top_score,
MAX(COALESCE(a.event_time, a.ingested_at)) AS latest_time
FROM atom_fts AS f
JOIN v_atoms_current AS a ON a.atom_id = f.atom_id
WHERE atom_fts MATCH ?
AND a.holder_id <> ''
GROUP BY a.holder_id
ORDER BY top_score DESC, matched DESC
LIMIT ?
""",
(fts_query(query), limit),
).fetchall()
return [
WhoKnowsCount(
holder_id=str(row["holder_id"]),
matched=int(row["matched"]),
top_score=float(row["top_score"]),
latest_time=None if row["latest_time"] is None else str(row["latest_time"]),
)
for row in rows
]
def _moment(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
raise ValueError(f"holder timestamps need an explicit offset, stored {value!r}")
return parsed
_HOLDER_KINDS: tuple[HolderKind, ...] = ("person", "agent", "service")
def _kind(value: str) -> HolderKind:
if value not in _HOLDER_KINDS:
raise ValueError(f"stored holder kind {value!r} is not one of person, agent, service")
return cast(HolderKind, value)
def _row_to_holder(row: sqlite3.Row) -> Holder:
return Holder(
holder_id=str(row["holder_id"]),
name=str(row["name"]),
kind=_kind(str(row["kind"])),
knows_about=from_json(row["knows_about_json"], []),
contact=from_json(row["contact_json"], {}),
created_at=_moment(str(row["created_at"])),
updated_at=_moment(str(row["updated_at"])),
)