echo-mem 0.5.3__tar.gz → 0.5.4__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 (110) hide show
  1. {echo_mem-0.5.3/src/echo_mem.egg-info → echo_mem-0.5.4}/PKG-INFO +81 -3
  2. {echo_mem-0.5.3 → echo_mem-0.5.4}/README.md +80 -2
  3. {echo_mem-0.5.3 → echo_mem-0.5.4}/pyproject.toml +1 -1
  4. {echo_mem-0.5.3 → echo_mem-0.5.4/src/echo_mem.egg-info}/PKG-INFO +81 -3
  5. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/main.py +8 -1
  6. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/bm25.py +18 -2
  7. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/query_memory.py +179 -12
  8. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/server.py +30 -28
  9. {echo_mem-0.5.3 → echo_mem-0.5.4}/LICENSE +0 -0
  10. {echo_mem-0.5.3 → echo_mem-0.5.4}/LICENSE-APACHE-2.0 +0 -0
  11. {echo_mem-0.5.3 → echo_mem-0.5.4}/setup.cfg +0 -0
  12. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_mem.egg-info/SOURCES.txt +0 -0
  13. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_mem.egg-info/dependency_links.txt +0 -0
  14. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_mem.egg-info/entry_points.txt +0 -0
  15. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_mem.egg-info/requires.txt +0 -0
  16. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_mem.egg-info/top_level.txt +0 -0
  17. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/__init__.py +0 -0
  18. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/audit/__init__.py +0 -0
  19. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/audit/get_audit_log.py +0 -0
  20. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/__init__.py +0 -0
  21. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/adopt.py +0 -0
  22. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/analyse.py +0 -0
  23. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/benchmark.py +0 -0
  24. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/calibrate.py +0 -0
  25. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/connect.py +0 -0
  26. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/dashboard.py +0 -0
  27. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/dashboard_html.py +0 -0
  28. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/export.py +0 -0
  29. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/graph.py +0 -0
  30. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/health.py +0 -0
  31. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/hooks.py +0 -0
  32. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/infer_causal.py +0 -0
  33. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/initdb.py +0 -0
  34. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/install.py +0 -0
  35. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/judge.py +0 -0
  36. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/llm.py +0 -0
  37. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/merge.py +0 -0
  38. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/queue.py +0 -0
  39. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/quickstart.py +0 -0
  40. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/reattribute.py +0 -0
  41. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/reattribute_cmd.py +0 -0
  42. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/recall.py +0 -0
  43. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/reconcile.py +0 -0
  44. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/reindex.py +0 -0
  45. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/session_start.py +0 -0
  46. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/skill.py +0 -0
  47. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/status.py +0 -0
  48. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/stop_gate.py +0 -0
  49. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/trial.py +0 -0
  50. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/unmerge.py +0 -0
  51. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/cli/why.py +0 -0
  52. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/client.py +0 -0
  53. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/eval/__init__.py +0 -0
  54. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/eval/external.py +0 -0
  55. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/eval/independent.py +0 -0
  56. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/eval/retrieval.py +0 -0
  57. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/eval/sweep.py +0 -0
  58. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/graph/__init__.py +0 -0
  59. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/graph/communities.py +0 -0
  60. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/__init__.py +0 -0
  61. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/config.py +0 -0
  62. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/db.py +0 -0
  63. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/logging.py +0 -0
  64. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/pool.py +0 -0
  65. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/infra/project.py +0 -0
  66. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/__init__.py +0 -0
  67. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/activity.py +0 -0
  68. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/bootstrap.py +0 -0
  69. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/capture.py +0 -0
  70. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/embeddings.py +0 -0
  71. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/neighbourhood.py +0 -0
  72. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/resolution.py +0 -0
  73. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/ingestion/write_episode.py +0 -0
  74. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/env.py +0 -0
  75. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0001_initial_schema.py +0 -0
  76. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0002_trial_observations.py +0 -0
  77. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0003_project_and_agent_provenance.py +0 -0
  78. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0004_bootstrap_state.py +0 -0
  79. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0005_recall_save_guards.py +0 -0
  80. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0006_project_analysis.py +0 -0
  81. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0007_backfill_unknown_agent.py +0 -0
  82. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0008_read_events.py +0 -0
  83. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0009_read_event_attribution.py +0 -0
  84. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0010_stop_gate_fired.py +0 -0
  85. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0011_backfill_absent_agent.py +0 -0
  86. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0012_read_event_agent.py +0 -0
  87. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0013_active_fact_indexes.py +0 -0
  88. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0014_session_activity.py +0 -0
  89. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0015_retracted_observations.py +0 -0
  90. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0016_one_node_per_name.py +0 -0
  91. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0017_writer_version.py +0 -0
  92. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0018_trial_runs_are_history.py +0 -0
  93. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0019_versions_expected_since.py +0 -0
  94. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0020_who_closed_a_queued_document.py +0 -0
  95. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0021_delivery_evidence.py +0 -0
  96. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0022_independent_evaluation.py +0 -0
  97. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0023_second_judge.py +0 -0
  98. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0024_node_by_id.py +0 -0
  99. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0025_lexical_statistics.py +0 -0
  100. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0026_late_causal_extraction.py +0 -0
  101. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/migrations/versions/0027_lexical_staleness_without_a_scan.py +0 -0
  102. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/__init__.py +0 -0
  103. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/causality.py +0 -0
  104. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/fusion.py +0 -0
  105. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/retrieval/salience.py +0 -0
  106. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/skill/SKILL.md +0 -0
  107. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/trial/__init__.py +0 -0
  108. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/trial/check.py +0 -0
  109. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/trial/observations.py +0 -0
  110. {echo_mem-0.5.3 → echo_mem-0.5.4}/src/echo_memory/trial/reads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echo-mem
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -80,6 +80,27 @@ echo-memory install --global
80
80
  > hosted product. The import package and the CLI are both `echo_memory` / `echo-memory`;
