Files
ely-eye/bench/run_external.py
T
ZacharyZhang-NY 181f6bf57e Give every memory a holder, an origin, a time and a scenario
An EvidenceAtom could say what was remembered and nothing else: not whose memory it
is, not where it came from, not when it was true, not in what situation it may be
used. The baseline measured the cost of that: the same ranker scores recall@10 0.412
over the whole corpus and 0.866 inside one holder's memory, so the missing holder
column is worth more than any ranking change in the ablation.

- schemas.py: MemoryOrigin, TimePrecision, Visibility; EvidenceAtom gains holder_id,
  origin, scenario, event_time / event_time_end / event_time_precision /
  event_time_source, valid_from / valid_to, supersedes, visibility, retention_until.
  `time` is renamed `ingested_at` — ingestion time gets one name, not two.
  A validator refuses valid_to before valid_from, event_time_end before event_time,
  and an atom that supersedes itself.
- migrations.py: schema v2 with PRAGMA user_version. A first-version database is
  upgraded in place (ALTER + RENAME COLUMN), rows intact, and a database newer than
  this build refuses to open instead of being silently downgraded.
- v_atoms_current hides memories that are closed, lapsed or not yet begun; unknown
  bounds read as visible. Time travel is db.atoms_as_of(moment, holder) because a
  SQLite view cannot take a parameter.
- evaluation_schemas.py: the proof and benchmark report shapes move out of schemas.py
  to keep it under the file-size limit; importers follow.
- backend/tests/test_memory_schema.py: 8 tests, including the v1 -> v2 upgrade and the
  validity timeline.

Verification of record: pytest backend/tests bench/tests -q -> 70 passed; ruff check on
every touched file -> all checks passed; mypy (fresh cache) on db.py, migrations.py,
schemas.py, evaluation_schemas.py and backend/tests -> no issues. evals.py carries 40
pre-existing mypy errors at HEAD and 40 after this change (measured on both copies);
its pyright findings are the same debt, and p0-6 rewrites that file. `peft` and
`pyarrow.parquet` "could not be resolved" is the documented pyright artifact: pi-lens
runs it with 0 config files, and both import at runtime.
2026-10-07 14:30:22 -04:00

213 lines
9.7 KiB
Python

