amfs-adapter-postgres 0.2.22__tar.gz → 0.2.23__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 (26) hide show
  1. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/adapter.py +148 -48
  4. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/async_adapter.py +99 -67
  5. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/.gitignore +0 -0
  6. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/__init__.py +0 -0
  7. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/_fts.py +0 -0
  8. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/knn.py +0 -0
  9. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  10. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  11. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  12. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  13. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  14. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  15. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  16. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  17. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  18. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  19. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
  20. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
  21. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
  22. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/retention.py +0 -0
  23. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/schema.sql +0 -0
  24. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/tenant_context.py +0 -0
  25. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/tenant_gucs.py +0 -0
  26. {amfs_adapter_postgres-0.2.22 → amfs_adapter_postgres-0.2.23}/src/amfs_postgres/trace_partitions.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: amfs-adapter-postgres
3
- Version: 0.2.22
3
+ Version: 0.2.23
4
4
  Summary: AMFS Postgres adapter with back-propagation triggers
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: amfs-core>=0.3.34
7
+ Requires-Dist: amfs-core>=0.3.37
8
8
  Requires-Dist: psycopg-pool>=3.1
9
9
  Requires-Dist: psycopg[binary]<4,>=3.1
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "amfs-adapter-postgres"
3
- version = "0.2.22"
3
+ version = "0.2.23"
4
4
  description = "AMFS Postgres adapter with back-propagation triggers"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
7
7
  dependencies = [
8
8
  # 0.3.14 adds the capture fields this adapter now persists and selects back,
9
9
  # tool_calls among them.
10
- "amfs-core>=0.3.34",
10
+ "amfs-core>=0.3.37",
11
11
  "psycopg[binary]>=3.1,<4",
12
12
  "psycopg-pool>=3.1",
13
13
  ]
@@ -34,6 +34,7 @@ from amfs_core.exclusions import (
34
34
  AGENT_ID_NOT_EXCLUDED_SQL,
35
35
  ENTITY_PATH_NOT_EXCLUDED_SQL,
36
36
  )
37
+ from amfs_core.actions import recorded_environment
37
38
  from amfs_core.evidence import cited_entries, outcome_model