81
81
  > only the distribution name differs.
82
82
 
83
+ ## Upgrading
84
+
85
+ ```bash
86
+ pipx upgrade echo-mem
87
+ echo-memory init-db # apply any new migrations
88
+ echo-memory reindex # rebuild what the new code derives
89
+ ```
90
+
91
+ Both steps, in that order, and neither is optional on a store that predates the version
92
+ you just installed.
93
+
94
+ **Skipping `init-db` fails loudly.** A read against a schema older than the code raises an
95
+ undefined column, which is the good case: it stops rather than answering from something it
96
+ half understands.
97
+
98
+ **Skipping `reindex` fails silently, which is worse.** Derived data that the new code reads
99
+ differently is still the old data, and nothing errors. Concretely, on an upgrade to 0.5.3
100
+ the lexical corpus statistics are treated as stale until rebuilt, so BM25 stays off and
101
+ your queries keep ranking by `ts_rank` while the setting reports as on. `reindex` rebuilds
102
+ the embeddings and those statistics together. `echo-memory health` tells you afterwards.
103
+
83
104
  ## Usage
84
105
 
85
106
  ```bash
@@ -99,6 +120,8 @@ echo-memory infer-causal-hints # type the facts whose own sentence states a
99
120
  echo-memory eval # retrieval quality against your own store
100
121
  echo-memory eval --context # what a recall costs against injecting everything
101
122
  echo-memory eval --context --sweep # the same, as a curve across corpus size
123
+ echo-memory eval-external locomo <path> # LoCoMo, the corpus the published figures use
124
+ echo-memory eval-external longmemeval <path> # LongMemEval, the same
102
125
  echo-memory calibrate # is entity resolution trustworthy on your data
103
126
  echo-memory benchmark # write, query and digest latency
104
127
  ```
@@ -108,7 +131,7 @@ echo-memory benchmark # write, query and digest latency
108
131
  | Tool | What it does |
109
132
  |---|---|
110
133
  | `write_episode` | Store entities and the facts connecting them. No model call. |
111
- | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. |
134
+ | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. Full text ranks by BM25. Takes `as_of` to read the store as it stood at an instant, and `about` to return the facts recorded against one or two named entities. |
112
135
  | `trace_cause` | Causal chains through the entities a subject matches, not a ranked list. |
113
136
  | `record_recall_save` | Mark that a recalled fact saved re explaining something. Refuses a fact no read returned. |
114
137
  | `get_audit_log` | Every change to memory, with a plain language reason. |
@@ -128,6 +151,59 @@ no summarisation, and retrieval today walks every active fact in the scope. It i
128
151
  described in [`docs/designs/`](docs/designs/) and listed below as v1c, and this paragraph
129
152
  used to claim it in the present tense.
130
153
 