"""Run the Mem0- and Zep-style counterparts over the frozen sets (b-4), same queries, same metrics.
The point of this runner is an external reference point measured on our own corpus: a flat
memory store with holder scoping and no invalidation (Mem0-style), and the same items with
time-bounded validity (Zep-style). Whatever ely-eye scores, it must beat these two on the
same queries, or the memory story is not real.
Run: python -m bench.run_external # after bench.ingest_baseline has merged the corpus
"""
from __future__ import annotations
import argparse
import json
import statistics
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from bench.counterpart import (
Counterpart,
SourceMeta,
build_counterparts,
build_items,
load_item_atoms,
load_source_index,
)
from bench.ingest_baseline import RUNS, database_path
from bench.metrics import aggregate, chance_recall_at_k, score_query
from bench.run import Query, git_commit, holder_index, load_queries
from bench.sets_common import ROOT
from bench.systems import machine_facts
REPORTS = ROOT / "reports"
TOP_K = 10
HOLDER_SCOPE = "holder scope"
WHOLE_CORPUS_SCOPE = "whole corpus"
SCOPES = (HOLDER_SCOPE, WHOLE_CORPUS_SCOPE)
PROGRESS_EVERY = 500
def chance_rows(index: dict[str, SourceMeta], queries: list[Query]) -> dict[str, Any]:
"""What random guessing scores in each candidate set, so a scoped score is not read as skill."""
per_holder: dict[str, int] = {}
for meta in index.values():
per_holder[meta.holder] = per_holder.get(meta.holder, 0) + 1
sizes = [per_holder[query.holder] for query in queries if query.holder in per_holder]
if not sizes:
raise SystemExit("no query holder appears in the corpus index")
return {
"recall_at_10": chance_recall_at_k(1, len(index), TOP_K),
"recall_at_10_holder_scope": sum(chance_recall_at_k(1, size, TOP_K) for size in sizes) / len(sizes),
"median_holder_sources": float(statistics.median(sizes)),
"note": "expected recall@10 of a random ranking inside the candidate set each row is allowed to see",
}
def score_store(store: Counterpart, queries: list[Query], holder_of: dict[str, str], scope: str) -> dict[str, Any]:
"""Rank every query against one counterpart store and aggregate the same metrics the arms use.
scope "holder" answers only from the asking holder's memory; scope "whole corpus" hides the
holder boundary instead, which is the only difference from what the product's arms are shown.
Without both rows the gain from scoping cannot be told apart from the gain from ranking.
"""
store.timings.clear()
scores = []
for index, query in enumerate(queries, start=1):
holder = query.holder if scope == HOLDER_SCOPE else None
ranked = store.search(query.text, holder, query.as_of, TOP_K)
scores.append(score_query(query.query_id, ranked, set(query.gold), holder=holder, holder_of=holder_of))
if index % PROGRESS_EVERY == 0:
print(f" {store.name} [{scope}] {index}/{len(queries)}", flush=True)
summary: dict[str, Any] = {
"store": f"{store.name} [{scope}]",
"items": len(store.items),
"closed_items": sum(1 for item in store.items if item.valid_to is not None),
"overall": aggregate(scores),
"by_modality": {
modality: aggregate([s for s, q in zip(scores, queries, strict=True) if q.modality == modality])
for modality in sorted({q.modality for q in queries})
},
"latency_seconds": {
"queries": len(store.timings),
"mean": sum(store.timings) / len(store.timings) if store.timings else 0.0,
"p50": store.percentile(0.50) if store.timings else 0.0,
"p95": store.percentile(0.95) if store.timings else 0.0,
"max": max(store.timings) if store.timings else 0.0,
},
}
return summary
def write_markdown(report: dict[str, Any], path: Path) -> None:
"""A table a reader can check: every number carries its n and its 95% lower bound."""
meta = report["meta"]
lines = [
"# BASELINE_EXTERNAL: memory counterparts on the same corpus",
"",
f"- generated {meta['generated_at']} at commit {meta['git_commit'][:12]}",
f"- {meta['queries']} queries ({meta['answerable']} answerable) against {meta['sources']} sources",
(
f"- machine: {meta['machine']['cpus']} cores, load average "
f"{meta['machine']['load_average_1_5_15'][0]:.1f} when the report was written"
),
(
"- extraction is lexical for both stores; Mem0's real add() and Zep's graph builder call a "
"generator model, which this round does not have"
),
(
f"- chance: random guessing scores recall@10 {report['chance']['recall_at_10']:.5f} over the whole "
f"corpus and {report['chance']['recall_at_10_holder_scope']:.4f} inside one holder's memory "
f"(median {report['chance']['median_holder_sources']:.0f} sources per holder); read each row against "
"its own floor"
),
"",
(
"| store | items | closed | recall@10 | hit@10 | hit@10 Wilson 95% lower | MRR | "
"abstain (answerable) | abstain (unanswerable) | p50 s | p95 s |"
),
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for store in report["stores"]:
overall = store["overall"]
latency = store["latency_seconds"]
lines.append(
f"| {store['store']} | {store['items']} | {store['closed_items']} | {overall['recall_at_10']:.3f} "
f"| {overall['hit_at_10']:.3f} | {overall['hit_at_10_wilson_lower']:.3f} | {overall['mrr']:.3f} "
f"| {overall['abstention_rate_answerable']:.3f} | {overall['abstention_rate_unanswerable']:.3f} "
f"| {latency['p50']:.3f} | {latency['p95']:.3f} |"
)
lines += ["", f"n per modality: {json.dumps(meta['queries_by_modality'], sort_keys=True)}", ""]
lines += [
"",
"`[holder scope]` answers only from the asking holder's memory; `[whole corpus]` searches every",
"holder's memory, which is what the product's arms are shown. The gap between the two rows is the",
"value of the holder boundary, not of a better ranker.",
"",
]
for store in report["stores"]:
lines.append(f"## {store['store']} by modality")
lines.append("")
lines.append("| modality | n answerable | recall@10 | hit@10 | Wilson lower | MRR |")
lines.append("| --- | --- | --- | --- | --- | --- |")
for modality, row in sorted(store["by_modality"].items()):
lines.append(
f"| {modality} | {row['n_answerable']} | {row['recall_at_10']:.3f} | {row['hit_at_10']:.3f} "
f"| {row['hit_at_10_wilson_lower']:.3f} | {row['mrr']:.3f} |"
)
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--limit", type=int, help="score at most this many queries per modality (smoke runs)")
parser.add_argument("--modality", action="append", choices=None)
parser.add_argument("--out", type=Path, default=REPORTS / "baseline_external.json")
parser.add_argument("--db", type=Path, default=database_path(RUNS), help="atoms to build items from (smoke runs use a shard)")
args = parser.parse_args()
modalities = args.modality or None
queries = load_queries(modalities) if modalities else load_queries()
if args.limit:
kept: list[Query] = []
for modality in sorted({q.modality for q in queries}):
kept.extend([q for q in queries if q.modality == modality][: args.limit])
queries = kept
if not queries:
raise SystemExit("no queries selected")
index = load_source_index()
atoms = load_item_atoms(args.db)
items = build_items(atoms, index)
if not items:
raise SystemExit("no memory items built; the corpus produced no text")
print(f"== {len(items)} memory items from {len(atoms)} atoms and {len(index)} indexed sources")
holder_of = holder_index()
stores = build_counterparts(items)
report: dict[str, Any] = {
"meta": {
"generated_at": datetime.now(UTC).isoformat(timespec="seconds"),
"git_commit": git_commit(),
"queries": len(queries),
"answerable": sum(1 for q in queries if q.answerable),
"limit_per_modality": args.limit,
"queries_by_modality": {m: sum(1 for q in queries if q.modality == m) for m in sorted({q.modality for q in queries})},
"sources": len(index),
"top_k": TOP_K,
"extraction": "lexical (see bench/counterpart.py); LLM extraction deferred with the generator",
"machine": machine_facts(),
},
"chance": chance_rows(index, queries),
"stores": [score_store(store, queries, holder_of, scope) for store in stores for scope in SCOPES],
}
REPORTS.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, indent=1, sort_keys=True), encoding="utf-8")
markdown = args.out.with_name("BASELINE_EXTERNAL.md")
write_markdown(report, markdown)
for store in report["stores"]:
overall = store["overall"]
print(
f"{store['store']:14s} recall@10 {overall['recall_at_10']:.3f} hit@10 {overall['hit_at_10']:.3f} "
f"(Wilson lower {overall['hit_at_10_wilson_lower']:.3f}) mrr {overall['mrr']:.3f} "
f"p50 {store['latency_seconds']['p50']:.3f}s"
)
print(f"== wrote {args.out} and {markdown}")
return 0
if __name__ == "__main__":
raise SystemExit(main())