Files
ely-eye/backend/src/ely_eye/transactive.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

224 lines
8.9 KiB
Python

"""Ask the store who knows something, then ask them.
The transactive rule this service implements: not knowing is fine when the directory knows
where to send you. Three things follow, and each one is a failure mode if left implicit.
- The directory is public, the content is not. `who_knows` counts every holder's matching
memories, including memories the asker may not read, because "she knows" is the answer.
- A referral never invents an answer. If the asker may not read the holder's memories, the
result is a pointer plus contact details, and the text stays where it is.
- Referral must terminate. Every hop records whom it asked, and the chain stops at the hop
limit or when every candidate has been asked once.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from .config import Settings, get_settings
from .db import Database
from .holder_store import HolderStore, WhoKnowsCount
from .retrieval import RetrievalService
from .schemas import SearchFilters
from .text_index import index_text
from .transactive_schemas import (
ReferralAnswer,
WhoKnowsHit,
WhoKnowsReport,
)
NO_HOLDER_KNOWS: Literal["no_holder_knows"] = "no_holder_knows"
LIBRARY = "library"
WHO_KNOWS_LIMIT = 8
MAX_HOPS_LIMIT = 10
# Confidence is ordinal: it orders candidates, it does not estimate a probability. The weights
# say what the evidence is worth relative to itself, and every hit carries the raw numbers too.
CONFIDENCE_SATURATE = 3
WEIGHT_STRENGTH = 0.60
WEIGHT_COVERAGE = 0.25
WEIGHT_DECLARED = 0.15
class TransactiveService:
"""who_knows and ask, built on the holder registry and the same retrieval as search."""
def __init__(self, settings: Settings | None = None, database: Database | None = None) -> None:
self.settings = settings or get_settings()
self.db = database or Database(self.settings)
self.holders = HolderStore(self.db)
self.retrieval = RetrievalService(self.settings, self.db)
def who_knows(
self, query: str, *, as_holder: str | None = None, limit: int = WHO_KNOWS_LIMIT
) -> WhoKnowsReport:
"""Rank the holders whose own memories answer this query.
An empty result is the honest answer, not a fallback: nobody holds this, so the caller
should say so instead of paraphrasing something unrelated.
"""
if not query.strip():
raise ValueError("who_knows needs a query")
if limit <= 0:
raise ValueError(f"who_knows needs a positive limit, got {limit}")
counts = self.holders.who_knows_counts(query, limit)
if not counts:
return WhoKnowsReport(query=query, as_holder=as_holder, status=NO_HOLDER_KNOWS, hits=[])
registered = {holder.holder_id: holder for holder in self.holders.list_holders()}
best = max(count.top_score for count in counts)
return WhoKnowsReport(
query=query,
as_holder=as_holder,
status="found",
hits=[
self._hit(count, best, registered.get(count.holder_id), query)
for count in counts
],
)
def ask(
self,
question: str,
*,
as_holder: str | None = None,
max_hops: int = 3,
include_text: bool = False,
) -> ReferralAnswer:
"""Answer a question by asking the asker first, then the directory's best candidates.
`answered_by` is the asker when their own memory answered, `library` when an unowned
artifact did, and the holder whose memory answered otherwise. `referred` means somebody
knows but their memory is not readable by the asker.
`max_hops` counts the holders asked besides the asker: the asker's own memory is hop 0,
so max_hops=1 still asks the best candidate.
"""
if not question.strip():
raise ValueError("ask needs a question")
if not 1 <= max_hops <= MAX_HOPS_LIMIT:
raise ValueError(f"ask needs max_hops between 1 and {MAX_HOPS_LIMIT}, got {max_hops}")
own = self._search(question, as_holder=as_holder, holder=None)
if own:
return self._answered(
question, as_holder, asked=[as_holder or LIBRARY], hops=0, hits=own, include_text=include_text
)
asked: list[str] = [as_holder or LIBRARY]
for hit in self.who_knows(question, as_holder=as_holder).hits:
if hit.holder_id in asked:
continue
if len(asked) - 1 >= max_hops:
break
asked.append(hit.holder_id)
readable = self._search(question, as_holder=as_holder, holder=hit.holder_id)
if readable:
return self._answered(
question,
as_holder,
asked=asked,
hops=len(asked) - 1,
hits=readable,
include_text=include_text,
)
return self._pointer(question, as_holder, asked, hit)
return ReferralAnswer(
question=question,
as_holder=as_holder,
status=NO_HOLDER_KNOWS,
asked=asked,
hops=len(asked) - 1,
basis=["no holder has a memory that answers this"],
)
def _search(self, query: str, *, as_holder: str | None, holder: str | None):
result = self.retrieval.search(
query,
top_k=5,
filters=SearchFilters(holder=holder, as_holder=as_holder),
)
return result.hits
def _answered(self, question, as_holder, *, asked, hops, hits, include_text) -> ReferralAnswer:
"""The hits already passed the visibility clause in SQL, so they may be shown."""
top = hits[0]
owner = top.atom.holder_id or LIBRARY
return ReferralAnswer(
question=question,
as_holder=as_holder,
status="answered",
answered_by=owner,
asked=asked,
hops=hops,
atom_ids=[hit.atom.atom_id for hit in hits],
answer=top.atom.text if include_text else None,
basis=[
f"{len(hits)} memories readable by {as_holder or 'anyone'}",
f"best match scores {top.final_score:.2f}",
f"held by {owner}",
],
)
def _pointer(self, question, as_holder, asked, hit: WhoKnowsHit) -> ReferralAnswer:
"""The candidate knows but the asker cannot read it: return the contact, not the text."""
contact = hit.contact
return ReferralAnswer(
question=question,
as_holder=as_holder,
status="referred",
answered_by=hit.holder_id,
asked=asked,
hops=len(asked) - 1,
basis=[
f"{hit.holder_id} holds {hit.matched} matching memories the asker may not read",
f"best match scores {hit.top_score:.2f}",
f"reachable via {contact}" if contact else "no contact details registered",
],
)
def _hit(self, count: WhoKnowsCount, best: float, holder, query: str) -> WhoKnowsHit:
declared = _declared_topics(holder.knows_about if holder else [], query)
strength = count.top_score / best if best > 0 else 0.0
coverage = min(1.0, count.matched / CONFIDENCE_SATURATE)
confidence = (
WEIGHT_STRENGTH * strength + WEIGHT_COVERAGE * coverage + WEIGHT_DECLARED * (1.0 if declared else 0.0)
)
basis = [
f"holds {count.matched} matching memories",
f"best match scores {count.top_score:.2f}",
]
if count.latest_time:
basis.append(f"most recent memory is from {count.latest_time}")
if declared:
basis.append(f"declared topics: {', '.join(declared)}")
contact = dict(holder.contact) if holder else {}
if contact:
basis.append(f"reachable via {contact}")
return WhoKnowsHit(
holder_id=count.holder_id,
name=holder.name if holder else "",
confidence=round(confidence, 3),
matched=count.matched,
top_score=round(count.top_score, 3),
latest_time=None if count.latest_time is None else _as_moment(count.latest_time),
basis=basis,
contact=contact,
)
def _as_moment(value: str) -> datetime:
"""A stored timestamp that will not parse is a corrupt row, not a missing one."""
try:
return datetime.fromisoformat(value)
except ValueError as error:
raise ValueError(f"stored timestamp {value!r} is unreadable: {error}") from error
def _declared_topics(topics: list[str], query: str) -> list[str]:
"""Declared topics that share a searchable token with the query, using the index's own split."""
query_tokens = set(index_text(query).split())
if not query_tokens:
return []
return [topic for topic in topics if query_tokens & set(index_text(topic).split())]