154
+ **A ranked answer that says why each fact is in it.** Every returned fact carries
155
+ `score`, its cosine similarity to the query, `rank`, its position, and `matched`, the
156
+ channels that found it. `score` is deliberately not the fusion number: reciprocal rank
157
+ values are sums of 1/(k+rank) and mean nothing from one query to the next, while a
158
+ similarity means the same thing every time, which is what a caller thresholding on it
159
+ needs. It is computed for every fact returned, including the ones only full text search
160
+ found, because which channel retrieved a fact is an implementation detail and has no
161
+ business reaching a field callers read as relevance.
162
+
163
+ **The full text channel ranks by BM25.** Postgres's `ts_rank` counts term occurrences and
164
+ nothing else: no inverse document frequency, so a word in every fact of a scope counts as
165
+ much as one in three; no saturation, so repetition scales linearly; no length
166
+ normalisation, so a long fact is punished for being long. On 324 real prompts the rank 3
167
+ and rank 4 scores were identical in 59% of them, and reciprocal rank fusion reads rank
168
+ position and never the score underneath it, so a channel ordered by scan order handed the
169
+ fusion a coin flip.
170
+
171
+ BM25 is computed in SQL rather than installed, so no extension is added to your database.
172
+ Corpus statistics are rebuilt amortised on write inside the transaction that already holds
173
+ the scope's lock, and by `echo-memory reindex`; a scope that has none falls back to
174
+ `ts_rank` rather than ranking on statistics it does not have. `ECHO_MEMORY_LEXICAL_BM25=0`
175
+ returns to `ts_rank` everywhere.
176
+
177
+ **Facts about an entity, which resemblance cannot express.** `query_memory` takes
178
+ `about`, one or two entity names, and returns only the facts recorded against them. One
179
+ name gives the edges incident to that node; two give the edges between them in either
180
+ direction, which is "both names in the same fact" without going near the fact text.
181
+
182
+ This is not a ranking improvement and it is not a substitute for one. A query about one
183
+ team returns a semantically close fact about a different team because the wrong fact
184
+ genuinely does resemble the question, and no amount of better ranking removes it.
185
+ Identity is not similarity, and a fact is an edge between two nodes, so asking which
186
+ entity a fact is about is structural and exact.
187
+
188
+ Name matching is exact and case insensitive, never a substring, because a short name is a
189
+ substring of longer unrelated words. A name nothing is recorded under returns no facts
190
+ rather than the nearest thing, and so does a pair with no fact joining them: that is real
191
+ absence and it means nobody recorded this, not here is something adjacent. More than two
192
+ names is refused rather than answered empty, because an edge has two endpoints and a fact
193
+ about three entities is a question this model cannot express.
194
+
195
+ **The history the store has always kept is readable.** Every fact has carried `t_valid`
196
+ and `t_invalid` since the first migration, and the read path had only ever asked whether a
197
+ fact is current, so the store paid to keep the whole record of what a scope believed and
198
+ could not answer the first question a post mortem asks. `query_memory` takes `as_of` in
199
+ unix seconds and every channel honours it: vector, full text, the graph hop, the digest.
200
+
201
+ Supersession is what makes that more than a curiosity. Writing the same source, target and
202
+ relation again does not edit the old fact, it ends it: the old edge takes a `t_invalid` and
203
+ stays queryable. Nothing is ever rewritten, so the history is real rather than
204
+ reconstructed, which is the property the memory reconsolidation literature gives up and
205
+ the reason this project refuses that idea.
206
+
131
207
  **Provenance on every fact.** Who wrote it, which tool, which project, when, and which
132
208
  reads returned it. A superseded fact is never deleted. It stops being drawn and stays
133
209
  reachable with its history.
@@ -190,6 +266,7 @@ difference nobody sized is not a result.
190
266
  | The same saving across 8x of corpus growth | 75.5% at 32 facts rising to **96.4% at 261**, hit@10 0.900 to 0.946 | `echo-memory eval --context --sweep` |
191
267
  | LoCoMo retrieval, 1,982 questions, 5,882 turns | recall@10 **0.601**, hit@10 0.658, MRR 0.460, session@10 0.850 | `echo-memory eval-external locomo` |
192
268
  | LongMemEval retrieval, 90 questions, 15 per type | session@10 **0.937**, recall@10 0.727, MRR 0.428 | `echo-memory eval-external longmemeval --per-type 15` |
269
+ | Lexical BM25 against `ts_rank` | **+0.0271** MRR on entity_pair [+0.0083, +0.0481] and on multihop [+0.0166, +0.0381], the other two shapes inside noise, none worse | `echo-memory eval --ablate`, the `without BM25` row with its sign reversed |
193
270
  | Server side model calls per write | **0** | `echo-memory benchmark` |
