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.
355 lines
14 KiB
Python
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]
|