Eval harness¶
Mneme ships a reproducible benchmark in evals/. Plays a labelled corpus of conversations through one of four memory strategies and reports two strategy-vs-strategy metrics: recall@k and token cost.
Not installed by pip install smolAmem — evals/ is a sibling package at the repo root, used by people who clone the repo to reproduce numbers.
Headline numbers¶
From a fresh clone on the 5-conversation starter corpus, k=5:
| Strategy | recall@5 | tokens / test point |
|---|---|---|
no_memory |
0.000 | 0.0 |
mneme (HashEmbedder, deterministic, no key) |
0.833 | 68.0 |
mneme (OpenAI text-embedding-3-small) |
1.000 | 67.7 |
full_history |
1.000 | 141.0 |
summary_buffer |
1.000 | 165.3 |
Read this as:
- With real OpenAI embeddings, Mneme matches the full-history oracle's accuracy (1.000) at less than half its token cost (67.7 vs 141.0). That's the entire pitch of the library, made falsifiable.
- With the HashEmbedder (deterministic, no API key) Mneme still hits 5/6 labelled facts at the same cost. Useful as a no-spend baseline in CI and for reviewers reproducing the harness without an OpenAI account.
summary_bufferhere is worse than full-history on tokens because the trivial built-in summariser concatenates rather than compressing. Plug in a real LLM summariser via the strategy'ssummarize_fncallback to make that comparison fair.
Running it¶
From a clone of the repo, with uv sync done:
uv run python -m evals --runner mneme --output out/mneme.json
uv run python -m evals --runner no_memory --output out/no_memory.json
uv run python -m evals --runner summary_buffer --output out/summary.json
uv run python -m evals --runner full_history --output out/full.json
Each invocation produces one JSON file. To compare:
jq '{strategy: .strategy.name,
recall: .metrics.recall_at_k.aggregate,
tokens_per_tp: .metrics.tokens.aggregate_tokens_per_test_point}' out/*.json
For real embeddings:
export OPENAI_API_KEY="sk-..."
uv run python -m evals --runner mneme --embedder openai --output out/mneme_oai.json
What gets measured¶
recall@k¶
For each test point: is any of the test point's expected_keywords (case-insensitively) present in any of the strategy's top-k retrieved records? Aggregate is hits / total_test_points.
Keyword overlap rather than exact-string match. The retrieved record is the verbatim turn ("I mostly work in TypeScript these days"); the canonical fact is paraphrased ("user prefers TypeScript"). Keywords like ["TypeScript"] bridge the two without either side having to match the other word-for-word.
Token cost¶
Sum of token counts across every retrieved record, per conversation and aggregated. Uses tiktoken with cl100k_base when available; falls back to "~4 chars per token". The cross-strategy ratio is what matters for cost comparisons; absolutes feed back-of-envelope $/1M tokens * this number = dollar figure estimates.
What's not measured (yet)¶
End-to-end accuracy via LLM-generated answers + LLM-as-judge scoring is reserved behind --with-answers --judge. Flags are accepted today, no-op'd. The v0.6 path is the cheap retrieval-only comparison; the expensive path will land in a later milestone when there's a corpus large enough for the spend to make sense.
CLI reference¶
python -m evals \
--corpus DIR # default: evals/corpus/
--runner {mneme,no_memory,summary_buffer,full_history}
--backend {memory,sqlite} # mneme only; default memory
--embedder {hash,openai} # mneme only; default hash
--output PATH # required
--seed INT # default 42
--k INT # default 5
--with-answers # reserved (LLM generation pass)
--judge # reserved (LLM-as-judge accuracy)
The cheap path (no --with-answers, no --judge) runs with no API calls and is deterministic.
Output shape¶
{
"strategy": {
"name": "mneme",
"backend": "InMemoryBackend",
"embedder": "HashEmbedder",
"dimensions": 16
},
"seed": 42,
"k": 5,
"corpus_dir": "evals/corpus",
"conversation_count": 5,
"metrics": {
"recall_at_k": {
"aggregate": 0.8333,
"hits": 5,
"total": 6,
"per_conversation": {
"001_typescript_preference": {"hits": 0, "total": 1, "recall": 0.0}
}
},
"tokens": {
"aggregate_context_tokens": 408,
"aggregate_tokens_per_test_point": 68.0,
"per_conversation": {
"001_typescript_preference": {
"context_tokens_sum": 105,
"test_points": 1,
"context_tokens_per_test_point": 105.0
}
}
}
},
"conversations": [
{
"conversation_id": "001_typescript_preference",
"description": "...",
"turn_count": 13,
"test_points": [
{
"turn_index": 12,
"question": "what programming language does the user prefer?",
"expected_fact": "user prefers TypeScript",
"expected_keywords": ["TypeScript"],
"retrieved_records": ["...", "..."]
}
]
}
]
}
The conversations array carries the raw play-through — useful for debugging "why did recall miss here?" without rerunning the strategy.
The corpus¶
JSON files in evals/corpus/. One conversation per file. Pydantic-validated via evals/schema.py. schema_version: "1" on every conversation.
Each conversation is a list of chat turns; at least one turn is annotated with a test_at block that names the question, the expected fact, and a keyword shortlist used for fuzzy matching:
{
"schema_version": "1",
"id": "006_my_case",
"description": "One-sentence: what is this conversation testing?",
"turns": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."},
{
"role": "user",
"content": "...",
"test_at": {
"question": "...",
"expected_fact": "...",
"expected_keywords": ["..."]
}
}
]
}
Adding a case is a one-file edit. Drop a JSON in the directory; the runner picks it up.
Test-point retrieval runs BEFORE the turn carrying test_at is added to memory. Otherwise we'd be measuring whether the strategy can find a fact inside the question itself, not whether it remembered.
The strategies¶
| Strategy | What it does | Expected behaviour |
|---|---|---|
no_memory |
Stores nothing, retrieves nothing | recall@k = 0.0 — the floor |
summary_buffer |
Last N turns verbatim + rolling summary of overflow (LangChain-style) | Depends on whether keywords survive the summary |
full_history |
Every turn, forever, all of it in context | recall@k = 1.0 — the ceiling at unbounded cost |
mneme |
MemoryManager with dual-write to working + episodic, semantic retrieval |
What we're measuring |
All four implement the same MemoryStrategy protocol (evals/baselines/base.py). Add a new one with five methods — no inheritance required.
Reproducibility¶
Two sources of non-determinism in a memory eval:
- The embedder. With
HashEmbedder, everything is deterministic — same bytes, same vector, same retrieval order. With OpenAI, the API itself is deterministic in practice but tiny precision drift between calls is real (we hit ~1e-5 element-wise drift in v0.5; tolerance loosened to 1e-3 in the embedding tests). - The LLM judge / answer generation. When those are on (
--with-answers,--judge), every run varies. Temperature=0 + a fixed seed reduces the variance but doesn't eliminate it.
The cheap path is fully reproducible. The expensive path is reproducible within a model release version + within OpenAI's nondeterminism budget. Always record model name + run date when publishing numbers.
Where to go next¶
- The full corpus authoring conventions:
evals/corpus/README.mdin the repo. - The strategies in detail:
evals/baselines/(one file per strategy). - The metric implementations:
evals/metrics/recall.pyandevals/metrics/tokens.py.