amfs-adapter-postgres 0.2.41__tar.gz → 0.2.43__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 (30) hide show
  1. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/adapter.py +152 -4
  4. amfs_adapter_postgres-0.2.43/src/amfs_postgres/migrations/014_outcome_covariates.sql +20 -0
  5. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/.gitignore +0 -0
  6. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/__init__.py +0 -0
  7. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/_fts.py +0 -0
  8. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/async_adapter.py +0 -0
  9. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/evidence_sql.py +0 -0
  10. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/knn.py +0 -0
  11. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  12. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  13. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  14. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  15. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  16. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  17. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  18. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  19. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  20. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  21. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
  22. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
  23. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
  24. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/012_evidence_from_model.sql +0 -0
  25. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/migrations/013_outcome_verified_by_backfill.sql +0 -0
  26. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/retention.py +0 -0
  27. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/schema.sql +0 -0
  28. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/tenant_context.py +0 -0
  29. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/src/amfs_postgres/tenant_gucs.py +0 -0
  30. {amfs_adapter_postgres-0.2.41 → amfs_adapter_postgres-0.2.43}/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.41
3
+ Version: 0.2.43
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.53
7
+ Requires-Dist: amfs-core>=0.3.54
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.41"
3
+ version = "0.2.43"
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.53",
10
+ "amfs-core>=0.3.54",
11
11
  "psycopg[binary]>=3.1,<4",
12
12
  "psycopg-pool>=3.1",
13
13
  ]
@@ -34,7 +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 entity_paths_of, recorded_environment
37
+ from amfs_core.actions import entity_paths_of, recorded_covariates, recorded_environment
38
38
  from amfs_core.evidence import cited_entries, outcome_model, outcome_read_keys, outcome_verified_by
