Files
ely-eye/bench/counterpart.py
T
ZacharyZhang-NY dbe1d2cc82 Measure the counterpart honestly: warm models, vectorised search, scope split
Three defects made the external baseline numbers and labels wrong.

- bench/systems.py: _sentence_model rebuilt the model on every query, so dense arm
  latency was mostly model loading and the latency-vs-size curve was flat because a
  constant dominated it. Models are now cached per process.
- bench/counterpart.py: visible() and search() walked 404k Python objects per query.
  Per-item holder/source/time columns are built once and the query path is numpy;
  12.5 queries/s instead of 0.7.
- bench/metrics.py: the aggregate key was named wrong_holder_confidence_unanswerable
  while it counts top hits that ARE from the asking holder. BASELINE.md read that
  backwards. Renamed to top_hit_from_holder_unanswerable and the sentence rewritten.
- bench/run_external.py: the by-modality table read row['answerable'] but aggregate
  emits n_answerable, so the writer died after scoring finished. Each store is now
  scored twice, [holder scope] and [whole corpus], so the gain from the holder
  boundary is separable from the gain from ranking; progress lines every 500 queries.

Verification: pytest bench/tests -q -> 61 passed; ruff check bench/ -> all checks
passed; mypy (fresh cache) -> no issues in 25 source files.
2026-10-07 13:33:13 -04:00

355 lines
14 KiB
Python

