- 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>
Write-gate evaluation
The v1 plugin had no way to measure extraction quality, so it degraded for three months without anyone noticing. When the corpus was finally audited, 20.5% of it was near-duplicate heartbeats — the largest single cluster was 119 near-identical training-progress frames — and organic searches had fallen from 257/month to 75/month. The memory got noisy, people stopped trusting it, and nothing in the system said so.
This directory is the instrument that would have said so. It scores the write gate against a labeled fixture set and fails a build when the score regresses.
The gate has two halves, and each mode measures one of them:
| Half | Where it lives | Measured by |
|---|---|---|
| Local trigger rules — mechanical noise never leaves the machine | src/mem0_agent/triggers.py |
--offline |
| Custom instructions — the platform stores nothing from a narration/activity/repo-file window | src/mem0_agent/config/project_config.py (INSTRUCTIONS) |
--live |
Running it
Offline (default, no network, no credentials)
cd integrations/mem0-agent
PYTHONPATH=src python3 eval/run.py --offline
PYTHONPATH=src python3 eval/run.py --offline --level aggressive # sweep a capture level
PYTHONPATH=src python3 eval/run.py --offline --check # exit non-zero on regression
Runs all fixtures through mem0_agent.triggers.classify and scores the decisions
against the labels. It costs nothing, so it belongs in CI on every commit that touches
triggers.py.
triggers.py is imported lazily. If it is missing or its classify() signature is
unreadable, the run reports the reason and scores nothing rather than crashing — but
--check then exits non-zero, because a gate that cannot measure must not pass.
Live (writes to a scratch project)
export MEM0_API_KEY=...
PYTHONPATH=src python3 eval/run.py --live \
--project-id proj_SCRATCH --org-id org_YOURS --cleanup --check
Replays every exclude and extract fixture through mem0_agent.api.Api with
infer=True, waits for extraction, reads back, and scores what the platform actually
stored. This is the only way to test the custom instructions; they are a prompt, and
prompts are not unit-testable.
Safety and correctness properties, each of which exists because of something that went wrong before:
--project-idand--org-idare required and have no defaults. A live run writes real memories. v1's benchmark data ended up in the production project because a harness defaulted to whatever the API key resolved to. Point these at a scratch project.- Every fixture gets its own user id,
eval-<runid>-<fixture id>, under app ideval-<runid>. Results are attributable to a fixture and a run, and anything left behind is trivially findable. - It polls; it does not sleep. Extraction landed anywhere between 20s and 5min
during validation, so any fixed wait is either wrong or wasteful.
--timeout(default 360s) bounds the wait;--min-settle(default 60s) is the minimum time before a zero read is allowed to count as suppression, since "nothing stored" and "not stored yet" look identical. - It scores
metadata.type, nevercategories. Platform categorization lags ~3.9h at the median and is 0% for memories under an hour old, so a category-based score would read zero on a fresh run and tell you nothing. --cleanupdeletes everything the run wrote. Without it the report prints theapp_idand user-id prefix needed to clean up by hand.
One honesty caveat on the live type_match metric: metadata.type is stamped by the
client at write time (from triggers.classify when available, otherwise from the
fixture's expect_type). So type_match measures that the type survives the round
trip, and — when the classifier is present — that the classifier chose the right one.
It is not the platform's independent opinion. The report records stamped_by per
fixture so a reader can tell which case they are looking at.
Both modes write eval/last_report.json: every score, every per-fixture row, the
thresholds in force, and the pass/fail verdict.
The fixture set
fixtures.py holds the labeled windows. Each entry is exactly:
{"id": str, "window": [{"role", "content"}, ...], "label": "drop"|"exclude"|"extract",
"expect_type": str | None, "note": str}
| Label | Contract | Enforced by |
|---|---|---|
drop |
Must never be sent to the platform at all | client trigger rules |
exclude |
May be sent, but the platform must store nothing from it | custom instructions |
extract |
Must produce ≥1 memory, ideally of expect_type |
both halves |
The drop and exclude windows use the wording of the audited v1 corpus: the
epoch/loss/ETA training heartbeat, the N of M chunks processed (X%) frame, the
markdown-ingest pid/elapsed line, monitoring status lines, tool-only turns,
assistant narration, file-modification lists, CLAUDE.md/README excerpts, one-off
directives (you do it), and session-only status (at 12:45:24 the job was wrapping up). Several noise classes appear two or three times with only the numbers changed —
that is what a near-duplicate cluster actually looks like, and a gate that catches the
first frame but not the third has learned the numbers rather than the shape.
The extract block covers all five durable types plus four mixed windows, where a
durable fact sits between two progress lines. Mixed windows are the most informative
fixtures in the set: they fail in both directions. A gate too eager to drop heartbeats
destroys the fact along with them; a gate too eager to store keeps the heartbeats.
Print coverage at any time:
python3 eval/fixtures.py
Thresholds
--check exits non-zero when a gated metric falls below its floor:
| Metric | Floor | Why |
|---|---|---|
hard_drop_recall |
0.95 | Fraction of drop fixtures the client never sends. This is the number that was silently 0 in v1. |
extract_recall |
0.80 | Fraction of extract fixtures that survive. Offline: flagged for capture. Live: ≥1 memory stored. A gate that stores nothing scores perfectly on noise. |
Both floors must hold; they measure opposite failure directions and either one alone is trivially gamed.
Other metrics are reported but not gated, because they diagnose rather than decide:
hard_drop_explicit— how much of the noise containment comes from a real drop rule rather than from no flag rule happening to match.capture.pyonly forwards windows whose action isflag, so askipdoes contain the noise — but only until someone adds a flag rule that matches it. A gap betweenhard_drop_recallandhard_drop_explicitis a list of heartbeats held back by luck.hard_drop_precision— of everything hard-dropped, how much was safe to drop. This is where over-broad drop rules show up, and it is the metric the mixed fixtures exist to move. A hard drop on anextractwindow is the one unrecoverable error: the memory is gone and nothing logs a miss. Hard-dropping anexcludewindow is not counted against this score — those are meant to be discarded, and discarding them locally is simply cheaper than having the platform do it.extract_skippedvsextract_hard_dropped— same lost memory, different repair. A skip means a flag rule is missing; a hard drop means a drop rule is too greedy.flag_precision,type_accuracy,type_coverage,exclude_suppression,noise_leak_count— the rest of the picture.
noise_leak_count deserves a note: it regex-matches stored memory text for heartbeat
markers (epoch, ETA, N% complete, chunks processed, pid NNNN, …). Any hit
means the class that ate 20.5% of the v1 corpus has found a new way through, even if
every count-based score looks fine.
Raising a floor is cheap and should be done once a score has held above the new bar for a while. Lowering one is a decision that belongs in a PR description, next to the reason.
Baseline (design validation)
Established against the live platform during the v2 design phase, using the same fixture wording:
- Exclude classes: 5 of 6 suppressed by the custom instructions alone. Training heartbeats, chunk-progress frames, file-modification lists, session-only status, and assistant narration all stored nothing.
- The repo-file class is client-side only. A pasted
CLAUDE.mdexcerpt produced three confident "preference" memories. This is not fixable by prompting: a pasted convention and a stated convention are textually identical, so the extractor is right to store it and the client must never send it.triggers.pyowns this rule, and it is mandatory rather than tunable. - Extract classes: 5 of 5 captured — preference, decision, convention, insight and runbook — plus the mixed window, where the bastion-host fact was stored and neither surrounding progress line was.
- Categories were empty on every memory at read time, which is what fixed the read
path on
metadata.typeand this harness with it.
Write latency was 0.38–0.51s for the fire-and-forget add; extraction landed 20s–5min
later. That gap is the whole reason the live mode polls.
Adding a fixture
- Append it to
_DROP,_EXCLUDEor_EXTRACTinfixtures.pywith a freshid. - Use real wording. A fixture invented to be easy to classify measures nothing — prefer a window copied from an actual session or an audit.
- Write a
notesaying which failure the fixture guards. Six months from now that sentence is the only thing standing between a red score and someone "fixing" it by deleting the fixture. extractfixtures need anexpect_typefrommem0_agent.config.project_config.TYPES;dropandexcludefixtures must haveexpect_type = None.- Run
PYTHONPATH=src python3 -m pytest tests/test_fixtures.py -q— it enforces the schema, unique ids, valid labels and types, and that every durable type is covered. - Re-run
--offlineand, when the change touches the instructions,--live.
Adding a fixture usually lowers a score. That is the point: the score was previously measuring a smaller world.