loop-memory 0.4.17__tar.gz → 0.4.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {loop_memory-0.4.17 → loop_memory-0.4.18}/PKG-INFO +6 -1
- {loop_memory-0.4.17 → loop_memory-0.4.18}/README.md +5 -0
- loop_memory-0.4.18/loop_memory/bench/__init__.py +42 -0
- loop_memory-0.4.18/loop_memory/bench/runner.py +361 -0
- loop_memory-0.4.18/loop_memory/cli/commands/bench.py +116 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/main.py +6 -0
- loop_memory-0.4.18/loop_memory/storage/lexical.py +327 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/storage/retrieval.py +78 -3
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/PKG-INFO +6 -1
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/SOURCES.txt +6 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/pyproject.toml +1 -1
- loop_memory-0.4.18/tests/test_bench_2026_11_01.py +240 -0
- loop_memory-0.4.18/tests/test_lexical_ranking_2026_11_01.py +284 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/LICENSE +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/doctor.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/read.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/rules.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/decorators.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/doctor.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/dream.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/export.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/system.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/storage/snapshot.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/storage/sqlite_store.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/setup.cfg +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_agents_init.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_audit_triggers_2026_09_27.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_busy_timeout_2026_09_24.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cache_cap_2026_09_23.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_audit_supersede.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_export_okf.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_init.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_memory_stats.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_recall_as_of.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_recall_paths.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_recall_verbose.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_rules.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_snapshot.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_content_hash_dedup_2026_10_18.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_decorators_2026_10_18.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_doctor_2026_10_12.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_dream_2026_10_25.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_dream_cli_http_2026_10_25.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_export_okf.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_graph.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_healthz_readyz_2026_09_24.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_import_okf_2026_09_27.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_latest_only_2026_10_25.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_loop.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_memory_stats.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_package_version.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_path_safety_2026_09_23.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_recall_as_of.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_recall_budget_2026_10_05.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_recall_paths.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_recall_provenance.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_recall_validity_http_2026_10_18.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_retrieval_channels_2026_10_18.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_routes_audit_2026_09_13.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_routes_audit_2026_09_20.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_short_query.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_snapshot.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_store.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_subgraph_hops_2026_09_23.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_subject_key_2026_10_25.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_supersession_chain.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.17 → loop_memory-0.4.18}/tests/test_wiki_prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.18
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,6 +66,8 @@ Dynamic: license-file
|
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
69
|
+
> **What's new in 0.4.18** — **the BM25 channel no longer publishes a score that collapses to zero, and there is finally a benchmark that measures it** (audit 2026-11-01, weekly ecosystem sync). SQLite's FTS5 `bm25()` computes the classic idf `log((N - n + 0.5) / (n + 0.5))` and **clamps every non-positive idf to `1e-6`** — so a query term present in 49 % of the corpus keeps a real idf while one present in 51 % has its weight annihilated, a factor of ~40 000 between two almost equally discriminative terms. With the `trigram` tokenizer that is the *normal* case for CJK (`知识图` alone occurs in more than half of any knowledge-graph-heavy corpus), and the visible symptom was the 0.4.15 evidence chain reporting **`bm25: 0.0`** — "this hit because bm25 0.0". The published score is now a textbook BM25 (`k1 = 1.2`, `b = 0.75`) with the Lucene / BM25+ idf `log(1 + (N - n + 0.5) / (n + 0.5))`, which is strictly positive for every term, so the scale cannot collapse. **A document that contains the query verbatim now outranks one that merely contains the same scattered trigrams** — the transferable half of `rohitg00/agentmemory` v0.9.31's prefix-score fix (2026-10-26: strict recall-all@5 **65.74 % → 84.68 %**, +18.94 pp), whose release note is careful to call its numbers "component retrieval results on inspected development data, not held-out answer accuracy". The `LIKE` fallback and the FTS5 hits are finally on the **same scale** (a `LIKE`-only hit used to publish the memory's `importance` under the name `bm25`). The candidate set is unchanged — the fix re-ranks, it does not add or drop documents. New **`loop-memory bench`**: a deterministic retrieval-quality benchmark over a fixed, versioned fixture (30 memories, 12 wiki pages, 11 judged queries) reporting **recall@1/3/5, MRR@10, nDCG@10** with the fixture's revision and the SHA-256 of its bytes; the run is fully offline (**no provider API is called, $0, no network**) and builds its store in a throwaway directory. On the shipped fixture the channel scored **MRR@10 = 0.6061, recall@1 = 0.2273, nDCG@10 = 0.7078** before and **1.0000 / 0.9545 / 1.0000** after — and the report keeps the raw FTS5 score next to the repaired one, so the defect stays visible instead of being quietly fixed and forgotten. Source: *agentmemory* v0.9.31 evaluation methodology + the FTS5 idf clamp in SQLite's `fts5_aux.c` (Apache-2.0 — *pattern only, no code copied*); honcho v3.4.1's "a filter must not silently drop a term" and v3.5.0's "a conclusion must cite its source" were reviewed and need no code (AGPL-3.0 — *pattern only*). 37 new regression cases; suite at **995 passed / 4 skipped**. [Full CHANGELOG →](./CHANGELOG.md#0418---2026-11-01)
|
|
70
|
+
> **What's new in 0.4.17** — **a dry-run dream pass no longer writes to the database** (audit 2026-10-25). `loop-memory dream` and `POST /api/v1/dream` have always documented themselves as *"nothing is written unless `apply=True`"* — but the pass's decay stage called `MemoryStore.rescore_all()` unconditionally, which rewrote the `memories.score` column of **every** row while claiming to only inspect the store. The expiry and merge decisions were correctly left unapplied; the score decay was not. `rescore_all()` now takes a `dry_run` flag: it computes every new score and reports how many rows *would* change but issues no `UPDATE`, and the dream pass runs it with `dry_run=not apply`. A dry run still reports the size of the decay job through `report.rescored` while touching nothing. The contract wording is now exact rather than aspirational — a dry run changes **no memory data** (no expiry, no supersession, no rescore); the single write it still performs is one `cognitive_audit` row per proposed action with `action="suggest"` instead of `"applied"`, because that row *is* the reviewable plan the dashboard shows before you commit to it (`record_audit=False` suppresses even that). Also fixed in this release: `publish.yml` now sends `make_latest` as a JSON **boolean** (`gh api -F`) instead of a JSON **string** — GitHub's release API ignores the string form, so the "mark as latest" step had been returning 200 and reporting success while promoting nothing, leaving `/releases/latest` pointing at v0.4.14 through the 0.4.15 and 0.4.16 ships. The step now also re-reads `/releases/latest` and fails the job when the tag does not match, instead of trusting the exit code. 3 new regression cases (`tests/test_dream_2026_10_25.py`); suite at **958 passed / 4 skipped**. [Full CHANGELOG →](./CHANGELOG.md#0417---2026-10-25)
|
|
69
71
|
> **What's new in 0.4.16** — **subject-key logical fact slots + `latest_only` recall + the "dream" sleep-time consolidation pass** (audit 2026-10-25, weekly ecosystem sync — Mem0 v2.2.1 "Dream", arXiv 2605.27366 "In Defense of Forgotten", Zep/Graphiti temporal edges, Letta memory blocks). New nullable `subject_key` column on `memories` names the *logical slot* a fact occupies (`"editor.preference"`); `upsert_memory(subject_key=...)` makes the new row the live version and soft-supersedes the previous one with a `superseded_by` pointer, so the version history survives and `recall_as_of()` can still answer "what did we believe last week?". The content-hash dedup path is now slot-aware — two different slots holding the same text stay two rows. `SCHEMA_VERSION` bumps 12 → 13 with an idempotent in-place migration. New `latest_only=False` flag on `recall()` / `recall_hybrid()` (Mem0 v2 `SearchMemoryOptions.latest_only` parity) collapses the answer in three layers: rows outside their validity window at *now* are dropped, a subject-key slot resolves to its newest **live** row (looked up in the store, so a stale version never comes back just because it matched the query better), and near-duplicate texts cluster by token Jaccard (default 0.85) with the newest row winning. Wired through `GET /api/recall?latest_only=1`, `GET /api/v1/recall?latest_only=1`, `loop-memory recall --latest-only` and `MemoryClient.recall(latest_only=True)`. New `loop_memory/jobs/dream.py` implements the sleep-time consolidation Mem0 v2 calls the "Dream" — deterministic, zero-dep and **non-destructive**: it decays scores, then judges every row past its deadline on **utility** (`0.45 × importance + 0.30 × usage + 0.25 × recency`) so a frequently-recalled old fact is *protected* while a never-used stale row is **soft-expired** (`invalid_at` stamped, row kept, history kept) instead of deleted, and finally merges near-duplicates into a supersession chain. Run it with `loop-memory dream [--apply] [--json]` or `POST /api/v1/dream` — it is a **dry run by default** and every decision lands in `cognitive_audit` so the dashboard can show and revert it. New store primitives `expire_memory()` / `revive_memory()`, and `memory_stats()` now reports `subject_key`, `ttl`, `valid_at`, `invalid_at` and a derived `ttl_state`. **Also fixed a real defect**: the hybrid recall path (BM25 / LIKE / semantic channels) did not filter `superseded_by`, so `recall_hybrid()` — the production path behind the CLI, MCP server and dashboard — returned both versions of a superseded fact. Source: Mem0 v2.2.1 Dream + ADD-only architecture + arXiv 2605.27366 utility-aware eviction + Zep/Graphiti bi-temporal edges (pattern only, no code copied). 77 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0416---2026-10-25)
|
|
70
72
|
> **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
|
|
71
73
|
> **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
|
|
@@ -251,6 +253,9 @@ loop-memory consolidate # rescore + GC + dedupe
|
|
|
251
253
|
# 4. Optional: sleep-time consolidation (soft expiry, never deletes)
|
|
252
254
|
loop-memory dream # dry run -- what would expire / merge
|
|
253
255
|
loop-memory dream --apply # write it (soft-expire + supersede)
|
|
256
|
+
|
|
257
|
+
# 5. Optional: measure the retrieval quality (offline, no API calls)
|
|
258
|
+
loop-memory bench # recall@k / MRR / nDCG over a fixed fixture
|
|
254
259
|
```
|
|
255
260
|
|
|
256
261
|
---
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
25
|
+
> **What's new in 0.4.18** — **the BM25 channel no longer publishes a score that collapses to zero, and there is finally a benchmark that measures it** (audit 2026-11-01, weekly ecosystem sync). SQLite's FTS5 `bm25()` computes the classic idf `log((N - n + 0.5) / (n + 0.5))` and **clamps every non-positive idf to `1e-6`** — so a query term present in 49 % of the corpus keeps a real idf while one present in 51 % has its weight annihilated, a factor of ~40 000 between two almost equally discriminative terms. With the `trigram` tokenizer that is the *normal* case for CJK (`知识图` alone occurs in more than half of any knowledge-graph-heavy corpus), and the visible symptom was the 0.4.15 evidence chain reporting **`bm25: 0.0`** — "this hit because bm25 0.0". The published score is now a textbook BM25 (`k1 = 1.2`, `b = 0.75`) with the Lucene / BM25+ idf `log(1 + (N - n + 0.5) / (n + 0.5))`, which is strictly positive for every term, so the scale cannot collapse. **A document that contains the query verbatim now outranks one that merely contains the same scattered trigrams** — the transferable half of `rohitg00/agentmemory` v0.9.31's prefix-score fix (2026-10-26: strict recall-all@5 **65.74 % → 84.68 %**, +18.94 pp), whose release note is careful to call its numbers "component retrieval results on inspected development data, not held-out answer accuracy". The `LIKE` fallback and the FTS5 hits are finally on the **same scale** (a `LIKE`-only hit used to publish the memory's `importance` under the name `bm25`). The candidate set is unchanged — the fix re-ranks, it does not add or drop documents. New **`loop-memory bench`**: a deterministic retrieval-quality benchmark over a fixed, versioned fixture (30 memories, 12 wiki pages, 11 judged queries) reporting **recall@1/3/5, MRR@10, nDCG@10** with the fixture's revision and the SHA-256 of its bytes; the run is fully offline (**no provider API is called, $0, no network**) and builds its store in a throwaway directory. On the shipped fixture the channel scored **MRR@10 = 0.6061, recall@1 = 0.2273, nDCG@10 = 0.7078** before and **1.0000 / 0.9545 / 1.0000** after — and the report keeps the raw FTS5 score next to the repaired one, so the defect stays visible instead of being quietly fixed and forgotten. Source: *agentmemory* v0.9.31 evaluation methodology + the FTS5 idf clamp in SQLite's `fts5_aux.c` (Apache-2.0 — *pattern only, no code copied*); honcho v3.4.1's "a filter must not silently drop a term" and v3.5.0's "a conclusion must cite its source" were reviewed and need no code (AGPL-3.0 — *pattern only*). 37 new regression cases; suite at **995 passed / 4 skipped**. [Full CHANGELOG →](./CHANGELOG.md#0418---2026-11-01)
|
|
26
|
+
> **What's new in 0.4.17** — **a dry-run dream pass no longer writes to the database** (audit 2026-10-25). `loop-memory dream` and `POST /api/v1/dream` have always documented themselves as *"nothing is written unless `apply=True`"* — but the pass's decay stage called `MemoryStore.rescore_all()` unconditionally, which rewrote the `memories.score` column of **every** row while claiming to only inspect the store. The expiry and merge decisions were correctly left unapplied; the score decay was not. `rescore_all()` now takes a `dry_run` flag: it computes every new score and reports how many rows *would* change but issues no `UPDATE`, and the dream pass runs it with `dry_run=not apply`. A dry run still reports the size of the decay job through `report.rescored` while touching nothing. The contract wording is now exact rather than aspirational — a dry run changes **no memory data** (no expiry, no supersession, no rescore); the single write it still performs is one `cognitive_audit` row per proposed action with `action="suggest"` instead of `"applied"`, because that row *is* the reviewable plan the dashboard shows before you commit to it (`record_audit=False` suppresses even that). Also fixed in this release: `publish.yml` now sends `make_latest` as a JSON **boolean** (`gh api -F`) instead of a JSON **string** — GitHub's release API ignores the string form, so the "mark as latest" step had been returning 200 and reporting success while promoting nothing, leaving `/releases/latest` pointing at v0.4.14 through the 0.4.15 and 0.4.16 ships. The step now also re-reads `/releases/latest` and fails the job when the tag does not match, instead of trusting the exit code. 3 new regression cases (`tests/test_dream_2026_10_25.py`); suite at **958 passed / 4 skipped**. [Full CHANGELOG →](./CHANGELOG.md#0417---2026-10-25)
|
|
25
27
|
> **What's new in 0.4.16** — **subject-key logical fact slots + `latest_only` recall + the "dream" sleep-time consolidation pass** (audit 2026-10-25, weekly ecosystem sync — Mem0 v2.2.1 "Dream", arXiv 2605.27366 "In Defense of Forgotten", Zep/Graphiti temporal edges, Letta memory blocks). New nullable `subject_key` column on `memories` names the *logical slot* a fact occupies (`"editor.preference"`); `upsert_memory(subject_key=...)` makes the new row the live version and soft-supersedes the previous one with a `superseded_by` pointer, so the version history survives and `recall_as_of()` can still answer "what did we believe last week?". The content-hash dedup path is now slot-aware — two different slots holding the same text stay two rows. `SCHEMA_VERSION` bumps 12 → 13 with an idempotent in-place migration. New `latest_only=False` flag on `recall()` / `recall_hybrid()` (Mem0 v2 `SearchMemoryOptions.latest_only` parity) collapses the answer in three layers: rows outside their validity window at *now* are dropped, a subject-key slot resolves to its newest **live** row (looked up in the store, so a stale version never comes back just because it matched the query better), and near-duplicate texts cluster by token Jaccard (default 0.85) with the newest row winning. Wired through `GET /api/recall?latest_only=1`, `GET /api/v1/recall?latest_only=1`, `loop-memory recall --latest-only` and `MemoryClient.recall(latest_only=True)`. New `loop_memory/jobs/dream.py` implements the sleep-time consolidation Mem0 v2 calls the "Dream" — deterministic, zero-dep and **non-destructive**: it decays scores, then judges every row past its deadline on **utility** (`0.45 × importance + 0.30 × usage + 0.25 × recency`) so a frequently-recalled old fact is *protected* while a never-used stale row is **soft-expired** (`invalid_at` stamped, row kept, history kept) instead of deleted, and finally merges near-duplicates into a supersession chain. Run it with `loop-memory dream [--apply] [--json]` or `POST /api/v1/dream` — it is a **dry run by default** and every decision lands in `cognitive_audit` so the dashboard can show and revert it. New store primitives `expire_memory()` / `revive_memory()`, and `memory_stats()` now reports `subject_key`, `ttl`, `valid_at`, `invalid_at` and a derived `ttl_state`. **Also fixed a real defect**: the hybrid recall path (BM25 / LIKE / semantic channels) did not filter `superseded_by`, so `recall_hybrid()` — the production path behind the CLI, MCP server and dashboard — returned both versions of a superseded fact. Source: Mem0 v2.2.1 Dream + ADD-only architecture + arXiv 2605.27366 utility-aware eviction + Zep/Graphiti bi-temporal edges (pattern only, no code copied). 77 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0416---2026-10-25)
|
|
26
28
|
> **What's new in 0.4.15** — **content-hash dedup + bi-temporal validity window + evidence chain + `@agent_memory` decorator** (audit 2026-10-18, weekly ecosystem sync). Three new columns on `memories`: `content_hash` (SHA-256 of normalised text, indexed), `valid_at` (epoch the memory becomes active), `invalid_at` (epoch it expires — NULL means still open-ended). The schema migration bumps `SCHEMA_VERSION` 11 → 12 with an idempotent backfill so legacy DBs upgrade in-place. New `upsert_memory(valid_at=, invalid_at=, dedup=)` parameters let callers schedule future-dated or expiring memories and opt out of content-hash dedup for episodic event streams. `recall()` and `recall_hybrid()` now accept an optional `as_of_ts` (float epoch or ISO-8601) — when set, both methods drop memories whose `valid_at > as_of` or `invalid_at <= as_of`, complementing the existing `recall_as_of()` supersession-chain walk. Every recall hit now carries an `evidence: [{field, value, weight}]` array — BM25 / semantic / entity / rrf_total / high_importance channels are exposed individually so a UI can render "this hit because ... [bm25 1.0] [entity 0.7]" instead of a black-box score (Cognee 1.0 / Mem0 v2 evidence-citation pattern, zero-dep). New `loop_memory.decorators` exposes `@agent_memory(store, agent_id=, limit=)` — a one-line wrapper that recalls the top-k relevant memories before the body runs and writes a fresh `kind="interaction"` memory after. Strings returned from the wrapped function are stored verbatim; dict/list returns are JSON-serialised; the recall list is injected as a `memory_context` keyword when the function declares it, otherwise attached to dict results under `memories`. Sister `remember(store, text, ...)` helper for seeding. The BM25 `_like_fallback` now falls through to per-token OR-LIKE when the whole-string match returns nothing — natural-language questions like "What is the deploy process?" now hit stored facts even when FTS5 trigram can't match quoted exact phrases. Source: Cognee 1.0 ECL pipeline + Mem0 v2 hybrid fusion + Zep/Graphiti bi-temporal validity fields + Letta block-memory (pattern only, no code copied). 59 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0415---2026-10-18)
|
|
27
29
|
> **What's new in 0.4.14** — **`loop-memory doctor` deep store-health check** (audit 2026-10-12). Ten diagnostic checks — `db_exists`, `db_integrity`, `schema_version`, `audit_triggers`, `audit_log_writable`, `audit_log_size`, `hooks_installed`, `version_consistent`, `wiki_no_orphans`, `recall_paths_works` — report `ok`/`info`/`warn`/`error` for the live `MemoryStore`. `--fix` auto-repairs the `audit_triggers` check. `--json` emits a stable shape for CI. `--list` prints the check function names without touching the store. `--verbose` shows `info`-level checks. Exit code `1` on any `error` status, `0` otherwise. Source: `cortexkit/magic-context doctor` auto-diagnostic pattern (MIT, 2026-10-12) + `dsh-memento` write-approval-gate pattern (Apache-2.0, 2026-10-12). Stdlib-only — we re-implement from scratch against our own `MemoryStore`. Also fixed a Python 3.14 bug: chained `store._conn().__enter__()` immediately triggers the contextmanager's `finally` and closes the connection mid-query — both call sites in `doctor.py` now use explicit `with` form. 27 new regression cases. [Full CHANGELOG →](./CHANGELOG.md#0414---2026-10-12)
|
|
@@ -207,6 +209,9 @@ loop-memory consolidate # rescore + GC + dedupe
|
|
|
207
209
|
# 4. Optional: sleep-time consolidation (soft expiry, never deletes)
|
|
208
210
|
loop-memory dream # dry run -- what would expire / merge
|
|
209
211
|
loop-memory dream --apply # write it (soft-expire + supersede)
|
|
212
|
+
|
|
213
|
+
# 5. Optional: measure the retrieval quality (offline, no API calls)
|
|
214
|
+
loop-memory bench # recall@k / MRR / nDCG over a fixed fixture
|
|
210
215
|
```
|
|
211
216
|
|
|
212
217
|
---
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Retrieval-quality benchmark for Loop Memory.
|
|
2
|
+
|
|
3
|
+
Audit 2026-11-01 (loop-memory 0.4.18).
|
|
4
|
+
|
|
5
|
+
Loop Memory shipped hybrid retrieval (BM25 + semantic + entity
|
|
6
|
+
fused with RRF) without ever *measuring* whether the BM25 channel
|
|
7
|
+
ranked anything sensibly. That gap is the reason a FTS5 scoring
|
|
8
|
+
quirk could sit unnoticed for a dozen releases: the ``trigram``
|
|
9
|
+
tokenizer makes common CJK trigrams reach a prevalence where
|
|
10
|
+
SQLite's ``bm25()`` collapses its inverse document frequency to a
|
|
11
|
+
constant, which silently flattened the ranking.
|
|
12
|
+
|
|
13
|
+
``rohitg00/agentmemory`` v0.9.31 (2026-10-26) fixed the same class
|
|
14
|
+
of bug in its prefix-expansion path and reported the delta on a
|
|
15
|
+
deterministic benchmark (strict recall-all@5 65.74% -> 84.68%,
|
|
16
|
+
+18.94 points on LongMemEval S500). Its methodology is what this
|
|
17
|
+
package copies:
|
|
18
|
+
|
|
19
|
+
* a **fixed fixture** with an explicit revision number, so two runs
|
|
20
|
+
measure the same corpus;
|
|
21
|
+
* a **SHA-256 of the fixture file** recorded in every report, so a
|
|
22
|
+
report says exactly which bytes it scored;
|
|
23
|
+
* **explicit limitations** -- these numbers are *retrieval* results
|
|
24
|
+
over a synthetic corpus, not held-out answer accuracy, and no
|
|
25
|
+
provider API is called, so the run costs $0 and needs no network.
|
|
26
|
+
|
|
27
|
+
The runner is deliberately dependency-free and offline.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from .runner import (
|
|
31
|
+
BenchmarkReport,
|
|
32
|
+
default_fixture_path,
|
|
33
|
+
load_fixture,
|
|
34
|
+
run_benchmark,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BenchmarkReport",
|
|
39
|
+
"default_fixture_path",
|
|
40
|
+
"load_fixture",
|
|
41
|
+
"run_benchmark",
|
|
42
|
+
]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Deterministic retrieval-quality benchmark runner.
|
|
2
|
+
|
|
3
|
+
Audit 2026-11-01 (loop-memory 0.4.18).
|
|
4
|
+
|
|
5
|
+
Measures the **ranking** quality of the BM25 / lexical channel, not
|
|
6
|
+
answer accuracy. The numbers are computed over a fixed synthetic
|
|
7
|
+
corpus so two runs are directly comparable; the fixture's SHA-256 is
|
|
8
|
+
recorded in every report so a reader can tell exactly which bytes
|
|
9
|
+
produced a score.
|
|
10
|
+
|
|
11
|
+
Metrics
|
|
12
|
+
-------
|
|
13
|
+
``recall@k``
|
|
14
|
+
Fraction of the judged-relevant documents that appear in the top
|
|
15
|
+
``k`` results (``k`` in 1, 3, 5). Macro-averaged over queries.
|
|
16
|
+
``mrr@10``
|
|
17
|
+
Mean reciprocal rank of the *first* relevant hit within the top
|
|
18
|
+
10. ``1.0`` means the right answer was ranked first every time.
|
|
19
|
+
``ndcg@10``
|
|
20
|
+
Normalised discounted cumulative gain with binary gains -- a
|
|
21
|
+
stricter view that also rewards putting *all* relevant hits high.
|
|
22
|
+
``score_scale``
|
|
23
|
+
``(min, max)`` of the raw channel score over the whole run. This
|
|
24
|
+
is a *diagnostic*, not a quality metric: SQLite's ``bm25()``
|
|
25
|
+
clamps a non-positive inverse document frequency to ``1e-6``, so a
|
|
26
|
+
corpus in which a query term is common collapses the reported
|
|
27
|
+
scores to ~1e-6 while the *ordering* survives. A run whose
|
|
28
|
+
``score_scale`` max is below 1e-4 is reporting a degenerate scale
|
|
29
|
+
even if the ranking happens to look fine.
|
|
30
|
+
|
|
31
|
+
Limitations (deliberate, and stated in every report)
|
|
32
|
+
----------------------------------------------------
|
|
33
|
+
* Synthetic corpus. These are **development retrieval results**, not
|
|
34
|
+
held-out answer accuracy on a real user corpus.
|
|
35
|
+
* No provider API is called; the run is offline and costs $0.
|
|
36
|
+
* Only the lexical channel is measured. The semantic channel needs an
|
|
37
|
+
embedding model and the entity channel needs a graph build, so
|
|
38
|
+
neither is included -- a benchmark that silently depends on a
|
|
39
|
+
downloaded model would not be reproducible.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import math
|
|
47
|
+
import shutil
|
|
48
|
+
import tempfile
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Any
|
|
52
|
+
|
|
53
|
+
DEFAULT_TOP_K = 10
|
|
54
|
+
DEFAULT_K_CUTS = (1, 3, 5)
|
|
55
|
+
|
|
56
|
+
#: A reported channel score below this counts as "degenerate scale".
|
|
57
|
+
#: SQLite's FTS5 ``bm25()`` floors a non-positive idf at 1e-6, and a
|
|
58
|
+
#: single clamped term in a two-term query keeps the whole score near
|
|
59
|
+
#: that floor. 1e-4 leaves a wide margin over that floor while still
|
|
60
|
+
#: flagging a real collapse.
|
|
61
|
+
DEGENERATE_SCORE_FLOOR = 1e-4
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class QueryResult:
|
|
66
|
+
"""Per-query outcome of one benchmark run."""
|
|
67
|
+
|
|
68
|
+
query_id: str
|
|
69
|
+
query: str
|
|
70
|
+
channel: str
|
|
71
|
+
relevant: list[str]
|
|
72
|
+
ranked: list[str]
|
|
73
|
+
recall_at: dict[int, float] = field(default_factory=dict)
|
|
74
|
+
reciprocal_rank: float = 0.0
|
|
75
|
+
ndcg: float = 0.0
|
|
76
|
+
first_relevant_rank: int | None = None
|
|
77
|
+
score_min: float = 0.0
|
|
78
|
+
score_max: float = 0.0
|
|
79
|
+
fts_score_min: float = 0.0
|
|
80
|
+
fts_score_max: float = 0.0
|
|
81
|
+
degenerate_fts_score: bool = False
|
|
82
|
+
note: str = ""
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"query_id": self.query_id,
|
|
87
|
+
"query": self.query,
|
|
88
|
+
"channel": self.channel,
|
|
89
|
+
"relevant": list(self.relevant),
|
|
90
|
+
"ranked": list(self.ranked),
|
|
91
|
+
"recall_at": {str(k): round(v, 6) for k, v in self.recall_at.items()},
|
|
92
|
+
"reciprocal_rank": round(self.reciprocal_rank, 6),
|
|
93
|
+
"ndcg": round(self.ndcg, 6),
|
|
94
|
+
"first_relevant_rank": self.first_relevant_rank,
|
|
95
|
+
"score_min": self.score_min,
|
|
96
|
+
"score_max": self.score_max,
|
|
97
|
+
"fts_score_min": self.fts_score_min,
|
|
98
|
+
"fts_score_max": self.fts_score_max,
|
|
99
|
+
"degenerate_fts_score": self.degenerate_fts_score,
|
|
100
|
+
"note": self.note,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class BenchmarkReport:
|
|
106
|
+
"""Aggregate outcome of one benchmark run."""
|
|
107
|
+
|
|
108
|
+
fixture_name: str
|
|
109
|
+
fixture_revision: int
|
|
110
|
+
fixture_sha256: str
|
|
111
|
+
top_k: int
|
|
112
|
+
queries: list[QueryResult] = field(default_factory=list)
|
|
113
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
114
|
+
score_scale: tuple[float, float] = (0.0, 0.0)
|
|
115
|
+
degenerate_score_scale: bool = False
|
|
116
|
+
fts_score_scale: tuple[float, float] = (0.0, 0.0)
|
|
117
|
+
degenerate_fts_queries: int = 0
|
|
118
|
+
limitations: list[str] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"fixture_name": self.fixture_name,
|
|
123
|
+
"fixture_revision": self.fixture_revision,
|
|
124
|
+
"fixture_sha256": self.fixture_sha256,
|
|
125
|
+
"top_k": self.top_k,
|
|
126
|
+
"metrics": {k: round(v, 6) for k, v in self.metrics.items()},
|
|
127
|
+
"score_scale": [self.score_scale[0], self.score_scale[1]],
|
|
128
|
+
"degenerate_score_scale": self.degenerate_score_scale,
|
|
129
|
+
"fts_score_scale": [self.fts_score_scale[0], self.fts_score_scale[1]],
|
|
130
|
+
"degenerate_fts_queries": self.degenerate_fts_queries,
|
|
131
|
+
"queries": [q.to_dict() for q in self.queries],
|
|
132
|
+
"limitations": list(self.limitations),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def default_fixture_path() -> Path:
|
|
137
|
+
"""Absolute path of the fixture shipped with the package."""
|
|
138
|
+
return Path(__file__).resolve().parent / "fixtures" / "default.json"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_fixture(path: str | Path | None = None) -> tuple[dict[str, Any], str]:
|
|
142
|
+
"""Load a benchmark fixture.
|
|
143
|
+
|
|
144
|
+
Returns ``(fixture, sha256)`` where ``sha256`` hashes the *raw
|
|
145
|
+
file bytes* -- the report therefore pins the exact input, not a
|
|
146
|
+
re-serialisation of it.
|
|
147
|
+
"""
|
|
148
|
+
p = Path(path).expanduser() if path else default_fixture_path()
|
|
149
|
+
raw = p.read_bytes()
|
|
150
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
151
|
+
data = json.loads(raw.decode("utf-8"))
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValueError(f"fixture {p} must contain a JSON object")
|
|
154
|
+
data.setdefault("name", p.stem)
|
|
155
|
+
data.setdefault("revision", 1)
|
|
156
|
+
data.setdefault("corpus", [])
|
|
157
|
+
data.setdefault("wiki", [])
|
|
158
|
+
data.setdefault("queries", [])
|
|
159
|
+
return data, digest
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _recall(ranked: list[str], relevant: list[str], k: int) -> float:
|
|
163
|
+
if not relevant:
|
|
164
|
+
return 0.0
|
|
165
|
+
top = set(ranked[:k])
|
|
166
|
+
hit = sum(1 for r in relevant if r in top)
|
|
167
|
+
return hit / float(len(relevant))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _reciprocal_rank(ranked: list[str], relevant: list[str], top_k: int) -> tuple[float, int | None]:
|
|
171
|
+
rel = set(relevant)
|
|
172
|
+
for idx, doc_id in enumerate(ranked[:top_k], start=1):
|
|
173
|
+
if doc_id in rel:
|
|
174
|
+
return 1.0 / idx, idx
|
|
175
|
+
return 0.0, None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _ndcg(ranked: list[str], relevant: list[str], top_k: int) -> float:
|
|
179
|
+
if not relevant:
|
|
180
|
+
return 0.0
|
|
181
|
+
rel = set(relevant)
|
|
182
|
+
dcg = 0.0
|
|
183
|
+
for idx, doc_id in enumerate(ranked[:top_k], start=1):
|
|
184
|
+
if doc_id in rel:
|
|
185
|
+
dcg += 1.0 / math.log2(idx + 1)
|
|
186
|
+
ideal_hits = min(len(rel), top_k)
|
|
187
|
+
idcg = sum(1.0 / math.log2(i + 1) for i in range(1, ideal_hits + 1))
|
|
188
|
+
return (dcg / idcg) if idcg > 0 else 0.0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ingest(store: Any, fixture: dict[str, Any]) -> dict[str, str]:
|
|
192
|
+
"""Load the fixture into ``store``; return a slug->id map."""
|
|
193
|
+
for row in fixture.get("corpus") or []:
|
|
194
|
+
store.upsert_memory(
|
|
195
|
+
id=row["id"],
|
|
196
|
+
kind=row.get("kind", "fact"),
|
|
197
|
+
text=row["text"],
|
|
198
|
+
importance=float(row.get("importance", 0.5)),
|
|
199
|
+
tags=list(row.get("tags") or []),
|
|
200
|
+
source=row.get("source"),
|
|
201
|
+
)
|
|
202
|
+
slug_to_id: dict[str, str] = {}
|
|
203
|
+
for row in fixture.get("wiki") or []:
|
|
204
|
+
store.upsert_wiki_page(
|
|
205
|
+
slug=row["slug"],
|
|
206
|
+
title=row.get("title", row["slug"]),
|
|
207
|
+
body=row.get("body", ""),
|
|
208
|
+
summary=row.get("summary"),
|
|
209
|
+
tags=list(row.get("tags") or []),
|
|
210
|
+
importance=float(row.get("importance", 0.5)),
|
|
211
|
+
)
|
|
212
|
+
page = store.get_wiki_page_by_slug(row["slug"])
|
|
213
|
+
if page:
|
|
214
|
+
slug_to_id[row["slug"]] = page["id"]
|
|
215
|
+
return slug_to_id
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_benchmark(
|
|
219
|
+
fixture: dict[str, Any] | None = None,
|
|
220
|
+
*,
|
|
221
|
+
fixture_path: str | Path | None = None,
|
|
222
|
+
fixture_sha256: str | None = None,
|
|
223
|
+
top_k: int = DEFAULT_TOP_K,
|
|
224
|
+
) -> BenchmarkReport:
|
|
225
|
+
"""Run the benchmark and return a :class:`BenchmarkReport`.
|
|
226
|
+
|
|
227
|
+
The store is built in a throwaway temporary directory so the
|
|
228
|
+
benchmark never touches the user's real database.
|
|
229
|
+
"""
|
|
230
|
+
from ..storage.retrieval import bm25_search
|
|
231
|
+
from ..storage.sqlite_store import MemoryStore
|
|
232
|
+
|
|
233
|
+
if fixture is None:
|
|
234
|
+
fixture, digest = load_fixture(fixture_path)
|
|
235
|
+
else:
|
|
236
|
+
digest = fixture_sha256 or ""
|
|
237
|
+
if not digest:
|
|
238
|
+
raw = json.dumps(fixture, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
239
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
240
|
+
|
|
241
|
+
tmpdir = tempfile.mkdtemp(prefix="loop-memory-bench-")
|
|
242
|
+
try:
|
|
243
|
+
store = MemoryStore(Path(tmpdir) / "bench.db")
|
|
244
|
+
slug_to_id = _ingest(store, fixture)
|
|
245
|
+
|
|
246
|
+
results: list[QueryResult] = []
|
|
247
|
+
score_min = float("inf")
|
|
248
|
+
score_max = float("-inf")
|
|
249
|
+
fts_min = float("inf")
|
|
250
|
+
fts_max = float("-inf")
|
|
251
|
+
|
|
252
|
+
for q in fixture.get("queries") or []:
|
|
253
|
+
channel = q.get("channel", "memories")
|
|
254
|
+
kind = "wiki" if channel == "wiki" else "memories"
|
|
255
|
+
relevant = []
|
|
256
|
+
for ref in q.get("relevant") or []:
|
|
257
|
+
relevant.append(slug_to_id.get(ref, ref))
|
|
258
|
+
hits = bm25_search(store, q["query"], kind=kind, limit=top_k)
|
|
259
|
+
ranked = [h["id"] for h in hits]
|
|
260
|
+
scores = [float(h.get("_score") or 0.0) for h in hits]
|
|
261
|
+
if scores:
|
|
262
|
+
score_min = min(score_min, min(scores))
|
|
263
|
+
score_max = max(score_max, max(scores))
|
|
264
|
+
fts_scores = [
|
|
265
|
+
float(h["_fts_score"])
|
|
266
|
+
for h in hits
|
|
267
|
+
if h.get("_fts_score") is not None
|
|
268
|
+
]
|
|
269
|
+
if fts_scores:
|
|
270
|
+
fts_min = min(fts_min, min(fts_scores))
|
|
271
|
+
fts_max = max(fts_max, max(fts_scores))
|
|
272
|
+
degenerate_fts = bool(fts_scores) and max(fts_scores) < DEGENERATE_SCORE_FLOOR
|
|
273
|
+
rr, first_rank = _reciprocal_rank(ranked, relevant, top_k)
|
|
274
|
+
results.append(
|
|
275
|
+
QueryResult(
|
|
276
|
+
query_id=q.get("id", q["query"]),
|
|
277
|
+
query=q["query"],
|
|
278
|
+
channel=channel,
|
|
279
|
+
relevant=relevant,
|
|
280
|
+
ranked=ranked,
|
|
281
|
+
recall_at={k: _recall(ranked, relevant, k) for k in DEFAULT_K_CUTS},
|
|
282
|
+
reciprocal_rank=rr,
|
|
283
|
+
ndcg=_ndcg(ranked, relevant, top_k),
|
|
284
|
+
first_relevant_rank=first_rank,
|
|
285
|
+
score_min=min(scores) if scores else 0.0,
|
|
286
|
+
score_max=max(scores) if scores else 0.0,
|
|
287
|
+
fts_score_min=min(fts_scores) if fts_scores else 0.0,
|
|
288
|
+
fts_score_max=max(fts_scores) if fts_scores else 0.0,
|
|
289
|
+
degenerate_fts_score=degenerate_fts,
|
|
290
|
+
note=q.get("note", ""),
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
n = len(results) or 1
|
|
295
|
+
metrics = {
|
|
296
|
+
f"recall@{k}": sum(r.recall_at[k] for r in results) / n for k in DEFAULT_K_CUTS
|
|
297
|
+
}
|
|
298
|
+
metrics[f"mrr@{top_k}"] = sum(r.reciprocal_rank for r in results) / n
|
|
299
|
+
metrics[f"ndcg@{top_k}"] = sum(r.ndcg for r in results) / n
|
|
300
|
+
|
|
301
|
+
if score_min is float("inf"):
|
|
302
|
+
score_min = score_max = 0.0
|
|
303
|
+
if fts_min is float("inf"):
|
|
304
|
+
fts_min = fts_max = 0.0
|
|
305
|
+
|
|
306
|
+
return BenchmarkReport(
|
|
307
|
+
fixture_name=fixture.get("name", "unknown"),
|
|
308
|
+
fixture_revision=int(fixture.get("revision", 1)),
|
|
309
|
+
fixture_sha256=digest,
|
|
310
|
+
top_k=top_k,
|
|
311
|
+
queries=results,
|
|
312
|
+
metrics=metrics,
|
|
313
|
+
score_scale=(score_min, score_max),
|
|
314
|
+
degenerate_score_scale=score_max < DEGENERATE_SCORE_FLOOR,
|
|
315
|
+
fts_score_scale=(fts_min, fts_max),
|
|
316
|
+
degenerate_fts_queries=sum(1 for r in results if r.degenerate_fts_score),
|
|
317
|
+
limitations=[
|
|
318
|
+
"Synthetic corpus: these are development retrieval results, "
|
|
319
|
+
"not held-out answer accuracy on a real user corpus.",
|
|
320
|
+
"No provider API is called; the run is offline and costs $0.",
|
|
321
|
+
"Only the lexical channel is measured; the semantic channel "
|
|
322
|
+
"needs an embedding model and the entity channel needs a "
|
|
323
|
+
"graph build, so neither is included.",
|
|
324
|
+
],
|
|
325
|
+
)
|
|
326
|
+
finally:
|
|
327
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def compare_reports(
|
|
331
|
+
baseline: dict[str, Any], current: dict[str, Any]
|
|
332
|
+
) -> dict[str, Any]:
|
|
333
|
+
"""Diff two report dicts (baseline first, current second).
|
|
334
|
+
|
|
335
|
+
Returns the per-metric deltas plus the fixture identity check. A
|
|
336
|
+
comparison across *different* fixtures is meaningless, so the
|
|
337
|
+
result says so explicitly instead of printing a bogus delta.
|
|
338
|
+
"""
|
|
339
|
+
same_fixture = (
|
|
340
|
+
baseline.get("fixture_sha256") == current.get("fixture_sha256")
|
|
341
|
+
and baseline.get("fixture_revision") == current.get("fixture_revision")
|
|
342
|
+
)
|
|
343
|
+
base_metrics = baseline.get("metrics") or {}
|
|
344
|
+
curr_metrics = current.get("metrics") or {}
|
|
345
|
+
deltas: dict[str, float] = {}
|
|
346
|
+
for key in sorted(set(base_metrics) | set(curr_metrics)):
|
|
347
|
+
deltas[key] = round(float(curr_metrics.get(key, 0.0)) - float(base_metrics.get(key, 0.0)), 6)
|
|
348
|
+
return {
|
|
349
|
+
"comparable": same_fixture,
|
|
350
|
+
"baseline_fixture": {
|
|
351
|
+
"name": baseline.get("fixture_name"),
|
|
352
|
+
"revision": baseline.get("fixture_revision"),
|
|
353
|
+
"sha256": baseline.get("fixture_sha256"),
|
|
354
|
+
},
|
|
355
|
+
"current_fixture": {
|
|
356
|
+
"name": current.get("fixture_name"),
|
|
357
|
+
"revision": current.get("fixture_revision"),
|
|
358
|
+
"sha256": current.get("fixture_sha256"),
|
|
359
|
+
},
|
|
360
|
+
"metrics": deltas,
|
|
361
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""``loop-memory bench`` -- deterministic retrieval-quality benchmark.
|
|
2
|
+
|
|
3
|
+
Audit 2026-11-01 (loop-memory 0.4.18).
|
|
4
|
+
|
|
5
|
+
Loop Memory had hybrid retrieval but no way to *measure* whether the
|
|
6
|
+
lexical channel ranked anything sensibly. This command scores the
|
|
7
|
+
lexical channel against a fixed, versioned fixture and prints the
|
|
8
|
+
result as JSON, so a ranking change can be reviewed the way a test
|
|
9
|
+
failure would be.
|
|
10
|
+
|
|
11
|
+
The methodology follows ``rohitg00/agentmemory`` v0.9.31 (2026-10-26):
|
|
12
|
+
a fixed corpus with an explicit revision, the SHA-256 of the fixture
|
|
13
|
+
bytes recorded in every report, and an explicit statement of what the
|
|
14
|
+
numbers do *not* prove.
|
|
15
|
+
|
|
16
|
+
The run is fully offline and calls no provider API.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _print_text(report: dict, comparison: dict | None = None) -> None:
|
|
28
|
+
"""Human-readable rendering of a benchmark report."""
|
|
29
|
+
lines = []
|
|
30
|
+
lines.append(
|
|
31
|
+
f"fixture : {report['fixture_name']} (revision {report['fixture_revision']})"
|
|
32
|
+
)
|
|
33
|
+
lines.append(f"sha256 : {report['fixture_sha256']}")
|
|
34
|
+
lines.append(f"top-k : {report['top_k']}")
|
|
35
|
+
lines.append("")
|
|
36
|
+
lines.append("metrics")
|
|
37
|
+
for key in sorted(report.get("metrics") or {}):
|
|
38
|
+
lines.append(f" {key:<12} {report['metrics'][key]:.4f}")
|
|
39
|
+
scale = report.get("score_scale") or [0, 0]
|
|
40
|
+
flag = " <-- degenerate scale" if report.get("degenerate_score_scale") else ""
|
|
41
|
+
lines.append("")
|
|
42
|
+
lines.append(f"score scale : {scale[0]:.6g} .. {scale[1]:.6g}{flag}")
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("per query")
|
|
45
|
+
for q in report.get("queries") or []:
|
|
46
|
+
rank = q.get("first_relevant_rank")
|
|
47
|
+
rank_txt = f"rank {rank}" if rank is not None else "not in top-k"
|
|
48
|
+
lines.append(
|
|
49
|
+
f" {q['query_id']:<4} {q['query'][:34]:<34} "
|
|
50
|
+
f"rr={q['reciprocal_rank']:.3f} ndcg={q['ndcg']:.3f} {rank_txt}"
|
|
51
|
+
)
|
|
52
|
+
if comparison is not None:
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append("comparison vs baseline")
|
|
55
|
+
if not comparison.get("comparable"):
|
|
56
|
+
lines.append(" NOT COMPARABLE: the two runs used different fixtures.")
|
|
57
|
+
for key in sorted(comparison.get("metrics") or {}):
|
|
58
|
+
delta = comparison["metrics"][key]
|
|
59
|
+
sign = "+" if delta >= 0 else ""
|
|
60
|
+
lines.append(f" {key:<12} {sign}{delta:.4f}")
|
|
61
|
+
lines.append("")
|
|
62
|
+
lines.append("limitations")
|
|
63
|
+
for item in report.get("limitations") or []:
|
|
64
|
+
lines.append(f" - {item}")
|
|
65
|
+
print("\n".join(lines))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_bench(args: list) -> int:
|
|
69
|
+
"""``loop-memory bench [--fixture PATH] [--json] [--out PATH] [--compare PATH]``"""
|
|
70
|
+
p = argparse.ArgumentParser(prog="loop-memory bench")
|
|
71
|
+
p.add_argument(
|
|
72
|
+
"--fixture",
|
|
73
|
+
default=None,
|
|
74
|
+
help="path to a benchmark fixture (defaults to the packaged fixture)",
|
|
75
|
+
)
|
|
76
|
+
p.add_argument("--top-k", type=int, default=10, help="results to consider per query")
|
|
77
|
+
p.add_argument("--json", action="store_true", help="print the raw JSON report")
|
|
78
|
+
p.add_argument("--out", default=None, help="write the JSON report to this path")
|
|
79
|
+
p.add_argument(
|
|
80
|
+
"--compare",
|
|
81
|
+
default=None,
|
|
82
|
+
help="path to a previous JSON report; prints the per-metric delta",
|
|
83
|
+
)
|
|
84
|
+
ns = p.parse_args(args)
|
|
85
|
+
|
|
86
|
+
from ...bench.runner import compare_reports, load_fixture, run_benchmark
|
|
87
|
+
|
|
88
|
+
fixture, digest = load_fixture(ns.fixture)
|
|
89
|
+
report = run_benchmark(
|
|
90
|
+
fixture, fixture_sha256=digest, top_k=ns.top_k
|
|
91
|
+
).to_dict()
|
|
92
|
+
|
|
93
|
+
comparison = None
|
|
94
|
+
if ns.compare:
|
|
95
|
+
try:
|
|
96
|
+
baseline = json.loads(Path(ns.compare).expanduser().read_text("utf-8"))
|
|
97
|
+
except Exception as exc: # noqa: BLE001 - CLI boundary
|
|
98
|
+
print(f"cannot read baseline report: {exc}", file=sys.stderr)
|
|
99
|
+
return 2
|
|
100
|
+
comparison = compare_reports(baseline, report)
|
|
101
|
+
|
|
102
|
+
if ns.out:
|
|
103
|
+
out_path = Path(ns.out).expanduser()
|
|
104
|
+
payload = {"report": report}
|
|
105
|
+
if comparison is not None:
|
|
106
|
+
payload["comparison"] = comparison
|
|
107
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
out_path.write_text(
|
|
109
|
+
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if ns.json:
|
|
113
|
+
print(json.dumps(report, ensure_ascii=False, indent=2, default=str))
|
|
114
|
+
else:
|
|
115
|
+
_print_text(report, comparison)
|
|
116
|
+
return 0
|
|
@@ -32,12 +32,14 @@ Usage:
|
|
|
32
32
|
loop-memory graph-edge <src> <dst> [--kind K] [--weight W] # push a relation
|
|
33
33
|
loop-memory subgraph <query> # print a small subgraph
|
|
34
34
|
loop-memory graph-rebuild # rebuild entities + entity_mentions
|
|
35
|
+
loop-memory bench # retrieval-quality benchmark (fixed fixture, offline)
|
|
35
36
|
"""
|
|
36
37
|
|
|
37
38
|
from __future__ import annotations
|
|
38
39
|
|
|
39
40
|
import sys
|
|
40
41
|
|
|
42
|
+
from .commands import bench as bench_cmd
|
|
41
43
|
from .commands import cognitive as cognitive_cmd
|
|
42
44
|
from .commands import diag as diag_cmd
|
|
43
45
|
from .commands import graph as graph_cmd
|
|
@@ -136,6 +138,9 @@ COMMANDS = {
|
|
|
136
138
|
# Audit 2026-09-27: OKF v0.2 bundle import (rune + okf-agent-memory #12-#15 + Deja-Vu).
|
|
137
139
|
"import-okf": cognitive_cmd.run_import_okf,
|
|
138
140
|
"rules": rules_cmd.run_rules,
|
|
141
|
+
# Audit 2026-11-01 (loop-memory 0.4.18) -- deterministic
|
|
142
|
+
# retrieval-quality benchmark over a fixed, versioned fixture.
|
|
143
|
+
"bench": bench_cmd.run_bench,
|
|
139
144
|
"version": _run_version,
|
|
140
145
|
}
|
|
141
146
|
|
|
@@ -187,6 +192,7 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
187
192
|
# Audit 2026-09-13 — per-agent identity bootstrap (Mem0 CLI init --agent pattern).
|
|
188
193
|
"init": "loop-memory init --agent <name> [--install-hooks] # register an agent + (optionally) install hooks.",
|
|
189
194
|
"rules": "loop-memory rules [--agent codex|claude|hermes|openclaw] [--write] [--force]\n Print, or append into the agent's rule file, the three-phase memory discipline (task start / mid-task / wrap-up). Never overwrites user content.",
|
|
195
|
+
"bench": "loop-memory bench [--fixture PATH] [--top-k N] [--json] [--out PATH] [--compare PATH] # deterministic retrieval-quality benchmark over a fixed, versioned fixture (audit 2026-11-01; agentmemory v0.9.31 methodology). Offline, no provider API called.",
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
|