39
39
  from amfs_core.models import (
40
40
  OUTCOME_MULTIPLIERS,
@@ -183,6 +183,45 @@ def _paginate_sql(
183
183
  return query, params
184
184
 
185
185
 
186
+ def priority_score_sql(now: datetime, config: Any | None = None) -> tuple[str, list[Any]]:
187
+ """``amfs_core.tiering.PriorityScorer.score`` as a SQL expression.
188
+
189
+ The tiers pages rank every current entry in the account by this score and
190
+ take the top 50 as Hot and the next 200 as Warm. Scoring in Python meant
191
+ loading the whole namespace first; on a 420K-entry account the two
192
+ requests the page makes together took an 8 GiB instance down. The
193
+ expression below is the same formula over the same columns, so the
194
+ database can order by it and hand back one page::
195
+
196
+ (alpha * importance + beta * recency) * freq_boost * time_decay
197
+
198
+ with ``importance = importance_score or confidence`` clamped to [0, 1],
199
+ ``recency = exp(-lambda * age_days)``, ``freq_boost = max(1, ln(1 +
200
+ recall_count))`` and ``time_decay = exp(-lambda * age_days / freq_boost)``.
201
+ ``importance_score`` of 0 falls through to confidence exactly as the
202
+ Python ``or`` does. Returns the expression and its parameters in placeholder
203
+ order: alpha, beta, lambda, *now*, lambda, *now*.
204
+ """
205
+ if config is None:
206
+ from amfs_core.models import TierConfig
207
+
208
+ config = TierConfig()
209
+ age_days = "GREATEST(0.0, EXTRACT(EPOCH FROM (%s - written_at)) / 86400.0)"
210
+ importance = (
211
+ "LEAST(1.0, GREATEST(0.0, COALESCE(NULLIF(importance_score, 0), confidence)::float8))"
212
+ )
213
+ freq_boost = "GREATEST(1.0, LN(1.0 + COALESCE(recall_count, 0)))"
214
+ expr = (
215
+ f"((%s * {importance} + %s * EXP(-%s * {age_days}))"
216
+ f" * {freq_boost}"
217
+ f" * EXP(-%s * {age_days} / {freq_boost}))"
218
+ )
219
+ # The age expression appears twice, each with its own ``now``.
220
+ lam = float(config.decay_lambda)
221
+ params: list[Any] = [float(config.alpha), float(config.beta), lam, now, lam, now]
222
+ return expr, params
223
+
224
+
186
225
  def list_conditions(
187
226
  namespace: str,
188
227
  entity_path: str | None,
@@ -274,6 +313,10 @@ _LOCAL_EVIDENCE_SQL = (
274
313
  _OUTCOME_TASK_TEXT_SQL = (
275
314
  Path(__file__).parent / "migrations" / "011_outcome_task_text.sql"
276
315
  ).read_text(encoding="utf-8")
316
+ #: Migration 014 — the covariates of the task on the outcome row.
317
+ _OUTCOME_COVARIATES_SQL = (
318
+ Path(__file__).parent / "migrations" / "014_outcome_covariates.sql"
319
+ ).read_text(encoding="utf-8")
277
320
  #: Migration 012 — the evidence functions rendered from ``amfs_core.evidence``
278
321
  #: (``evidence_sql.render_evidence_sql``), replacing the hand-written bodies
279
322
  #: of 008/009 with the same arithmetic read off the Python model. Applied
@@ -825,6 +868,7 @@ class PostgresAdapter(AdapterABC):
825
868
  self._has_outcome_embedding_col = False
826
869
  self._has_outcome_env_col = False
827
870
  self._has_task_text_col = False
871
+ self._has_covariates_col = False
828
872
  self._has_verified_by_col = False
829
873
  self._pgvector_version: tuple[int, ...] | None = None
830
874
  # When an embedder is provided, embeddings are computed at write time and
@@ -1336,6 +1380,12 @@ class PostgresAdapter(AdapterABC):
1336
1380
  ALTER TABLE amfs_outcomes
1337
1381
  ADD COLUMN IF NOT EXISTS environment JSONB
1338
1382
  """)
1383
+ # The covariates of the task — the run's declared attributes other
1384
+ # than its environment and situation (the customer's plan, the app
1385
+ # version a lookup showed) — so a contested situation's record can be
1386
+ # split by what separates the runs the old action still wins from the
1387
+ # ones it lost (amfs_core.actions.split_contested). See migration 014.
1388
+ cur.execute(_OUTCOME_COVARIATES_SQL)
1339
1389
  # Query-conditioned evidence: see migration 010.
1340
1390
  cur.execute(_LOCAL_EVIDENCE_SQL)
1341
1391
  # The task text on outcomes: see migration 011.
@@ -1806,7 +1856,7 @@ class PostgresAdapter(AdapterABC):
1806
1856
  WHERE table_name = 'amfs_outcomes'
1807
1857
  AND column_name IN (
1808
1858
  'actions_taken', 'task_embedding', 'environment', 'task_text',
1809
- 'verified_by'
1859
+ 'verified_by', 'covariates'
1810
1860
  )
1811
1861
  """,
1812
1862
  )
@@ -1816,6 +1866,7 @@ class PostgresAdapter(AdapterABC):
1816
1866
  self._has_outcome_env_col = "environment" in found
1817
1867
  self._has_task_text_col = "task_text" in found
1818
1868
  self._has_verified_by_col = "verified_by" in found
1869
+ self._has_covariates_col = "covariates" in found
1819
1870
 
1820
1871
  # ------------------------------------------------------------------
1821
1872
  # read
@@ -2500,6 +2551,90 @@ class PostgresAdapter(AdapterABC):
2500
2551
  row = cur.fetchone()
2501
2552
  return int(row["n"]) if row else 0
2502
2553
 
2554
+ def priority_stats(
2555
+ self,
2556
+ *,
2557
+ scope: SqlScope | None = None,
2558
+ agent_id: str | None = None,
2559
+ now: datetime | None = None,
2560
+ branch: str = "main",
2561
+ ) -> tuple[int, float | None]:
2562
+ """``(count, mean priority score)`` over the rows :meth:`list` returns.
2563
+
2564
+ What the tiers distribution needs and nothing more: the count fixes
2565
+ the Hot/Warm/Archive split (the tiers are rank bands) and the mean is
2566
+ the page's "average score". Computed by the database with
2567
+ :func:`priority_score_sql`; no entry row leaves it.
2568
+ """
2569
+ now = now or datetime.now(timezone.utc)
2570
+ score, score_params = priority_score_sql(now)
2571
+ with self._pool.connection() as conn:
2572
+ with conn.cursor() as cur:
2573
+ conditions, params = list_conditions(
2574
+ self._namespace,
2575
+ None,
2576
+ include_superseded=False,
2577
+ branch=branch,
2578
+ scope=scope,
2579
+ agent_id=agent_id,
2580
+ branch_scope=self._branch_scope(cur, branch),
2581
+ )
2582
+ where = " AND ".join(conditions)
2583
+ cur.execute(
2584
+ f"SELECT COUNT(*) AS n, AVG({score}) AS mean "
2585
+ f"FROM amfs_memory_entries WHERE {where}",
2586
+ [*score_params, *params],
2587
+ )
2588
+ row = cur.fetchone()
2589
+ if not row or not row["n"]:
2590
+ return 0, None
2591
+ mean = row["mean"]
2592
+ return int(row["n"]), (float(mean) if mean is not None else None)
2593
+
2594
+ def list_by_priority(
2595
+ self,
2596
+ *,
2597
+ scope: SqlScope | None = None,
2598
+ agent_id: str | None = None,
2599
+ limit: int,
2600
+ offset: int = 0,
2601
+ now: datetime | None = None,
2602
+ branch: str = "main",
2603
+ ) -> list[MemoryEntry]:
2604
+ """One page of the rows :meth:`list` returns, highest priority first.
2605
+
2606
+ The order is :func:`priority_score_sql` descending with the stable
2607
+ ``written_at DESC, entity_path, key`` tiebreak, so a page boundary is
2608
+ deterministic. A tier is a band of this ranking: rows 0–49 are Hot,
2609
+ 50–249 Warm, the rest Archive — the caller picks *offset* and *limit*
2610
+ from the band and the database returns only that page.
2611
+ """
2612
+ now = now or datetime.now(timezone.utc)
2613
+ score, score_params = priority_score_sql(now)
2614
+ with self._pool.connection() as conn:
2615
+ with conn.cursor() as cur:
2616
+ conditions, params = list_conditions(
2617
+ self._namespace,
2618
+ None,
2619
+ include_superseded=False,
2620
+ branch=branch,
2621
+ scope=scope,
2622
+ agent_id=agent_id,
2623
+ branch_scope=self._branch_scope(cur, branch),
2624
+ )
2625
+ where = " AND ".join(conditions)
2626
+ query = (
2627
+ f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} "
2628
+ f"FROM amfs_memory_entries WHERE {where} "
2629
+ f"ORDER BY {score} DESC, written_at DESC, entity_path, key"
2630
+ )
2631
+ query, all_params = _paginate_sql(
2632
+ query, [*params, *score_params], limit=limit, offset=offset
2633
+ )
2634
+ cur.execute(query, all_params)
2635
+ rows = cur.fetchall()
2636
+ return [self._row_to_entry(r) for r in rows]
2637
+
2503
2638
  def entry_authors(self, refs: list[tuple[str, str]]) -> dict[tuple[str, str], str]:
2504
2639
  """Who wrote each ``(entity_path, key)``, for the current version.
2505
2640
 
@@ -4125,6 +4260,15 @@ class PostgresAdapter(AdapterABC):
4125
4260
  columns.append("environment")
4126
4261
  placeholders.append("%s::jsonb")
4127
4262
  params.append(json.dumps(env))
4263
+ if getattr(self, "_has_covariates_col", False):
4264
+ covariates = recorded_covariates({
4265
+ "covariates": record.covariates or None,
4266
+ "session_metadata": record.session_metadata or {},
4267
+ })
4268
+ if covariates:
4269
+ columns.append("covariates")
4270
+ placeholders.append("%s::jsonb")
4271
+ params.append(json.dumps(covariates, default=str))
4128
4272
  task_embedding = self._outcome_embedding(record)
4129
4273
  if task_embedding is not None:
4130
4274
  columns.append("task_embedding")
@@ -4433,9 +4577,10 @@ class PostgresAdapter(AdapterABC):
4433
4577
  conditions.append("committed_at >= %s")
4434
4578
  params.append(since)
4435
4579
  env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
4580
+ cov_col = ", covariates" if getattr(self, "_has_covariates_col", False) else ""
4436
4581
  sql = f"""
4437
4582
  SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation,
4438
- 1 - (task_embedding <=> %s::vector) AS similarity{env_col}
4583
+ 1 - (task_embedding <=> %s::vector) AS similarity{env_col}{cov_col}
4439
4584
  FROM amfs_outcomes
4440
4585
  WHERE {" AND ".join(conditions)}
4441
4586
  ORDER BY task_embedding <=> %s::vector
@@ -4458,6 +4603,7 @@ class PostgresAdapter(AdapterABC):
4458
4603
  "situation": row.get("situation"),
4459
4604
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4460
4605
  "environment": _jsonb(row.get("environment"), {}) or {},
4606
+ "covariates": _jsonb(row.get("covariates"), {}) or {},
4461
4607
  "similarity": sim,
4462
4608
  })