194
271
  | Write, query, digest latency (median) | 15ms, 8ms, 1ms | `echo-memory benchmark` |
195
272
  | Entity resolution AUC | 0.666, 95% CI [0.421, 0.881] | `echo-memory calibrate` |
@@ -299,7 +376,8 @@ not a thing to withhold from the person whose data it is.
299
376
  organisation wide shared graph, with no forced migration later. The novel work is the
300
377
  memory structure and the read/write algorithm on top of it, not a new database engine.
301
378
 
302
- **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a.
379
+ **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a, with
380
+ the full text side ranked by BM25 computed in SQL over per scope corpus statistics.
303
381
  Personalised PageRank via `networkx` lands in v1b for multi hop associative retrieval.
304
382
 
305
383
  **Interface** [Model Context Protocol](https://modelcontextprotocol.io), so any compliant
@@ -57,6 +57,27 @@ echo-memory install --global
57
57
  > hosted product. The import package and the CLI are both `echo_memory` / `echo-memory`;
58
58
  > only the distribution name differs.
59
59
 
60
+ ## Upgrading
61
+
62
+ ```bash
63
+ pipx upgrade echo-mem
64
+ echo-memory init-db # apply any new migrations
65
+ echo-memory reindex # rebuild what the new code derives
66
+ ```
67
+
68
+ Both steps, in that order, and neither is optional on a store that predates the version
69
+ you just installed.
70
+
71
+ **Skipping `init-db` fails loudly.** A read against a schema older than the code raises an
72
+ undefined column, which is the good case: it stops rather than answering from something it
73
+ half understands.
74
+
75
+ **Skipping `reindex` fails silently, which is worse.** Derived data that the new code reads
76
+ differently is still the old data, and nothing errors. Concretely, on an upgrade to 0.5.3
77
+ the lexical corpus statistics are treated as stale until rebuilt, so BM25 stays off and
78
+ your queries keep ranking by `ts_rank` while the setting reports as on. `reindex` rebuilds
79
+ the embeddings and those statistics together. `echo-memory health` tells you afterwards.
80
+
60
81
  ## Usage
61
82
 
62
83
  ```bash
@@ -76,6 +97,8 @@ echo-memory infer-causal-hints # type the facts whose own sentence states a
76
97
  echo-memory eval # retrieval quality against your own store
77
98
  echo-memory eval --context # what a recall costs against injecting everything
78
99
  echo-memory eval --context --sweep # the same, as a curve across corpus size
100
+ echo-memory eval-external locomo <path> # LoCoMo, the corpus the published figures use
101
+ echo-memory eval-external longmemeval <path> # LongMemEval, the same
79
102
  echo-memory calibrate # is entity resolution trustworthy on your data
80
103
  echo-memory benchmark # write, query and digest latency
81
104
  ```
@@ -85,7 +108,7 @@ echo-memory benchmark # write, query and digest latency
85
108
  | Tool | What it does |
86
109
  |---|---|
87
110
  | `write_episode` | Store entities and the facts connecting them. No model call. |
88
- | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. |
111
+ | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. Full text ranks by BM25. Takes `as_of` to read the store as it stood at an instant, and `about` to return the facts recorded against one or two named entities. |
89
112
  | `trace_cause` | Causal chains through the entities a subject matches, not a ranked list. |
90
113
  | `record_recall_save` | Mark that a recalled fact saved re explaining something. Refuses a fact no read returned. |
91
114
  | `get_audit_log` | Every change to memory, with a plain language reason. |
@@ -105,6 +128,59 @@ no summarisation, and retrieval today walks every active fact in the scope. It i
105
128
  described in [`docs/designs/`](docs/designs/) and listed below as v1c, and this paragraph
106
129
  used to claim it in the present tense.
107
130
 
131
+ **A ranked answer that says why each fact is in it.** Every returned fact carries
132
+ `score`, its cosine similarity to the query, `rank`, its position, and `matched`, the
133
+ channels that found it. `score` is deliberately not the fusion number: reciprocal rank
134
+ values are sums of 1/(k+rank) and mean nothing from one query to the next, while a
135
+ similarity means the same thing every time, which is what a caller thresholding on it
136
+ needs. It is computed for every fact returned, including the ones only full text search
137
+ found, because which channel retrieved a fact is an implementation detail and has no
138
+ business reaching a field callers read as relevance.
139
+
140
+ **The full text channel ranks by BM25.** Postgres's `ts_rank` counts term occurrences and
141
+ nothing else: no inverse document frequency, so a word in every fact of a scope counts as
142
+ much as one in three; no saturation, so repetition scales linearly; no length
143
+ normalisation, so a long fact is punished for being long. On 324 real prompts the rank 3
144
+ and rank 4 scores were identical in 59% of them, and reciprocal rank fusion reads rank
145
+ position and never the score underneath it, so a channel ordered by scan order handed the
146
+ fusion a coin flip.
147
+
148
+ BM25 is computed in SQL rather than installed, so no extension is added to your database.
149
+ Corpus statistics are rebuilt amortised on write inside the transaction that already holds
150
+ the scope's lock, and by `echo-memory reindex`; a scope that has none falls back to
151
+ `ts_rank` rather than ranking on statistics it does not have. `ECHO_MEMORY_LEXICAL_BM25=0`
152
+ returns to `ts_rank` everywhere.
153
+
154
+ **Facts about an entity, which resemblance cannot express.** `query_memory` takes
155
+ `about`, one or two entity names, and returns only the facts recorded against them. One
156
+ name gives the edges incident to that node; two give the edges between them in either
157
+ direction, which is "both names in the same fact" without going near the fact text.
158
+
159
+ This is not a ranking improvement and it is not a substitute for one. A query about one
160
+ team returns a semantically close fact about a different team because the wrong fact
161
+ genuinely does resemble the question, and no amount of better ranking removes it.
162
+ Identity is not similarity, and a fact is an edge between two nodes, so asking which
163
+ entity a fact is about is structural and exact.
164
+
165
+ Name matching is exact and case insensitive, never a substring, because a short name is a
166
+ substring of longer unrelated words. A name nothing is recorded under returns no facts
167
+ rather than the nearest thing, and so does a pair with no fact joining them: that is real
168
+ absence and it means nobody recorded this, not here is something adjacent. More than two
169
+ names is refused rather than answered empty, because an edge has two endpoints and a fact
170
+ about three entities is a question this model cannot express.
171
+
172
+ **The history the store has always kept is readable.** Every fact has carried `t_valid`
173
+ and `t_invalid` since the first migration, and the read path had only ever asked whether a
174
+ fact is current, so the store paid to keep the whole record of what a scope believed and
175
+ could not answer the first question a post mortem asks. `query_memory` takes `as_of` in
176
+ unix seconds and every channel honours it: vector, full text, the graph hop, the digest.
177
+
178
+ Supersession is what makes that more than a curiosity. Writing the same source, target and
179
+ relation again does not edit the old fact, it ends it: the old edge takes a `t_invalid` and
180
+ stays queryable. Nothing is ever rewritten, so the history is real rather than
181
+ reconstructed, which is the property the memory reconsolidation literature gives up and
182
+ the reason this project refuses that idea.
183
+
108
184
  **Provenance on every fact.** Who wrote it, which tool, which project, when, and which
109
185
  reads returned it. A superseded fact is never deleted. It stops being drawn and stays
110
186
  reachable with its history.
@@ -167,6 +243,7 @@ difference nobody sized is not a result.
167
243
  | The same saving across 8x of corpus growth | 75.5% at 32 facts rising to **96.4% at 261**, hit@10 0.900 to 0.946 | `echo-memory eval --context --sweep` |
168
244
  | LoCoMo retrieval, 1,982 questions, 5,882 turns | recall@10 **0.601**, hit@10 0.658, MRR 0.460, session@10 0.850 | `echo-memory eval-external locomo` |
169
245
  | LongMemEval retrieval, 90 questions, 15 per type | session@10 **0.937**, recall@10 0.727, MRR 0.428 | `echo-memory eval-external longmemeval --per-type 15` |
246
+ | Lexical BM25 against `ts_rank` | **+0.0271** MRR on entity_pair [+0.0083, +0.0481] and on multihop [+0.0166, +0.0381], the other two shapes inside noise, none worse | `echo-memory eval --ablate`, the `without BM25` row with its sign reversed |
170
247
  | Server side model calls per write | **0** | `echo-memory benchmark` |
171
248
  | Write, query, digest latency (median) | 15ms, 8ms, 1ms | `echo-memory benchmark` |
172
249
  | Entity resolution AUC | 0.666, 95% CI [0.421, 0.881] | `echo-memory calibrate` |
@@ -276,7 +353,8 @@ not a thing to withhold from the person whose data it is.
276
353
  organisation wide shared graph, with no forced migration later. The novel work is the
277
354
  memory structure and the read/write algorithm on top of it, not a new database engine.
278
355
 
279
- **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a.
356
+ **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a, with
357
+ the full text side ranked by BM25 computed in SQL over per scope corpus statistics.
280
358
  Personalised PageRank via `networkx` lands in v1b for multi hop associative retrieval.
281
359
 
282
360
  **Interface** [Model Context Protocol](https://modelcontextprotocol.io), so any compliant
@@ -6,7 +6,7 @@
6
6
  # (`-m echo_memory.server`) and every existing install reference it, and
7
7
  # renaming the module would break live configuration to fix a cosmetic mismatch.
8
8
  name = "echo-mem"
9
- version = "0.5.3"
9
+ version = "0.5.4"
10
10
  description = "A long-horizon memory architecture for AI agents"
11
11
  readme = "README.md"
12
12
  # BUSL-1.1 is the SPDX identifier for the Business Source License 1.1, so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: echo-mem
3
- Version: 0.5.3
3
+ Version: 0.5.4
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -80,6 +80,27 @@ echo-memory install --global
80
80
  > hosted product. The import package and the CLI are both `echo_memory` / `echo-memory`;
81
81
  > only the distribution name differs.
82
82
 
83
+ ## Upgrading
84
+
85
+ ```bash
86
+ pipx upgrade echo-mem
87
+ echo-memory init-db # apply any new migrations
88
+ echo-memory reindex # rebuild what the new code derives
89
+ ```
90
+
91
+ Both steps, in that order, and neither is optional on a store that predates the version
92
+ you just installed.
93
+
94
+ **Skipping `init-db` fails loudly.** A read against a schema older than the code raises an
95
+ undefined column, which is the good case: it stops rather than answering from something it
96
+ half understands.
97
+
98
+ **Skipping `reindex` fails silently, which is worse.** Derived data that the new code reads
99
+ differently is still the old data, and nothing errors. Concretely, on an upgrade to 0.5.3
100
+ the lexical corpus statistics are treated as stale until rebuilt, so BM25 stays off and
101
+ your queries keep ranking by `ts_rank` while the setting reports as on. `reindex` rebuilds
102
+ the embeddings and those statistics together. `echo-memory health` tells you afterwards.
103
+
83
104
  ## Usage
84
105
 
85
106
  ```bash
@@ -99,6 +120,8 @@ echo-memory infer-causal-hints # type the facts whose own sentence states a
99
120
  echo-memory eval # retrieval quality against your own store
100
121
  echo-memory eval --context # what a recall costs against injecting everything
101
122
  echo-memory eval --context --sweep # the same, as a curve across corpus size
123
+ echo-memory eval-external locomo <path> # LoCoMo, the corpus the published figures use
124
+ echo-memory eval-external longmemeval <path> # LongMemEval, the same
102
125
  echo-memory calibrate # is entity resolution trustworthy on your data
103
126
  echo-memory benchmark # write, query and digest latency
104
127
  ```
@@ -108,7 +131,7 @@ echo-memory benchmark # write, query and digest latency
108
131
  | Tool | What it does |
109
132
  |---|---|
110
133
  | `write_episode` | Store entities and the facts connecting them. No model call. |
111
- | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. |
134
+ | `query_memory` | Hybrid vector and full text retrieval, fused by reciprocal rank. Full text ranks by BM25. Takes `as_of` to read the store as it stood at an instant, and `about` to return the facts recorded against one or two named entities. |
112
135
  | `trace_cause` | Causal chains through the entities a subject matches, not a ranked list. |
113
136
  | `record_recall_save` | Mark that a recalled fact saved re explaining something. Refuses a fact no read returned. |
114
137
  | `get_audit_log` | Every change to memory, with a plain language reason. |
@@ -128,6 +151,59 @@ no summarisation, and retrieval today walks every active fact in the scope. It i
128
151
  described in [`docs/designs/`](docs/designs/) and listed below as v1c, and this paragraph
129
152
  used to claim it in the present tense.
130
153
 
154
+ **A ranked answer that says why each fact is in it.** Every returned fact carries
155
+ `score`, its cosine similarity to the query, `rank`, its position, and `matched`, the
156
+ channels that found it. `score` is deliberately not the fusion number: reciprocal rank
157
+ values are sums of 1/(k+rank) and mean nothing from one query to the next, while a
158
+ similarity means the same thing every time, which is what a caller thresholding on it
159
+ needs. It is computed for every fact returned, including the ones only full text search
160
+ found, because which channel retrieved a fact is an implementation detail and has no
161
+ business reaching a field callers read as relevance.
162
+
163
+ **The full text channel ranks by BM25.** Postgres's `ts_rank` counts term occurrences and
164
+ nothing else: no inverse document frequency, so a word in every fact of a scope counts as
165
+ much as one in three; no saturation, so repetition scales linearly; no length
166
+ normalisation, so a long fact is punished for being long. On 324 real prompts the rank 3
167
+ and rank 4 scores were identical in 59% of them, and reciprocal rank fusion reads rank
168
+ position and never the score underneath it, so a channel ordered by scan order handed the
169
+ fusion a coin flip.
170
+
171
+ BM25 is computed in SQL rather than installed, so no extension is added to your database.
172
+ Corpus statistics are rebuilt amortised on write inside the transaction that already holds
173
+ the scope's lock, and by `echo-memory reindex`; a scope that has none falls back to
174
+ `ts_rank` rather than ranking on statistics it does not have. `ECHO_MEMORY_LEXICAL_BM25=0`
175
+ returns to `ts_rank` everywhere.
176
+
177
+ **Facts about an entity, which resemblance cannot express.** `query_memory` takes
178
+ `about`, one or two entity names, and returns only the facts recorded against them. One
179
+ name gives the edges incident to that node; two give the edges between them in either
180
+ direction, which is "both names in the same fact" without going near the fact text.
181
+
182
+ This is not a ranking improvement and it is not a substitute for one. A query about one
183
+ team returns a semantically close fact about a different team because the wrong fact
184
+ genuinely does resemble the question, and no amount of better ranking removes it.
185
+ Identity is not similarity, and a fact is an edge between two nodes, so asking which
186
+ entity a fact is about is structural and exact.
187
+
188
+ Name matching is exact and case insensitive, never a substring, because a short name is a
189
+ substring of longer unrelated words. A name nothing is recorded under returns no facts
190
+ rather than the nearest thing, and so does a pair with no fact joining them: that is real
191
+ absence and it means nobody recorded this, not here is something adjacent. More than two
192
+ names is refused rather than answered empty, because an edge has two endpoints and a fact
193
+ about three entities is a question this model cannot express.
194
+
195
+ **The history the store has always kept is readable.** Every fact has carried `t_valid`
196
+ and `t_invalid` since the first migration, and the read path had only ever asked whether a
197
+ fact is current, so the store paid to keep the whole record of what a scope believed and
198
+ could not answer the first question a post mortem asks. `query_memory` takes `as_of` in
199
+ unix seconds and every channel honours it: vector, full text, the graph hop, the digest.
200
+
201
+ Supersession is what makes that more than a curiosity. Writing the same source, target and
202
+ relation again does not edit the old fact, it ends it: the old edge takes a `t_invalid` and
203
+ stays queryable. Nothing is ever rewritten, so the history is real rather than
204
+ reconstructed, which is the property the memory reconsolidation literature gives up and
205
+ the reason this project refuses that idea.
206
+
131
207
  **Provenance on every fact.** Who wrote it, which tool, which project, when, and which
132
208
  reads returned it. A superseded fact is never deleted. It stops being drawn and stays
133
209
  reachable with its history.
@@ -190,6 +266,7 @@ difference nobody sized is not a result.
190
266
  | The same saving across 8x of corpus growth | 75.5% at 32 facts rising to **96.4% at 261**, hit@10 0.900 to 0.946 | `echo-memory eval --context --sweep` |
191
267
  | LoCoMo retrieval, 1,982 questions, 5,882 turns | recall@10 **0.601**, hit@10 0.658, MRR 0.460, session@10 0.850 | `echo-memory eval-external locomo` |
192
268
  | LongMemEval retrieval, 90 questions, 15 per type | session@10 **0.937**, recall@10 0.727, MRR 0.428 | `echo-memory eval-external longmemeval --per-type 15` |
269
+ | Lexical BM25 against `ts_rank` | **+0.0271** MRR on entity_pair [+0.0083, +0.0481] and on multihop [+0.0166, +0.0381], the other two shapes inside noise, none worse | `echo-memory eval --ablate`, the `without BM25` row with its sign reversed |
193
270
  | Server side model calls per write | **0** | `echo-memory benchmark` |
194
271
  | Write, query, digest latency (median) | 15ms, 8ms, 1ms | `echo-memory benchmark` |
195
272
  | Entity resolution AUC | 0.666, 95% CI [0.421, 0.881] | `echo-memory calibrate` |
@@ -299,7 +376,8 @@ not a thing to withhold from the person whose data it is.
299
376
  organisation wide shared graph, with no forced migration later. The novel work is the
300
377
  memory structure and the read/write algorithm on top of it, not a new database engine.
301
378
 
302
- **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a.
379
+ **Retrieval** Hybrid vector and full text search fused by reciprocal rank in v1a, with
380
+ the full text side ranked by BM25 computed in SQL over per scope corpus statistics.
303
381
  Personalised PageRank via `networkx` lands in v1b for multi hop associative retrieval.
304
382
 
305
383
  **Interface** [Model Context Protocol](https://modelcontextprotocol.io), so any compliant
@@ -825,7 +825,14 @@ def main(argv: list[str] | None = None) -> int:
825
825
  ("vector only", {"vector_only": True}),
826
826
  ("lexical only", {"lexical_only": True}),
827
827
  ("+ graph hop", {"graph_hops": 1}),
828
- ("+ BM25 lexical", {"lexical_bm25": True}),
828
+ # Subtractive, not additive, because BM25 is the default since
829
+ # 0.5.3. An ablation reads a difference against shipping, so
830
+ # "+ BM25" against a shipping baseline that already has BM25
831
+ # measured one configuration against itself and reported
832
+ # +0.0000 with a zero width interval on every shape - a row
833
+ # that looks like "no effect" and is really "no experiment".
834
+ # The others stay additive because they are still off.
835
+ ("without BM25", {"lexical_bm25": False}),
829
836
  ("+ routed expansion", {"route_expansion": True}),
830
837
  ("+ salience", {"use_salience": True}),
831
838
  ]
@@ -164,7 +164,9 @@ def usable(conn, group_id: str) -> bool:
164
164
  return not _grown_past_tolerance(row)
165
165
 
166
166
 
167
- def candidates(conn, group_id: str, terms: list[str], limit: int) -> list[tuple[str, float]]:
167
+ def candidates(
168
+ conn, group_id: str, terms: list[str], limit: int, only: list[str] | None = None,
169
+ ) -> list[tuple[str, float]]:
168
170
  """(edge_id, bm25 score), best first, for facts matching any query term.
169
171
 
170
172
  Scored only over rows that matched, which is what makes the per-document
@@ -186,6 +188,19 @@ def candidates(conn, group_id: str, terms: list[str], limit: int) -> list[tuple[
186
188
  """
187
189
  if not terms:
188
190
  return []
191
+ # An explicit id restriction, for a read scoped to an entity. The filter
192
+ # sits inside the matched CTE rather than around the result, so a scoped
193
+ # read ranks only the facts in scope instead of ranking the whole scope and
194
+ # discarding most of it.
195
+ only_sql = "" if only is None else "AND f.id::text = ANY(%s)"
196
+ # Positional, so the order has to match where each %s lands in the SQL
197
+ # below: scope's group_id, the terms, weighted's group_id, matched's
198
+ # group_id, then this filter (it sits INSIDE matched, after that
199
+ # group_id), then the limit.
200
+ params: list = [group_id, list(terms), group_id, group_id]
201
+ if only is not None:
202
+ params.append([str(e) for e in only])
203
+ params.append(limit)
189
204
  rows = conn.execute(
190
205
  f"""
191
206
  WITH scope AS (
@@ -222,6 +237,7 @@ def candidates(conn, group_id: str, terms: list[str], limit: int) -> list[tuple[
222
237
  LATERAL unnest(to_tsvector('english', {_FACT_TEXT})) AS t
223
238
  WHERE {_SCOPED} AND {_ACTIVE}
224
239
  AND t.lexeme IN (SELECT lexeme FROM weighted)
240
+ {only_sql}
225
241
  )
226
242
  SELECT m.id::text,
227
243
  sum(w.idf * (m.tf * ({K1} + 1))
@@ -236,7 +252,7 @@ def candidates(conn, group_id: str, terms: list[str], limit: int) -> list[tuple[
236
252
  ORDER BY score DESC, m.id
237
253
  LIMIT %s
238
254
  """,
239
- (group_id, list(terms), group_id, group_id, limit),
255
+ params,
240
256
  ).fetchall()
241
257
  return [(str(edge_id), float(score)) for edge_id, score in rows]
242
258