loop-memory 0.4.16__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.
Files changed (176) hide show
  1. {loop_memory-0.4.16 → loop_memory-0.4.18}/PKG-INFO +10 -2
  2. {loop_memory-0.4.16 → loop_memory-0.4.18}/README.md +9 -1
  3. loop_memory-0.4.18/loop_memory/bench/__init__.py +42 -0
  4. loop_memory-0.4.18/loop_memory/bench/runner.py +361 -0
  5. loop_memory-0.4.18/loop_memory/cli/commands/bench.py +116 -0
  6. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/main.py +6 -0
  7. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/dream.py +20 -3
  8. loop_memory-0.4.18/loop_memory/storage/lexical.py +327 -0
  9. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/storage/retrieval.py +78 -3
  10. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/storage/sqlite_store.py +15 -6
  11. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/PKG-INFO +10 -2
  12. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/SOURCES.txt +6 -0
  13. {loop_memory-0.4.16 → loop_memory-0.4.18}/pyproject.toml +1 -1
  14. loop_memory-0.4.18/tests/test_bench_2026_11_01.py +240 -0
  15. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_dream_2026_10_25.py +38 -1
  16. loop_memory-0.4.18/tests/test_lexical_ranking_2026_11_01.py +284 -0
  17. {loop_memory-0.4.16 → loop_memory-0.4.18}/LICENSE +0 -0
  18. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/__init__.py +0 -0
  19. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/backends/__init__.py +0 -0
  20. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/backends/embedding.py +0 -0
  21. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/backends/sentence_embedder.py +0 -0
  22. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/backends/vector_store.py +0 -0
  23. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/__init__.py +0 -0
  24. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/_common.py +0 -0
  25. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/__init__.py +0 -0
  26. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/cognitive.py +0 -0
  27. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/diag.py +0 -0
  28. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/doctor.py +0 -0
  29. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/graph.py +0 -0
  30. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/hooks.py +0 -0
  31. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/read.py +0 -0
  32. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/rules.py +0 -0
  33. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/serve.py +0 -0
  34. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/cli/commands/write.py +0 -0
  35. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/decorators.py +0 -0
  36. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/doctor.py +0 -0
  37. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/engine/__init__.py +0 -0
  38. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/engine/loop.py +0 -0
  39. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/engine/reflect.py +0 -0
  40. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/examples/__init__.py +0 -0
  41. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/examples/demo.py +0 -0
  42. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/export/__init__.py +0 -0
  43. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/export/memory_md.py +0 -0
  44. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/graph/__init__.py +0 -0
  45. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/graph/build.py +0 -0
  46. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/graph/extract.py +0 -0
  47. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/ingest/__init__.py +0 -0
  48. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/ingest/loader.py +0 -0
  49. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/ingest/pipeline.py +0 -0
  50. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/__init__.py +0 -0
  51. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/cognitive.py +0 -0
  52. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/compact.py +0 -0
  53. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/consolidate.py +0 -0
  54. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/contradiction.py +0 -0
  55. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/evolution.py +0 -0
  56. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/graph.py +0 -0
  57. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/llm_compact_pass.py +0 -0
  58. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/llm_consolidate.py +0 -0
  59. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/jobs/scheduler.py +0 -0
  60. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/llm/__init__.py +0 -0
  61. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/llm/base.py +0 -0
  62. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/llm/openai_adapter.py +0 -0
  63. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/llm/providers.py +0 -0
  64. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/mcp/__init__.py +0 -0
  65. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/memory/__init__.py +0 -0
  66. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/memory/types.py +0 -0
  67. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/privacy/__init__.py +0 -0
  68. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/privacy/private.py +0 -0
  69. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/privacy/redact.py +0 -0
  70. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/py.typed +0 -0
  71. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/sdk.py +0 -0
  72. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/sdk_extensions.py +0 -0
  73. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/security/__init__.py +0 -0
  74. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/security/secrets.py +0 -0
  75. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/__init__.py +0 -0
  76. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/app.py +0 -0
  77. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/handlers.py +0 -0
  78. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/_shared.py +0 -0
  79. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/admin.py +0 -0
  80. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/cognitive.py +0 -0
  81. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/export.py +0 -0
  82. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/graph.py +0 -0
  83. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/insights.py +0 -0
  84. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/memories.py +0 -0
  85. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/sessions.py +0 -0
  86. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/system.py +0 -0
  87. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/routes/wiki.py +0 -0
  88. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/static/__init__.py +0 -0
  89. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/static/index.html +0 -0
  90. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/serve/watcher.py +0 -0
  91. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/storage/__init__.py +0 -0
  92. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/storage/snapshot.py +0 -0
  93. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/wiki/__init__.py +0 -0
  94. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/wiki/backfill.py +0 -0
  95. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/wiki/classifier.py +0 -0
  96. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/wiki/prompts.py +0 -0
  97. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory/wiki/scope.py +0 -0
  98. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/dependency_links.txt +0 -0
  99. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/entry_points.txt +0 -0
  100. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/requires.txt +0 -0
  101. {loop_memory-0.4.16 → loop_memory-0.4.18}/loop_memory.egg-info/top_level.txt +0 -0
  102. {loop_memory-0.4.16 → loop_memory-0.4.18}/setup.cfg +0 -0
  103. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_admin_ingest_route.py +0 -0
  104. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_agent_memory_api.py +0 -0
  105. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_agent_memory_sdk.py +0 -0
  106. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_agents_init.py +0 -0
  107. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_audit_triggers_2026_09_27.py +0 -0
  108. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_auth_token_rotate.py +0 -0
  109. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_busy_timeout_2026_09_24.py +0 -0
  110. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cache_cap_2026_09_23.py +0 -0
  111. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_audit_supersede.py +0 -0
  112. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_export_okf.py +0 -0
  113. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_init.py +0 -0
  114. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_memory_stats.py +0 -0
  115. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_recall_as_of.py +0 -0
  116. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_recall_paths.py +0 -0
  117. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_recall_verbose.py +0 -0
  118. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_rules.py +0 -0
  119. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_snapshot.py +0 -0
  120. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_v7.py +0 -0
  121. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_cli_version.py +0 -0
  122. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_content_hash_dedup_2026_10_18.py +0 -0
  123. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_contradictions.py +0 -0
  124. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_decorators_2026_10_18.py +0 -0
  125. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_doctor_2026_10_12.py +0 -0
  126. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_dream_cli_http_2026_10_25.py +0 -0
  127. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_evolution.py +0 -0
  128. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_evolution_quality.py +0 -0
  129. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_expanduser.py +0 -0
  130. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_export_ask.py +0 -0
  131. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_export_okf.py +0 -0
  132. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_graph.py +0 -0
  133. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_healthz_readyz_2026_09_24.py +0 -0
  134. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_import_okf_2026_09_27.py +0 -0
  135. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_ingest.py +0 -0
  136. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_latest_only_2026_10_25.py +0 -0
  137. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_llm_consolidator.py +0 -0
  138. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  139. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_llm_providers.py +0 -0
  140. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_llm_test_endpoint.py +0 -0
  141. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_loop.py +0 -0
  142. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_mcp.py +0 -0
  143. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_memories_pagination.py +0 -0
  144. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_memory_stats.py +0 -0
  145. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_openclaw_loader.py +0 -0
  146. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_package_version.py +0 -0
  147. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_path_safety_2026_09_23.py +0 -0
  148. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_recall_as_of.py +0 -0
  149. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_recall_budget_2026_10_05.py +0 -0
  150. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_recall_paths.py +0 -0
  151. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_recall_provenance.py +0 -0
  152. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_recall_validity_http_2026_10_18.py +0 -0
  153. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_reflection.py +0 -0
  154. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_repo_framing.py +0 -0
  155. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_retrieval_channels_2026_10_18.py +0 -0
  156. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_routes_audit_2026_09_13.py +0 -0
  157. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_routes_audit_2026_09_20.py +0 -0
  158. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_score_api.py +0 -0
  159. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_scoring_v2.py +0 -0
  160. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_secrets.py +0 -0
  161. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_serve_app.py +0 -0
  162. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_serve_handlers.py +0 -0
  163. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_session_order.py +0 -0
  164. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_short_query.py +0 -0
  165. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_snapshot.py +0 -0
  166. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_store.py +0 -0
  167. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_subgraph_hops_2026_09_23.py +0 -0
  168. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_subject_key_2026_10_25.py +0 -0
  169. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_summarization.py +0 -0
  170. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_supersession_chain.py +0 -0
  171. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_universal_memory.py +0 -0
  172. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_vector_store.py +0 -0
  173. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_watcher.py +0 -0
  174. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_wiki_classifier.py +0 -0
  175. {loop_memory-0.4.16 → loop_memory-0.4.18}/tests/test_wiki_export_escape.py +0 -0
  176. {loop_memory-0.4.16 → 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.16
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
  ---
@@ -470,7 +475,10 @@ never-used stale row is expired. Age alone never decides — that is the
470
475
  whole point of "In Defense of Forgotten" (arXiv 2605.27366).
471
476
 
472
477
  The pass is a **dry run by default**: `--apply` (CLI) or `"apply": true`
473
- (HTTP) is what actually writes.
478
+ (HTTP) is what actually writes. A dry run changes **no memory data** --
479
+ no expiry, no supersession, no rescore -- it only appends a
480
+ `cognitive_audit` row per proposed action (`action="suggest"`), which
481
+ is the reviewable plan the dashboard shows before you commit.
474
482
 
475
483
  ```bash
476
484
  loop-memory dream --json # machine-readable dry run
@@ -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
  ---
@@ -426,7 +431,10 @@ never-used stale row is expired. Age alone never decides — that is the
426
431
  whole point of "In Defense of Forgotten" (arXiv 2605.27366).
427
432
 
428
433
  The pass is a **dry run by default**: `--apply` (CLI) or `"apply": true`
429
- (HTTP) is what actually writes.
434
+ (HTTP) is what actually writes. A dry run changes **no memory data** --
435
+ no expiry, no supersession, no rescore -- it only appends a
436
+ `cognitive_audit` row per proposed action (`action="suggest"`), which
437
+ is the reviewable plan the dashboard shows before you commit.
430
438
 
431
439
  ```bash
432
440
  loop-memory dream --json # machine-readable dry run
@@ -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