echo-mem 0.5.0__tar.gz → 0.5.2__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 (103) hide show
  1. {echo_mem-0.5.0/src/echo_mem.egg-info → echo_mem-0.5.2}/PKG-INFO +1 -1
  2. {echo_mem-0.5.0 → echo_mem-0.5.2}/pyproject.toml +1 -1
  3. {echo_mem-0.5.0 → echo_mem-0.5.2/src/echo_mem.egg-info}/PKG-INFO +1 -1
  4. echo_mem-0.5.2/src/echo_memory/infra/db.py +83 -0
  5. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/causality.py +11 -6
  6. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/query_memory.py +131 -14
  7. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/server.py +47 -2
  8. echo_mem-0.5.0/src/echo_memory/infra/db.py +0 -21
  9. {echo_mem-0.5.0 → echo_mem-0.5.2}/LICENSE +0 -0
  10. {echo_mem-0.5.0 → echo_mem-0.5.2}/LICENSE-APACHE-2.0 +0 -0
  11. {echo_mem-0.5.0 → echo_mem-0.5.2}/README.md +0 -0
  12. {echo_mem-0.5.0 → echo_mem-0.5.2}/setup.cfg +0 -0
  13. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/SOURCES.txt +0 -0
  14. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/dependency_links.txt +0 -0
  15. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/entry_points.txt +0 -0
  16. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/requires.txt +0 -0
  17. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/top_level.txt +0 -0
  18. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/__init__.py +0 -0
  19. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/audit/__init__.py +0 -0
  20. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/audit/get_audit_log.py +0 -0
  21. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/__init__.py +0 -0
  22. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/adopt.py +0 -0
  23. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/analyse.py +0 -0
  24. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/benchmark.py +0 -0
  25. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/calibrate.py +0 -0
  26. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/connect.py +0 -0
  27. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard.py +0 -0
  28. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard_html.py +0 -0
  29. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/export.py +0 -0
  30. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/graph.py +0 -0
  31. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/health.py +0 -0
  32. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/hooks.py +0 -0
  33. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/initdb.py +0 -0
  34. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/install.py +0 -0
  35. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/judge.py +0 -0
  36. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/main.py +0 -0
  37. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/merge.py +0 -0
  38. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/queue.py +0 -0
  39. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/quickstart.py +0 -0
  40. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute.py +0 -0
  41. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute_cmd.py +0 -0
  42. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/recall.py +0 -0
  43. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reconcile.py +0 -0
  44. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reindex.py +0 -0
  45. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/session_start.py +0 -0
  46. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/skill.py +0 -0
  47. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/status.py +0 -0
  48. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/stop_gate.py +0 -0
  49. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/trial.py +0 -0
  50. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/unmerge.py +0 -0
  51. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/why.py +0 -0
  52. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/client.py +0 -0
  53. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/__init__.py +0 -0
  54. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/independent.py +0 -0
  55. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/retrieval.py +0 -0
  56. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/sweep.py +0 -0
  57. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/graph/__init__.py +0 -0
  58. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/graph/communities.py +0 -0
  59. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/__init__.py +0 -0
  60. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/config.py +0 -0
  61. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/logging.py +0 -0
  62. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/pool.py +0 -0
  63. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/project.py +0 -0
  64. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/__init__.py +0 -0
  65. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/activity.py +0 -0
  66. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/bootstrap.py +0 -0
  67. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/capture.py +0 -0
  68. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/embeddings.py +0 -0
  69. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/neighbourhood.py +0 -0
  70. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/resolution.py +0 -0
  71. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/write_episode.py +0 -0
  72. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/env.py +0 -0
  73. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0001_initial_schema.py +0 -0
  74. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0002_trial_observations.py +0 -0
  75. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0003_project_and_agent_provenance.py +0 -0
  76. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0004_bootstrap_state.py +0 -0
  77. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0005_recall_save_guards.py +0 -0
  78. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0006_project_analysis.py +0 -0
  79. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0007_backfill_unknown_agent.py +0 -0
  80. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0008_read_events.py +0 -0
  81. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0009_read_event_attribution.py +0 -0
  82. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0010_stop_gate_fired.py +0 -0
  83. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0011_backfill_absent_agent.py +0 -0
  84. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0012_read_event_agent.py +0 -0
  85. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0013_active_fact_indexes.py +0 -0
  86. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0014_session_activity.py +0 -0
  87. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0015_retracted_observations.py +0 -0
  88. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0016_one_node_per_name.py +0 -0
  89. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0017_writer_version.py +0 -0
  90. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0018_trial_runs_are_history.py +0 -0
  91. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0019_versions_expected_since.py +0 -0
  92. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0020_who_closed_a_queued_document.py +0 -0
  93. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0021_delivery_evidence.py +0 -0
  94. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0022_independent_evaluation.py +0 -0
  95. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0023_second_judge.py +0 -0
  96. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0024_node_by_id.py +0 -0
  97. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/__init__.py +0 -0
  98. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/fusion.py +0 -0
  99. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/skill/SKILL.md +0 -0
  100. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/__init__.py +0 -0
  101. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/check.py +0 -0
  102. {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/observations.py +0 -0
  103. {echo_mem-0.5.0 → echo_mem-0.5.2}/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.0
3
+ Version: 0.5.2
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -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.0"
9
+ version = "0.5.2"
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.0
3
+ Version: 0.5.2
4
4
  Summary: A long-horizon memory architecture for AI agents
5
5
  License-Expression: BUSL-1.1
6
6
  Requires-Python: >=3.11
@@ -0,0 +1,83 @@
1
+ """Connection helper: every AGE-aware connection needs the extension loaded
2
+ and ag_catalog on the search_path before any Cypher call (see PR0a's spike
3
+ notes in the design doc's Foundational spike section)."""
4
+
5
+ import os
6
+
7
+ import psycopg
8
+ from pgvector.psycopg import register_vector
9
+
10
+ from echo_memory.infra.logging import get_logger
11
+
12
+ GRAPH_NAME = "echo_memory"
13
+
14
+ _logger = get_logger("db")
15
+
16
+ # How hard HNSW looks before answering. Set per connection rather than per
17
+ # query because every connection from the pool wants the same behaviour, and
18
+ # because autocommit is on: SET LOCAL would expire at the end of the single
19
+ # statement it was issued in and reach nothing.
20
+ #
21
+ # 200 is measured, not guessed. Against a real 38,169 fact scope, 20 probe
22
+ # vectors, recall of the exact top 50 and the cost of getting it:
23
+ #
24
+ # ef_search mean recall worst top 10 identical mean ms
25
+ # 40 (dflt) 0.817 0.24 10 of 20 1.85
26
+ # 100 0.944 0.74 13 of 20 1.72
27
+ # 200 0.980 0.86 17 of 20 3.85
28
+ # 400 0.985 0.90 19 of 20 8.26
29
+ # exact 1.000 1.00 20 of 20 52.47
30
+ #
31
+ # The default of 40 is below LIST_DEPTH, which is the specific reason it is
32
+ # bad: asking an index for 50 neighbours while it keeps a candidate list of
33
+ # 40 cannot go well, and 0.24 recall on the worst probe is what that looks
34
+ # like. 200 buys 98% recall for 7% of the exact scan's time, and the exact
35
+ # scan is O(n) - at ten times this scope it is half a second, per query.
36
+ HNSW_EF_SEARCH = int(os.environ.get("ECHO_MEMORY_HNSW_EF_SEARCH", "200"))
37
+
38
+ _warned_no_iterative_scan = False
39
+
40
+
41
+ def configure_connection(conn: psycopg.Connection) -> None:
42
+ with conn.cursor() as cur:
43
+ cur.execute("LOAD 'age'")
44
+ cur.execute('SET search_path = ag_catalog, "$user", public')
45
+ _configure_vector_search(cur)
46
+ register_vector(conn)
47
+
48
+
49
+ def _configure_vector_search(cur) -> None:
50
+ """HNSW settings, on a database that may be too old to have them.
51
+
52
+ iterative_scan is pgvector 0.8. Without it an index scan under a filter
53
+ stops at the first candidate batch and returns FEWER rows than the LIMIT
54
+ asked for - measured at 25 of 50 on the scope above, which is a silent
55
+ recall cut rather than an error. With it the scan continues until the
56
+ limit is met.
57
+
58
+ A 0.7 database has no such setting and errors on SET. That is not a
59
+ reason to refuse to start: the queries still work there, just exactly and
60
+ slowly, which is what every version of this code did until now. Warned
61
+ once, not per connection, because a pool opening ten of them would
62
+ otherwise say it ten times.
63
+ """
64
+ global _warned_no_iterative_scan
65
+ try:
66
+ cur.execute("SET hnsw.iterative_scan = strict_order")
67
+ # An int, interpolated: SET takes no parameters, and the value is
68
+ # coerced rather than passed through so the environment cannot put
69
+ # anything but a number into the statement.
70
+ cur.execute(f"SET hnsw.ef_search = {int(HNSW_EF_SEARCH)}")
71
+ except psycopg.errors.UndefinedObject:
72
+ if not _warned_no_iterative_scan:
73
+ _warned_no_iterative_scan = True
74
+ _logger.warning(
75
+ "hnsw_iterative_scan_unavailable",
76
+ extra={"detail": "pgvector < 0.8; vector search stays exact and O(n)"},
77
+ )
78
+
79
+
80
+ def connect(database_url: str) -> psycopg.Connection:
81
+ conn = psycopg.connect(database_url, autocommit=True)
82
+ configure_connection(conn)
83
+ return conn
@@ -83,22 +83,27 @@ def _anchor_nodes(conn, group_id: str, subject: str, embedder, anchors: int) ->
83
83
  question and part of the answer.
84
84
  """
85
85
  embedding = embedder.embed(subject)
86
+ # Distance only in the ORDER BY, for the reason spelled out in
87
+ # query_memory._vector_candidates: a second sort key costs the HNSW index
88
+ # and turns this into a scan of every entity in the scope. This function
89
+ # shipped with that mistake in it earlier today. The tiebreak it wanted is
90
+ # applied below instead, where it is free.
86
91
  rows = conn.execute(
87
92
  """SELECT ne.node_id::text, -(ne.embedding <#> %s::vector) AS similarity
88
93
  FROM public.node_embedding ne
89
94
  WHERE ne.group_id = %s
90
- -- A stated tiebreak, so two entities at an identical distance
91
- -- always resolve the same way and the same store answers the same
92
- -- question the same way twice.
93
- ORDER BY ne.embedding <#> %s::vector, ne.node_id
95
+ ORDER BY ne.embedding <#> %s::vector
94
96
  LIMIT %s""",
95
97
  (embedding, group_id, embedding, anchors),
96
98
  ).fetchall()
97
99
  if not rows:
98
100
  return []
99
101
 
100
- best = float(rows[0][1])
101
- kept = [(nid, float(sim)) for nid, sim in rows if float(sim) >= best - ANCHOR_MARGIN]
102
+ ordered = sorted(
103
+ ((nid, float(sim)) for nid, sim in rows), key=lambda row: (-row[1], row[0])
104
+ )
105
+ best = ordered[0][1]
106
+ kept = [(nid, sim) for nid, sim in ordered if sim >= best - ANCHOR_MARGIN]
102
107
  names = _node_names(conn, group_id, [nid for nid, _ in kept])
103
108
  return [
104
109
  {"node_id": nid, "name": names.get(nid, "?"), "similarity": round(sim, 4)}
@@ -282,26 +282,53 @@ def adaptive_cosine_floor(conn, group_id: str) -> float:
282
282
  return floor
283
283
 
284
284
 
285
- def _vector_candidates(
286
- conn, group_id: str, embedding: list[float], limit: int, floor: float | None = None
287
- ) -> list[str]:
288
- rows = conn.execute(
289
- f"""
285
+ # Held as a constant so the test that checks this query is still indexable
286
+ # runs EXPLAIN on the same text the function executes, rather than on a copy
287
+ # that can drift away from it. See tests/integration/test_vector_index.py.
288
+ VECTOR_CANDIDATE_SQL = f"""
290
289
  SELECT fe.edge_id::text, -(fe.embedding <#> %s::vector) AS score
291
290
  FROM public.fact_embedding fe
292
291
  JOIN {GRAPH}."FACT" f ON f.id = fe.edge_id
293
292
  WHERE fe.group_id = %s
294
293
  AND (f.properties ->> '"t_invalid"'::agtype) IS NULL
295
- -- Same reason as the lexical channel below: a stated tiebreak, so two
296
- -- facts at an identical distance always resolve the same way.
297
- ORDER BY fe.embedding <#> %s::vector, fe.edge_id
294
+ ORDER BY fe.embedding <#> %s::vector
298
295
  LIMIT %s
299
- """,
300
- (embedding, group_id, embedding, limit),
301
- ).fetchall()
296
+ """
297
+
298
+
299
+ def _vector_candidates(
300
+ conn, group_id: str, embedding: list[float], limit: int, floor: float | None = None,
301
+ scores: dict[str, float] | None = None,
302
+ ) -> list[str]:
303
+ """Ids, best first. Pass `scores` to also collect the cosine similarity
304
+ this already computed and then threw away - the one number in this system
305
+ a caller can threshold on, because unlike a fusion score it means the same
306
+ thing from one query to the next."""
307
+ # ORDER BY the distance and NOTHING else. pgvector's HNSW index can only
308
+ # answer `ORDER BY <distance> LIMIT n`; a second sort key makes the whole
309
+ # clause unindexable, and the planner falls back to reading every
310
+ # embedding in the scope and sorting them.
311
+ #
312
+ # There WAS a second key here - `, fe.edge_id`, added so two facts at an
313
+ # identical distance always resolved the same way. It cost the index.
314
+ # Measured on a real 38,169 fact scope: 52.47ms doing the full scan
315
+ # against 3.85ms using the index, and the scan is O(n), so ten times the
316
+ # facts is half a second on every query. The determinism it bought is
317
+ # still wanted and is now applied below, where it costs nothing.
318
+ rows = conn.execute(VECTOR_CANDIDATE_SQL, (embedding, group_id, embedding, limit)).fetchall()
302
319
  if floor is None:
303
320
  floor = adaptive_cosine_floor(conn, group_id)
304
- return [edge_id for edge_id, score in rows if score >= floor]
321
+ # The tiebreak, moved out of SQL. Sorting by (-score, edge_id) reproduces
322
+ # what the old ORDER BY produced - best first, ties resolved by id - on a
323
+ # list of at most LIST_DEPTH rows, which is free. Python's sort is stable,
324
+ # so rows that were already ordered by the index keep that order.
325
+ ranked = sorted(
326
+ ((edge_id, float(score)) for edge_id, score in rows if score >= floor),
327
+ key=lambda row: (-row[1], row[0]),
328
+ )
329
+ if scores is not None:
330
+ scores.update(dict(ranked))
331
+ return [edge_id for edge_id, _ in ranked]
305
332
 
306
333
 
307
334
 
@@ -535,6 +562,83 @@ def _provenance(raw, agent_id, project) -> dict | None:
535
562
  return out
536
563
 
537
564
 
565
+ def _missing_similarity(
566
+ conn, group_id: str, embedding: list[float], edge_ids: list[str]
567
+ ) -> dict[str, float]:
568
+ """Cosine for facts the vector channel never scored.
569
+
570
+ The lexical channel computes no similarity, so a fact only it found
571
+ arrived with score null. That is an implementation detail - which channel
572
+ happened to retrieve it - leaking into a field callers read as relevance.
573
+
574
+ Fixed by computing the number instead of omitting it. At most top_k rows,
575
+ by primary key, with the query embedding already in hand.
576
+ """
577
+ if not edge_ids:
578
+ return {}
579
+ rows = conn.execute(
580
+ """SELECT fe.edge_id::text, -(fe.embedding <#> %s::vector)
581
+ FROM public.fact_embedding fe
582
+ WHERE fe.group_id = %s
583
+ AND fe.edge_id = ANY(SELECT unnest(%s::text[])::graphid)""",
584
+ (embedding, group_id, list(edge_ids)),
585
+ ).fetchall()
586
+ return {str(edge_id): float(score) for edge_id, score in rows}
587
+
588
+
589
+ def _annotate(
590
+ facts: list[dict], similarity: dict[str, float],
591
+ vector_ids: set[str], lexical_ids: set[str],
592
+ ) -> None:
593
+ """Say why each fact is in the answer, in place.
594
+
595
+ rank position in this answer, 1 first. THIS is the ordering to
596
+ trust and the one to truncate by. It is the fused result of
597
+ every channel, which is the system's whole opinion; score is
598
+ one input to it, and re-sorting by a single input discards
599
+ the fusion.
600
+ score cosine similarity to the query. Present on every fact of
601
+ every answer the MCP surface can ask for, and it means the
602
+ same thing for every fact whatever channel found it.
603
+ Diagnostic, NOT a correctness gate: a fact found by exact
604
+ keyword match can be the right answer at a low semantic
605
+ score.
606
+
607
+ Absent in exactly one place, and not one a tool caller can
608
+ reach: the UserPromptSubmit hook runs lexical_only in a fresh
609
+ process precisely to avoid a 6.2 second model load, so there
610
+ is no embedding to compare against and no honest number to
611
+ give. Omitted rather than faked.
612
+ matched which channels found it, as information rather than quality.
613
+
614
+ The null that used to appear here caused real harm and the shape of it is
615
+ worth keeping. score was omitted for lexical-only facts, a customer sorted
616
+ by score with nulls last, and every fact full text search had found went
617
+ to the back of the list - where a character budget truncated it and the
618
+ model filled the gap by inventing something. The fact it dropped was
619
+ correct and was in the store.
620
+
621
+ Their own calibration says why that is backwards. Over ten questions with
622
+ every fact judged against every question: lexical-only scored R@3 0.640
623
+ against vector-only's 0.460, and beat the fused ranking outright on two of
624
+ the ten. The channel that was being demoted has the better recall.
625
+
626
+ Absent on a digest too, which ranks nothing against nothing.
627
+ """
628
+ for position, fact in enumerate(facts, start=1):
629
+ edge_id = fact["fact_id"]
630
+ matched = [
631
+ name for name, ids in (("vector", vector_ids), ("lexical", lexical_ids))
632
+ if edge_id in ids
633
+ ]
634
+ if not matched:
635
+ continue
636
+ fact["rank"] = position
637
+ if edge_id in similarity:
638
+ fact["score"] = round(similarity[edge_id], 4)
639
+ fact["matched"] = matched
640
+
641
+
538
642
  def query_memory(
539
643
  conn, group_id: str, query: str | None, top_k: int, embedder,
540
644
  digest: bool = False, lexical_only: bool = False,
@@ -584,6 +688,8 @@ def query_memory(
584
688
  )
585
689
  return {"error": str(e)}
586
690
 
691
+ similarity: dict[str, float] = {}
692
+ query_embedding: list[float] | None = None
587
693
  if digest:
588
694
  ranked_ids = _digest_candidates(conn, group_id, top_k)
589
695
  vector_ids, lexical_ids = [], []
@@ -592,8 +698,10 @@ def query_memory(
592
698
  lexical_ids = _lexical_any_candidates(conn, group_id, query, LIST_DEPTH)
593
699
  ranked_ids = lexical_ids[:top_k]
594
700
  else:
595
- embedding = embedder.embed(query)
596
- vector_ids = _vector_candidates(conn, group_id, embedding, LIST_DEPTH, floor=floor)
701
+ embedding = query_embedding = embedder.embed(query)
702
+ vector_ids = _vector_candidates(
703
+ conn, group_id, embedding, LIST_DEPTH, floor=floor, scores=similarity
704
+ )
597
705
  # ANY-term, not websearch_to_tsquery's implicit AND.
598
706
  #
599
707
  # The AND form requires every non-stopword term of the query to appear
@@ -661,6 +769,15 @@ def query_memory(
661
769
 
662
770
  facts_by_id = _fetch_facts(conn, ranked_ids)
663
771
  facts = [facts_by_id[edge_id] for edge_id in ranked_ids if edge_id in facts_by_id]
772
+ # Score whatever the vector channel did not, so every returned fact has
773
+ # one. Only on the query path: a digest ranks nothing and has no query to
774
+ # be similar to.
775
+ if query_embedding is not None:
776
+ unscored = [f["fact_id"] for f in facts if f["fact_id"] not in similarity]
777
+ similarity.update(
778
+ _missing_similarity(conn, group_id, query_embedding, unscored)
779
+ )
780
+ _annotate(facts, similarity, set(vector_ids), set(lexical_ids))
664
781
 
665
782
  log_query_memory(
666
783
  _logger,
@@ -5,6 +5,7 @@ Runs over stdio by default (mcp.server.mcpserver's MCPServer.run default),
5
5
  not a network listener at all, let alone one bound beyond localhost; see
6
6
  the design doc's Constraints ("v1 is single-user, local-only")."""
7
7
 
8
+ import atexit
8
9
  import inspect
9
10
  import json
10
11
  import threading
@@ -106,6 +107,30 @@ def startup(config: Config | None = None, embedder: Embedder | None = None) -> N
106
107
  # a background task turns into confusing output somewhere else.
107
108
  _state.warming = threading.Thread(target=_warm, args=(warm,), daemon=True)
108
109
  _state.warming.start()
110
+ # Wait for it at exit, briefly, rather than letting the interpreter
111
+ # tear down underneath it.
112
+ #
113
+ # A daemon thread is killed abruptly at shutdown. This one is inside
114
+ # torch when that happens, and killing a thread mid C++ produces
115
+ # "terminate called without an active exception" and SIGABRT - exit
116
+ # code 134, after every test has already passed. CI saw exactly that:
117
+ # 771 passed, 5 skipped, then aborted. It is timing dependent, so it
118
+ # is rare on a machine with the model cached and common on one
119
+ # downloading it, which is the wrong way round for a release gate.
120
+ #
121
+ # Bounded, because the point of loading in the background is that
122
+ # nothing waits for it. A load still running after this long is one
123
+ # the process was never going to benefit from anyway.
124
+ atexit.register(_finish_warming)
125
+
126
+
127
+ WARM_SHUTDOWN_GRACE_SECONDS = 10.0
128
+
129
+
130
+ def _finish_warming() -> None:
131
+ warming = getattr(_state, "warming", None)
132
+ if warming is not None and warming.is_alive():
133
+ warming.join(timeout=WARM_SHUTDOWN_GRACE_SECONDS)
109
134
 
110
135
 
111
136
  def _warm(warm) -> None:
@@ -119,8 +144,16 @@ def _warm(warm) -> None:
119
144
  "embedder_warm",
120
145
  extra={"duration_ms": (time.perf_counter() - started) * 1000},
121
146
  )
122
- except Exception: # see docstring
123
- _logger.warning("embedder_warm_failed", exc_info=True)
147
+ except Exception: # noqa: BLE001 - see docstring
148
+ # Logging can itself fail here, and does: a thread still running while
149
+ # the interpreter shuts down finds the log stream closed and atexit
150
+ # refusing new registrations, so reporting the first failure raises a
151
+ # second one out of a thread with nowhere to put it. The report is
152
+ # best effort; the swallow is the contract in the docstring.
153
+ try:
154
+ _logger.warning("embedder_warm_failed", exc_info=True)
155
+ except Exception: # noqa: BLE001, S110 - nowhere left to report to
156
+ pass
124
157
 
125
158
 
126
159
  def _tool(fn):
@@ -254,6 +287,18 @@ def query_memory(scope: str, query: str | None = None, top_k: int = 10, digest:
254
287
  facts instead, as an opt-in "catch me up" convenience; call it
255
288
  explicitly at session start if you want one, it's never automatic.
256
289
 
290
+ Each fact carries rank (1 first), score and matched.
291
+
292
+ If you must drop facts to fit a budget, drop by rank. It is the fused
293
+ result of every channel, which is this server's whole opinion; score is
294
+ one input to it, and re-sorting by a single input throws the rest away.
295
+
296
+ score is cosine similarity to your query, present on every fact and
297
+ comparable across queries. It is diagnostic, not a correctness test: a
298
+ fact found by exact keyword match can be the right answer at a low
299
+ score. matched names the channels that found it, as information - full
300
+ text search is not the weaker one, it has the better recall here.
301
+
257
302
  A pending_ingest field appears when memory files have been written that
258
303
  the graph hasn't heard about yet. Read each listed file and call
259
304
  write_episode with the entities and facts it states, then mark it done
@@ -1,21 +0,0 @@
1
- """Connection helper: every AGE-aware connection needs the extension loaded
2
- and ag_catalog on the search_path before any Cypher call (see PR0a's spike
3
- notes in the design doc's Foundational spike section)."""
4
-
5
- import psycopg
6
- from pgvector.psycopg import register_vector
7
-
8
- GRAPH_NAME = "echo_memory"
9
-
10
-
11
- def configure_connection(conn: psycopg.Connection) -> None:
12
- with conn.cursor() as cur:
13
- cur.execute("LOAD 'age'")
14
- cur.execute('SET search_path = ag_catalog, "$user", public')
15
- register_vector(conn)
16
-
17
-
18
- def connect(database_url: str) -> psycopg.Connection:
19
- conn = psycopg.connect(database_url, autocommit=True)
20
- configure_connection(conn)
21
- return conn
File without changes
File without changes
File without changes
File without changes