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.
- {echo_mem-0.5.0/src/echo_mem.egg-info → echo_mem-0.5.2}/PKG-INFO +1 -1
- {echo_mem-0.5.0 → echo_mem-0.5.2}/pyproject.toml +1 -1
- {echo_mem-0.5.0 → echo_mem-0.5.2/src/echo_mem.egg-info}/PKG-INFO +1 -1
- echo_mem-0.5.2/src/echo_memory/infra/db.py +83 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/causality.py +11 -6
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/query_memory.py +131 -14
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/server.py +47 -2
- echo_mem-0.5.0/src/echo_memory/infra/db.py +0 -21
- {echo_mem-0.5.0 → echo_mem-0.5.2}/LICENSE +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/LICENSE-APACHE-2.0 +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/README.md +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/setup.cfg +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/SOURCES.txt +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/dependency_links.txt +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/entry_points.txt +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/requires.txt +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_mem.egg-info/top_level.txt +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/audit/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/audit/get_audit_log.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/adopt.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/analyse.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/benchmark.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/calibrate.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/connect.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/dashboard_html.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/export.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/graph.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/health.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/hooks.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/initdb.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/install.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/judge.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/main.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/merge.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/queue.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/quickstart.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reattribute_cmd.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/recall.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reconcile.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/reindex.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/session_start.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/skill.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/status.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/stop_gate.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/trial.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/unmerge.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/cli/why.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/client.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/independent.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/retrieval.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/eval/sweep.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/graph/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/graph/communities.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/config.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/logging.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/pool.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/infra/project.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/activity.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/bootstrap.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/capture.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/embeddings.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/neighbourhood.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/resolution.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/ingestion/write_episode.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/env.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0001_initial_schema.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0002_trial_observations.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0003_project_and_agent_provenance.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0004_bootstrap_state.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0005_recall_save_guards.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0006_project_analysis.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0007_backfill_unknown_agent.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0008_read_events.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0009_read_event_attribution.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0010_stop_gate_fired.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0011_backfill_absent_agent.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0012_read_event_agent.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0013_active_fact_indexes.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0014_session_activity.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0015_retracted_observations.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0016_one_node_per_name.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0017_writer_version.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0018_trial_runs_are_history.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0019_versions_expected_since.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0020_who_closed_a_queued_document.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0021_delivery_evidence.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0022_independent_evaluation.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0023_second_judge.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0024_node_by_id.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/retrieval/fusion.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/skill/SKILL.md +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/__init__.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/check.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/observations.py +0 -0
- {echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/trial/reads.py +0 -0
|
@@ -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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0001_initial_schema.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0002_trial_observations.py
RENAMED
|
File without changes
|
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0004_bootstrap_state.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0005_recall_save_guards.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0006_project_analysis.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0007_backfill_unknown_agent.py
RENAMED
|
File without changes
|
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0009_read_event_attribution.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0010_stop_gate_fired.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0011_backfill_absent_agent.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0012_read_event_agent.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0013_active_fact_indexes.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0014_session_activity.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0015_retracted_observations.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0016_one_node_per_name.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0017_writer_version.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0018_trial_runs_are_history.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0021_delivery_evidence.py
RENAMED
|
File without changes
|
{echo_mem-0.5.0 → echo_mem-0.5.2}/src/echo_memory/migrations/versions/0022_independent_evaluation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|