38
39
  from amfs_core.models import (
39
40
  OUTCOME_MULTIPLIERS,
@@ -110,6 +111,33 @@ logger = logging.getLogger(__name__)
110
111
  # collapse, so '@%%/%%' matches exactly what '@%/%' does.
111
112
  _EXCLUDE_SHARED_PATHS = "entity_path NOT LIKE '@%%/%%'"
112
113
 
114
+ # Stands in for the branch condition in a WHERE list until a connection is open
115
+ # and ``PostgresAdapter._branch_scope`` can resolve the branch's parent. Compared
116
+ # by identity, so it can never collide with a real SQL fragment.
117
+ _BRANCH_SCOPE = "<branch scope>"
118
+
119
+
120
+ def branch_scope_sql(branch: str, parent: str | None) -> tuple[str, list[Any]]:
121
+ """The ``WHERE`` fragment reading *branch* as an overlay on *parent*, with
122
+ its parameters in order: the branch's own live rows, plus the parent's live
123
+ row for every ``(entity_path, key)`` the branch has not touched. With no
124
+ parent (``main``, or a branch the adapter has no record of) it is the plain
125
+ branch filter. Shared by both adapters; see ``PostgresAdapter._branch_scope``
126
+ for why the parent is read live rather than as of the branch point.
127
+ """
128
+ if parent is None:
129
+ return "branch = %s", [branch]
130
+ return (
131
+ "(branch = %s OR (branch = %s AND NOT EXISTS ("
132
+ "SELECT 1 FROM amfs_memory_entries b"
133
+ " WHERE b.namespace = amfs_memory_entries.namespace"
134
+ " AND b.branch = %s"
135
+ " AND b.entity_path = amfs_memory_entries.entity_path"
136
+ " AND b.key = amfs_memory_entries.key"
137
+ " AND b.superseded_at IS NULL)))",
138
+ [branch, parent, branch],
139
+ )
140
+
113
141
  # Benchmark and system rows, kept out of the aggregates that describe how much
114
142
  # memory an account has. The predicates come from amfs_core.exclusions so that
115
143
  # this and the Python aggregate cannot answer differently; see that module.
@@ -163,15 +191,25 @@ def list_conditions(
163
191
  branch: str,
164
192
  scope: SqlScope | None,
165
193
  agent_id: str | None,
194
+ branch_scope: tuple[str, list[Any]] | None = None,
166
195
  ) -> tuple[list[str], list[Any]]:
167
196
  """The WHERE behind ``list()`` and ``count_entries()`` on both adapters.
168
197
 
169
198
  One definition so a page and the ``total`` it reports cannot disagree
170
199
  about which rows exist, and so the shared-namespace guard is applied to
171
200
  the unscoped read in one place (``tests/test_shared_path_scoping.py``).
201
+
202
+ *branch_scope* replaces the plain ``branch = %s`` predicate with the
203
+ overlay fragment from :func:`branch_scope_sql` (a branch's own live rows
204
+ plus the parent's for every key it has not touched) — the adapters pass
205
+ it once they hold a cursor to look the parent up on.
172
206
  """
173
- conditions = ["namespace = %s", "branch = %s"]
174
- params: list[Any] = [namespace, branch]
207
+ if branch_scope is not None:
208
+ branch_sql, branch_params = branch_scope
209
+ else:
210
+ branch_sql, branch_params = "branch = %s", [branch]
211
+ conditions = ["namespace = %s", branch_sql]
212
+ params: list[Any] = [namespace, *branch_params]
175
213
 
176
214
  if entity_path is not None:
177
215
  conditions.append("entity_path = %s")
@@ -723,6 +761,7 @@ class PostgresAdapter(AdapterABC):
723
761
  self._has_validators_col = False
724
762
  self._has_action_cols = False
725
763
  self._has_outcome_embedding_col = False
764
+ self._has_outcome_env_col = False
726
765
  self._has_task_text_col = False
727
766
  self._pgvector_version: tuple[int, ...] | None = None
728
767
  # When an embedder is provided, embeddings are computed at write time and
@@ -1225,6 +1264,14 @@ class PostgresAdapter(AdapterABC):
1225
1264
  cur.execute(_OUTCOME_EVIDENCE_SQL)
1226
1265
  # Action-level learning (trigger v4): see migration 009.
1227
1266
  cur.execute(_ACTION_PRIORS_SQL)
1267
+ # The environment an outcome was recorded in (model / agent_version /
1268
+ # runtime / platform, see amfs_core.models.ENVIRONMENT_KEYS), so priors
1269
+ # can down-weight what won under another runtime. Derived at commit
1270
+ # from the record's session metadata, which already travels with it.
1271
+ cur.execute("""
1272
+ ALTER TABLE amfs_outcomes
1273
+ ADD COLUMN IF NOT EXISTS environment JSONB
1274
+ """)
1228
1275
  # Query-conditioned evidence: see migration 010.
1229
1276
  cur.execute(_LOCAL_EVIDENCE_SQL)
1230
1277
  # The task text on outcomes: see migration 011.
@@ -1675,18 +1722,51 @@ class PostgresAdapter(AdapterABC):
1675
1722
  """
1676
1723
  SELECT column_name FROM information_schema.columns
1677
1724
  WHERE table_name = 'amfs_outcomes'
1678
- AND column_name IN ('actions_taken', 'task_embedding', 'task_text')
1725
+ AND column_name IN (
1726
+ 'actions_taken', 'task_embedding', 'environment', 'task_text'
1727
+ )
1679
1728
  """,
1680
1729
  )
1681
1730
  found = {row["column_name"] for row in cur.fetchall()}
1682
1731
  self._has_action_cols = "actions_taken" in found
