@pentatonic-ai/ai-agent-sdk 0.10.19 → 0.10.21

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.
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """Prompt-version-drift re-distillation — RFC-decay-and-fusion Part B′.
3
+
4
+ The third triage verb of the Fusion Drive: regenerate high-value extractions
5
+ that were produced by a *superseded* teacher/prompt by re-running their source
6
+ event through the *current clean* teacher. Fusion converges horizontally, decay
7
+ ages vertically; this refreshes in depth.
8
+
9
+ TRIGGER (not raw age): an event is stale when `event_distillations` shows it was
10
+ distilled only under a dirty `system_prompt_hash` (pre-clean: bbdaba/f1e0ff/
11
+ ef0647…) and never under the clean one (`6ccfe70f…`, SDK 0.10.19 = #126 + #129).
12
+
13
+ MECHANISM: re-insert the event into `distillation_queue` (status=pending). The
14
+ existing extractor-async worker + autoscaler do the rest, writing a fresh clean
15
+ extraction AND a clean trace (which `build_retrain_corpus.py` then harvests — one
16
+ loop repairs the graph and grows the retrain corpus).
17
+
18
+ SUPERSEDENCE (the load-bearing requirement — Part B′4): the store is
19
+ pure-accretion, so a naive re-enqueue makes the clean extraction land *beside*
20
+ the dirty one and WORSENS fragmentation. The durable fix routes supersede
21
+ through Fusion's tombstone machinery (RFC A2/A3), which is not yet live. This
22
+ tool ships the **interim event-scoped supersede** (Part B′4 Open Q): with
23
+ --supersede-facts it deletes only facts whose provenance set is exactly
24
+ {this event} under the old hash — the single-provenance majority, where no
25
+ corroboration is lost — after dumping a rollback payload. Multi-provenance facts
26
+ and ALL entities are left untouched (entity-level fragmentation is fusion/decay's
27
+ job; deleting an entity cascades to relationships and NULLs other facts' FKs).
28
+
29
+ SAFETY: dry-run by default; --apply gated; --arena REQUIRED (no global runs);
30
+ --limit caps the batch; every destructive op is dumped to a rollback JSONL first;
31
+ a run ledger is written per invocation. Idempotent: skips events that already
32
+ have a pending/claimed queue row or an existing clean-hash distillation.
33
+
34
+ Usage:
35
+ # size the work (no writes):
36
+ python redistill.py --arena 'pentatonic-team%' --dry-run
37
+ # re-enqueue a bounded slice (non-destructive — accretes until fusion runs):
38
+ python redistill.py --arena 'pentatonic-team%' --limit 25 --apply
39
+ # re-enqueue AND interim-supersede single-provenance stale facts:
40
+ python redistill.py --arena 'pentatonic-team%' --limit 25 --apply --supersede-facts
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import json
46
+ import os
47
+ import sys
48
+ from datetime import datetime, timezone
49
+
50
+ CLEAN_PROMPT_HASH = "6ccfe70f1286a131" # SDK 0.10.19 (#126 + #129); verify vs worker.SYSTEM_PROMPT_HASH
51
+
52
+
53
+ def _connect(dsn: str):
54
+ import psycopg
55
+ import psycopg.rows
56
+ return psycopg.connect(dsn, row_factory=psycopg.rows.dict_row)
57
+
58
+
59
+ def triage(cur, arena: str, clean_hash: str, dirty_hashes: list[str] | None, limit: int,
60
+ order_by_recency: bool = False):
61
+ """Stale events: distilled under a dirty prompt, not yet re-distilled clean.
62
+
63
+ IDEMPOTENCY SIGNAL = `distillation_traces.system_prompt_hash`, NOT
64
+ `event_distillations`. The teacher ALWAYS writes a trace (worker.py
65
+ `_insert_trace`, producer=='teacher'), but `event_distillations` is written
66
+ ONLY when CASCADE_ENABLED (worker.py line ~2325). In teacher-only mode a
67
+ re-distilled event gets a clean *trace* but its event_distillations row stays
68
+ stamped at the old student hash — so keying the clean-check off the ledger
69
+ would re-select the same events forever. Traces are the reliable per-event
70
+ prompt-version record here.
71
+
72
+ Dirty evidence = a dirty-hash trace OR a dirty-hash event_distillations row
73
+ (covers student-distilled events, which have a ledger row but no trace)."""
74
+ params: list = [arena, clean_hash] # arena, clean (no-clean-trace check)
75
+ dirty_trace = "AND t2.system_prompt_hash <> %s"
76
+ params_tail_dirty: list = [clean_hash]
77
+ if dirty_hashes:
78
+ dirty_trace = "AND t2.system_prompt_hash = ANY(%s)"
79
+ params_tail_dirty = [dirty_hashes]
80
+ # Build param order to match placeholders below.
81
+ params = [arena, clean_hash] + params_tail_dirty + [clean_hash, limit]
82
+ # ORDER BY received_at forces a full scan+sort of the whole arena (~270k rows
83
+ # for pentatonic-team) every call — pathological for bulk/loop work. Default
84
+ # OFF so the planner early-terminates once LIMIT stale rows are found (most
85
+ # arena rows ARE stale, so it scans ~LIMIT rows). Recency is only a salience
86
+ # proxy anyway, moot until the salience column lands (RFC Part D). Opt in with
87
+ # --order-by-recency for a one-off "freshest first" pass.
88
+ order_clause = "ORDER BY e.received_at DESC" if order_by_recency else ""
89
+ cur.execute(
90
+ f"""
91
+ WITH stale AS (
92
+ SELECT e.id AS event_id, e.received_at AS recency
93
+ FROM events e
94
+ WHERE e.arena LIKE %s
95
+ AND NOT EXISTS ( -- not yet re-distilled clean
96
+ SELECT 1 FROM distillation_traces t
97
+ WHERE t.event_id = e.id AND t.system_prompt_hash = %s)
98
+ AND NOT EXISTS ( -- not already queued (so loops/batches advance)
99
+ SELECT 1 FROM distillation_queue q
100
+ WHERE q.event_id = e.id AND q.status IN ('pending','claimed'))
101
+ AND (
102
+ EXISTS (SELECT 1 FROM distillation_traces t2 -- dirty teacher trace
103
+ WHERE t2.event_id = e.id {dirty_trace})
104
+ OR EXISTS (SELECT 1 FROM event_distillations d -- or dirty student/teacher ledger
105
+ WHERE d.event_id = e.id
106
+ AND d.system_prompt_hash IS NOT NULL
107
+ AND d.system_prompt_hash <> %s)
108
+ )
109
+ {order_clause}
110
+ LIMIT %s
111
+ )
112
+ SELECT s.event_id,
113
+ -- `@> ARRAY[id]` (containment) uses idx_facts_provenance (GIN);
114
+ -- `id = ANY(col)` does NOT and seq-scans facts per row (2 min+ at
115
+ -- limit 1000). Must stay @> for the bulk/loop path to be viable.
116
+ (SELECT count(*) FROM facts f
117
+ WHERE f.provenance_event_ids @> ARRAY[s.event_id]) AS fact_n,
118
+ (SELECT count(*) FROM facts f
119
+ WHERE f.provenance_event_ids @> ARRAY[s.event_id]
120
+ AND cardinality(f.provenance_event_ids) = 1) AS solo_fact_n,
121
+ EXISTS (SELECT 1 FROM distillation_queue q
122
+ WHERE q.event_id = s.event_id
123
+ AND q.status IN ('pending','claimed')) AS in_flight
124
+ FROM stale s
125
+ ORDER BY fact_n DESC
126
+ """,
127
+ params,
128
+ )
129
+ return cur.fetchall()
130
+
131
+
132
+ def dump_and_delete_solo_facts(cur, event_id: str, rollback_fh) -> int:
133
+ """Delete facts whose provenance is exactly {event_id}; dump them first."""
134
+ solo = "provenance_event_ids @> ARRAY[%s] AND cardinality(provenance_event_ids) = 1"
135
+ cur.execute(f"SELECT * FROM facts WHERE {solo}", (event_id,))
136
+ rows = cur.fetchall()
137
+ for r in rows:
138
+ rollback_fh.write(json.dumps({"op": "delete_fact", "event_id": event_id,
139
+ "row": _jsonable(r)}, ensure_ascii=False) + "\n")
140
+ if rows:
141
+ cur.execute(f"DELETE FROM facts WHERE {solo}", (event_id,))
142
+ return len(rows)
143
+
144
+
145
+ def _jsonable(row: dict) -> dict:
146
+ out = {}
147
+ for k, v in row.items():
148
+ out[k] = v.isoformat() if isinstance(v, datetime) else v
149
+ return out
150
+
151
+
152
+ def main() -> int:
153
+ ap = argparse.ArgumentParser(description=__doc__,
154
+ formatter_class=argparse.RawDescriptionHelpFormatter)
155
+ ap.add_argument("--arena", required=True, help="arena LIKE filter (REQUIRED — scope safety)")
156
+ ap.add_argument("--clean-hash", default=CLEAN_PROMPT_HASH)
157
+ ap.add_argument("--dirty-hashes", help="comma-separated hashes to target (default: any != clean)")
158
+ ap.add_argument("--limit", type=int, default=50, help="max events to re-enqueue (safety cap)")
159
+ ap.add_argument("--apply", action="store_true", help="actually write (default: dry-run)")
160
+ ap.add_argument("--supersede-facts", action="store_true",
161
+ help="also delete single-provenance stale facts (interim supersede, Part B′4)")
162
+ ap.add_argument("--order-by-recency", action="store_true",
163
+ help="freshest stale events first (forces a full arena scan+sort — slow; off by default)")
164
+ ap.add_argument("--rollback-dir", default=".", help="dir for rollback + ledger JSONL")
165
+ ap.add_argument("--pg-dsn", default=os.environ.get("PG_DSN", ""), help="Postgres DSN")
166
+ args = ap.parse_args()
167
+
168
+ if not args.pg_dsn:
169
+ print("FATAL: no --pg-dsn / PG_DSN", file=sys.stderr)
170
+ return 2
171
+ dirty = [h.strip() for h in args.dirty_hashes.split(",")] if args.dirty_hashes else None
172
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
173
+
174
+ with _connect(args.pg_dsn) as conn:
175
+ with conn.cursor() as cur:
176
+ # The org-model postgres container runs with the Docker default 64MB
177
+ # /dev/shm; a parallel-gather over the facts GIN exhausts the dynamic
178
+ # shared-memory segment ("DiskFull ... shared memory"). Disable
179
+ # parallel workers for this session — temp spills go to the (ample)
180
+ # data disk instead of shm.
181
+ cur.execute("SET max_parallel_workers_per_gather = 0")
182
+ cands = triage(cur, args.arena, args.clean_hash, dirty, args.limit,
183
+ order_by_recency=args.order_by_recency)
184
+
185
+ total = len(cands)
186
+ in_flight = sum(1 for c in cands if c["in_flight"])
187
+ actionable = [c for c in cands if not c["in_flight"]]
188
+ solo = sum(c["solo_fact_n"] for c in actionable)
189
+ multi = sum(c["fact_n"] - c["solo_fact_n"] for c in actionable)
190
+ print(json.dumps({
191
+ "mode": "apply" if args.apply else "dry-run",
192
+ "arena": args.arena, "clean_hash": args.clean_hash,
193
+ "candidates": total, "in_flight_skipped": in_flight,
194
+ "actionable": len(actionable),
195
+ "stale_facts_solo_provenance": solo,
196
+ "stale_facts_multi_provenance_LEFT_ALONE": multi,
197
+ "supersede_facts": args.supersede_facts,
198
+ }, indent=2))
199
+
200
+ if not args.apply:
201
+ print("\n[dry-run] no writes. Re-run with --apply to re-enqueue"
202
+ + (" + supersede solo facts." if args.supersede_facts else "."))
203
+ return 0
204
+
205
+ rb_path = os.path.join(args.rollback_dir, f"redistill_rollback_{stamp}.jsonl")
206
+ ledger_path = os.path.join(args.rollback_dir, f"redistill_runs_{stamp}.jsonl")
207
+ enq = deleted = 0
208
+ with open(rb_path, "w", encoding="utf-8") as rb, \
209
+ open(ledger_path, "w", encoding="utf-8") as led:
210
+ for c in actionable:
211
+ eid = c["event_id"]
212
+ if args.supersede_facts:
213
+ deleted += dump_and_delete_solo_facts(cur, eid, rb)
214
+ cur.execute(
215
+ "INSERT INTO distillation_queue (event_id, status, attempts) "
216
+ "VALUES (%s, 'pending', 0)", (eid,))
217
+ enq += 1
218
+ led.write(json.dumps({
219
+ "event_id": eid, "arena": args.arena,
220
+ "old_hash": "dirty", "target_hash": args.clean_hash,
221
+ "solo_facts_superseded": c["solo_fact_n"] if args.supersede_facts else 0,
222
+ "enqueued_at": stamp,
223
+ }, ensure_ascii=False) + "\n")
224
+ conn.commit()
225
+
226
+ print(json.dumps({
227
+ "applied": True, "re_enqueued": enq, "facts_superseded": deleted,
228
+ "rollback": rb_path, "ledger": ledger_path,
229
+ }, indent=2))
230
+ if deleted:
231
+ print(f"\nROLLBACK: facts are in {rb_path} — re-INSERT them to undo.")
232
+ return 0
233
+
234
+
235
+ if __name__ == "__main__":
236
+ raise SystemExit(main())