amfs-adapter-postgres 0.2.16__tar.gz → 0.2.17__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 (23) hide show
  1. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/PKG-INFO +2 -2
  2. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/pyproject.toml +2 -2
  3. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/adapter.py +274 -31
  4. amfs_adapter_postgres-0.2.17/src/amfs_postgres/migrations/009_action_priors.sql +254 -0
  5. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/schema.sql +44 -5
  6. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/.gitignore +0 -0
  7. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/__init__.py +0 -0
  8. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/_fts.py +0 -0
  9. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/async_adapter.py +0 -0
  10. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/001_initial.sql +0 -0
  11. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/002_artifact_refs.sql +0 -0
  12. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/002_search_indexes.sql +0 -0
  13. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/003_knowledge_graph.sql +0 -0
  14. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/004_agent_groups.sql +0 -0
  15. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/005_configurable_embedding_dim.sql +0 -0
  16. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/006_is_artifact.sql +0 -0
  17. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/006_outcome_confidence_semantics.sql +0 -0
  18. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/007_outcome_propagation_row_copy.sql +0 -0
  19. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/migrations/008_outcome_evidence.sql +0 -0
  20. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/retention.py +0 -0
  21. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/tenant_context.py +0 -0
  22. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/src/amfs_postgres/tenant_gucs.py +0 -0
  23. {amfs_adapter_postgres-0.2.16 → amfs_adapter_postgres-0.2.17}/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.16
3
+ Version: 0.2.17
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.29
7
+ Requires-Dist: amfs-core>=0.3.30
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.16"
3
+ version = "0.2.17"
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.29",
10
+ "amfs-core>=0.3.30",
11
11
  "psycopg[binary]>=3.1,<4",
12
12
  "psycopg-pool>=3.1",
13
13
  ]
@@ -146,6 +146,14 @@ _SCHEMA_SQL = (Path(__file__).parent / "schema.sql").read_text(encoding="utf-8")
146
146
  _OUTCOME_EVIDENCE_SQL = (
147
147
  Path(__file__).parent / "migrations" / "008_outcome_evidence.sql"
148
148
  ).read_text(encoding="utf-8")
149
+ #: Migration 009 — action-level learning (actions_taken / entity_paths /
150
+ #: situation on outcomes, validators on entries, first-strike tolerance in the
151
+ #: step function). Same treatment as 008. The outcomes' ``task_embedding``
152
+ #: column is not in the file: it is a pgvector column whose dimension follows
153
+ #: the entries' embedding, so ``ensure_outcome_embedding_column`` adds it.
154
+ _ACTION_PRIORS_SQL = (
155
+ Path(__file__).parent / "migrations" / "009_action_priors.sql"
156
+ ).read_text(encoding="utf-8")
149
157
 
150
158
  # Shared by the sync and async adapters so the two cannot drift apart. account_id
151
159
  # is omitted on purpose: the hosted product gives the column a default in a tenant
@@ -518,9 +526,14 @@ ENTRY_SELECT = ", ".join(_ENTRY_COLUMNS)
518
526
  ENTRY_SELECT_NO_ARTIFACT_COL = ", ".join(c for c in _ENTRY_COLUMNS if c != "is_artifact")
519
527
 
520
528
 
521
- def entry_select(has_is_artifact_col: bool) -> str:
522
- """The SELECT list for queries whose rows become MemoryEntry objects."""
523
- return ENTRY_SELECT if has_is_artifact_col else ENTRY_SELECT_NO_ARTIFACT_COL
529
+ def entry_select(has_is_artifact_col: bool, has_validators_col: bool = False) -> str:
530
+ """The SELECT list for queries whose rows become MemoryEntry objects.
531
+
532
+ ``validators`` (migration 009) is appended only once the column exists; the
533
+ decoder defaults it to an empty list otherwise.
534
+ """
535
+ base = ENTRY_SELECT if has_is_artifact_col else ENTRY_SELECT_NO_ARTIFACT_COL
536
+ return base + ", validators" if has_validators_col else base
524
537
 
525
538
 
526
539
  def _evidence_params(entry: MemoryEntry) -> list[Any]:
@@ -537,6 +550,18 @@ def _evidence_params(entry: MemoryEntry) -> list[Any]:
537
550
  ]
538
551
 
539
552
 
553
+ def _jsonb(raw: Any, default: Any) -> Any:
554
+ """A JSONB column as Python, whether the driver decoded it or left a string."""
555
+ if raw is None:
556
+ return default
557
+ if isinstance(raw, str):
558
+ try:
559
+ return json.loads(raw)
560
+ except ValueError:
561
+ return default
562
+ return raw
563
+
564
+
540
565
  def _inherit_from_row(entry: MemoryEntry, row: Any) -> MemoryEntry:
541
566
  """Apply ``inherit_evidence`` against the live row fetched inside the write
542
567
  transaction, so the hot paths that bypass the engine honour the same rule."""
@@ -556,10 +581,21 @@ def _inherit_from_row(entry: MemoryEntry, row: Any) -> MemoryEntry:
556
581
  for f in EVIDENCE_FIELDS},
557
582
  "evidence_success": float(row.get("evidence_success") or 0.0),
558
583
  "evidence_failure": float(row.get("evidence_failure") or 0.0),
584
+ "validators": _validators_of(row),
559
585
  })
560
586
  return inherit_evidence(entry, current)
561
587
 
562
588
 
589
+ def _validators_of(row: Any) -> list[str]:
590
+ raw = row.get("validators") if hasattr(row, "get") else None
591
+ if isinstance(raw, str):
592
+ try:
593
+ raw = json.loads(raw)
594
+ except ValueError:
595
+ raw = None
596
+ return [str(v) for v in raw] if isinstance(raw, list) else []
597
+
598
+
563
599
  class PostgresAdapter(AdapterABC):