1683
1732
  self._has_outcome_embedding_col = "task_embedding" in found
1733
+ self._has_outcome_env_col = "environment" in found
1684
1734
  self._has_task_text_col = "task_text" in found
1685
1735
 
1686
1736
  # ------------------------------------------------------------------
1687
1737
  # read
1688
1738
  # ------------------------------------------------------------------
1689
1739
 
1740
+ def _parent_branch(self, cur: Any, branch: str) -> str | None:
1741
+ """The parent a non-main *branch* overlays, or ``None`` for ``main``
1742
+ and for a branch the adapter has no record of."""
1743
+ if branch == "main":
1744
+ return None
1745
+ row = cur.execute(
1746
+ "SELECT parent_branch FROM amfs_branches WHERE namespace = %s AND name = %s",
1747
+ (self._namespace, branch),
1748
+ ).fetchone()
1749
+ if not row:
1750
+ return None
1751
+ return str(row["parent_branch"] or "main")
1752
+
1753
+ def _branch_scope(self, cur: Any, branch: str) -> tuple[str, list[Any]]:
1754
+ """The ``WHERE`` fragment that reads *branch* as an overlay on its parent.
1755
+
1756
+ A branch is a delta: its own live rows, and for every ``(entity_path,
1757
+ key)`` it has not touched, the parent's **live** row. Live, not the
1758
+ parent as of ``branched_at`` — a session pointed at a repair branch or
1759
+ a canary must see the same memory a session on ``main`` sees, differing
1760
+ only in the entries the branch changed; a snapshot would drift from
1761
+ ``main`` for as long as the branch lives and confound the comparison.
1762
+ ``main`` itself, and a branch with no record, read their own rows only.
1763
+
1764
+ The fragment is :func:`branch_scope_sql`; the async adapter builds the
1765
+ same one from its own parent lookup, so the two read paths cannot
1766
+ disagree about what a branch sees.
1767
+ """
1768
+ return branch_scope_sql(branch, self._parent_branch(cur, branch))
1769
+
1690
1770
  def read(
1691
1771
  self,
1692
1772
  entity_path: str,
@@ -1709,23 +1789,20 @@ class PostgresAdapter(AdapterABC):
1709
1789
  )
1710
1790
  row = cur.fetchone()
1711
1791
 
1712
- if row is None and branch != "main":
1713
- branch_info = cur.execute(
1714
- "SELECT parent_branch, branched_at FROM amfs_branches WHERE namespace = %s AND name = %s",
1715
- (self._namespace, branch),
1716
- ).fetchone()
1717
- if branch_info:
1792
+ if row is None:
1793
+ # Not on the branch: the parent's live row, per the overlay
1794
+ # semantics in ``_branch_scope``.
1795
+ parent = self._parent_branch(cur, branch)
1796
+ if parent is not None:
1718
1797
  cur.execute(
1719
1798
  """
1720
1799
  SELECT * FROM amfs_memory_entries
1721
1800
  WHERE namespace = %s AND branch = %s
1722
1801
  AND entity_path = %s AND key = %s
1723
1802
  AND superseded_at IS NULL
1724
- AND written_at <= %s
1725
1803
  ORDER BY version DESC LIMIT 1
1726
1804
  """,
1727
- (self._namespace, branch_info["parent_branch"],
1728
- entity_path, key, branch_info["branched_at"]),
1805
+ (self._namespace, parent, entity_path, key),
1729
1806
  )
1730
1807
  row = cur.fetchone()
1731
1808
 
