Files
mem0/integrations/mem0-agent/eval/run.py
T
Deshraj YadavandClaude Fable 5 4d5f2e653c feat(mem0-agent): capture gate, eval harness, and mixed-window fix
- triggers.py: hard-drop rules now filter PER TURN, so a durable fact sitting
  between two progress lines survives instead of being dropped with them. The
  eval harness caught this: 3 mixed-window fixtures were being destroyed
  client-side before the extractor ever saw them.
- widened flag rules for 13 plainly durable windows the gate was missing
  (standing preferences, X-over-Y decisions, stated rules, diagnoses, verified
  procedures); verified procedures moved from aggressive to balanced
- eval/: 56 labeled fixtures from the audited v1 corpus plus offline and live
  runners with a --check regression gate
- un-ignore integrations/mem0-agent/eval (root .gitignore excluded it silently)

Offline scorecard: hard_drop_recall 1.000, extract_recall 1.000,
flag_precision 1.000, 0 misclassified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 02:02:37 -07:00

581 lines
26 KiB
Python

#!/usr/bin/env python3
"""Write-gate evaluation harness.
v1 shipped with no way to measure extraction quality, so it degraded unnoticed for
three months: 20.5% of the corpus turned into near-duplicate heartbeats and organic
searches fell from 257/month to 75/month. Nobody noticed because nobody could look.
This harness is the thing that looks.
Two modes:
--offline (default, no network)
Runs every fixture through mem0_agent.triggers.classify and scores the client's
local rules: hard-drop recall, hard-drop precision, flag precision/recall and
per-type accuracy. Costs nothing, so it can run in CI on every commit.
--live --project-id P --org-id O
Replays the `exclude` and `extract` fixtures against a SCRATCH project through
mem0_agent.api.Api with infer=True, polls until extraction lands, reads back and
scores what the platform actually stored. This measures the half of the gate that
lives in the custom instructions and cannot be unit-tested.
Both modes print a scorecard and write eval/last_report.json. `--check` turns the run
into a gate: it exits non-zero if hard-drop recall drops below 0.95 or extract recall
below 0.80, so no edit to the trigger rules or the custom instructions ships unmeasured.
Classifier contract (offline mode)
----------------------------------
`mem0_agent.triggers.classify(window)` is expected to take the message-window list and
return a decision. The adapter below accepts every reasonable shape -- a bool, a string,
a (decision, type) pair, a dict, or an object with attributes -- because the module is
built in parallel with this one. If the module is missing, offline scoring is skipped
with a clear message rather than crashing; if it exists but returns something
unreadable, that is reported as a contract mismatch, not as a score.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import uuid
from pathlib import Path
from typing import Any, Callable
EVAL_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(EVAL_DIR))
sys.path.insert(0, str(EVAL_DIR.parent / "src"))
import fixtures as fx # noqa: E402
REPORT_PATH = EVAL_DIR / "last_report.json"
# --check gates. Raise them only when the measured score has been above the new bar for
# a while; lowering one is a decision that belongs in a PR description.
THRESHOLDS: dict[str, float] = {
"hard_drop_recall": 0.95,
"extract_recall": 0.80,
}
# Text that must never appear inside a stored memory. If one of these survives the gate
# the heartbeat class is leaking again, which is precisely how v1 rotted.
NOISE_PATTERNS = [
re.compile(r"\beta\b[^.]{0,20}\b\d", re.I),
re.compile(r"\bepoch\b", re.I),
re.compile(r"\d+\s*%\s*(complete|done)", re.I),
re.compile(r"chunks?\s+processed", re.I),
re.compile(r"\bpid\s*\d+", re.I),
re.compile(r"\bgradient\s+norm\b", re.I),
re.compile(r"\bqueue\s+depth\b", re.I),
re.compile(r"\belapsed\s+\d\d:\d\d", re.I),
]
def noise_leaks(text: str) -> list[str]:
return [p.pattern for p in NOISE_PATTERNS if p.search(text or "")]
# ---------------------------------------------------------------------------
# classifier adapter
# ---------------------------------------------------------------------------
# Three outcomes, not two. "drop" is a hard drop -- mechanical noise the client refuses
# to send at any level. "skip" is "nothing worth storing right now", which is a soft miss:
# harmless on an exclude window, a lost memory on an extract one. Collapsing the two
# would hide exactly the regression this harness exists to catch.
_DROP_WORDS = {"drop", "noise", "ignore", "suppress", "reject", "block", "hard_drop"}
_SKIP_WORDS = {"skip", "none", "no", "defer", "wait", "noop", "no_trigger"}
_SEND_WORDS = {"send", "capture", "flag", "flagged", "store", "keep", "extract", "accept", "allow",
"pass", "yes"}
DECISIONS = ("drop", "skip", "send", "unknown")
class ContractMismatch(RuntimeError):
"""classify() exists but neither its signature nor its return value is readable."""
def load_classifier() -> tuple[Callable | None, str]:
"""Import lazily. triggers.py is written by a parallel workstream and may not exist."""
try:
from mem0_agent import triggers # type: ignore
except ImportError as e:
return None, f"mem0_agent.triggers is not available yet ({e})"
except Exception as e: # a broken module is a different problem than a missing one
return None, f"mem0_agent.triggers failed to import: {type(e).__name__}: {e}"
fn = getattr(triggers, "classify", None)
if not callable(fn):
return None, "mem0_agent.triggers exists but has no callable classify()"
return fn, ""
def _invoke(fn: Callable, window: list[dict], level: str | None = None) -> Any:
"""Try the plausible call shapes, most likely first."""
attempts: tuple[Callable[[], Any], ...] = ()
if level:
attempts += (lambda: fn(window, level), lambda: fn(window, level=level))
attempts += (
lambda: fn(window),
lambda: fn(messages=window),
lambda: fn(window=window),
lambda: fn("\n".join(m.get("content", "") for m in window)),
)
last: Exception | None = None
for call in attempts:
try:
return call()
except TypeError as e:
last = e
raise ContractMismatch(f"classify() rejected every call shape: {last}")
def _type_of(value: Any) -> str | None:
if isinstance(value, str) and value.lower() in _known_types():
return value.lower()
return None
def _known_types() -> set[str]:
try:
from mem0_agent.config.project_config import TYPES
return set(TYPES)
except Exception:
return set(fx.counts_by_type()) | {"session_state"}
def _look(result: Any, names: tuple[str, ...]) -> Any:
for n in names:
if isinstance(result, dict):
if n in result:
return result[n]
elif hasattr(result, n):
return getattr(result, n)
return None
def normalize(result: Any) -> tuple[str, str | None]:
"""Map whatever classify() returned onto (decision, type|None). See DECISIONS."""
if result is None:
return "skip", None
if isinstance(result, bool):
return ("send" if result else "skip"), None
if isinstance(result, str):
low = result.strip().lower()
if low in _known_types():
return "send", low
if low in _DROP_WORDS:
return "drop", None
if low in _SKIP_WORDS:
return "skip", None
if low in _SEND_WORDS:
return "send", None
return "unknown", None
if isinstance(result, (tuple, list)):
if not result:
return "unknown", None
decision, _ = normalize(result[0])
mtype = _type_of(result[1]) if len(result) > 1 else None
if decision == "unknown" and mtype:
decision = "send"
return decision, mtype
mtype = _type_of(_look(result, ("type", "mtype", "memory_type", "kind")))
# A string verdict is the most expressive shape, so it wins over the booleans:
# `flagged=False` cannot tell a hard drop apart from a soft skip.
verdict = _look(result, ("action", "decision", "verdict", "outcome", "status", "result"))
if isinstance(verdict, str):
decision, vtype = normalize(verdict)
if decision != "unknown":
return decision, (mtype or vtype)
drop = _look(result, ("drop", "dropped", "is_drop", "should_drop"))
if isinstance(drop, bool) and drop:
return "drop", mtype
send = _look(result, ("send", "capture", "should_capture", "flag", "flagged", "should_send", "keep"))
if isinstance(send, bool):
return ("send" if send else ("skip" if drop is False else "drop")), mtype
if isinstance(drop, bool):
return "send", mtype
if mtype:
return "send", mtype
return "unknown", None
def reason_of(result: Any) -> str:
"""Whatever the classifier called this rule -- the most useful column in the report."""
raw = _look(result, ("reason", "rule", "why", "trigger", "explanation"))
return str(raw) if isinstance(raw, (str, int)) else ""
# ---------------------------------------------------------------------------
# scoring
# ---------------------------------------------------------------------------
def _ratio(num: int, den: int) -> float | None:
return round(num / den, 4) if den else None
def score_offline(rows: list[dict]) -> dict[str, Any]:
"""rows: {"id","label","expect_type","decision","got_type"}."""
drops = [r for r in rows if r["label"] == "drop"]
extracts = [r for r in rows if r["label"] == "extract"]
excludes = [r for r in rows if r["label"] == "exclude"]
dropped = [r for r in rows if r["decision"] == "drop"]
flagged = [r for r in rows if r["decision"] == "send"]
per_type: dict[str, dict[str, int]] = {}
for r in extracts:
want = r["expect_type"] or "unspecified"
bucket = per_type.setdefault(want, {"n": 0, "correct": 0, "typed": 0})
bucket["n"] += 1
if r["got_type"]:
bucket["typed"] += 1
if r["got_type"] == want:
bucket["correct"] += 1
typed = sum(b["typed"] for b in per_type.values())
correct = sum(b["correct"] for b in per_type.values())
return {
# The metric that keeps the corpus clean: mechanical noise must never be sent.
# capture.py only forwards windows whose action is "flag", so both "drop" and
# "skip" satisfy the contract -- the label says "never sent", not "matched a
# rule named drop".
"hard_drop_recall": _ratio(sum(1 for r in drops if r["decision"] in ("drop", "skip")), len(drops)),
# Advisory, not gated: how much of that containment comes from an explicit drop
# rule rather than from no flag rule happening to match. Noise held back only by
# the absence of a flag rule leaks the day someone adds one.
"hard_drop_explicit": _ratio(sum(1 for r in drops if r["decision"] == "drop"), len(drops)),
# Of everything hard-dropped, how much was safe to drop. A hard drop on an
# `extract` window is the one unrecoverable error -- the memory is gone and
# nothing logs a miss. Hard-dropping an `exclude` window is not counted against
# this: those are meant to be discarded, and doing it locally is simply cheaper.
"hard_drop_precision": _ratio(sum(1 for r in dropped if r["label"] != "extract"), len(dropped)),
# The metric that keeps the gate useful: durable knowledge must survive locally.
"extract_recall": _ratio(sum(1 for r in extracts if r["decision"] == "send"), len(extracts)),
# Of everything forwarded to the platform, how much was worth forwarding.
"flag_precision": _ratio(sum(1 for r in flagged if r["label"] == "extract"), len(flagged)),
# Extract windows lost to a soft "skip" rather than a hard drop. Same lost
# memory, different fix: a missing flag rule, not an over-broad drop rule.
"extract_skipped": sum(1 for r in extracts if r["decision"] == "skip"),
"extract_hard_dropped": sum(1 for r in extracts if r["decision"] == "drop"),
# Exclude windows the client suppressed locally -- free wins, not required, since
# the platform instructions are the designated owner of that class.
"exclude_suppressed_early": _ratio(
sum(1 for r in excludes if r["decision"] in ("drop", "skip")), len(excludes)),
"type_accuracy": _ratio(correct, typed),
"type_coverage": _ratio(typed, len(extracts)),
"per_type": per_type,
"unreadable": sum(1 for r in rows if r["decision"] == "unknown"),
}
def run_offline(level: str | None = None) -> dict[str, Any]:
fn, why = load_classifier()
if fn is None:
return {"mode": "offline", "skipped": True, "reason": why, "scores": {}, "rows": [],
"level": level}
rows: list[dict] = []
try:
for f in fx.FIXTURES:
try:
raw = _invoke(fn, f["window"], level)
decision, got_type = normalize(raw)
reason, err = reason_of(raw), ""
except ContractMismatch:
raise
except Exception as e: # a fixture that blows up the classifier is a real finding
decision, got_type, reason, err = "unknown", None, "", f"{type(e).__name__}: {e}"
rows.append({
"id": f["id"], "label": f["label"], "expect_type": f["expect_type"],
"decision": decision, "got_type": got_type, "reason": reason, "error": err,
})
except ContractMismatch as e:
return {"mode": "offline", "skipped": True, "reason": str(e), "scores": {}, "rows": [],
"level": level}
return {"mode": "offline", "skipped": False, "reason": "", "level": level,
"scores": score_offline(rows), "rows": rows}
# ---------------------------------------------------------------------------
# live mode
# ---------------------------------------------------------------------------
def _memory_type(mem: dict) -> str | None:
"""Read the type from metadata. NEVER from categories: categorization lags ~3.9h
(median) and is 0% for memories under an hour old, so a fresh read sees nothing."""
meta = mem.get("metadata") or {}
return meta.get("type") if isinstance(meta, dict) else None
def score_live(rows: list[dict]) -> dict[str, Any]:
extracts = [r for r in rows if r["label"] == "extract"]
excludes = [r for r in rows if r["label"] == "exclude"]
per_type: dict[str, dict[str, int]] = {}
for r in extracts:
want = r["expect_type"] or "unspecified"
bucket = per_type.setdefault(want, {"n": 0, "stored": 0, "typed_correct": 0})
bucket["n"] += 1
if r["stored"] >= 1:
bucket["stored"] += 1
if want in (r["got_types"] or []):
bucket["typed_correct"] += 1
return {
"extract_recall": _ratio(sum(1 for r in extracts if r["stored"] >= 1), len(extracts)),
"exclude_suppression": _ratio(sum(1 for r in excludes if r["stored"] == 0), len(excludes)),
"type_match": _ratio(sum(b["typed_correct"] for b in per_type.values()),
sum(b["n"] for b in per_type.values())),
"noise_leak_count": sum(len(r["leaks"]) for r in rows),
"memories_written": sum(r["stored"] for r in rows),
"per_type": per_type,
}
def run_live(args: argparse.Namespace) -> dict[str, Any]:
from mem0_agent.api import Api, results_of # noqa: PLC0415
from mem0_agent.config.filters import all_in_scope # noqa: PLC0415
from mem0_agent.config.project_config import POLICY_VERSION # noqa: PLC0415
key = args.api_key or os.environ.get("MEM0_API_KEY")
if not key:
raise SystemExit("live mode needs an API key: --api-key or MEM0_API_KEY")
runid = args.run_id or uuid.uuid4().hex[:8]
app_id = f"eval-{runid}"
api = Api(key, org_id=args.org_id, project_id=args.project_id, strict=True)
classifier, why = load_classifier()
if classifier is None:
print(f"note: {why}; metadata.type will be stamped from expect_type (self-stamped, "
f"so type_match measures the round trip only)")
targets = [f for f in fx.FIXTURES if f["label"] in ("exclude", "extract")]
print(f"live run {runid}: project={args.project_id} app_id={app_id} fixtures={len(targets)}")
sent: list[dict] = []
for f in targets:
uid = f"eval-{runid}-{f['id']}"
mtype = f["expect_type"]
stamped_by = "expect_type"
if classifier is not None:
try:
decision, got = normalize(_invoke(classifier, f["window"], args.level))
if got:
mtype, stamped_by = got, "classifier"
except Exception:
pass
meta = {"session_id": f"eval-{runid}", "editor": "eval-harness",
"policy": POLICY_VERSION, "fixture": f["id"]}
if mtype:
meta["type"] = mtype
t0 = time.time()
status, body = api.add(f["window"], user_id=uid, app_id=app_id, infer=True, metadata=meta)
ok = status in (200, 201, 202)
print(f" sent {f['id']:34s} {status} {round(time.time() - t0, 2)}s"
+ ("" if ok else f" <- {body}"))
sent.append({"id": f["id"], "label": f["label"], "expect_type": f["expect_type"],
"user_id": uid, "add_status": status, "stamped_type": mtype,
"stamped_by": stamped_by, "accepted": ok})
def read(user_id: str) -> list[dict]:
status, body = api.get_all(all_in_scope(user_id, app_id), page_size=100)
return results_of(body) if status == 200 else []
# Poll rather than sleep: extraction landed anywhere from 20s to 5min in validation,
# so any fixed wait is either wrong or wasteful. Only the extract fixtures have a
# target to poll for; a zero read on an exclude fixture is only meaningful once the
# settle window has passed, so those are read once at the end.
started = time.time()
deadline = started + args.timeout
pending = {r["id"]: r["user_id"] for r in sent if r["label"] == "extract" and r["accepted"]}
landed: dict[str, list[dict]] = {}
interval = 5.0
print(f"polling for extraction (timeout {args.timeout:.0f}s, settle {args.min_settle:.0f}s)...")
while pending and time.time() < deadline:
time.sleep(min(interval, max(1.0, deadline - time.time())))
interval = min(interval * 1.4, 20.0)
for fid, uid in list(pending.items()):
mems = read(uid)
if mems:
landed[fid] = mems
pending.pop(fid, None)
print(f" t+{int(time.time() - started)}s extract landed "
f"{len(landed)}/{len(landed) + len(pending)}")
remaining_settle = args.min_settle - (time.time() - started)
if remaining_settle > 0:
print(f" settling {int(remaining_settle)}s before the suppression read...")
time.sleep(remaining_settle)
# Final read for everything: extract fixtures may have gained a second memory since
# they first landed, and the exclude fixtures are read here for the first time.
found = {r["id"]: read(r["user_id"]) for r in sent}
rows: list[dict] = []
for r in sent:
mems = found.get(r["id"], [])
texts = [m.get("memory") or "" for m in mems]
rows.append({**r,
"stored": len(mems),
"got_types": sorted({t for t in (_memory_type(m) for m in mems) if t}),
"memories": [{"id": m.get("id"), "text": t, "type": _memory_type(m),
"categories": m.get("categories")} for m, t in zip(mems, texts)],
"leaks": [lk for t in texts for lk in noise_leaks(t)]})
report = {"mode": "live", "skipped": False, "reason": "", "run_id": runid,
"project_id": args.project_id, "app_id": app_id,
"scores": score_live(rows), "rows": rows}
if args.cleanup:
deleted = 0
for r in rows:
status, _ = api.delete_all(user_id=r["user_id"], app_id=app_id)
deleted += r["stored"] if status in (200, 202, 204) else 0
report["cleanup"] = {"deleted_scopes": len(rows), "deleted_memories": deleted}
print(f"cleanup: removed {deleted} memories across {len(rows)} scopes")
else:
report["cleanup"] = {"skipped": True,
"hint": f"delete with app_id={app_id} / user_id prefix eval-{runid}-"}
return report
# ---------------------------------------------------------------------------
# reporting
# ---------------------------------------------------------------------------
def print_scorecard(report: dict[str, Any]) -> None:
counts = fx.counts()
print()
print("=" * 72)
title = f" mem0-agent write gate -- {report['mode']} scorecard"
if report.get("level"):
title += f" (level={report['level']})"
print(title)
print("=" * 72)
print(f" coverage: {fx.coverage_line()}")
if report.get("skipped"):
print(f" SKIPPED: {report['reason']}")
print("=" * 72)
return
scores = report["scores"]
for name, value in scores.items():
if name == "per_type" or isinstance(value, dict):
continue
gate = THRESHOLDS.get(name)
shown = "n/a" if value is None else f"{value:.3f}" if isinstance(value, float) else str(value)
mark = ""
if gate is not None:
mark = " FAIL" if (value is None or value < gate) else " ok"
shown += f" (min {gate:.2f}){mark}"
print(f" {name:24s} {shown}")
per_type = scores.get("per_type") or {}
if per_type:
print(" per expected type:")
for t, b in sorted(per_type.items()):
body = " ".join(f"{k}={v}" for k, v in b.items())
print(f" {t:14s} {body}")
if report["mode"] == "offline":
soft = [r for r in report["rows"] if r["label"] == "drop" and r["decision"] == "skip"]
if soft:
print(f" noise contained only by the absence of a flag rule ({len(soft)}, advisory):")
for r in soft:
print(f" {r['id']:38s} {r.get('reason', '')}")
bad = [r for r in report["rows"]
if (r["label"] == "drop" and r["decision"] not in ("drop", "skip"))
or (r["label"] == "extract" and r["decision"] != "send")
or r["decision"] == "unknown"]
if bad:
print(f" misclassified ({len(bad)}):")
for r in bad:
want = "drop" if r["label"] == "drop" else "send"
detail = r.get("error") or r.get("reason") or ""
print(f" {r['id']:38s} want={want:5s} got={r['decision']:8s} {detail}")
else:
for r in report["rows"]:
want = "0" if r["label"] == "exclude" else ">=1"
ok = (r["stored"] == 0) if r["label"] == "exclude" else (r["stored"] >= 1)
print(f" [{'PASS' if ok else 'FAIL'}] {r['id']:34s} stored={r['stored']} want={want} "
f"types={r['got_types']}")
for m in r["memories"]:
print(f" -> {(m['text'] or '')[:96]}")
if scores.get("noise_leak_count"):
print(f" NOISE LEAK: {scores['noise_leak_count']} stored memories match heartbeat patterns")
print(f" labels: {counts}")
print("=" * 72)
def check(report: dict[str, Any]) -> tuple[bool, list[str]]:
"""A gate that cannot measure must not pass."""
if report.get("skipped"):
return False, [f"nothing was measured: {report['reason']}"]
failures = []
scores = report["scores"]
applicable = {k: v for k, v in THRESHOLDS.items() if k in scores}
if not applicable:
return False, [f"no gated metric present in {report['mode']} scores"]
for name, floor in applicable.items():
value = scores.get(name)
if value is None:
failures.append(f"{name} not measured (threshold {floor})")
elif value < floor:
failures.append(f"{name}={value:.3f} below threshold {floor}")
return (not failures), failures
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="mem0-agent write-gate evaluation")
mode = p.add_mutually_exclusive_group()
mode.add_argument("--offline", action="store_true", help="score the local trigger rules (default)")
mode.add_argument("--live", action="store_true", help="replay fixtures against a scratch project")
p.add_argument("--project-id", help="SCRATCH project id (required for --live)")
p.add_argument("--org-id", help="org id (required for --live)")
p.add_argument("--api-key", help="defaults to $MEM0_API_KEY")
p.add_argument("--run-id", help="override the generated run id")
p.add_argument("--timeout", type=float, default=360.0, help="live: max seconds to poll (default 360)")
p.add_argument("--min-settle", type=float, default=60.0,
help="live: minimum seconds before a zero read counts as suppression (default 60)")
p.add_argument("--level", help="capture aggressiveness passed to classify() "
"(conservative|balanced|aggressive); default is the module's own")
p.add_argument("--cleanup", action="store_true", help="live: delete everything this run wrote")
p.add_argument("--check", action="store_true", help="exit non-zero if scores regress below thresholds")
p.add_argument("--report", default=str(REPORT_PATH), help=f"report path (default {REPORT_PATH})")
args = p.parse_args(argv)
if args.live:
# A live run writes real memories. It must never be able to land in production
# by omission, so both ids are required and neither has a default.
missing = [n for n, v in (("--project-id", args.project_id), ("--org-id", args.org_id)) if not v]
if missing:
p.error("live mode requires " + " and ".join(missing)
+ " -- point them at a scratch project, never a production one")
report = run_live(args)
else:
report = run_offline(args.level)
report["thresholds"] = THRESHOLDS
report["fixture_counts"] = fx.counts()
report["fixture_counts_by_type"] = fx.counts_by_type()
report["generated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
passed, failures = check(report)
report["check"] = {"passed": passed, "failures": failures}
print_scorecard(report)
Path(args.report).write_text(json.dumps(report, indent=1))
print(f"report written to {args.report}")
if args.check:
if failures:
print("CHECK FAILED:")
for f in failures:
print(f" - {f}")
return 1
print("CHECK PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())