"""Mem0- and Zep-style memory counterparts, so the baseline table has an external reference point.
Both counterparts read the same ingested corpus and the same embeddings as the ely-eye arms;
only the memory management differs, which is the thing being compared.
* Mem0-style: one flat store, every item scoped by holder, sha256 dedup, no invalidation —
a newer fact about the same subject sits next to the old one forever.
* Zep-style: the same items carry valid_from / valid_to; a newer item about the same subject
closes the older one, and a query at time T cannot see a closed item.
Extraction is lexical, not LLM: Mem0's real `add()` and Zep's graph builder call a generator
model, and this round has no generator installed. The deviation is recorded in the report and
in bench/README.md; it is the same extractor for both counterparts, so the comparison between
them stays fair.
Run: python -m bench.run_external
"""
from __future__ import annotations
import hashlib
import re
import sqlite3
import time
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass, field, replace
from itertools import pairwise
from pathlib import Path
import numpy as np
from bench.ingest_baseline import source_map
from bench.sets_common import MODALITIES, SETS, read_jsonl
from bench.systems import (
DENSE_MODEL,
MODELS,
Atom,
DenseIndex,
local_model_dir,
tokenize,
)
ITEM_CHARS = 400
OVERLAP_SUPERSEDE_LIMIT = 0.5
MIN_SUBJECT_TOKEN_CHARS = 4
STOPWORDS = frozenset(
[
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with", "that", "this",
"these", "those", "is", "are", "was", "were", "be", "been", "being", "what", "which",
"who", "whom", "whose", "when", "where", "why", "how", "not", "no", "nor", "but", "if",
"then", "than", "so", "as", "at", "by", "from", "into", "about", "over", "under",
"again", "further", "once", "here", "there", "all", "any", "both", "each", "few",
"more", "most", "other", "some", "such", "only", "own", "same", "he", "she", "they",
"them", "his", "her", "their", "its", "it", "we", "you", "i", "me", "us", "him",
"一个", "什么", "怎么", "为什么", "哪里", "时候", "这个", "那个", "可以", "应该",
"我们", "你们", "他们", "自己", "因为", "所以", "但是", "如果",
]
)
@dataclass(frozen=True)
class SourceMeta:
"""What the frozen corpus index says about one source."""
source_id: str
holder: str
scenario: str
modality: str
observed_at: str
@dataclass
class MemoryItem:
"""One remembered thing, with the three facts a shared memory needs: who, when, what."""
item_id: str
text: str
source_id: str
holder: str
scenario: str
observed_at: str
subject: str
valid_from: str
valid_to: str | None = None
def load_source_index() -> dict[str, SourceMeta]:
"""source_id -> holder / scenario / modality / observed_at, from the frozen corpus index."""
index: dict[str, SourceMeta] = {}
for modality in MODALITIES:
for record in read_jsonl(SETS / "corpus" / f"{modality}.jsonl"):
missing = [key for key in ("source_id", "holder", "scenario", "observed_at") if not record.get(key)]
if missing:
raise SystemExit(f"{modality}: corpus record {record.get('source_id')} lacks {missing}")
meta = SourceMeta(
source_id=str(record["source_id"]),
holder=str(record["holder"]),
scenario=str(record["scenario"]),
modality=modality,
observed_at=str(record["observed_at"]),
)
if meta.source_id in index:
raise SystemExit(f"{meta.source_id}: source id appears in two corpus indexes")
index[meta.source_id] = meta
if not index:
raise SystemExit("no corpus index found, run python -m bench.build_sets first")
return index
def load_item_atoms(db_path: Path) -> list[Atom]:
"""Atom text for every source the corpus index names, re-keyed from product ids to corpus ids.
The product names a source by file content ({stem}_{sha256[:16]}, storage.py:28), which is not the
id the frozen sets use. The sources table keeps the file path, and bench.ingest_baseline.source_map()
is the corpus-id-to-path map, so the join recovers the corpus identity the gold answers are written in.
"""
if not db_path.is_file():
raise SystemExit(f"{db_path}: no baseline database, run python -m bench.ingest_baseline first")
path_to_corpus_id = source_map()
connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
rows = connection.execute(
"SELECT a.atom_id, s.path, a.text FROM atoms a JOIN sources s ON s.source_id = a.source_id"
" ORDER BY a.atom_id"
).fetchall()
except sqlite3.OperationalError as error:
raise SystemExit(f"{db_path}: cannot read the atoms table ({error})") from error
finally:
connection.close()
if not rows:
raise SystemExit(f"{db_path}: the atoms table is empty")
atoms = [
Atom(atom_id=str(atom_id), source_id=path_to_corpus_id[path], text=str(text or ""))
for atom_id, path, text in rows
if path in path_to_corpus_id
]
if not atoms:
raise SystemExit(f"{db_path}: none of its {len(rows)} atoms map to a corpus source id")
return atoms
def split_items(text: str) -> list[str]:
"""Cut source text into rememberable chunks; sentence boundaries first, then a hard cut."""
cleaned = re.sub(r"\s+", " ", text).strip()
if not cleaned:
return []
pieces: list[str] = []
for sentence in re.split(r"(?<=[.!?。!?;;])\s+", cleaned):
sentence = sentence.strip()
if not sentence:
continue
while len(sentence) > ITEM_CHARS:
pieces.append(sentence[:ITEM_CHARS])
sentence = sentence[ITEM_CHARS:]
pieces.append(sentence)
merged: list[str] = []
buffer = ""
for piece in pieces:
if not buffer:
buffer = piece
elif len(buffer) + 1 + len(piece) <= ITEM_CHARS:
buffer = f"{buffer} {piece}"
else:
merged.append(buffer)
buffer = piece
if buffer:
merged.append(buffer)
return merged
def subject_of(text: str) -> str:
"""The lexical stand-in for an entity: the most frequent content token in the item."""
counts = Counter(
token
for token in tokenize(text)
if len(token) >= MIN_SUBJECT_TOKEN_CHARS and token not in STOPWORDS and not token.isdigit()
)
if not counts:
return ""
token, count = counts.most_common(1)[0]
return token if count >= 2 else ""
def overlap(first: str, second: str) -> float:
"""Jaccard overlap of two items' token sets; low overlap means they are not the same statement."""
a, b = set(tokenize(first)), set(tokenize(second))
if not a or not b:
return 0.0
return len(a & b) / len(a | b)
def build_items(atoms: Sequence[Atom], index: dict[str, SourceMeta]) -> list[MemoryItem]:
"""Turn ingested atoms into memory items, deduped by (holder, text hash) the way Mem0 dedups."""
items: list[MemoryItem] = []
seen: set[tuple[str, str]] = set()
unknown: set[str] = set()
for atom in atoms:
meta = index.get(atom.source_id)
if meta is None:
unknown.add(atom.source_id)
continue
for position, text in enumerate(split_items(atom.text)):
key = (meta.holder, hashlib.sha256(text.encode("utf-8")).hexdigest())
if key in seen:
continue
seen.add(key)
items.append(
MemoryItem(
item_id=f"{atom.source_id}#{position}",
text=text,
source_id=atom.source_id,
holder=meta.holder,
scenario=meta.scenario,
observed_at=meta.observed_at,
subject=subject_of(text),
valid_from=meta.observed_at,
)
)
if unknown:
print(f"note: {len(unknown)} ingested sources are not in the corpus index and carry no memory scope")
return items
def supersede(items: Sequence[MemoryItem]) -> int:
"""Close older items about the same subject when a newer, different statement arrives (Zep-style)."""
by_subject: dict[tuple[str, str], list[MemoryItem]] = {}
for item in items:
if item.subject:
by_subject.setdefault((item.holder, item.subject), []).append(item)
closed = 0
for group in by_subject.values():
group.sort(key=lambda item: (item.observed_at, item.item_id))
for older, newer in pairwise(group):
if older.valid_to is not None or older.observed_at >= newer.observed_at:
continue
if overlap(older.text, newer.text) >= OVERLAP_SUPERSEDE_LIMIT:
continue
older.valid_to = newer.observed_at
closed += 1
return closed
@dataclass(frozen=True)
class _ItemArrays:
"""Per-item columns, built once so a query never walks 400k Python objects."""
holder_ids: dict[str, int]
holders: np.ndarray
sources: list[str]
sources_of_item: np.ndarray
valid_from: np.ndarray
valid_to: np.ndarray
@dataclass
class Counterpart:
"""One counterpart store: the same items, the same embeddings, different memory semantics."""
name: str
temporal: bool
items: list[MemoryItem]
model_dir: Path
cache_name: str = "counterpart-items"
timings: list[float] = field(default_factory=list)
_index: DenseIndex | None = None
_arrays: _ItemArrays | None = None
def __post_init__(self) -> None:
"""The store is cheap to build; embeddings are paid for on the first search."""
def _ensure_arrays(self) -> _ItemArrays:
if self._arrays is None:
holder_ids = {holder: code for code, holder in enumerate(sorted({item.holder for item in self.items}))}
ordered_sources = sorted({item.source_id for item in self.items})
source_ids = {source: code for code, source in enumerate(ordered_sources)}
self._arrays = _ItemArrays(
holder_ids=holder_ids,
holders=np.array([holder_ids[item.holder] for item in self.items], dtype=np.int32),
sources=list(ordered_sources),
sources_of_item=np.array([source_ids[item.source_id] for item in self.items], dtype=np.int32),
valid_from=np.array([item.valid_from for item in self.items], dtype="U64"),
valid_to=np.array([item.valid_to or "" for item in self.items], dtype="U64"),
)
return self._arrays
def _ensure_index(self) -> DenseIndex:
if self._index is None:
self._index = DenseIndex(
[Atom(atom_id=item.item_id, source_id=item.source_id, text=item.text) for item in self.items],
self.model_dir,
cache_name=self.cache_name,
)
return self._index
def visible(self, holder: str | None, as_of: str | None) -> np.ndarray:
"""Which items this query may see.
Both stores isolate by holder. Only the temporal store uses time: an item is hidden once
a newer statement closed it, and hidden until it was observed. The flat store has no notion
of as_of, which is exactly Mem0's behaviour and exactly what the comparison measures.
"""
arrays = self._ensure_arrays()
if holder is None:
mask = np.ones(len(self.items), dtype=bool)
else:
mask = arrays.holders == arrays.holder_ids.get(holder, -1)
if self.temporal and as_of is not None:
mask &= arrays.valid_from <= as_of
mask &= (arrays.valid_to == "") | (arrays.valid_to > as_of)
return mask
def search(self, query: str, holder: str | None, as_of: str | None, top_k: int = 10) -> list[str]:
"""Rank sources for one query, restricted to the items the query is allowed to see."""
started = time.perf_counter()
scores = self._ensure_index().scores(query, self.model_dir)
mask = self.visible(holder, as_of)
arrays = self._ensure_arrays()
masked = np.where(mask, scores, -np.inf)
ranked: list[str] = []
seen: set[int] = set()
for position in np.argsort(-masked, kind="stable"):
if not np.isfinite(masked[position]):
break
code = int(arrays.sources_of_item[position])
if code in seen:
continue
seen.add(code)
ranked.append(arrays.sources[code])
if len(ranked) == top_k:
break
self.timings.append(time.perf_counter() - started)
return ranked
def percentile(self, fraction: float) -> float:
if not self.timings:
raise SystemExit(f"{self.name}: no timings recorded")
if not 0.0 <= fraction <= 1.0:
raise SystemExit(f"percentile must be within [0, 1], got {fraction}")
return float(np.percentile(np.asarray(self.timings), fraction * 100))
def build_counterparts(items: list[MemoryItem], models: Path = MODELS) -> list[Counterpart]:
"""Mem0-style first (no invalidation), then Zep-style (the same items, closed by time)."""
model_dir = local_model_dir(models, DENSE_MODEL)
if not model_dir.is_dir():
raise SystemExit(f"{model_dir}: dense model missing, run python -m bench.fetch_models first")
mem0 = Counterpart(name="mem0_flat", temporal=False, items=items, model_dir=model_dir)
zep_items = [replace(item) for item in items] # same order, same ids: only validity windows differ
closed = supersede(zep_items)
print(f"zep_temporal: closed {closed} items by supersession out of {len(zep_items)}")
# Both stores share one embedding cache because the item sequence is identical; only visibility differs.
zep = Counterpart(name="zep_temporal", temporal=True, items=zep_items, model_dir=model_dir)
return [mem0, zep]