564
600
  """Store AMFS entries in PostgreSQL.
565
601
 
@@ -597,6 +633,9 @@ class PostgresAdapter(AdapterABC):
597
633
  self._has_search_tsv = False
598
634
  self._has_is_artifact_col = False
599
635
  self._has_evidence_cols = False
636
+ self._has_validators_col = False
637
+ self._has_action_cols = False
638
+ self._has_outcome_embedding_col = False
600
639
  # When an embedder is provided, embeddings are computed at write time and
601
640
  # persisted, so ANN retrieval (semantic_search / pgvector HNSW) works
602
641
  # without a separate backfill pass. embedding_dim must match the column
@@ -739,7 +778,7 @@ class PostgresAdapter(AdapterABC):
739
778
  if migrations is None:
740
779
  return None
741
780
  return hashlib.sha256(
742
- (_SCHEMA_SQL + migrations + _OUTCOME_EVIDENCE_SQL).encode("utf-8")
781
+ (_SCHEMA_SQL + migrations + _OUTCOME_EVIDENCE_SQL + _ACTION_PRIORS_SQL).encode("utf-8")
743
782
  ).hexdigest()
744
783
 
745
784
  def _expected_tables(self) -> frozenset[str]:
@@ -1086,6 +1125,30 @@ class PostgresAdapter(AdapterABC):
1086
1125
  # with its helpers. Applied from the migration file rather than a copy
1087
1126
  # here — the copy is what drifted twice before (see 006 and 007).
1088
1127
  cur.execute(_OUTCOME_EVIDENCE_SQL)
1128
+ # Action-level learning (trigger v4): see migration 009.
1129
+ cur.execute(_ACTION_PRIORS_SQL)
1130
+ # task_embedding on outcomes follows the entries' embedding dimension,
1131
+ # so it is derived here rather than declared in the migration.
1132
+ cur.execute(
1133
+ """
1134
+ SELECT format_type(a.atttypid, a.atttypmod) AS t
1135
+ FROM pg_attribute a
1136
+ WHERE a.attrelid = 'amfs_memory_entries'::regclass
1137
+ AND a.attname = 'embedding' AND NOT a.attisdropped
1138
+ """
1139
+ )
1140
+ row = cur.fetchone()
1141
+ m = re.search(r"vector\((\d+)\)", str(row["t"])) if row else None
1142
+ if m:
1143
+ cur.execute(
1144
+ f"ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS "
1145
+ f"task_embedding vector({int(m.group(1))})"
1146
+ )
1147
+ cur.execute(
1148
+ "CREATE INDEX IF NOT EXISTS idx_outcomes_task_embedding "
1149
+ "ON amfs_outcomes USING hnsw (task_embedding vector_cosine_ops) "
1150
+ "WITH (m = 16, ef_construction = 64)"
1151
+ )
1089
1152
  # Soft-delete for team members (user removal flow)
1090
1153
  cur.execute("""
1091
1154
  ALTER TABLE amfs_team_members
@@ -1474,7 +1537,7 @@ class PostgresAdapter(AdapterABC):
1474
1537
  """
1475
1538
  SELECT column_name FROM information_schema.columns
1476
1539
  WHERE table_name = 'amfs_memory_entries'
1477
- AND column_name IN ('embedding', 'search_tsv', 'is_artifact', 'success_count')
1540
+ AND column_name IN ('embedding', 'search_tsv', 'is_artifact', 'success_count', 'validators')
1478
1541
  """,
1479
1542
  )
1480
1543
  found = {row["column_name"] for row in cur.fetchall()}
@@ -1482,6 +1545,17 @@ class PostgresAdapter(AdapterABC):
1482
1545
  self._has_search_tsv = "search_tsv" in found
1483
1546
  self._has_is_artifact_col = "is_artifact" in found
1484
1547
  self._has_evidence_cols = "success_count" in found
1548
+ self._has_validators_col = "validators" in found
1549
+ cur.execute(
1550
+ """
1551
+ SELECT column_name FROM information_schema.columns
1552
+ WHERE table_name = 'amfs_outcomes'
1553
+ AND column_name IN ('actions_taken', 'task_embedding')
1554
+ """,
1555
+ )
1556
+ found = {row["column_name"] for row in cur.fetchall()}
1557
+ self._has_action_cols = "actions_taken" in found
1558
+ self._has_outcome_embedding_col = "task_embedding" in found
1485
1559
 
1486
1560
  # ------------------------------------------------------------------
1487
1561
  # read
@@ -1574,7 +1648,7 @@ class PostgresAdapter(AdapterABC):
1574
1648
  with conn.cursor() as cur:
1575
1649
  cur.execute(
1576
1650
  f"""
1577
- SELECT version, value, confidence{_EVIDENCE_SELECT if self._has_evidence_cols else ""}
1651
+ SELECT version, value, confidence{_EVIDENCE_SELECT if self._has_evidence_cols else ""}{", validators" if self._has_validators_col else ""}
1578
1652
  FROM amfs_memory_entries
1579
1653
  WHERE namespace = %s AND branch = %s
1580
1654
  AND entity_path = %s AND key = %s
@@ -1657,6 +1731,9 @@ class PostgresAdapter(AdapterABC):
1657
1731
  if self._has_evidence_cols:
1658
1732
  columns.extend(_EVIDENCE_COLUMNS)
1659
1733
  params.extend(_evidence_params(entry))
1734
+ if self._has_validators_col:
1735
+ columns.append("validators")
1736
+ params.append(json.dumps(list(entry.validators or [])))
1660
1737
 
1661
1738
  # Write-time embedding: compute and persist an embedding when
1662
1739
  # the caller did not supply one and an embedder is configured.
@@ -1968,7 +2045,7 @@ class PostgresAdapter(AdapterABC):
1968
2045
 
1969
2046
  where = " AND ".join(conditions)
1970
2047
  query = (
1971
- f"SELECT {entry_select(self._has_is_artifact_col)} FROM amfs_memory_entries "
2048
+ f"SELECT {entry_select(self._has_is_artifact_col, self._has_validators_col)} FROM amfs_memory_entries "
1972
2049
  f"WHERE {where} ORDER BY entity_path, key, version"
1973
2050
  )
1974
2051
 
@@ -2073,7 +2150,7 @@ class PostgresAdapter(AdapterABC):
2073
2150
 
2074
2151
  where = " AND ".join(conditions)
2075
2152
  sql = f"""
2076
- SELECT {entry_select(col_ready)} FROM amfs_memory_entries
2153
+ SELECT {entry_select(col_ready, self._has_validators_col)} FROM amfs_memory_entries
2077
2154
  WHERE {where}
2078
2155
  ORDER BY {order}
2079
2156
  LIMIT %s
@@ -3231,29 +3308,44 @@ class PostgresAdapter(AdapterABC):
3231
3308
  "SELECT set_config('amfs.outcome_model', %s, true)",
3232
3309
  (outcome_model(),),
3233
3310
  )