4463
4609
  return out
@@ -4665,8 +4811,9 @@ class PostgresAdapter(AdapterABC):
4665
4811
  conditions.append("committed_at >= %s")
4666
4812
  params.append(since)
4667
4813
  env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
4814
+ cov_col = ", covariates" if getattr(self, "_has_covariates_col", False) else ""
4668
4815
  sql = f"""
4669
- SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation{env_col}
4816
+ SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation{env_col}{cov_col}
4670
4817
  FROM amfs_outcomes
4671
4818
  WHERE {" AND ".join(conditions)}
4672
4819
  ORDER BY committed_at DESC
@@ -4684,6 +4831,7 @@ class PostgresAdapter(AdapterABC):
4684
4831
  "situation": row.get("situation"),
4685
4832
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4686
4833
  "environment": _jsonb(row.get("environment"), {}) or {},
4834
+ "covariates": _jsonb(row.get("covariates"), {}) or {},
4687
4835
  "similarity": 1.0,
4688
4836
  } for row in rows]
4689
4837
 
@@ -0,0 +1,20 @@
1
+ -- Migration 014: the covariates of the task on the outcome row
2
+ --
3
+ -- An outcome records what was done (`actions_taken`) in what kind of task
4
+ -- (`situation`) under what runtime (`environment`). It did not record what
5
+ -- the task *was like*: the customer's plan, the app version the lookup
6
+ -- showed, the card's issuer country. When a rule changes for part of a
7
+ -- situation — the export fix stops working on paid plans and keeps working
8
+ -- on the free one — the situation's record turns contested (wins and losses
9
+ -- interleaved, no streak) and nothing in it says which runs the old action
10
+ -- still wins. On the 2026-10-21 regime study the recommendation kept the
11
+ -- stale action as `act` on 59 % of the first searches after such a change.
12
+ --
13
+ -- The covariates are the scalar attributes a run declared about the task
14
+ -- (`set_session_attributes`, `Run.begin(covariates=...)`) less its
15
+ -- environment keys and the situation label, copied here at commit from the
16
+ -- record so `amfs_core.actions.split_contested` can read them off the same
17
+ -- rows the priors are aggregated from. Scalars only; at most the attribute
18
+ -- bag's 20 keys. Written only when the record has any.
19
+
20
+ ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS covariates JSONB;