@@ -2176,24 +2253,28 @@ class PostgresAdapter(AdapterABC):
2176
2253
  - *limit*/*offset* page the result in the query. Filtering happens
2177
2254
  before paging by construction, so a caller can never page past rows
2178
2255
  its scope hides.
2179
- """
2180
- conditions, params = list_conditions(
2181
- self._namespace,
2182
- entity_path,
2183
- include_superseded=include_superseded,
2184
- branch=branch,
2185
- scope=scope,
2186
- agent_id=agent_id,
2187
- )
2188
- where = " AND ".join(conditions)
2189
- query = (
2190
- f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} "
2191
- f"FROM amfs_memory_entries WHERE {where} ORDER BY {_list_order_sql(order_by)}"
2192
- )
2193
- query, params = _paginate_sql(query, params, limit=limit, offset=offset)
2194
2256
 
2257
+ *branch* is read as an overlay on its parent (``_branch_scope``): a
2258
+ repair branch or a canary lists the same memory ``main`` does, apart
2259
+ from the entries it changed.
2260
+ """
2195
2261
  with self._pool.connection() as conn:
2196
2262
  with conn.cursor() as cur:
2263
+ conditions, params = list_conditions(
2264
+ self._namespace,
2265
+ entity_path,
2266
+ include_superseded=include_superseded,
2267
+ branch=branch,
2268
+ scope=scope,
2269
+ agent_id=agent_id,
2270
+ branch_scope=self._branch_scope(cur, branch),
2271
+ )
2272
+ where = " AND ".join(conditions)
2273
+ query = (
2274
+ f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} "
2275
+ f"FROM amfs_memory_entries WHERE {where} ORDER BY {_list_order_sql(order_by)}"
2276
+ )
2277
+ query, params = _paginate_sql(query, params, limit=limit, offset=offset)
2197
2278
  cur.execute(query, params)
2198
2279
  rows = cur.fetchall()
2199
2280
 
@@ -2286,18 +2367,20 @@ class PostgresAdapter(AdapterABC):
2286
2367
  """``COUNT(*)`` over exactly the rows :meth:`list` would return.
2287
2368
 
2288
2369
  The ``total`` a paged listing reports, computed without fetching a row.
2370
+ Same branch overlay as :meth:`list`, so the two cannot disagree.
2289
2371
  """
2290
- conditions, params = list_conditions(
2291
- self._namespace,
2292
- entity_path,
2293
- include_superseded=include_superseded,
2294
- branch=branch,
2295
- scope=scope,
2296
- agent_id=agent_id,
2297
- )
2298
- where = " AND ".join(conditions)
2299
2372
  with self._pool.connection() as conn:
2300
2373
  with conn.cursor() as cur:
2374
+ conditions, params = list_conditions(
2375
+ self._namespace,
2376
+ entity_path,
2377
+ include_superseded=include_superseded,
2378
+ branch=branch,
2379
+ scope=scope,
2380
+ agent_id=agent_id,
2381
+ branch_scope=self._branch_scope(cur, branch),
2382
+ )
2383
+ where = " AND ".join(conditions)
2301
2384
  cur.execute(f"SELECT COUNT(*) AS n FROM amfs_memory_entries WHERE {where}", params)
2302
2385
  row = cur.fetchone()
2303
2386
  return int(row["n"]) if row else 0
@@ -2349,8 +2432,10 @@ class PostgresAdapter(AdapterABC):
2349
2432
  col_ready = getattr(self, "_has_is_artifact_col", False)
2350
2433
  tsq_sql, tsq_params = or_tsquery(query.query) if use_fts else ("", [])
2351
2434
 
2352
- conditions = ["namespace = %s", "branch = %s", "superseded_at IS NULL"]
2353
- params: list[Any] = [self._namespace, branch]
2435
+ # The branch scope is resolved on the connection below (it may need the
2436
+ # branch's parent); a placeholder keeps its position in the WHERE.
2437
+ conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL"]
2438
+ params: list[Any] = [self._namespace]
2354
2439
 
2355
2440
  if query.depth < 3:
2356
2441
  conditions.append("tier <= %s")
@@ -2422,18 +2507,23 @@ class PostgresAdapter(AdapterABC):
2422
2507
  elif not col_ready:
2423
2508
  fetch_limit = min(max(fetch_limit, query.limit * 3), 1000)
2424
2509
 
2425
- where = " AND ".join(conditions)
2426
- sql = f"""
2427
- SELECT {entry_select(col_ready, self._has_validators_col)} FROM amfs_memory_entries
2428
- WHERE {where}
2429
- ORDER BY {order}
2430
- LIMIT %s
2431
- """
2432
2510
  params.append(fetch_limit)
2433
2511
 
2434
2512
  with self._pool.connection() as conn:
2435
2513
  with conn.cursor() as cur:
2436
- cur.execute(sql, params)
2514
+ scope_sql, scope_params = self._branch_scope(cur, branch)
2515
+ where = " AND ".join(
2516
+ scope_sql if c is _BRANCH_SCOPE else c for c in conditions
2517
+ )
2518
+ sql = f"""
2519
+ SELECT {entry_select(col_ready, self._has_validators_col)} FROM amfs_memory_entries
2520
+ WHERE {where}
2521
+ ORDER BY {order}
2522
+ LIMIT %s
2523
+ """
2524
+ # The scope's params sit where its placeholder sits: right
2525
+ # after the namespace.
2526
+ cur.execute(sql, [params[0], *scope_params, *params[1:]])
2437
2527
  rows = cur.fetchall()
2438
2528
 
2439
2529
  entries = [self._row_to_entry(r) for r in rows]
@@ -3705,6 +3795,12 @@ class PostgresAdapter(AdapterABC):
3705
3795
  list(record.entity_paths or []),
3706
3796
  record.situation,
3707
3797
  ]
3798
+ if getattr(self, "_has_outcome_env_col", False):
3799
+ env = recorded_environment({"session_metadata": record.session_metadata or {}})
3800
+ if env:
3801
+ columns.append("environment")
3802
+ placeholders.append("%s::jsonb")
3803
+ params.append(json.dumps(env))
3708
3804
  task_embedding = self._outcome_embedding(record)
3709
3805
  if task_embedding is not None:
3710
3806
  columns.append("task_embedding")
@@ -3907,9 +4003,10 @@ class PostgresAdapter(AdapterABC):
3907
4003
  if since is not None:
3908
4004
  conditions.append("committed_at >= %s")
3909
4005
  params.append(since)
4006
+ env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
3910
4007
  sql = f"""
3911
4008
  SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation,
3912
- 1 - (task_embedding <=> %s::vector) AS similarity
4009
+ 1 - (task_embedding <=> %s::vector) AS similarity{env_col}
3913
4010
  FROM amfs_outcomes
3914
4011
  WHERE {" AND ".join(conditions)}
3915
4012
  ORDER BY task_embedding <=> %s::vector
@@ -3931,6 +4028,7 @@ class PostgresAdapter(AdapterABC):
3931
4028
  "agent_id": row["agent_id"],
3932
4029
  "situation": row.get("situation"),
3933
4030
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4031
+ "environment": _jsonb(row.get("environment"), {}) or {},
3934
4032
  "similarity": sim,
3935
4033
  })
3936
4034
  return out
@@ -4137,8 +4235,9 @@ class PostgresAdapter(AdapterABC):
4137
4235
  if since is not None:
4138
4236
  conditions.append("committed_at >= %s")
4139
4237
  params.append(since)
4238
+ env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
4140
4239
  sql = f"""
4141
- SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation
4240
+ SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation{env_col}
4142
4241
  FROM amfs_outcomes
4143
4242
  WHERE {" AND ".join(conditions)}
4144
4243
  ORDER BY committed_at DESC
@@ -4155,6 +4254,7 @@ class PostgresAdapter(AdapterABC):
4155
4254
  "agent_id": row["agent_id"],
4156
4255
  "situation": row.get("situation"),
4157
4256
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4257
+ "environment": _jsonb(row.get("environment"), {}) or {},
4158
4258
  "similarity": 1.0,
4159
4259
  } for row in rows]
4160
4260
 
@@ -38,6 +38,7 @@ from psycopg_pool import AsyncConnectionPool
38
38
 
39
39
  from amfs_postgres._fts import or_tsquery
40
40
  from amfs_postgres.adapter import (
41
+ _BRANCH_SCOPE,
41
42
  _EVIDENCE_COLUMNS,
42
43
  _EVIDENCE_SELECT,
43
44
  _EXCLUDE_SHARED_PATHS,
@@ -47,6 +48,7 @@ from amfs_postgres.adapter import (
47
48
  _inherit_from_row,
48
49
  _list_order_sql,
49
50
  _paginate_sql,
51
+ branch_scope_sql,
50
52
  connection_options,
51
53
  entry_select,
52
54
  list_conditions,
@@ -257,6 +259,30 @@ class AsyncPostgresAdapter:
257
259
  except ImportError:
258
260
  return None
259
261
 
262
+ # ── branches as overlays ────────────────────────────────────────
263
+
264
+ async def _parent_branch(self, cur: Any, branch: str) -> str | None:
265
+ """The parent a non-main *branch* overlays, or ``None`` for ``main``
266
+ and for a branch the adapter has no record of. The async twin of
267
+ ``PostgresAdapter._parent_branch``."""
268
+ if branch == "main":
269
+ return None
270
+ await cur.execute(
271
+ "SELECT parent_branch FROM amfs_branches WHERE namespace = %s AND name = %s",
272
+ (self._namespace, branch),
273
+ )
274
+ row = await cur.fetchone()
275
+ if not row:
276
+ return None
277
+ return str(row["parent_branch"] or "main")
278
+
279
+ async def _branch_scope(self, cur: Any, branch: str) -> tuple[str, list[Any]]:
280
+ """The ``WHERE`` fragment reading *branch* as an overlay on its live
281
+ parent — the same fragment the sync adapter builds, from the same
282
+ helper, so ``/search``, ``/retrieve`` and ``/entries`` (which run here)
283
+ see exactly what a sync ``list`` or ``search`` on the branch sees."""
284
+ return branch_scope_sql(branch, await self._parent_branch(cur, branch))
285
+
260
286
  # ──────────────────────────────────────────────────────────────
261
287
  # 1. read
262
288
  # ──────────────────────────────────────────────────────────────
@@ -283,29 +309,21 @@ class AsyncPostgresAdapter:
283
309
  )
284
310
  row = await cur.fetchone()
285
311
 
286
- if row is None and branch != "main":
287
- await cur.execute(
288
- "SELECT parent_branch, branched_at FROM amfs_branches WHERE namespace = %s AND name = %s",
289
- (self._namespace, branch),
290
- )
291
- branch_info = await cur.fetchone()
292
- if branch_info:
312
+ if row is None:
313
+ # Not on the branch: the parent's *live* row. A branch is a
314
+ # delta over its parent, not a snapshot of it — see
315
+ # ``PostgresAdapter._branch_scope``.
316
+ parent = await self._parent_branch(cur, branch)
317
+ if parent is not None:
293
318
  await cur.execute(
294
319
  """
295
320
  SELECT * FROM amfs_memory_entries
296
321
  WHERE namespace = %s AND branch = %s
297
322
  AND entity_path = %s AND key = %s
298
323
  AND superseded_at IS NULL
299
- AND written_at <= %s
300
324
  ORDER BY version DESC LIMIT 1
301
325
  """,
302
- (
303
- self._namespace,
304
- branch_info["parent_branch"],
305
- entity_path,
306
- key,
307
- branch_info["branched_at"],
308
- ),
326
+ (self._namespace, parent, entity_path, key),
309
327
  )
310
328
  row = await cur.fetchone()
311
329
 
@@ -471,25 +489,26 @@ class AsyncPostgresAdapter:
471
489
 
472
490
  The async twin of :meth:`PostgresAdapter.list`; see there for what the
473
491
  keyword arguments beyond the ABC's mean. The WHERE comes from the same
474
- ``list_conditions`` so the two adapters cannot drift.
492
+ ``list_conditions`` so the two adapters cannot drift, and *branch* is
493
+ read through the same overlay on its parent.
475
494
  """
476
- conditions, params = list_conditions(
477
- self._namespace,
478
- entity_path,
479
- include_superseded=include_superseded,
480
- branch=branch,
481
- scope=scope,
482
- agent_id=agent_id,
483
- )
484
- where = " AND ".join(conditions)
485
- query = (
486
- f"SELECT {entry_select(self._has_is_artifact_col)} FROM amfs_memory_entries "
487
- f"WHERE {where} ORDER BY {_list_order_sql(order_by)}"
488
- )
489
- query, params = _paginate_sql(query, params, limit=limit, offset=offset)
490
-
491
495
  async with self._pool.connection() as conn:
492
496
  async with conn.cursor() as cur:
497
+ conditions, params = list_conditions(
498
+ self._namespace,
499
+ entity_path,
500
+ include_superseded=include_superseded,
501
+ branch=branch,
502
+ scope=scope,
503
+ agent_id=agent_id,
504
+ branch_scope=await self._branch_scope(cur, branch),
505
+ )
506
+ where = " AND ".join(conditions)
507
+ query = (
508
+ f"SELECT {entry_select(self._has_is_artifact_col)} FROM amfs_memory_entries "
509
+ f"WHERE {where} ORDER BY {_list_order_sql(order_by)}"
510
+ )
511
+ query, params = _paginate_sql(query, params, limit=limit, offset=offset)
493
512
  await cur.execute(query, params)
494
513
  rows = await cur.fetchall()
495
514
 
@@ -504,18 +523,20 @@ class AsyncPostgresAdapter:
504
523
  scope: SqlScope | None = None,
505
524
  agent_id: str | None = None,
506
525
  ) -> int:
507
- """``COUNT(*)`` over exactly the rows :meth:`list` would return."""
508
- conditions, params = list_conditions(
509
- self._namespace,
510
- entity_path,
511
- include_superseded=include_superseded,
512
- branch=branch,
513
- scope=scope,
514
- agent_id=agent_id,
515
- )
516
- where = " AND ".join(conditions)
526
+ """``COUNT(*)`` over exactly the rows :meth:`list` would return —
527
+ the same branch overlay included, so a page and its ``total`` agree."""
517
528
  async with self._pool.connection() as conn:
518
529
  async with conn.cursor() as cur:
530
+ conditions, params = list_conditions(
531
+ self._namespace,
532
+ entity_path,
533
+ include_superseded=include_superseded,
534
+ branch=branch,
535
+ scope=scope,
536
+ agent_id=agent_id,
537
+ branch_scope=await self._branch_scope(cur, branch),
538
+ )
539
+ where = " AND ".join(conditions)
519
540
  await cur.execute(
520
541
  f"SELECT COUNT(*) AS n FROM amfs_memory_entries WHERE {where}", params
521
542
  )
@@ -531,8 +552,10 @@ class AsyncPostgresAdapter:
531
552
  col_ready = self._has_is_artifact_col
532
553
  tsq_sql, tsq_params = or_tsquery(query.query) if use_fts else ("", [])
533
554
 
534
- conditions = ["namespace = %s", "branch = %s", "superseded_at IS NULL"]
535
- params: list[Any] = [self._namespace, branch]
555
+ # ``_BRANCH_SCOPE`` is swapped for the real branch condition once a
556
+ # connection is open; its params are spliced in after the namespace.
557
+ conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL"]
558
+ params: list[Any] = [self._namespace]
536
559
 
537
560
  if query.depth < 3:
538
561
  conditions.append("tier <= %s")
@@ -597,18 +620,21 @@ class AsyncPostgresAdapter:
597
620
  elif not col_ready:
598
621
  fetch_limit = min(query.limit * 3, 1000)
599
622
 
600
- where = " AND ".join(conditions)
601
- sql = f"""
602
- SELECT {entry_select(col_ready)} FROM amfs_memory_entries
603
- WHERE {where}
604
- ORDER BY {order}
605
- LIMIT %s
606
- """
607
623
  params.append(fetch_limit)
608
624
 
609
625
  async with self._pool.connection() as conn:
610
626
  async with conn.cursor() as cur:
611
- await cur.execute(sql, params)
627
+ scope_sql, scope_params = await self._branch_scope(cur, branch)
628
+ where = " AND ".join(
629
+ scope_sql if c is _BRANCH_SCOPE else c for c in conditions
630
+ )
631
+ sql = f"""
632
+ SELECT {entry_select(col_ready)} FROM amfs_memory_entries
633
+ WHERE {where}
634
+ ORDER BY {order}
635
+ LIMIT %s
636
+ """
637
+ await cur.execute(sql, [params[0], *scope_params, *params[1:]])
612
638
  rows = await cur.fetchall()
613
639
 
614
640
  entries = [self._row_to_entry(r) for r in rows]
@@ -654,13 +680,15 @@ class AsyncPostgresAdapter:
654
680
  query_vec = await asyncio.to_thread(embedder.embed, query.text)
655
681
  vec_str = f"[{','.join(str(v) for v in query_vec)}]"
656
682
 
683
+ # The branch condition is resolved once a connection is open; its
684
+ # params follow the namespace, after the leading query vector.
657
685
  conditions = [
658
686
  "namespace = %s",
659
- "branch = %s",
687
+ _BRANCH_SCOPE,
660
688
  "superseded_at IS NULL",
661
689
  "embedding IS NOT NULL",
662
690
  ]
663
- params: list[Any] = [self._namespace, branch]
691
+ params: list[Any] = [self._namespace]
664
692
  if query.entity_path is not None:
665
693
  conditions.append("entity_path = %s")
666
694
  params.append(query.entity_path)
@@ -673,21 +701,25 @@ class AsyncPostgresAdapter:
673
701
  conditions.append("confidence <= %s")
674
702
  params.append(query.max_confidence)
675
703
 
676
- where = " AND ".join(conditions)
677
- # Projected, not ``*``: the ranked rows do not need their own 1536-dim
678
- # vectors shipped back only to be dropped by ``_row_to_entry`` — that
679
- # was ~6 KB per candidate, per query variant.
680
- sql = f"""
681
- SELECT {entry_select(self._has_is_artifact_col)},
682
- 1 - (embedding <=> %s::vector) AS similarity
683
- FROM amfs_memory_entries
684
- WHERE {where}
685
- ORDER BY embedding <=> %s::vector
686
- LIMIT %s
687
- """
688
- sql_params = [vec_str] + params + [vec_str, query.limit]
689
-
690
704
  async with self._pool.connection() as conn:
705
+ async with conn.cursor() as cur:
706
+ scope_sql, scope_params = await self._branch_scope(cur, branch)
707
+ where = " AND ".join(
708
+ scope_sql if c is _BRANCH_SCOPE else c for c in conditions
709
+ )
710
+ # Projected, not ``*``: the ranked rows do not need their own 1536-dim
711
+ # vectors shipped back only to be dropped by ``_row_to_entry`` — that
712
+ # was ~6 KB per candidate, per query variant.
713
+ sql = f"""
714
+ SELECT {entry_select(self._has_is_artifact_col)},
715
+ 1 - (embedding <=> %s::vector) AS similarity
716
+ FROM amfs_memory_entries
717
+ WHERE {where}
718
+ ORDER BY embedding <=> %s::vector
719
+ LIMIT %s
720
+ """
721
+ sql_params = [vec_str, params[0], *scope_params, *params[1:], vec_str, query.limit]
722
+
691
723
  rows: list[dict[str, Any]] | None = None
692
724
  if use_hnsw_scan(entity_path=query.entity_path, version=self._pgvector_version):
693
725
  # Account-wide: put this one statement on the HNSW index (see