amfs-adapter-postgres 0.2.42__tar.gz → 0.2.44__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.42 → amfs_adapter_postgres-0.2.44}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/adapter.py +64 -12
  4. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/async_adapter.py +16 -3
  5. amfs_adapter_postgres-0.2.44/src/amfs_postgres/migrations/014_outcome_covariates.sql +20 -0
  6. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/.gitignore +0 -0
  7. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/__init__.py +0 -0
  8. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/_fts.py +0 -0
  9. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/evidence_sql.py +0 -0
  10. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/knn.py +0 -0
  11. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  12. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  13. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  14. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  15. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  16. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  17. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  18. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  19. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  20. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  21. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/009_action_priors.sql +0 -0
  22. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/010_local_evidence.sql +0 -0
  23. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/011_outcome_task_text.sql +0 -0
  24. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/012_evidence_from_model.sql +0 -0
  25. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/migrations/013_outcome_verified_by_backfill.sql +0 -0
  26. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/retention.py +0 -0
  27. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/schema.sql +0 -0
  28. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/tenant_context.py +0 -0
  29. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/src/amfs_postgres/tenant_gucs.py +0 -0
  30. {amfs_adapter_postgres-0.2.42 → amfs_adapter_postgres-0.2.44}/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.42
3
+ Version: 0.2.44
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.56
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.42"
3
+ version = "0.2.44"
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.56",
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,
@@ -222,6 +222,14 @@ def priority_score_sql(now: datetime, config: Any | None = None) -> tuple[str, l
222
222
  return expr, params
223
223
 
224
224
 
225
+ #: A row whose TTL has not passed: the predicate every read path that
226
+ #: surfaces current entries carries (``list``, ``search``, ``semantic_search``,
227
+ #: the agent listing). ``NOW()`` rather than a parameter because the fragment
228
+ #: sits in WHERE lists whose parameters are positional and spliced around by
229
+ #: their callers; with nothing to bind it cannot shift any of them.
230
+ _NOT_EXPIRED = "(ttl_at IS NULL OR ttl_at > NOW())"
231
+
232
+
225
233
  def list_conditions(
226
234
  namespace: str,
227
235
  entity_path: str | None,
@@ -231,6 +239,7 @@ def list_conditions(
231
239
  scope: SqlScope | None,
232
240
  agent_id: str | None,
233
241
  branch_scope: tuple[str, list[Any]] | None = None,
242
+ include_expired: bool = False,
234
243
  ) -> tuple[list[str], list[Any]]:
235
244
  """The WHERE behind ``list()`` and ``count_entries()`` on both adapters.
236
245
 
@@ -238,6 +247,13 @@ def list_conditions(
238
247
  about which rows exist, and so the shared-namespace guard is applied to
239
248
  the unscoped read in one place (``tests/test_shared_path_scoping.py``).
240
249
 
250
+ The rows are the live ones: current and not past their ``ttl_at``, which
251
+ is the rule ``read`` and ``search`` apply, so a TTL takes effect on every
252
+ listing at once instead of when the sweep next archives the row. Two
253
+ callers see expired rows all the same. *include_superseded* is the
254
+ history, which hides nothing; *include_expired* is for the sweep, whose
255
+ whole purpose is to find them (``list_expired``).
256
+
241
257
  *branch_scope* replaces the plain ``branch = %s`` predicate with the
242
258
  overlay fragment from :func:`branch_scope_sql` (a branch's own live rows
243
259
  plus the parent's for every key it has not touched) — the adapters pass
@@ -258,6 +274,8 @@ def list_conditions(
258
274
 
259
275
  if not include_superseded:
260
276
  conditions.append("superseded_at IS NULL")
277
+ if not include_expired:
278
+ conditions.append(_NOT_EXPIRED)
261
279
  if agent_id is not None:
262
280
  conditions.append("agent_id = %s")
263
281
  params.append(agent_id)
@@ -313,6 +331,10 @@ _LOCAL_EVIDENCE_SQL = (
313
331
  _OUTCOME_TASK_TEXT_SQL = (
314
332
  Path(__file__).parent / "migrations" / "011_outcome_task_text.sql"
315
333
  ).read_text(encoding="utf-8")
334
+ #: Migration 014 — the covariates of the task on the outcome row.
335
+ _OUTCOME_COVARIATES_SQL = (
336
+ Path(__file__).parent / "migrations" / "014_outcome_covariates.sql"
337
+ ).read_text(encoding="utf-8")
316
338
  #: Migration 012 — the evidence functions rendered from ``amfs_core.evidence``
317
339
  #: (``evidence_sql.render_evidence_sql``), replacing the hand-written bodies
318
340
  #: of 008/009 with the same arithmetic read off the Python model. Applied
@@ -864,6 +886,7 @@ class PostgresAdapter(AdapterABC):
864
886
  self._has_outcome_embedding_col = False
865
887
  self._has_outcome_env_col = False
866
888
  self._has_task_text_col = False
889
+ self._has_covariates_col = False
867
890
  self._has_verified_by_col = False
868
891
  self._pgvector_version: tuple[int, ...] | None = None
869
892
  # When an embedder is provided, embeddings are computed at write time and
@@ -1375,6 +1398,12 @@ class PostgresAdapter(AdapterABC):
1375
1398
  ALTER TABLE amfs_outcomes
1376
1399
  ADD COLUMN IF NOT EXISTS environment JSONB
1377
1400
  """)
1401
+ # The covariates of the task — the run's declared attributes other
1402
+ # than its environment and situation (the customer's plan, the app
1403
+ # version a lookup showed) — so a contested situation's record can be
1404
+ # split by what separates the runs the old action still wins from the
1405
+ # ones it lost (amfs_core.actions.split_contested). See migration 014.
1406
+ cur.execute(_OUTCOME_COVARIATES_SQL)
1378
1407
  # Query-conditioned evidence: see migration 010.
1379
1408
  cur.execute(_LOCAL_EVIDENCE_SQL)
1380
1409
  # The task text on outcomes: see migration 011.
@@ -1845,7 +1874,7 @@ class PostgresAdapter(AdapterABC):
1845
1874
  WHERE table_name = 'amfs_outcomes'
1846
1875
  AND column_name IN (
1847
1876
  'actions_taken', 'task_embedding', 'environment', 'task_text',
1848
- 'verified_by'
1877
+ 'verified_by', 'covariates'
1849
1878
  )
1850
1879
  """,
1851
1880
  )
@@ -1855,6 +1884,7 @@ class PostgresAdapter(AdapterABC):
1855
1884
  self._has_outcome_env_col = "environment" in found
1856
1885
  self._has_task_text_col = "task_text" in found
1857
1886
  self._has_verified_by_col = "verified_by" in found
1887
+ self._has_covariates_col = "covariates" in found
1858
1888
 
1859
1889
  # ------------------------------------------------------------------
1860
1890
  # read
@@ -2441,13 +2471,15 @@ class PostgresAdapter(AdapterABC):
2441
2471
 
2442
2472
  What a TTL sweep needs: the rows to archive, found through the
2443
2473
  partial index on ``ttl_at`` (``idx_entries_ttl``) rather than by
2444
- listing the tenant and checking each entry's clock in Python. Same
2445
- row set as ``list()`` — current versions, this branch, shared paths
2446
- excluded — narrowed to the expired ones. *limit* bounds one sweep;
2447
- the next sweep picks up the rest.
2474
+ listing the tenant and checking each entry's clock in Python. The
2475
+ scope is ``list()``'s — current versions, this branch, shared paths
2476
+ excluded — and the rows are the ones ``list()`` leaves out: those past
2477
+ their TTL and not yet archived. *limit* bounds one sweep; the next
2478
+ sweep picks up the rest.
2448
2479
  """
2449
2480
  conditions, params = list_conditions(
2450
2481
  self._namespace, None, include_superseded=False, branch=branch, scope=None, agent_id=None,
2482
+ include_expired=True,
2451
2483
  )
2452
2484
  conditions.append("ttl_at IS NOT NULL AND ttl_at <= %s")
2453
2485
  params.append(now or datetime.now(timezone.utc))
@@ -2672,7 +2704,7 @@ class PostgresAdapter(AdapterABC):
2672
2704
 
2673
2705
  # The branch scope is resolved on the connection below (it may need the
2674
2706
  # branch's parent); a placeholder keeps its position in the WHERE.
2675
- conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL"]
2707
+ conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL", _NOT_EXPIRED]
2676
2708
  params: list[Any] = [self._namespace]
2677
2709
 
2678
2710
  if query.depth < 3:
@@ -2809,7 +2841,7 @@ class PostgresAdapter(AdapterABC):
2809
2841
 
2810
2842
  query_vec = query.embedding if query.embedding is not None else embedder.embed(query.text)
2811
2843
 
2812
- conditions = ["namespace = %s", "superseded_at IS NULL", "embedding IS NOT NULL"]
2844
+ conditions = ["namespace = %s", "superseded_at IS NULL", "embedding IS NOT NULL", _NOT_EXPIRED]
2813
2845
  params: list[Any] = [self._namespace]
2814
2846
 
2815
2847
  if query.entity_path is not None:
@@ -2916,15 +2948,20 @@ class PostgresAdapter(AdapterABC):
2916
2948
  belonging to another agent turns up in ``keys``. A NULL *agent_id*
2917
2949
  compares as unknown rather than true, so passing nothing counts shared
2918
2950
  entries only — the safe direction.
2951
+
2952
+ The TTL predicate is here for the same reason: ``list()`` leaves out an
2953
+ entry past its ``ttl_at``, so the key of one must not turn up in
2954
+ ``keys`` either.
2919
2955
  """
2920
2956
  with self._pool.connection() as conn:
2921
2957
  row = conn.execute(
2922
- """SELECT count(*) AS total,
2958
+ f"""SELECT count(*) AS total,
2923
2959
  count(*) FILTER (WHERE coalesce(recall_count, 0) = 0)
2924
2960
  AS never_read
2925
2961
  FROM amfs_memory_entries
2926
2962
  WHERE namespace = %s AND branch = %s AND entity_path = %s
2927
2963
  AND superseded_at IS NULL
2964
+ AND {_NOT_EXPIRED}
2928
2965
  AND (%s::text IS NULL OR key <> %s)
2929
2966
  AND (shared OR agent_id = %s::text)""",
2930
2967
  (self._namespace, branch, entity_path, exclude_key, exclude_key,
@@ -2932,9 +2969,10 @@ class PostgresAdapter(AdapterABC):
2932
2969
  ).fetchone()
2933
2970
  keys = [
2934
2971
  r["key"] for r in conn.execute(
2935
- """SELECT key FROM amfs_memory_entries
2972
+ f"""SELECT key FROM amfs_memory_entries
2936
2973
  WHERE namespace = %s AND branch = %s AND entity_path = %s
2937
2974
  AND superseded_at IS NULL
2975
+ AND {_NOT_EXPIRED}
2938
2976
  AND (%s::text IS NULL OR key <> %s)
2939
2977
  AND (shared OR agent_id = %s::text)
2940
2978
  ORDER BY key LIMIT %s""",
@@ -4248,6 +4286,15 @@ class PostgresAdapter(AdapterABC):
4248
4286
  columns.append("environment")
4249
4287
  placeholders.append("%s::jsonb")
4250
4288
  params.append(json.dumps(env))
4289
+ if getattr(self, "_has_covariates_col", False):
4290
+ covariates = recorded_covariates({
4291
+ "covariates": record.covariates or None,
4292
+ "session_metadata": record.session_metadata or {},
4293
+ })
4294
+ if covariates:
4295
+ columns.append("covariates")
4296
+ placeholders.append("%s::jsonb")
4297
+ params.append(json.dumps(covariates, default=str))
4251
4298
  task_embedding = self._outcome_embedding(record)
4252
4299
  if task_embedding is not None:
4253
4300
  columns.append("task_embedding")
@@ -4556,9 +4603,10 @@ class PostgresAdapter(AdapterABC):
4556
4603
  conditions.append("committed_at >= %s")
4557
4604
  params.append(since)
4558
4605
  env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
4606
+ cov_col = ", covariates" if getattr(self, "_has_covariates_col", False) else ""
4559
4607
  sql = f"""
4560
4608
  SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation,
4561
- 1 - (task_embedding <=> %s::vector) AS similarity{env_col}
4609
+ 1 - (task_embedding <=> %s::vector) AS similarity{env_col}{cov_col}
4562
4610
  FROM amfs_outcomes
4563
4611
  WHERE {" AND ".join(conditions)}
4564
4612
  ORDER BY task_embedding <=> %s::vector
@@ -4581,6 +4629,7 @@ class PostgresAdapter(AdapterABC):
4581
4629
  "situation": row.get("situation"),
4582
4630
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4583
4631
  "environment": _jsonb(row.get("environment"), {}) or {},
4632
+ "covariates": _jsonb(row.get("covariates"), {}) or {},
4584
4633
  "similarity": sim,
4585
4634
  })
4586
4635
  return out
@@ -4788,8 +4837,9 @@ class PostgresAdapter(AdapterABC):
4788
4837
  conditions.append("committed_at >= %s")
4789
4838
  params.append(since)
4790
4839
  env_col = ", environment" if getattr(self, "_has_outcome_env_col", False) else ""
4840
+ cov_col = ", covariates" if getattr(self, "_has_covariates_col", False) else ""
4791
4841
  sql = f"""
4792
- SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation{env_col}
4842
+ SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation{env_col}{cov_col}
4793
4843
  FROM amfs_outcomes
4794
4844
  WHERE {" AND ".join(conditions)}
4795
4845
  ORDER BY committed_at DESC
@@ -4807,6 +4857,7 @@ class PostgresAdapter(AdapterABC):
4807
4857
  "situation": row.get("situation"),
4808
4858
  "actions_taken": _jsonb(row.get("actions_taken"), []),
4809
4859
  "environment": _jsonb(row.get("environment"), {}) or {},
4860
+ "covariates": _jsonb(row.get("covariates"), {}) or {},
4810
4861
  "similarity": 1.0,
4811
4862
  } for row in rows]
4812
4863
 
@@ -5061,6 +5112,7 @@ class PostgresAdapter(AdapterABC):
5061
5112
  "agent_id = %s",
5062
5113
  "superseded_at IS NULL",
5063
5114
  "branch = 'main'",
5115
+ _NOT_EXPIRED,
5064
5116
  ]
5065
5117
  params: list[Any] = [namespace, agent_id]
5066
5118
  for prefix in exclude_prefixes:
@@ -42,6 +42,7 @@ from amfs_postgres.adapter import (
42
42
  _EVIDENCE_COLUMNS,
43
43
  _EVIDENCE_SELECT,
44
44
  _EXCLUDE_SHARED_PATHS,
45
+ _NOT_EXPIRED,
45
46
  _REUSE_EVENT_INSERT_SQL,
46
47
  PostgresAdapter,
47
48
  _evidence_params,
@@ -353,6 +354,12 @@ class AsyncPostgresAdapter:
353
354
  entry = self._row_to_entry(row)
354
355
  if entry.confidence < min_confidence:
355
356
  return None
357
+ # As ``PostgresAdapter.read`` does: a TTL takes effect on read, without
358
+ # waiting for the sweep. Missing here, the HTTP read route — which
359
+ # tries this adapter first — served an expired entry that the sync
360
+ # adapter behind the same route would have withheld.
361
+ if entry.is_expired():
362
+ return None
356
363
  return entry
357
364
 
358
365
  # ──────────────────────────────────────────────────────────────
@@ -580,7 +587,7 @@ class AsyncPostgresAdapter:
580
587
 
581
588
  # ``_BRANCH_SCOPE`` is swapped for the real branch condition once a
582
589
  # connection is open; its params are spliced in after the namespace.
583
- conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL"]
590
+ conditions = ["namespace = %s", _BRANCH_SCOPE, "superseded_at IS NULL", _NOT_EXPIRED]
584
591
  params: list[Any] = [self._namespace]
585
592
 
586
593
  if query.depth < 3:
@@ -713,6 +720,7 @@ class AsyncPostgresAdapter:
713
720
  _BRANCH_SCOPE,
714
721
  "superseded_at IS NULL",
715
722
  "embedding IS NOT NULL",
723
+ _NOT_EXPIRED,
716
724
  ]
717
725
  params: list[Any] = [self._namespace]
718
726
  if query.entity_path is not None:
@@ -1145,15 +1153,19 @@ class AsyncPostgresAdapter:
1145
1153
  ``list()`` applied, and this block's ``keys`` land in a tool result. A
1146
1154
  NULL *agent_id* compares as unknown, so passing nothing counts shared
1147
1155
  entries only.
1156
+
1157
+ The TTL predicate is carried for the same reason: ``list()`` leaves out
1158
+ an entry past its ``ttl_at``, so its key must not turn up here either.
1148
1159
  """
1149
1160
  async with self._pool.connection() as conn:
1150
1161
  cur = await conn.execute(
1151
- """SELECT count(*) AS total,
1162
+ f"""SELECT count(*) AS total,
1152
1163
  count(*) FILTER (WHERE coalesce(recall_count, 0) = 0)
1153
1164
  AS never_read
1154
1165
  FROM amfs_memory_entries
1155
1166
  WHERE namespace = %s AND branch = %s AND entity_path = %s
1156
1167
  AND superseded_at IS NULL
1168
+ AND {_NOT_EXPIRED}
1157
1169
  AND (%s::text IS NULL OR key <> %s)
1158
1170
  AND (shared OR agent_id = %s::text)""",
1159
1171
  (self._namespace, branch, entity_path, exclude_key, exclude_key,
@@ -1161,9 +1173,10 @@ class AsyncPostgresAdapter:
1161
1173
  )
1162
1174
  row = await cur.fetchone()
1163
1175
  cur = await conn.execute(
1164
- """SELECT key FROM amfs_memory_entries
1176
+ f"""SELECT key FROM amfs_memory_entries
1165
1177
  WHERE namespace = %s AND branch = %s AND entity_path = %s
1166
1178
  AND superseded_at IS NULL
1179
+ AND {_NOT_EXPIRED}
1167
1180
  AND (%s::text IS NULL OR key <> %s)
1168
1181
  AND (shared OR agent_id = %s::text)
1169
1182
  ORDER BY key LIMIT %s""",
@@ -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;