etchmem 0.1.0__py3-none-any.whl

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,559 @@
1
+ """
2
+ Consolidation Worker — the engine of etchmem.
3
+
4
+ Called by Engine.consolidate(). Runs in-process, single-pass, crash-safe.
5
+
6
+ Pipeline:
7
+ Stage A — Intake: pull relational deposits into buffer (hash-dedup).
8
+ Stage B — Cluster: group buffer docs (deposits + recall-events) by topic.
9
+ Stage C — Evaluate: route each cluster: drop | keep | form | reconsolidate.
10
+ Reconciliation pass: over all candidate new articles + their injected
11
+ neighbours, decide the final set, write, confirm,
12
+ hard-delete superseded articles.
13
+ Flush: remove promoted/dropped/recall-event buffer docs.
14
+ Mid-band deposits (kept) stay.
15
+
16
+ Crash-safety anchor: flush is the LAST operation. A crash before flush
17
+ means the next run sees the full buffer and re-runs cleanly. Source-set
18
+ hashing prevents double-writes; deleting already-deleted ids is a no-op.
19
+
20
+ All LLM inference happens only at formation-promotion and
21
+ reconsolidation-with-new-signal. Everything else is free arithmetic.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from typing import Any
29
+
30
+ from etchmem.config import Config
31
+ from etchmem.connector.base import LLMConnector
32
+ from etchmem.models import (
33
+ Cluster,
34
+ Deposit,
35
+ InjectedArticle,
36
+ RecallEvent,
37
+ SearchResult,
38
+ )
39
+ from etchmem.stores.chroma_store import ChromaStore
40
+ from etchmem.util import affectability as aff_util
41
+ from etchmem.util.hashing import content_hash, source_set_hash
42
+
43
+
44
+ # ── Synthesis prompts ──────────────────────────────────────────────────────────
45
+
46
+ _FORMATION_PROMPT = """\
47
+ You are a knowledge distillation engine. Your task is to synthesize a coherent,
48
+ accurate knowledge article from a set of raw signal fragments.
49
+
50
+ ## Raw signal fragments
51
+ {fragments}
52
+
53
+ ## Instructions
54
+ - Write a single, cohesive knowledge article that captures all key facts.
55
+ - Do NOT invent facts not present in the fragments.
56
+ - At the end of the article, output a JSON block on its own line:
57
+ {{"tags": {{"access_level": "...", "topic": "...", "product": "..."}}}}
58
+
59
+ Output the article first, then the JSON tags block.
60
+ """
61
+
62
+ _RECONSOLIDATION_PROMPT = """\
63
+ You are a knowledge reconsolidation engine. An existing knowledge article
64
+ is being updated with fresh context. Rewrite the article so it remains
65
+ accurate and incorporates the new information.
66
+
67
+ ## Existing article
68
+ {existing}
69
+
70
+ ## Fresh context (new deposits and recall context)
71
+ {fresh}
72
+
73
+ ## Instructions
74
+ - Preserve ALL load-bearing facts from the existing article.
75
+ - Incorporate relevant new information from the fresh context.
76
+ - Remove outdated claims only when the fresh context explicitly supersedes them.
77
+ - At the end, output a JSON block on its own line:
78
+ {{"tags": {{"access_level": "...", "topic": "...", "product": "..."}}}}
79
+
80
+ Output the rewritten article first, then the JSON tags block.
81
+ """
82
+
83
+ _FACT_RETENTION_CHECK_PROMPT = """\
84
+ Does the NEW article preserve the key facts of the OLD article?
85
+
86
+ ## OLD article
87
+ {old}
88
+
89
+ ## NEW article
90
+ {new}
91
+
92
+ Reply with exactly one word: YES or NO.
93
+ """
94
+
95
+
96
+ def _parse_synthesis_output(raw: str) -> tuple[str, dict]:
97
+ """Split LLM output into (article_text, tags_dict)."""
98
+ lines = raw.strip().split("\n")
99
+ tags: dict = {}
100
+ article_lines = []
101
+ for line in lines:
102
+ stripped = line.strip()
103
+ if stripped.startswith("{") and "tags" in stripped:
104
+ try:
105
+ parsed = json.loads(stripped)
106
+ tags = parsed.get("tags", {})
107
+ continue
108
+ except json.JSONDecodeError:
109
+ pass
110
+ article_lines.append(line)
111
+ article = "\n".join(article_lines).strip()
112
+ return article, tags
113
+
114
+
115
+ # ── Run summary ───────────────────────────────────────────────────────────────
116
+
117
+ @dataclass
118
+ class RunSummary:
119
+ intake_new: int = 0 # relational records newly copied to buffer
120
+ intake_skipped: int = 0 # relational records skipped (already in buffer)
121
+ clusters_total: int = 0
122
+ formed: int = 0 # new injected articles created
123
+ reconsolidated: int = 0 # existing articles rewritten
124
+ dropped: int = 0 # buffer records dropped (noise)
125
+ kept: int = 0 # buffer records kept for next run
126
+ superseded: int = 0 # old injected articles hard-deleted
127
+ flushed: int = 0 # buffer records flushed at end
128
+
129
+ def to_dict(self) -> dict[str, int]:
130
+ return {
131
+ "intake_new": self.intake_new,
132
+ "intake_skipped": self.intake_skipped,
133
+ "clusters_total": self.clusters_total,
134
+ "formed": self.formed,
135
+ "reconsolidated": self.reconsolidated,
136
+ "dropped": self.dropped,
137
+ "kept": self.kept,
138
+ "superseded": self.superseded,
139
+ "flushed": self.flushed,
140
+ }
141
+
142
+
143
+ # ── Worker ────────────────────────────────────────────────────────────────────
144
+
145
+ class Worker:
146
+ """
147
+ Consolidation worker. Instantiated fresh per Engine.consolidate() call.
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ relational_store: ChromaStore,
153
+ buffer_store: ChromaStore,
154
+ injected_store: ChromaStore,
155
+ connector: LLMConnector,
156
+ config: Config,
157
+ ) -> None:
158
+ self._relational = relational_store
159
+ self._buffer = buffer_store
160
+ self._injected = injected_store
161
+ self._connector = connector
162
+ self._cfg = config
163
+
164
+ def run(self, num_records: int | str, method: str) -> dict[str, Any]:
165
+ summary = RunSummary()
166
+
167
+ # ── Stage A: Intake ───────────────────────────────────────────────
168
+ self._stage_a(num_records, method, summary)
169
+
170
+ # ── Stage B: Cluster ──────────────────────────────────────────────
171
+ all_buffer_docs = self._get_all_buffer_docs()
172
+ if not all_buffer_docs:
173
+ return summary.to_dict()
174
+
175
+ clusters = self._stage_b(all_buffer_docs)
176
+ summary.clusters_total = len(clusters)
177
+
178
+ # ── Stage C: Evaluate + route ─────────────────────────────────────
179
+ # Collect candidate new articles keyed by article_id → InjectedArticle
180
+ # and which old article ids they supersede.
181
+ candidate_articles: dict[str, InjectedArticle] = {}
182
+ # same-run siblings: article_ids that were deliberately branched
183
+ # from the same cluster (excluded from mutual dedup-deletion)
184
+ same_run_sibling_sets: list[frozenset[str]] = []
185
+
186
+ # ids to flush after the reconciliation pass
187
+ to_flush_ids: list[str] = [] # promoted + dropped + recall-events
188
+ to_keep_ids: list[str] = [] # mid-band deposits — NOT flushed
189
+
190
+ for cluster in clusters:
191
+ action, result = self._stage_c(cluster)
192
+
193
+ doc_ids = [d.id for d in cluster.documents]
194
+
195
+ if action == "drop":
196
+ summary.dropped += len(cluster.documents)
197
+ to_flush_ids.extend(doc_ids)
198
+
199
+ elif action == "keep":
200
+ summary.kept += len(cluster.deposits)
201
+ # Recall-events are always flushed even from kept clusters
202
+ to_flush_ids.extend([d.id for d in cluster.recall_events])
203
+ to_keep_ids.extend([d.id for d in cluster.deposits])
204
+
205
+ elif action == "form":
206
+ articles: list[InjectedArticle] = result # type: ignore[assignment]
207
+ if len(articles) > 1:
208
+ same_run_sibling_sets.append(frozenset(a.id for a in articles))
209
+ for article in articles:
210
+ candidate_articles[article.id] = article
211
+ summary.formed += len(articles)
212
+ to_flush_ids.extend(doc_ids)
213
+
214
+ elif action == "reconsolidate":
215
+ articles = result # type: ignore[assignment]
216
+ if len(articles) > 1:
217
+ same_run_sibling_sets.append(frozenset(a.id for a in articles))
218
+ for article in articles:
219
+ candidate_articles[article.id] = article
220
+ summary.reconsolidated += len(articles)
221
+ to_flush_ids.extend(doc_ids)
222
+
223
+ # ── Reconciliation pass ───────────────────────────────────────────
224
+ superseded_ids = self._reconcile(
225
+ candidate_articles=candidate_articles,
226
+ same_run_sibling_sets=same_run_sibling_sets,
227
+ )
228
+ summary.superseded = len(superseded_ids)
229
+
230
+ # Write new articles first, then delete superseded
231
+ articles_to_write = [
232
+ a for aid, a in candidate_articles.items()
233
+ if not self._injected.exists(aid)
234
+ ]
235
+ if articles_to_write:
236
+ self._injected.upsert(articles_to_write)
237
+
238
+ if superseded_ids:
239
+ self._injected.delete(superseded_ids)
240
+
241
+ # ── Flush ─────────────────────────────────────────────────────────
242
+ # Flush only after all writes and deletes are confirmed.
243
+ # Mid-band kept deposits are NOT flushed.
244
+ flush_ids = [fid for fid in to_flush_ids if fid not in to_keep_ids]
245
+ if flush_ids:
246
+ self._buffer.delete(flush_ids)
247
+ summary.flushed = len(flush_ids)
248
+
249
+ # Sweep expired relational records
250
+ self._relational.expire()
251
+
252
+ return summary.to_dict()
253
+
254
+ # ── Stage A — Intake ──────────────────────────────────────────────────
255
+
256
+ def _stage_a(
257
+ self,
258
+ num_records: int | str,
259
+ method: str,
260
+ summary: RunSummary,
261
+ ) -> None:
262
+ """
263
+ Pull relational deposits into buffer.
264
+ Hash-dedup: skip if the content hash already exists in the buffer.
265
+ Relational originals are NOT deleted; they expire via TTL.
266
+ """
267
+ for record in self._relational.iter_for_consolidation(num_records, method):
268
+ if self._buffer.exists(record.id):
269
+ summary.intake_skipped += 1
270
+ continue
271
+
272
+ deposit = Deposit(
273
+ id=record.id,
274
+ content=record.content,
275
+ skill=record.skill,
276
+ hint=record.hint,
277
+ metadata=record.metadata,
278
+ created_at=record.created_at,
279
+ )
280
+ self._buffer.upsert([deposit])
281
+ summary.intake_new += 1
282
+
283
+ # ── Stage B — Cluster ─────────────────────────────────────────────────
284
+
285
+ def _stage_b(
286
+ self,
287
+ documents: list[Deposit | RecallEvent],
288
+ ) -> list[Cluster]:
289
+ """Cluster all buffer documents together."""
290
+ return self._buffer.cluster(
291
+ documents=documents,
292
+ branch_threshold=self._cfg.branch_threshold,
293
+ )
294
+
295
+ # ── Stage C — Evaluate + route ────────────────────────────────────────
296
+
297
+ def _stage_c(
298
+ self,
299
+ cluster: Cluster,
300
+ ) -> tuple[str, list[InjectedArticle]]:
301
+ """
302
+ Evaluate a cluster and return (action, articles).
303
+ action: "drop" | "keep" | "form" | "reconsolidate"
304
+ articles: the synthesized InjectedArticle objects (may be empty for drop/keep)
305
+ """
306
+ deposits = cluster.deposits
307
+ recall_events = cluster.recall_events
308
+
309
+ # Determine effective affectability score
310
+ # Non-singleton clusters (>1 deposit) bypass the gate → score = 1.0
311
+ if len(deposits) >= self._cfg.min_cluster_size:
312
+ effective_score = 1.0
313
+ else:
314
+ # Singleton or recall-event-only cluster — compute from hints
315
+ affs = [aff_util.seed(d.id, d.hint) for d in cluster.documents]
316
+ effective_score = aff_util.cluster_rise(affs)
317
+
318
+ action = aff_util.route(
319
+ score=effective_score,
320
+ has_recall_events=cluster.has_recall_events,
321
+ drop_below=self._cfg.affectability_drop_below,
322
+ keep_below=self._cfg.affectability_keep_below,
323
+ )
324
+
325
+ if action in ("drop", "keep"):
326
+ return action, []
327
+
328
+ if action == "form":
329
+ articles = self._form(cluster)
330
+ return "form", articles
331
+
332
+ # action == "reconsolidate"
333
+ articles = self._reconsolidate(cluster)
334
+ return "reconsolidate", articles
335
+
336
+ # ── Formation ─────────────────────────────────────────────────────────
337
+
338
+ def _form(self, cluster: Cluster) -> list[InjectedArticle]:
339
+ """
340
+ Synthesize one (or more, if branching) injected articles from a cluster.
341
+ """
342
+ # Check idempotency: has this source-set already been synthesized?
343
+ source_hashes = frozenset(d.id for d in cluster.deposits)
344
+ article_id = source_set_hash(source_hashes)
345
+
346
+ if self._injected.exists(article_id):
347
+ # Already formed in a previous run — skip synthesis, return as-is
348
+ results = self._injected.get_all(filters={"doc_type": "injected"})
349
+ for item in results:
350
+ if isinstance(item, InjectedArticle) and item.id == article_id:
351
+ return [item]
352
+ return []
353
+
354
+ # Synthesize
355
+ fragments = "\n\n---\n\n".join(d.content for d in cluster.deposits)
356
+ prompt = _FORMATION_PROMPT.format(fragments=fragments)
357
+ raw = self._connector.complete(prompt)
358
+ article_text, tags = _parse_synthesis_output(raw)
359
+
360
+ article = InjectedArticle(
361
+ id=article_id,
362
+ content=article_text,
363
+ source_hashes=source_hashes,
364
+ skill=cluster.skill,
365
+ tags=tags,
366
+ created_at=time.time(),
367
+ )
368
+ return [article]
369
+
370
+ # ── Reconsolidation ───────────────────────────────────────────────────
371
+
372
+ def _reconsolidate(self, cluster: Cluster) -> list[InjectedArticle]:
373
+ """
374
+ Rewrite existing injected knowledge blended with fresh context.
375
+
376
+ Steps:
377
+ 1. Re-query injected with each recall-event's query → current articles.
378
+ 2. Cheap gate (no LLM): does the cluster add anything new?
379
+ 3. If yes, synthesize. Fact-retention check before deleting old.
380
+ 4. Branch if distinct experiences warrant multiple articles.
381
+ """
382
+ # Re-query injected with the recall queries (pointer-based, always current)
383
+ current_articles: dict[str, InjectedArticle] = {}
384
+ for query in cluster.queries:
385
+ results = self._injected.query(
386
+ query_text=query,
387
+ top_k=self._cfg.recall_top_k_injected,
388
+ filters={"doc_type": "injected"},
389
+ source="injected",
390
+ )
391
+ for r in results:
392
+ if r.id not in current_articles:
393
+ items = self._injected.get_all(filters={"doc_type": "injected"})
394
+ for item in items:
395
+ if isinstance(item, InjectedArticle) and item.id == r.id:
396
+ current_articles[r.id] = item
397
+ break
398
+
399
+ # Fresh deposit content for the cluster
400
+ fresh_texts = [d.content for d in cluster.deposits]
401
+ snapshots = [re.relational_snapshot for re in cluster.recall_events]
402
+ all_fresh = fresh_texts + snapshots
403
+ fresh_combined = "\n\n---\n\n".join(t for t in all_fresh if t.strip())
404
+
405
+ new_articles: list[InjectedArticle] = []
406
+
407
+ if not current_articles:
408
+ # No existing injected knowledge — treat as formation
409
+ return self._form(cluster)
410
+
411
+ for old_article in current_articles.values():
412
+ # ── Cheap gate (no LLM) ───────────────────────────────────────
413
+ # Does the cluster add anything the current article doesn't cover?
414
+ # Heuristic: check if any fresh fragment token is not in old article.
415
+ if not self._has_new_signal(old_article.content, fresh_combined):
416
+ # Nothing new — leave injected untouched; recall-events will be flushed
417
+ continue
418
+
419
+ # ── Source-set identity ───────────────────────────────────────
420
+ source_hashes = old_article.source_hashes | frozenset(
421
+ d.id for d in cluster.deposits
422
+ )
423
+ article_id = source_set_hash(source_hashes)
424
+
425
+ if self._injected.exists(article_id):
426
+ # Already synthesized this exact combination — skip
427
+ continue
428
+
429
+ # ── Synthesize ────────────────────────────────────────────────
430
+ prompt = _RECONSOLIDATION_PROMPT.format(
431
+ existing=old_article.content,
432
+ fresh=fresh_combined,
433
+ )
434
+ raw = self._connector.complete(prompt)
435
+ article_text, tags = _parse_synthesis_output(raw)
436
+
437
+ # ── Fact-retention check ──────────────────────────────────────
438
+ if not self._fact_retention_ok(old_article.content, article_text):
439
+ # New article doesn't preserve load-bearing facts — fallback
440
+ # to a safe merge (prepend old, append new)
441
+ article_text = (
442
+ old_article.content + "\n\n[Updated context:]\n" + article_text
443
+ )
444
+
445
+ new_article = InjectedArticle(
446
+ id=article_id,
447
+ content=article_text,
448
+ source_hashes=source_hashes,
449
+ skill=cluster.skill or old_article.skill,
450
+ tags=tags or old_article.tags,
451
+ created_at=time.time(),
452
+ metadata={"supersedes": old_article.id},
453
+ )
454
+ new_articles.append(new_article)
455
+
456
+ return new_articles
457
+
458
+ # ── Reconciliation pass ────────────────────────────────────────────────
459
+
460
+ def _reconcile(
461
+ self,
462
+ candidate_articles: dict[str, InjectedArticle],
463
+ same_run_sibling_sets: list[frozenset[str]],
464
+ ) -> list[str]:
465
+ """
466
+ Over (new articles ∪ their current injected neighbours), decide the
467
+ final set using dedup_delete_threshold.
468
+
469
+ Returns list of old injected article ids to hard-delete.
470
+
471
+ Crash-safety: write new → confirm → then delete old.
472
+ Same-run siblings are excluded from mutual dedup-deletion.
473
+ """
474
+ superseded_ids: list[str] = []
475
+ seen_superseded: set[str] = set()
476
+
477
+ # Build sibling lookup for fast exclusion
478
+ sibling_of: dict[str, frozenset[str]] = {}
479
+ for sib_set in same_run_sibling_sets:
480
+ for aid in sib_set:
481
+ sibling_of[aid] = sib_set
482
+
483
+ for new_article in candidate_articles.values():
484
+ # Find existing injected neighbours within dedup threshold
485
+ neighbors = self._injected.get_neighbors(
486
+ query_text=new_article.content,
487
+ top_k=10,
488
+ distance_threshold=self._cfg.dedup_delete_threshold,
489
+ )
490
+ for neighbor in neighbors:
491
+ # Skip if already marked superseded this run
492
+ if neighbor.id in seen_superseded:
493
+ continue
494
+ # Skip if it's a sibling of the new article (deliberate branch)
495
+ if neighbor.id in candidate_articles:
496
+ continue
497
+ # Skip if it IS the new article
498
+ if neighbor.id == new_article.id:
499
+ continue
500
+ superseded_ids.append(neighbor.id)
501
+ seen_superseded.add(neighbor.id)
502
+
503
+ # Also check metadata-recorded supersession (from reconsolidation)
504
+ old_id = new_article.metadata.get("supersedes")
505
+ if old_id and old_id not in seen_superseded and old_id not in candidate_articles:
506
+ superseded_ids.append(old_id)
507
+ seen_superseded.add(old_id)
508
+
509
+ return superseded_ids
510
+
511
+ # ── Helpers ───────────────────────────────────────────────────────────
512
+
513
+ def _get_all_buffer_docs(self) -> list[Deposit | RecallEvent]:
514
+ """Return all current buffer documents (deposits + recall-events)."""
515
+ all_items = self._buffer.get_all()
516
+ return [
517
+ item for item in all_items
518
+ if isinstance(item, (Deposit, RecallEvent))
519
+ ]
520
+
521
+ def _has_new_signal(self, existing: str, fresh: str) -> bool:
522
+ """
523
+ Cheap (no-LLM) gate: does `fresh` contain information not in `existing`?
524
+
525
+ Heuristic: tokenise both by whitespace, check whether fresh introduces
526
+ any words not already in the existing article (after lowercasing).
527
+ This is intentionally conservative — false positives (sending to LLM
528
+ when unnecessary) are cheaper than false negatives (missing an update).
529
+ """
530
+ if not fresh.strip():
531
+ return False
532
+ existing_tokens = set(existing.lower().split())
533
+ fresh_tokens = set(fresh.lower().split())
534
+ # Stop-words we don't want to count as "new signal"
535
+ _STOP = {
536
+ "the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
537
+ "for", "of", "with", "by", "is", "are", "was", "were", "be",
538
+ "been", "has", "have", "had", "it", "its", "this", "that",
539
+ "these", "those", "as", "from", "not", "no", "so", "if",
540
+ }
541
+ new_tokens = (fresh_tokens - existing_tokens) - _STOP
542
+ return len(new_tokens) > 0
543
+
544
+ def _fact_retention_ok(self, old_article: str, new_article: str) -> bool:
545
+ """
546
+ Check that new_article preserves the load-bearing facts of old_article.
547
+
548
+ Uses an LLM call (cheap, bounded prompt).
549
+ Falls back to True on any error (conservative — prefer keeping data).
550
+ """
551
+ try:
552
+ prompt = _FACT_RETENTION_CHECK_PROMPT.format(
553
+ old=old_article[:2000], # cap for token budget
554
+ new=new_article[:2000],
555
+ )
556
+ response = self._connector.complete(prompt, max_tokens=10)
557
+ return response.strip().upper().startswith("Y")
558
+ except Exception:
559
+ return True # conservative fallback