3234
- cur.execute(
3235
- """
3236
- INSERT INTO amfs_outcomes (
3237
- namespace, outcome_ref, outcome_type, causal_confidence,
3238
- committed_at, causal_entry_keys, agent_id,
3239
- attempts, final_action_index, causal_entry_versions
3240
- ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s::jsonb)
3241
- """,
3242
- (
3243
- self._namespace,
3244
- record.outcome_ref,
3245
- record.outcome_type.value,
3246
- record.causal_confidence,
3247
- record.committed_at,
3248
- record.causal_entry_keys,
3249
- record.agent_id,
3250
- json.dumps(
3251
- [a.model_dump(mode="json") for a in record.attempts],
3252
- default=str,
3253
- ),
3254
- record.final_action_index,
3255
- json.dumps(dict(record.causal_entry_versions or {})),
3311
+ columns = [
3312
+ "namespace", "outcome_ref", "outcome_type", "causal_confidence",
3313
+ "committed_at", "causal_entry_keys", "agent_id",
3314
+ "attempts", "final_action_index", "causal_entry_versions",
3315
+ ]
3316
+ placeholders = ["%s"] * 7 + ["%s::jsonb", "%s", "%s::jsonb"]
3317
+ params: list[Any] = [
3318
+ self._namespace,
3319
+ record.outcome_ref,
3320
+ record.outcome_type.value,
3321
+ record.causal_confidence,
3322
+ record.committed_at,
3323
+ record.causal_entry_keys,
3324
+ record.agent_id,
3325
+ json.dumps(
3326
+ [a.model_dump(mode="json") for a in record.attempts],
3327
+ default=str,
3256
3328
  ),
3329
+ record.final_action_index,
3330
+ json.dumps(dict(record.causal_entry_versions or {})),
3331
+ ]
3332
+ if self._has_action_cols:
3333
+ columns += ["actions_taken", "entity_paths", "situation"]
3334
+ placeholders += ["%s::jsonb", "%s", "%s"]
3335
+ params += [
3336
+ json.dumps(list(record.actions_taken or []), default=str),
3337
+ list(record.entity_paths or []),
3338
+ record.situation,
3339
+ ]
3340
+ task_embedding = self._outcome_embedding(record)
3341
+ if task_embedding is not None:
3342
+ columns.append("task_embedding")
3343
+ placeholders.append("%s")
3344
+ params.append(f"[{','.join(str(v) for v in task_embedding)}]")
3345
+ cur.execute(
3346
+ f"INSERT INTO amfs_outcomes ({', '.join(columns)}) "
3347
+ f"VALUES ({', '.join(placeholders)})",
3348
+ params,
3257
3349
  )
3258
3350
 
3259
3351
  # Every entry any step cited, including the failed attempts' — those
@@ -3320,10 +3412,11 @@ class PostgresAdapter(AdapterABC):
3320
3412
  where, params = self._outcome_conditions(
3321
3413
  entity_path=entity_path, since=since, outcome_ref=outcome_ref
3322
3414
  )
3415
+ extra = ", actions_taken, entity_paths, situation" if self._has_action_cols else ""
3323
3416
  sql = f"""
3324
3417
  SELECT outcome_ref, outcome_type, causal_confidence,
3325
3418
  committed_at, causal_entry_keys, agent_id,
3326
- attempts, final_action_index
3419
+ attempts, final_action_index, causal_entry_versions{extra}
3327
3420
  FROM amfs_outcomes
3328
3421
  WHERE {where}
3329
3422
  ORDER BY committed_at DESC
@@ -3352,10 +3445,159 @@ class PostgresAdapter(AdapterABC):
3352
3445
  agent_id=row["agent_id"],
3353
3446
  attempts=_attempts_from_row(row.get("attempts")),
3354
3447
  final_action_index=row.get("final_action_index"),
3448
+ causal_entry_versions=_jsonb(row.get("causal_entry_versions"), {}),
3449
+ actions_taken=_jsonb(row.get("actions_taken"), []),
3450
+ entity_paths=list(row.get("entity_paths") or []),
3451
+ situation=row.get("situation"),
3355
3452
  )
3356
3453
  )
3357
3454
  return results
3358
3455
 
3456
+ # ------------------------------------------------------------------
3457
+ # action priors (migration 009)
3458
+ # ------------------------------------------------------------------
3459
+
3460
+ def _outcome_embedding(self, record: OutcomeRecord) -> list[float] | None:
3461
+ """The vector stored with an outcome so later tasks can find it by
3462
+ similarity. Built from the situation label and the task input; ``None``
3463
+ when there is nothing to embed, no embedder, or no column."""
3464
+ if self._embedder is None or not self._has_outcome_embedding_col:
3465
+ return None
3466
+ text = " ".join(t for t in (record.situation, record.task_input) if t).strip()
3467
+ if not text:
3468
+ return None
3469
+ try:
3470
+ return self._embedder.embed(text[:2000])
3471
+ except Exception: # noqa: BLE001
3472
+ logger.warning("outcome embedding failed for %s; storing without vector",
3473
+ record.outcome_ref, exc_info=True)
3474
+ return None
3475
+
3476
+ def ensure_outcome_embedding_column(self) -> bool:
3477
+ """Add ``amfs_outcomes.task_embedding`` at the entries' embedding dimension.
3478
+
3479
+ Idempotent. Returns ``False`` (and does nothing) when the entries table
3480
+ has no embedding column yet — the dimension is read from it, never
3481
+ assumed, because deployments provision 384 or 1024 (see
3482
+ ``ensure_embedding_column``). The HNSW index is built over a column that
3483
+ is NULL on every existing row, so it is cheap on a populated table.
3484
+ """
3485
+ with self._maintenance_connection() as conn:
3486
+ with conn.cursor() as cur:
3487
+ cur.execute(
3488
+ """
3489
+ SELECT format_type(a.atttypid, a.atttypmod) AS t
3490
+ FROM pg_attribute a
3491
+ WHERE a.attrelid = 'amfs_memory_entries'::regclass
3492
+ AND a.attname = 'embedding' AND NOT a.attisdropped
3493
+ """
3494
+ )
3495
+ row = cur.fetchone()
3496
+ if not row:
3497
+ return False
3498
+ m = re.search(r"vector\((\d+)\)", str(row["t"]))
3499
+ if not m:
3500
+ return False
3501
+ dim = int(m.group(1))
3502
+ cur.execute(
3503
+ f"ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS task_embedding vector({dim})"
3504
+ )
3505
+ cur.execute(
3506
+ "CREATE INDEX IF NOT EXISTS idx_outcomes_task_embedding "
3507
+ "ON amfs_outcomes USING hnsw (task_embedding vector_cosine_ops) "
3508
+ "WITH (m = 16, ef_construction = 64)"
3509
+ )
3510
+ self._detect_optional_columns()
3511
+ return True
3512
+
3513
+ def similar_outcomes(
3514
+ self,
3515
+ entity_path: str,
3516
+ embedding: list[float],
3517
+ *,
3518
+ k: int = 20,
3519
+ min_similarity: float = 0.75,
3520
+ since: datetime | None = None,
3521
+ ) -> list[dict[str, Any]]:
3522
+ """The ``k`` committed outcomes about ``entity_path`` whose task was most
3523
+ like the one embedded, each with its ``actions_taken`` and ``similarity``.
3524
+
3525
+ Empty when the store has no task embeddings (no embedder, or the column
3526
+ is not provisioned), so callers fall back to :meth:`action_stats`.
3527
+ """
3528
+ if not self._has_outcome_embedding_col or not self._has_action_cols or not embedding:
3529
+ return []
3530
+ vec = f"[{','.join(str(v) for v in embedding)}]"
3531
+ conditions = ["namespace = %s", "task_embedding IS NOT NULL", "%s = ANY(entity_paths)"]
3532
+ params: list[Any] = [self._namespace, entity_path]
3533
+ if since is not None:
3534
+ conditions.append("committed_at >= %s")
3535
+ params.append(since)
3536
+ sql = f"""
3537
+ SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation,
3538
+ 1 - (task_embedding <=> %s::vector) AS similarity
3539
+ FROM amfs_outcomes
3540
+ WHERE {" AND ".join(conditions)}
3541
+ ORDER BY task_embedding <=> %s::vector
3542
+ LIMIT %s
3543
+ """
3544
+ with self._pool.connection() as conn:
3545
+ with conn.cursor() as cur:
3546
+ cur.execute(sql, [vec, *params, vec, int(k)])
3547
+ rows = cur.fetchall()
3548
+ out: list[dict[str, Any]] = []
3549
+ for row in rows:
3550
+ sim = float(row["similarity"] or 0.0)
3551
+ if sim < min_similarity:
3552
+ continue
3553
+ out.append({
3554
+ "outcome_ref": row["outcome_ref"],
3555
+ "outcome_type": row["outcome_type"],
3556
+ "committed_at": row["committed_at"],
3557
+ "agent_id": row["agent_id"],
3558
+ "situation": row.get("situation"),
3559
+ "actions_taken": _jsonb(row.get("actions_taken"), []),
3560
+ "similarity": sim,
3561
+ })
3562
+ return out
3563
+
3564
+ def action_stats(
3565
+ self,
3566
+ entity_path: str,
3567
+ *,
3568
+ since: datetime | None = None,
3569
+ limit: int = 500,
3570
+ ) -> list[dict[str, Any]]:
3571
+ """The most recent outcomes about ``entity_path`` with their
3572
+ ``actions_taken`` — the similarity-free fallback for priors."""
3573
+ if not self._has_action_cols:
3574
+ return []
3575
+ conditions = ["namespace = %s", "%s = ANY(entity_paths)", "actions_taken <> '[]'::jsonb"]
3576
+ params: list[Any] = [self._namespace, entity_path]
3577
+ if since is not None:
3578
+ conditions.append("committed_at >= %s")
3579
+ params.append(since)
3580
+ sql = f"""
3581
+ SELECT outcome_ref, outcome_type, committed_at, agent_id, actions_taken, situation
3582
+ FROM amfs_outcomes
3583
+ WHERE {" AND ".join(conditions)}
3584
+ ORDER BY committed_at DESC
3585
+ LIMIT %s
3586
+ """
3587
+ with self._pool.connection() as conn:
3588
+ with conn.cursor() as cur:
3589
+ cur.execute(sql, [*params, int(limit)])
3590
+ rows = cur.fetchall()
3591
+ return [{
3592
+ "outcome_ref": row["outcome_ref"],
3593
+ "outcome_type": row["outcome_type"],
3594
+ "committed_at": row["committed_at"],
3595
+ "agent_id": row["agent_id"],
3596
+ "situation": row.get("situation"),
3597
+ "actions_taken": _jsonb(row.get("actions_taken"), []),
3598
+ "similarity": 1.0,
3599
+ } for row in rows]
3600
+
3359
3601
  # ------------------------------------------------------------------
3360
3602
  # decision traces
3361
3603
  # ------------------------------------------------------------------
@@ -3931,6 +4173,7 @@ class PostgresAdapter(AdapterABC):
3931
4173
  last_outcome=row.get("last_outcome"),
3932
4174
  last_outcome_at=row.get("last_outcome_at"),
3933
4175
  discredited_at=row.get("discredited_at"),
4176
+ validators=_validators_of(row),
3934
4177
  recall_count=row.get("recall_count", 0),
3935
4178
  priority_score=float(row["priority_score"]) if row.get("priority_score") is not None else None,
3936
4179
  tier=row.get("tier", 3),
@@ -0,0 +1,254 @@
1
+ -- Migration 009: action-level learning, first-strike tolerance, validators
2
+ --
3
+ -- Grid v2 of the continual-learning benchmark showed two things the evidence
4
+ -- model of 008 could not do:
5
+ --
6
+ -- * Memory entries record what someone *wrote*; nothing recorded what
7
+ -- happened when an agent *did* something. A support fleet spent 24
8
+ -- attempts per ticket class on the same two failing actions and never
9
+ -- tried the one that worked, because "tried and failed here" was not a
10
+ -- thing memory could say. `actions_taken` on the outcome row is that
11
+ -- record: the action that ended each failed attempt (a loss) and the
12
+ -- terminal action (the outcome). `entity_paths` scopes it to the entities
13
+ -- the outcome was about; `situation` is an optional caller label. The
14
+ -- server adds `task_embedding` (a pgvector column at the same dimension as
15
+ -- the entries' embedding — provisioned by the adapter, not here, because
16
+ -- the dimension is deployment-specific) so a retrieve can ask "on the
17
+ -- tasks most like this one, what worked?".
18
+ -- * A single failure on a rule validated four times discredited it
19
+ -- (0.89 -> 0.49) and pulled it out of retrieval. That is how the surprise
20
+ -- term should treat a fresh claim, not a proven one. The first failure of
21
+ -- an entry with a clean record of at least three successes is now weighed
22
+ -- without surprise: the rule is left contested (~0.62), and a second
23
+ -- failure still discredits it (~0.40), so a regime change is unlearned in
24
+ -- the same two strikes as before.
25
+ --
26
+ -- `validators` on entries lists the distinct agents whose successes credited
27
+ -- the claim; the trigger maintains it. The step function gains the committing
28
+ -- agent as an eighth argument; the seven-argument form is retired.
29
+ --
30
+ -- Arithmetic mirrors amfs_core/evidence.py and must stay identical to
31
+ -- schema.sql, which carries the same function bodies.
32
+
33
+ ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS actions_taken JSONB NOT NULL DEFAULT '[]';
34
+ ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS entity_paths TEXT[] NOT NULL DEFAULT '{}';
35
+ ALTER TABLE amfs_outcomes ADD COLUMN IF NOT EXISTS situation TEXT;
36
+ ALTER TABLE amfs_memory_entries ADD COLUMN IF NOT EXISTS validators JSONB NOT NULL DEFAULT '[]';
37
+
38
+ -- Priors are scoped by entity overlap, then narrowed by vector similarity.
39
+ CREATE INDEX IF NOT EXISTS idx_outcomes_entity_paths
40
+ ON amfs_outcomes USING gin (entity_paths);
41
+ CREATE INDEX IF NOT EXISTS idx_outcomes_ns_committed
42
+ ON amfs_outcomes (namespace, committed_at DESC);
43
+
44
+ -- The seven-argument step predates validators; the trigger below calls the
45
+ -- eight-argument one, so the old overload is retired here.
46
+ DROP FUNCTION IF EXISTS amfs_apply_outcome_step(TEXT, UUID, TEXT[], TEXT, NUMERIC, TEXT, JSONB);
47
+
48
+ CREATE OR REPLACE FUNCTION amfs_apply_outcome_step(
49
+ p_namespace TEXT,
50
+ p_account UUID,
51
+ p_keys TEXT[],
52
+ p_outcome_type TEXT,
53
+ p_causal_confidence NUMERIC,
54
+ p_model TEXT,
55
+ p_versions JSONB,
56
+ p_agent TEXT
57
+ ) RETURNS INTEGER AS $$
58
+ DECLARE
59
+ keys TEXT[];
60
+ entry_key TEXT;
61
+ ep TEXT;
62
+ k TEXT;
63
+ cur RECORD;
64
+ read_version INTEGER;
65
+ read_value JSONB;
66
+ n_keys INTEGER;
67
+ is_ok BOOLEAN;
68
+ target NUMERIC;
69
+ w NUMERIC;
70
+ prior NUMERIC;
71
+ e_s NUMERIC;
72
+ e_f NUMERIC;
73
+ new_conf NUMERIC;
74
+ disc TIMESTAMPTZ;
75
+ surprise NUMERIC;
76
+ vals JSONB;
77
+ touched INTEGER := 0;
78
+ BEGIN
79
+ -- Distinct, well-formed specs only; a key cited twice is one citation.
80
+ SELECT COALESCE(array_agg(DISTINCT x), '{}') INTO keys
81
+ FROM unnest(COALESCE(p_keys, '{}')) AS x
82
+ WHERE position('/' in x) > 0;
83
+ n_keys := cardinality(keys);
84
+ IF n_keys = 0 THEN
85
+ RETURN 0;
86
+ END IF;
87
+
88
+ -- An outcome type neither side of the model knows is not evidence of
89
+ -- anything (the multipliers treated it as x1.0; this treats it as no step).
90
+ IF NOT amfs_outcome_is_known(p_outcome_type) THEN
91
+ RETURN 0;
92
+ END IF;
93
+ is_ok := amfs_outcome_is_success(p_outcome_type);
94
+ target := CASE WHEN is_ok THEN 1.0 ELSE 0.0 END;
95
+
96
+ FOREACH entry_key IN ARRAY keys
97
+ LOOP
98
+ -- Last-slash split, matching Python's rsplit("/", 1).
99
+ k := substring(entry_key from '([^/]+)$');
100
+ ep := left(entry_key, length(entry_key) - length(k) - 1);
101
+
102
+ SELECT * INTO cur FROM amfs_memory_entries
103
+ WHERE namespace = p_namespace
104
+ AND entity_path = ep
105
+ AND key = k
106
+ AND superseded_at IS NULL
107
+ AND account_id IS NOT DISTINCT FROM p_account
108
+ ORDER BY version DESC LIMIT 1;
109
+
110
+ IF NOT FOUND THEN
111
+ CONTINUE;
112
+ END IF;
113
+
114
+ -- Credit the claim that was read. If the key has been rewritten since
115
+ -- and now says something else, this outcome is about the old claim.
116
+ read_version := NULLIF(COALESCE(p_versions, '{}'::jsonb)->>entry_key, '')::INTEGER;
117
+ IF read_version IS NOT NULL AND read_version <> cur.version THEN
118
+ SELECT value INTO read_value FROM amfs_memory_entries
119
+ WHERE namespace = p_namespace
120
+ AND entity_path = ep
121
+ AND key = k
122
+ AND version = read_version
123
+ AND account_id IS NOT DISTINCT FROM p_account
124
+ LIMIT 1;
125
+ IF FOUND AND read_value IS DISTINCT FROM cur.value THEN
126
+ CONTINUE;
127
+ END IF;
128
+ END IF;
129
+
130
+ prior := LEAST(1.0, GREATEST(0.0, COALESCE(cur.prior_confidence, cur.confidence)));
131
+
132
+ IF p_model = 'multiplicative' THEN
133
+ new_conf := LEAST(1.0, GREATEST(0.0,
134
+ cur.confidence * amfs_outcome_multiplier(p_outcome_type) * p_causal_confidence));
135
+ e_s := COALESCE(cur.evidence_success, 0) + CASE WHEN is_ok THEN 1 ELSE 0 END;
136
+ e_f := COALESCE(cur.evidence_failure, 0) + CASE WHEN is_ok THEN 0 ELSE 1 END;
137
+ ELSE
138
+ -- First strike: the first failure of an entry with a clean record
139
+ -- of at least three successes is weighed without the surprise
140
+ -- term (amfs_core.evidence.first_strike). One slip on a validated
141
+ -- rule leaves it contested; the second still discredits it.
142
+ surprise := 1.0 + abs(target - LEAST(1.0, GREATEST(0.0, cur.confidence)));
143
+ IF NOT is_ok AND COALESCE(cur.failure_count, 0) = 0
144
+ AND COALESCE(cur.success_count, 0) >= 3 THEN
145
+ surprise := 1.0;
146
+ END IF;
147
+ w := amfs_outcome_severity(p_outcome_type)
148
+ * GREATEST(0.0, p_causal_confidence)
149
+ / n_keys
150
+ * surprise;
151
+ e_s := COALESCE(cur.evidence_success, 0) * 0.8 + CASE WHEN is_ok THEN w ELSE 0 END;
152
+ e_f := COALESCE(cur.evidence_failure, 0) * 0.8 + CASE WHEN is_ok THEN 0 ELSE w END;
153
+ new_conf := LEAST(1.0, GREATEST(0.0, (2.0 * prior + e_s) / (2.0 + e_s + e_f)));
154
+ END IF;
155
+
156
+ IF new_conf < 0.5 AND NOT is_ok THEN
157
+ disc := COALESCE(cur.discredited_at, NOW());
158
+ ELSIF new_conf >= 0.5 THEN
159
+ disc := NULL;
160
+ ELSE
161
+ disc := cur.discredited_at;
162
+ END IF;
163
+
164
+ -- Validators: distinct agents whose successes credited this claim,
165
+ -- most recent last, capped at ten (amfs_core.evidence.validators_after).
166
+ vals := COALESCE(cur.validators, '[]'::jsonb);
167
+ IF is_ok AND COALESCE(p_agent, '') <> '' THEN
168
+ vals := vals - p_agent;
169
+ vals := vals || to_jsonb(ARRAY[p_agent]);
170
+ IF jsonb_array_length(vals) > 10 THEN
171
+ SELECT COALESCE(jsonb_agg(v), '[]'::jsonb) INTO vals
172
+ FROM (
173
+ SELECT v FROM jsonb_array_elements(vals) WITH ORDINALITY AS t(v, ord)
174
+ ORDER BY ord OFFSET jsonb_array_length(vals) - 10
175
+ ) s;
176
+ END IF;
177
+ END IF;
178
+
179
+ UPDATE amfs_memory_entries
180
+ SET superseded_at = NOW()
181
+ WHERE id = cur.id;
182
+
183
+ -- Copy the row (see 007); override only what this version changes.
184
+ INSERT INTO amfs_memory_entries
185
+ SELECT * FROM jsonb_populate_record(
186
+ NULL::amfs_memory_entries,
187
+ to_jsonb(cur) || jsonb_build_object(
188
+ 'id', gen_random_uuid(),
189
+ 'version', cur.version + 1,
190
+ 'confidence', new_conf,
191
+ 'outcome_count', cur.outcome_count + 1,
192
+ 'success_count', COALESCE(cur.success_count, 0) + CASE WHEN is_ok THEN 1 ELSE 0 END,
193
+ 'failure_count', COALESCE(cur.failure_count, 0) + CASE WHEN is_ok THEN 0 ELSE 1 END,
194
+ 'evidence_success', e_s,
195
+ 'evidence_failure', e_f,
196
+ 'prior_confidence', prior,
197
+ 'last_outcome', p_outcome_type,
198
+ 'last_outcome_at', NOW(),
199
+ 'discredited_at', disc,
200
+ 'validators', vals,
201
+ 'superseded_at', NULL
202
+ )
203
+ );
204
+ touched := touched + 1;
205
+ END LOOP;
206
+ RETURN touched;
207
+ END;
208
+ $$ LANGUAGE plpgsql;
209
+
210
+ CREATE OR REPLACE FUNCTION amfs_propagate_outcome() RETURNS TRIGGER AS $$
211
+ DECLARE
212
+ model TEXT;
213
+ att JSONB;
214
+ att_keys TEXT[];
215
+ BEGIN
216
+ model := COALESCE(NULLIF(current_setting('amfs.outcome_model', true), ''), 'evidence');
217
+
218
+ -- Failed attempts first, oldest to newest: each hands its own failure to
219
+ -- the entries that attempt relied on.
220
+ FOR att IN
221
+ SELECT value FROM jsonb_array_elements(COALESCE(NEW.attempts, '[]'::jsonb))
222
+ ORDER BY COALESCE((value->>'attempt')::INTEGER, 0)
223
+ LOOP
224
+ SELECT COALESCE(array_agg(x), '{}') INTO att_keys
225
+ FROM jsonb_array_elements_text(COALESCE(att->'causal_entry_keys', '[]'::jsonb)) AS x;
226
+ PERFORM amfs_apply_outcome_step(
227
+ NEW.namespace, NEW.account_id, att_keys,
228
+ COALESCE(att->>'outcome_type', 'minor_failure'),
229
+ NEW.causal_confidence, model,
230
+ COALESCE(att->'causal_entry_versions', '{}'::jsonb),
231
+ NEW.agent_id
232
+ );
233
+ END LOOP;
234
+
235
+ -- Then the terminal outcome to the entries the resolution relied on.
236
+ PERFORM amfs_apply_outcome_step(
237
+ NEW.namespace, NEW.account_id, NEW.causal_entry_keys,
238
+ NEW.outcome_type, NEW.causal_confidence, model,
239
+ COALESCE(NEW.causal_entry_versions, '{}'::jsonb),
240
+ NEW.agent_id
241
+ );
242
+
243
+ PERFORM pg_notify('amfs_outcome', json_build_object(
244
+ 'namespace', NEW.namespace,
245
+ 'outcome_ref', NEW.outcome_ref,
246
+ 'outcome_type', NEW.outcome_type,
247
+ 'agent_id', NEW.agent_id,
248
+ 'causal_confidence', NEW.causal_confidence,
249
+ 'attempts', jsonb_array_length(COALESCE(NEW.attempts, '[]'::jsonb))
250
+ )::TEXT);
251
+
252
+ RETURN NEW;
253
+ END;
254
+ $$ LANGUAGE plpgsql;
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS amfs_memory_entries (
22
22
  last_outcome TEXT,
23
23
  last_outcome_at TIMESTAMPTZ,
24
24
  discredited_at TIMESTAMPTZ,
25
+ validators JSONB NOT NULL DEFAULT '[]',
25
26
  recall_count INTEGER DEFAULT 0,
26
27
  priority_score NUMERIC(10,6),
27
28
  tier SMALLINT DEFAULT 3,
@@ -58,7 +59,10 @@ CREATE TABLE IF NOT EXISTS amfs_outcomes (
58
59
  account_id UUID,
59
60
  attempts JSONB NOT NULL DEFAULT '[]',
60
61
  causal_entry_versions JSONB NOT NULL DEFAULT '{}',
61
- final_action_index INTEGER
62
+ final_action_index INTEGER,
63
+ actions_taken JSONB NOT NULL DEFAULT '[]',
64
+ entity_paths TEXT[] NOT NULL DEFAULT '{}',
65
+ situation TEXT
62
66
  );
63
67
 
64
68
  -- Range-partitioned by month on created_at. Traces are append-only and read by
@@ -427,6 +431,10 @@ CREATE INDEX IF NOT EXISTS idx_pr_reviews_pr
427
431
  CREATE INDEX IF NOT EXISTS idx_entries_discredited
428
432
  ON amfs_memory_entries (namespace, entity_path, discredited_at)
429
433
  WHERE discredited_at IS NOT NULL AND superseded_at IS NULL;
434
+ CREATE INDEX IF NOT EXISTS idx_outcomes_entity_paths
435
+ ON amfs_outcomes USING gin (entity_paths);
436
+ CREATE INDEX IF NOT EXISTS idx_outcomes_ns_committed
437
+ ON amfs_outcomes (namespace, committed_at DESC);
430
438
 
431
439
  CREATE OR REPLACE FUNCTION amfs_outcome_is_success(t TEXT) RETURNS BOOLEAN AS $$
432
440
  SELECT t IN ('success', 'clean_deploy');
@@ -470,6 +478,7 @@ $$ LANGUAGE sql IMMUTABLE;
470
478
  -- credit-split over those keys. The trigger calls this once per failed
471
479
  -- attempt and once for the terminal outcome.
472
480
  DROP FUNCTION IF EXISTS amfs_apply_outcome_step(TEXT, UUID, TEXT[], TEXT, NUMERIC, TEXT);
481
+ DROP FUNCTION IF EXISTS amfs_apply_outcome_step(TEXT, UUID, TEXT[], TEXT, NUMERIC, TEXT, JSONB);
473
482
 
474
483
  CREATE OR REPLACE FUNCTION amfs_apply_outcome_step(
475
484
  p_namespace TEXT,
@@ -478,7 +487,8 @@ CREATE OR REPLACE FUNCTION amfs_apply_outcome_step(
478
487
  p_outcome_type TEXT,
479
488
  p_causal_confidence NUMERIC,
480
489
  p_model TEXT,
481
- p_versions JSONB
490
+ p_versions JSONB,
491
+ p_agent TEXT
482
492
  ) RETURNS INTEGER AS $$
483
493
  DECLARE
484
494
  keys TEXT[];
@@ -497,6 +507,8 @@ DECLARE
497
507
  e_f NUMERIC;
498
508
  new_conf NUMERIC;
499
509
  disc TIMESTAMPTZ;
510
+ surprise NUMERIC;
511
+ vals JSONB;
500
512
  touched INTEGER := 0;
501
513
  BEGIN
502
514
  -- Distinct, well-formed specs only; a key cited twice is one citation.
@@ -558,10 +570,19 @@ BEGIN
558
570
  e_s := COALESCE(cur.evidence_success, 0) + CASE WHEN is_ok THEN 1 ELSE 0 END;
559
571
  e_f := COALESCE(cur.evidence_failure, 0) + CASE WHEN is_ok THEN 0 ELSE 1 END;
560
572
  ELSE
573
+ -- First strike: the first failure of an entry with a clean record
574
+ -- of at least three successes is weighed without the surprise
575
+ -- term (amfs_core.evidence.first_strike). One slip on a validated
576
+ -- rule leaves it contested; the second still discredits it.
577
+ surprise := 1.0 + abs(target - LEAST(1.0, GREATEST(0.0, cur.confidence)));
578
+ IF NOT is_ok AND COALESCE(cur.failure_count, 0) = 0
579
+ AND COALESCE(cur.success_count, 0) >= 3 THEN
580
+ surprise := 1.0;
581
+ END IF;
561
582
  w := amfs_outcome_severity(p_outcome_type)
562
583
  * GREATEST(0.0, p_causal_confidence)
563
584
  / n_keys
564
- * (1.0 + abs(target - LEAST(1.0, GREATEST(0.0, cur.confidence))));
585
+ * surprise;
565
586
  e_s := COALESCE(cur.evidence_success, 0) * 0.8 + CASE WHEN is_ok THEN w ELSE 0 END;
566
587
  e_f := COALESCE(cur.evidence_failure, 0) * 0.8 + CASE WHEN is_ok THEN 0 ELSE w END;
567
588
  new_conf := LEAST(1.0, GREATEST(0.0, (2.0 * prior + e_s) / (2.0 + e_s + e_f)));
@@ -575,6 +596,21 @@ BEGIN
575
596
  disc := cur.discredited_at;
576
597
  END IF;
577
598
 
599
+ -- Validators: distinct agents whose successes credited this claim,
600
+ -- most recent last, capped at ten (amfs_core.evidence.validators_after).
601
+ vals := COALESCE(cur.validators, '[]'::jsonb);
602
+ IF is_ok AND COALESCE(p_agent, '') <> '' THEN
603
+ vals := vals - p_agent;
604
+ vals := vals || to_jsonb(ARRAY[p_agent]);
605
+ IF jsonb_array_length(vals) > 10 THEN
606
+ SELECT COALESCE(jsonb_agg(v), '[]'::jsonb) INTO vals
607
+ FROM (
608
+ SELECT v FROM jsonb_array_elements(vals) WITH ORDINALITY AS t(v, ord)
609
+ ORDER BY ord OFFSET jsonb_array_length(vals) - 10
610
+ ) s;
611
+ END IF;
612
+ END IF;
613
+
578
614
  UPDATE amfs_memory_entries
579
615
  SET superseded_at = NOW()
580
616
  WHERE id = cur.id;
@@ -596,6 +632,7 @@ BEGIN
596
632
  'last_outcome', p_outcome_type,
597
633
  'last_outcome_at', NOW(),
598
634
  'discredited_at', disc,
635
+ 'validators', vals,
599
636
  'superseded_at', NULL
600
637
  )
601
638
  );
@@ -625,7 +662,8 @@ BEGIN
625
662
  NEW.namespace, NEW.account_id, att_keys,
626
663
  COALESCE(att->>'outcome_type', 'minor_failure'),
627
664
  NEW.causal_confidence, model,
628
- COALESCE(att->'causal_entry_versions', '{}'::jsonb)
665
+ COALESCE(att->'causal_entry_versions', '{}'::jsonb),
666
+ NEW.agent_id
629
667
  );
630
668
  END LOOP;
631
669
 
@@ -633,7 +671,8 @@ BEGIN
633
671
  PERFORM amfs_apply_outcome_step(
634
672
  NEW.namespace, NEW.account_id, NEW.causal_entry_keys,
635
673
  NEW.outcome_type, NEW.causal_confidence, model,
636
- COALESCE(NEW.causal_entry_versions, '{}'::jsonb)
674
+ COALESCE(NEW.causal_entry_versions, '{}'::jsonb),
675
+ NEW.agent_id
637
676
  );
638
677
 
639
678
  PERFORM pg_notify('amfs_outcome', json_build_object(