graph-engine-memory 0.6.1__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.
Files changed (53) hide show
  1. graph_engine/__init__.py +1 -0
  2. graph_engine/__main__.py +1275 -0
  3. graph_engine/access.py +202 -0
  4. graph_engine/agent_memory.py +103 -0
  5. graph_engine/brain.py +731 -0
  6. graph_engine/brain_engine.py +559 -0
  7. graph_engine/communities.py +503 -0
  8. graph_engine/consolidation.py +191 -0
  9. graph_engine/context.py +203 -0
  10. graph_engine/decay.py +65 -0
  11. graph_engine/demo.py +165 -0
  12. graph_engine/dream.py +408 -0
  13. graph_engine/embedder.py +84 -0
  14. graph_engine/engine.py +58 -0
  15. graph_engine/entities.py +376 -0
  16. graph_engine/evals.py +1206 -0
  17. graph_engine/feedback.py +155 -0
  18. graph_engine/gaps.py +209 -0
  19. graph_engine/graph.py +133 -0
  20. graph_engine/hygiene.py +213 -0
  21. graph_engine/inference.py +251 -0
  22. graph_engine/intent.py +207 -0
  23. graph_engine/mcp/__init__.py +1 -0
  24. graph_engine/mcp/agent/instructions.md +34 -0
  25. graph_engine/mcp/format.py +66 -0
  26. graph_engine/mcp/server.py +407 -0
  27. graph_engine/merge.py +220 -0
  28. graph_engine/model.py +56 -0
  29. graph_engine/namespaces.py +74 -0
  30. graph_engine/recall.py +161 -0
  31. graph_engine/report.py +401 -0
  32. graph_engine/reranker.py +86 -0
  33. graph_engine/retrieval.py +296 -0
  34. graph_engine/review.py +95 -0
  35. graph_engine/runtime.py +83 -0
  36. graph_engine/server.py +239 -0
  37. graph_engine/similarity.py +42 -0
  38. graph_engine/store.py +63 -0
  39. graph_engine/suggester.py +63 -0
  40. graph_engine/temporal.py +216 -0
  41. graph_engine/tiers.py +135 -0
  42. graph_engine/web/app.js +578 -0
  43. graph_engine/web/index.html +169 -0
  44. graph_engine/web/report.html +39 -0
  45. graph_engine/web/report.js +50 -0
  46. graph_engine/web/review.html +104 -0
  47. graph_engine/web/review.js +211 -0
  48. graph_engine_memory-0.6.1.dist-info/METADATA +125 -0
  49. graph_engine_memory-0.6.1.dist-info/RECORD +53 -0
  50. graph_engine_memory-0.6.1.dist-info/WHEEL +5 -0
  51. graph_engine_memory-0.6.1.dist-info/entry_points.txt +3 -0
  52. graph_engine_memory-0.6.1.dist-info/licenses/LICENSE +21 -0
  53. graph_engine_memory-0.6.1.dist-info/top_level.txt +1 -0
graph_engine/evals.py ADDED
@@ -0,0 +1,1206 @@
1
+ """Eval layer for the engine (Roadmap V2#4).
2
+
3
+ End-state verification instead of transcripts: each eval task describes an
4
+ ingest sequence + an ORACLE over the final brain state (does the node exist,
5
+ are the edges set, was the duplicate merged?). Deterministic checkers run
6
+ against the actual brain state — no peeking at intermediate steps.
7
+
8
+ Two tiers (tiered gates):
9
+ - GOLDEN_SET: regression on every engine change. These cases MUST be green
10
+ on the current state — they freeze the as-is behavior as a baseline.
11
+ - ROADMAP_CASES: document desired future behavior from the roadmap
12
+ (confidence bands, intent edges, contradictions). They are registered as
13
+ a specification and turn green once the feature is implemented — making
14
+ progress measurable without breaking the baseline.
15
+
16
+ pass^k: run_eval(... k=...) runs the same task k times against a fresh
17
+ brain and requires ALL runs to pass (protection against flakiness).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Callable
24
+
25
+ from .brain import Brain, Node
26
+ from .brain_engine import BrainEngine
27
+ from .intent import INTENT_KINDS
28
+ from .recall import aggregate as aggregate_recalls
29
+ from .retrieval import explain, retrieve
30
+ from .reranker import ReverseReranker
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Oracle / tasks
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass
38
+ class EdgeExpectation:
39
+ """An expected edge, text-based (source/target NODE CONTENT).
40
+
41
+ kind="*" means: any active edge between the two nodes suffices
42
+ (robust against exact kind names when only "being connected" matters).
43
+ pending/min_confidence are optional and check the confidence band (V2#3).
44
+ """
45
+ source: str
46
+ target: str
47
+ kind: str
48
+ pending: bool | None = None # if set: edge must have this pending value
49
+ min_confidence: float | None = None # if set: Edge.confidence >= this value
50
+ # Who created the edge (`roadmap-edge-origin`): "suggester" (cosine kNN),
51
+ # "intent" (marker heuristic), "manual" (ig link / declared relations),
52
+ # "consolidator" (dream pass). Maintenance passes may rewrite heuristic
53
+ # edges; manual ones are user-authored and must be left alone.
54
+ origin: str | None = None
55
+
56
+
57
+ @dataclass
58
+ class RetrievalExpectation:
59
+ """A retrieval expectation (hybrid dense+BM25): query must return matching nodes."""
60
+ query: str
61
+ top: int = 5
62
+ includes: list[str] = field(default_factory=list) # node texts that must appear in the top-`top`
63
+ excludes: list[str] = field(default_factory=list) # node texts that must NOT appear
64
+
65
+
66
+ @dataclass
67
+ class ExplanationExpectation:
68
+ """Explain a result with reconstructible scores and contextual edges."""
69
+ query: str
70
+ node_text: str
71
+ retrieved: bool = True
72
+ related_texts: list[str] = field(default_factory=list)
73
+
74
+
75
+ @dataclass
76
+ class CommunityExpectation:
77
+ """Structural expectation over the community partition of the FINAL graph.
78
+
79
+ Topology evals (report #3): `together` texts must share ONE community,
80
+ `apart` pairs must sit in DIFFERENT communities, `gap` pairs must appear
81
+ together in a reported structural gap. All three are asserted — each one
82
+ alone is trivially green (everything-in-one / everything-singleton /
83
+ report-every-pair). min_size mirrors the CLI default's role: communities
84
+ smaller than this are not gap candidates.
85
+ """
86
+ together: list[list[str]] = field(default_factory=list)
87
+ apart: list[tuple[str, str]] = field(default_factory=list)
88
+ gap: list[tuple[str, str]] = field(default_factory=list)
89
+ min_size: int = 2
90
+
91
+
92
+ @dataclass
93
+ class NeighborhoodExpectation:
94
+ """Graph-traversal expectation (report #1): the node with `node_text`
95
+ must have each of `expected_neighbor_texts` within `hops` undirected
96
+ live-edge hops. `absent_neighbor_texts` must NOT be reachable within
97
+ `hops` — an expectation family alone is trivially green, so both run
98
+ together (same rule as CommunityExpectation).
99
+ """
100
+ node_text: str
101
+ expected_neighbor_texts: list[str] = field(default_factory=list)
102
+ absent_neighbor_texts: list[str] = field(default_factory=list)
103
+ hops: int = 1
104
+
105
+
106
+ @dataclass
107
+ class EvalOracle:
108
+ """Desired brain state after the ingest sequence."""
109
+ node_count: int | None = None
110
+ nodes_present: list[str] = field(default_factory=list)
111
+ node_absent: list[str] = field(default_factory=list)
112
+ # (text, required sources): the node with this text must have all these sources.
113
+ duplicate_merged: list[tuple[str, list[str]]] = field(default_factory=list)
114
+ edges: list[EdgeExpectation] = field(default_factory=list)
115
+ no_edge: list[EdgeExpectation] = field(default_factory=list)
116
+ retrieval: list[RetrievalExpectation] = field(default_factory=list)
117
+ explanations: list[ExplanationExpectation] = field(default_factory=list)
118
+ # text -> expected node status (V2#2 memory hygiene)
119
+ node_status: dict[str, str] = field(default_factory=dict)
120
+ # topology expectations (report #3): partition-level assertions
121
+ communities: list[CommunityExpectation] = field(default_factory=list)
122
+ # rendered-output expectations (report #7): strings that must appear in /
123
+ # must not appear in the generated BRAIN_REPORT digest
124
+ report_contains: list[str] = field(default_factory=list)
125
+ report_absent: list[str] = field(default_factory=list)
126
+ # graph-traversal expectations (report #1): neighborhood reachability
127
+ neighborhoods: list[NeighborhoodExpectation] = field(default_factory=list)
128
+ # Intent fan-out dam (`roadmap-intent-fanout-cap`): no source node may hold
129
+ # more than this many AUTO-ACCEPTED (pending=False) intent edges. Intent
130
+ # edges carry no confidence, so the confidence bands cannot judge them and
131
+ # the marker heuristic is the only producer — the cap is the structural
132
+ # bound on that stream.
133
+ max_auto_intent_per_source: int | None = None
134
+ # Recall tracking (`roadmap-recall-tracking`): text -> expected
135
+ # `recall_count` after the recall ledger was aggregated. This is the input
136
+ # signal for promotion/decay — a memory that does not know what it is asked
137
+ # for cannot decide what to keep.
138
+ node_recalls: dict[str, int] = field(default_factory=dict)
139
+ # Edge-provenance floors (`roadmap-dream-distill`): origin -> minimum number
140
+ # of LIVE edges carrying that origin. A pass that writes `consolidator` edges
141
+ # is only observable through counts — the texts it generates are not
142
+ # knowable up front. Pending/rejected/invalidated edges do not count.
143
+ min_edges_by_origin: dict[str, int] = field(default_factory=dict)
144
+
145
+
146
+ @dataclass
147
+ class EvalTask:
148
+ id: str
149
+ name: str
150
+ ingests: list[tuple[str, dict]] # (text, kwargs) — order is part of the scenario
151
+ oracle: EvalOracle
152
+ # Optional actions AFTER the ingests (e.g. manual same_as links,
153
+ # consolidation, demotion). Return values are ignored.
154
+ actions: list[Callable[[BrainEngine], object]] = field(default_factory=list)
155
+ # Optional reranker (V2#1): set after engine construction so retrieval
156
+ # evals can measure the cross-encoder rerank pass with a deterministic
157
+ # stand-in. None = engine default (no rerank).
158
+ reranker: object | None = None
159
+
160
+
161
+ @dataclass
162
+ class EvalResult:
163
+ task_id: str
164
+ name: str
165
+ passed: bool
166
+ failures: list[str]
167
+ runs: int
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # End-State-Checker
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def _norm(text: str) -> str:
175
+ return " ".join(text.lower().split())
176
+
177
+
178
+ def find_node_by_text(brain: Brain, text: str) -> Node | None:
179
+ """Resolve an oracle text to a node: exact normalized match wins.
180
+
181
+ Prefix fallback exists because memory evolution appends an auto-accepted
182
+ "[evolved …]" cross-reference to the text — the original text stays a
183
+ prefix and must still resolve. Audit #27: when one oracle text is a
184
+ prefix of another, first-hit-wins prefix matching resolved to the wrong
185
+ node — an exact match now always beats a prefix match, and prefix
186
+ matching prefers the SHORTEST prefix (the original, pre-evolution text).
187
+ """
188
+ target = _norm(text)
189
+ nodes = brain.read_nodes()
190
+ for n in nodes:
191
+ if _norm(n.text) == target:
192
+ return n
193
+ best: Node | None = None
194
+ for n in nodes:
195
+ if _norm(n.text).startswith(target):
196
+ if best is None or len(_norm(n.text)) < len(_norm(best.text)):
197
+ best = n
198
+ return best
199
+
200
+
201
+ def verify_end_state(brain: Brain, oracle: EvalOracle) -> list[str]:
202
+ """Check the final brain state against the oracle. Returns all failures ([] = green)."""
203
+ failures: list[str] = []
204
+ nodes = brain.read_nodes()
205
+ edges = brain.read_edges()
206
+
207
+ if oracle.node_count is not None and len(nodes) != oracle.node_count:
208
+ failures.append(f"node_count: expected {oracle.node_count}, got {len(nodes)}")
209
+
210
+ for text in oracle.nodes_present:
211
+ if find_node_by_text(brain, text) is None:
212
+ failures.append(f"node missing: {text!r}")
213
+
214
+ for text in oracle.node_absent:
215
+ if find_node_by_text(brain, text) is not None:
216
+ failures.append(f"node should be absent: {text!r}")
217
+
218
+ for text, req_sources in oracle.duplicate_merged:
219
+ n = find_node_by_text(brain, text)
220
+ if n is None:
221
+ failures.append(f"dup node missing: {text!r}")
222
+ else:
223
+ for s in req_sources:
224
+ if s not in n.sources:
225
+ failures.append(f"node {text!r} missing source {s!r} (got {n.sources})")
226
+
227
+ for text, expected_status in oracle.node_status.items():
228
+ n = find_node_by_text(brain, text)
229
+ if n is None:
230
+ failures.append(f"status node missing: {text!r}")
231
+ elif n.status != expected_status:
232
+ failures.append(f"node {text!r}: expected status {expected_status!r}, got {n.status!r}")
233
+
234
+ for eexp in oracle.edges:
235
+ s = find_node_by_text(brain, eexp.source)
236
+ t = find_node_by_text(brain, eexp.target)
237
+ if s is None or t is None:
238
+ failures.append(f"edge endpoint missing: {eexp.source!r}->{eexp.target!r}")
239
+ continue
240
+ # Direction-agnostic: auto edges point from the newer to the older node,
241
+ # so direction doesn't matter for "being connected".
242
+ active = [
243
+ e for e in edges
244
+ if e.is_current
245
+ and ((e.source == s.id and e.target == t.id) or (e.source == t.id and e.target == s.id))
246
+ ]
247
+ if eexp.kind != "*":
248
+ kind_match = [e for e in active if e.kind == eexp.kind]
249
+ else:
250
+ kind_match = active
251
+ if not kind_match:
252
+ failures.append(f"edge missing: {eexp.source!r} --[{eexp.kind}]--> {eexp.target!r}")
253
+ continue
254
+ if eexp.pending is not None and not any(e.pending == eexp.pending for e in kind_match):
255
+ failures.append(
256
+ f"edge {eexp.source!r}->{eexp.target!r}: expected pending={eexp.pending}, "
257
+ f"got {[e.pending for e in kind_match]}"
258
+ )
259
+ if eexp.min_confidence is not None and not any(
260
+ (e.confidence or 0.0) >= eexp.min_confidence for e in kind_match
261
+ ):
262
+ failures.append(
263
+ f"edge {eexp.source!r}->{eexp.target!r}: expected confidence>={eexp.min_confidence}, "
264
+ f"got {[e.confidence for e in kind_match]}"
265
+ )
266
+ # Edge provenance (`roadmap-edge-origin`): who CREATED the edge. A
267
+ # maintenance pass may rewrite heuristic edges but never manual ones,
268
+ # so the origin has to be asserted, not inferred from the text.
269
+ if eexp.origin is not None and not any(
270
+ getattr(e, "origin", None) == eexp.origin for e in kind_match
271
+ ):
272
+ failures.append(
273
+ f"edge {eexp.source!r}->{eexp.target!r}: expected origin "
274
+ f"{eexp.origin!r}, got {[getattr(e, 'origin', None) for e in kind_match]}"
275
+ )
276
+
277
+ for eexp in oracle.no_edge:
278
+ s = find_node_by_text(brain, eexp.source)
279
+ t = find_node_by_text(brain, eexp.target)
280
+ if s is None or t is None:
281
+ continue
282
+ # Audit #27: mirror the positive path — only ACTIVE edges count, and
283
+ # kind="*" means any kind; a specific kind must match exactly. An
284
+ # invalidated edge or a different-kind edge is NOT a violation.
285
+ violating = [
286
+ e for e in edges
287
+ if e.is_current
288
+ and ((e.source == s.id and e.target == t.id) or (e.source == t.id and e.target == s.id))
289
+ and (eexp.kind == "*" or e.kind == eexp.kind)
290
+ ]
291
+ if violating:
292
+ failures.append(f"unexpected edge: {eexp.source!r} --[{eexp.kind}]--> {eexp.target!r}")
293
+
294
+ if oracle.report_contains or oracle.report_absent:
295
+ from .report import render_report
296
+ rendered = render_report(brain)
297
+ for needle in oracle.report_contains:
298
+ if needle not in rendered:
299
+ failures.append(f"report missing: {needle!r}")
300
+ for needle in oracle.report_absent:
301
+ if needle in rendered:
302
+ failures.append(f"report should not contain: {needle!r}")
303
+
304
+ if oracle.node_recalls:
305
+ for text, expected in oracle.node_recalls.items():
306
+ n = find_node_by_text(brain, text)
307
+ if n is None:
308
+ failures.append(f"recall node missing: {text!r}")
309
+ elif getattr(n, "recall_count", 0) != expected:
310
+ failures.append(
311
+ f"node {text!r}: expected recall_count {expected}, "
312
+ f"got {getattr(n, 'recall_count', 0)}")
313
+
314
+ if oracle.max_auto_intent_per_source is not None:
315
+ per_source: dict[str, int] = {}
316
+ for e in edges:
317
+ if (e.kind in INTENT_KINDS and not e.pending and not e.rejected
318
+ and e.is_current):
319
+ per_source[e.source] = per_source.get(e.source, 0) + 1
320
+ over = {s: n for s, n in per_source.items()
321
+ if n > oracle.max_auto_intent_per_source}
322
+ if over:
323
+ worst_source, worst_n = max(over.items(), key=lambda kv: kv[1])
324
+ failures.append(
325
+ f"intent fan-out: source {worst_source} has {worst_n} auto-accepted "
326
+ f"intent edges (max {oracle.max_auto_intent_per_source}); "
327
+ f"{len(over)} source(s) over the cap")
328
+
329
+ for origin, minimum in oracle.min_edges_by_origin.items():
330
+ found = sum(1 for e in edges
331
+ if getattr(e, "origin", None) == origin
332
+ and not e.pending and not e.rejected and e.is_current)
333
+ if found < minimum:
334
+ failures.append(
335
+ f"edges with origin {origin!r}: expected >= {minimum} live, got {found}")
336
+
337
+ return failures
338
+
339
+
340
+ def verify_retrieval(engine: BrainEngine, expectations: list[RetrievalExpectation]) -> list[str]:
341
+ """Check hybrid retrieval: query must return expected nodes in the top-k (or exclude them)."""
342
+ failures: list[str] = []
343
+ id2node = {n.id: n for n in engine.brain.read_nodes()}
344
+ for exp in expectations:
345
+ hits = retrieve(engine, exp.query, k=exp.top)
346
+ hit_texts = [_norm(id2node[nid].text) for nid, _ in hits if nid in id2node]
347
+ for want in exp.includes:
348
+ if not any(_norm(want) in t or t.startswith(_norm(want)) for t in hit_texts):
349
+ failures.append(f"retrieval '{exp.query}' should hit {want!r} in top-{exp.top}")
350
+ for avoid in exp.excludes:
351
+ if any(_norm(avoid) in t or t.startswith(_norm(avoid)) for t in hit_texts):
352
+ failures.append(f"retrieval '{exp.query}' should NOT hit {avoid!r} in top-{exp.top}")
353
+ return failures
354
+
355
+
356
+ def verify_explanations(engine: BrainEngine,
357
+ expectations: list[ExplanationExpectation]) -> list[str]:
358
+ """Require score fidelity and expected graph context in the final brain."""
359
+ import math
360
+
361
+ failures: list[str] = []
362
+ for exp in expectations:
363
+ node = find_node_by_text(engine.brain, exp.node_text)
364
+ if node is None:
365
+ failures.append(f"explanation node missing: {exp.node_text!r}")
366
+ continue
367
+ result = explain(engine, node.id, exp.query)
368
+ hits = dict(retrieve(engine, exp.query, persist=False))
369
+ rank = next((i for i, nid in enumerate(hits, start=1) if nid == node.id), None)
370
+ expected_final = (result["reranker_score"] if result["final_score_kind"] == "reranker"
371
+ else hits.get(node.id)) if rank is not None else None
372
+ if (result["retrieved"] != exp.retrieved or result["rank"] != rank
373
+ or result["retrieval_score"] != hits.get(node.id)
374
+ or result["final_score"] != expected_final):
375
+ failures.append(f"explanation disagrees with retrieval: {exp.node_text!r}")
376
+ contributions = sum(result[channel]["rrf_contribution"] for channel in ("bm25", "dense"))
377
+ if not math.isclose(result["rrf"]["score"], contributions):
378
+ failures.append(f"explanation RRF contributions do not sum: {exp.node_text!r}")
379
+ connected = {edge[side] for edge in result["graph_context"]
380
+ for side in ("source", "target")} - {node.id}
381
+ for text in exp.related_texts:
382
+ related = find_node_by_text(engine.brain, text)
383
+ if related is None or related.id not in connected:
384
+ failures.append(f"explanation missing connection: {text!r}")
385
+ return failures
386
+
387
+
388
+ def verify_neighborhood(brain: Brain, expectations: list) -> list[str]:
389
+ """Check neighborhood reachability against NeighborhoodExpectation entries.
390
+
391
+ Degrades gracefully when graph_engine.graph does not exist: the gate test
392
+ must fail for the RIGHT reason (expectation violation), never crash the
393
+ harness with an ImportError (same trap as a missing no-op kwarg stub).
394
+ """
395
+ try:
396
+ from .graph import neighbors
397
+ except ImportError:
398
+ return ["graph traversal not implemented (graph_engine/graph.py missing)"]
399
+ failures: list[str] = []
400
+ if not expectations:
401
+ return failures
402
+ for exp in expectations:
403
+ node = find_node_by_text(brain, exp.node_text)
404
+ if node is None:
405
+ continue # missing nodes already reported by verify_end_state
406
+ # expected + absent in ONE call set: include_pending=True because the
407
+ # harness verifies what the graph CONTAINS, not the review gate state.
408
+ found = neighbors(brain, node.id, hops=exp.hops, include_pending=True,
409
+ limit=None)
410
+ found_ids = {n.id for n in found}
411
+ for want in exp.expected_neighbor_texts:
412
+ w = find_node_by_text(brain, want)
413
+ if w is None or w.id not in found_ids:
414
+ failures.append(
415
+ f"neighborhood '{exp.node_text[:40]}': {want!r} not within "
416
+ f"{exp.hops} hops")
417
+ for avoid in exp.absent_neighbor_texts:
418
+ w = find_node_by_text(brain, avoid)
419
+ if w is not None and w.id in found_ids:
420
+ failures.append(
421
+ f"neighborhood '{exp.node_text[:40]}': {avoid!r} must NOT be "
422
+ f"within {exp.hops} hops")
423
+ return failures
424
+
425
+
426
+ def verify_communities(brain: Brain, expectations: list) -> list[str]:
427
+ """Check the final partition against CommunityExpectation entries.
428
+
429
+ All three assertion families run together on purpose: `together` alone
430
+ is trivially green if everything lands in one community, `apart` alone
431
+ if every node is its own community, and `gap` alone if the analyzer
432
+ reported every pair. Requires all three to hold.
433
+ """
434
+ from .communities import analyze_communities
435
+
436
+ failures: list[str] = []
437
+ if not expectations:
438
+ return failures
439
+ # One analysis per expectation list, min_size = the strictest requested.
440
+ min_size = min(exp.min_size for exp in expectations)
441
+ # top is generous on purpose: the verifier needs the COMPLETE gap list
442
+ # (truncating to the CLI default could hide a pair the oracle asserts).
443
+ rep = analyze_communities(brain, min_size=min_size, top=10_000,
444
+ include_pending=True, with_members=True)
445
+ # Community membership lookup: id -> canonical community index.
446
+ id2com: dict[str, int] = {}
447
+ for c in rep.communities:
448
+ for nid in c.members:
449
+ id2com[nid] = c.id
450
+ gap_pairs: set[tuple[int, int]] = {(g.a, g.b) for g in rep.gaps}
451
+
452
+ def _resolve(text: str):
453
+ node = find_node_by_text(brain, text)
454
+ return node.id if node else None
455
+
456
+ for exp in expectations:
457
+ for group in exp.together:
458
+ ids = [_resolve(t) for t in group]
459
+ if any(i is None for i in ids):
460
+ continue # missing nodes already reported by verify_end_state
461
+ coms = {id2com[i] for i in ids}
462
+ if len(coms) != 1:
463
+ failures.append(
464
+ f"together violated: {group!r} spans communities {sorted(coms)}")
465
+ for a, b in exp.apart:
466
+ ia, ib = _resolve(a), _resolve(b)
467
+ if ia is None or ib is None:
468
+ continue
469
+ if id2com.get(ia) == id2com.get(ib):
470
+ failures.append(
471
+ f"apart violated: {a!r} and {b!r} share community {id2com.get(ia)}")
472
+ for a, b in exp.gap:
473
+ ia, ib = _resolve(a), _resolve(b)
474
+ if ia is None or ib is None:
475
+ continue
476
+ pair = (min(id2com.get(ia, -1), id2com.get(ib, -1)),
477
+ max(id2com.get(ia, -1), id2com.get(ib, -1)))
478
+ if pair not in gap_pairs:
479
+ failures.append(
480
+ f"gap not reported: {a!r} <-> {b!r} (communities {pair[0]}/{pair[1]})")
481
+ return failures
482
+
483
+
484
+ # ---------------------------------------------------------------------------
485
+ # Runner (pass^k)
486
+ # ---------------------------------------------------------------------------
487
+
488
+ EngineFactory = Callable[[], BrainEngine]
489
+
490
+
491
+ def run_eval(task: EvalTask, engine_factory: EngineFactory, k: int = 1) -> EvalResult:
492
+ """Run the task k times against a fresh brain; all runs must be green."""
493
+ for run in range(1, k + 1):
494
+ engine = engine_factory()
495
+ if task.reranker is not None:
496
+ engine.reranker = task.reranker
497
+ for text, kwargs in task.ingests:
498
+ engine.ingest(text, **kwargs)
499
+ for action in task.actions:
500
+ action(engine)
501
+ failures = verify_end_state(engine.brain, task.oracle)
502
+ failures += verify_retrieval(engine, task.oracle.retrieval)
503
+ failures += verify_explanations(engine, task.oracle.explanations)
504
+ failures += verify_communities(engine.brain, task.oracle.communities)
505
+ failures += verify_neighborhood(engine.brain, task.oracle.neighborhoods)
506
+ if failures:
507
+ return EvalResult(task.id, task.name, False, failures, run)
508
+ return EvalResult(task.id, task.name, True, [], k)
509
+
510
+
511
+ def run_tasks(tasks: list[EvalTask], engine_factory: EngineFactory, k: int = 1) -> list[EvalResult]:
512
+ return [run_eval(t, engine_factory, k) for t in tasks]
513
+
514
+
515
+ def report(results: list[EvalResult]) -> tuple[int, list[EvalResult]]:
516
+ """Returns (number green, all) — useful for CLI/logging."""
517
+ failed = [r for r in results if not r.passed]
518
+ return len(results) - len(failed), failed
519
+
520
+
521
+ # ---------------------------------------------------------------------------
522
+ # Golden set — regression on every engine change (MUST be green)
523
+ # ---------------------------------------------------------------------------
524
+
525
+ def _seed_episodic_consolidation(engine: BrainEngine) -> None:
526
+ for _ in range(2):
527
+ engine.observe("User requested brief replies", allow_duplicates=True,
528
+ observed_at="2000-01-01T00:00:00Z")
529
+ engine.observe("A button was clicked", observed_at="2000-01-01T00:00:00Z")
530
+
531
+
532
+ def _consolidate_episodic(engine: BrainEngine) -> None:
533
+ from .consolidation import ConsolidationConfig, consolidate
534
+ consolidate(engine.brain, config=ConsolidationConfig(),
535
+ extractor=lambda evidence: "User prefers concise answers")
536
+
537
+
538
+ def _dream_lifecycle() -> Callable[[BrainEngine], None]:
539
+ """Test action: the lifecycle pass under the case's gates."""
540
+ def action(engine: BrainEngine) -> None:
541
+ from .dream import lifecycle
542
+ lifecycle(engine.brain, min_recall=1, min_degree=2, stale_days=0,
543
+ max_degree=2)
544
+ return action
545
+
546
+
547
+ def _record_recall(query: str, texts: list[str]) -> Callable[[BrainEngine], None]:
548
+ """Test action: record a recall of the given nodes (the promotion signal)."""
549
+ def action(engine: BrainEngine) -> None:
550
+ from .recall import record
551
+ ids = [n.id for n in engine.brain.read_nodes()
552
+ if n.text.strip() in {t.strip() for t in texts}]
553
+ record(engine.brain, query, ids)
554
+ return action
555
+
556
+
557
+ def _agent_remember(text: str, relation_target: str | None = None) -> Callable[[BrainEngine], None]:
558
+ """Test action: an agent writes a note (Welle C write path)."""
559
+ def action(engine: BrainEngine) -> None:
560
+ from .agent_memory import remember
561
+ relations = [(relation_target, "extends")] if relation_target else None
562
+ remember(engine, text, relations=relations)
563
+ return action
564
+
565
+
566
+ def _agent_forget(text: str, reason: str) -> Callable[[BrainEngine], None]:
567
+ """Test action: an agent forgets a node — tombstoned, never deleted."""
568
+ def action(engine: BrainEngine) -> None:
569
+ from .agent_memory import forget
570
+ node = find_node_by_text(engine.brain, text)
571
+ if node is not None:
572
+ forget(engine.brain, node.id, reason=reason)
573
+ return action
574
+
575
+
576
+ def _link_same_as(source_text: str, target_text: str) -> Callable[[BrainEngine], None]:
577
+ def action(engine: BrainEngine) -> None:
578
+ s = find_node_by_text(engine.brain, source_text)
579
+ t = find_node_by_text(engine.brain, target_text)
580
+ if s and t:
581
+ engine.link(s.id, t.id, "same_as")
582
+ return action
583
+
584
+
585
+ def _force_kind(source_text: str, target_text: str, kind: str) -> Callable[[BrainEngine], None]:
586
+ """Test action: rewrite the kind of an existing similarity edge.
587
+
588
+ Simulates drift/legacy data (an edge whose kind no longer matches the band
589
+ rule its cosine implies) so `dream.refresh()` has something to re-derive.
590
+ """
591
+ def action(engine: BrainEngine) -> None:
592
+ s = find_node_by_text(engine.brain, source_text)
593
+ t = find_node_by_text(engine.brain, target_text)
594
+ if not s or not t:
595
+ return
596
+ edges = engine.brain.read_edges(include_rejected=True)
597
+ for edge in edges:
598
+ if ({edge.source, edge.target} == {s.id, t.id}
599
+ and edge.kind in ("extends", "similar")):
600
+ edge.kind = kind
601
+ engine.brain.write_edges(edges)
602
+ return action
603
+
604
+
605
+ def _dream_distill(engine: BrainEngine) -> None:
606
+ from .dream import distill
607
+ distill(engine.brain, min_size=2, limit=5, members_per_summary=3)
608
+
609
+
610
+ def _dream_refresh(engine: BrainEngine) -> None:
611
+ from .dream import refresh
612
+ refresh(engine.brain)
613
+
614
+
615
+ GOLDEN_SET: list[EvalTask] = [
616
+ EvalTask(
617
+ id="dup-exact",
618
+ name="exact duplicate gets merged (sources accumulate)",
619
+ ingests=[
620
+ ("Katzen jagen Maeuse nachts", {"source": "agent/test"}),
621
+ ("Katzen jagen Maeuse nachts", {"source": "human"}),
622
+ ],
623
+ oracle=EvalOracle(
624
+ node_count=1,
625
+ duplicate_merged=[("Katzen jagen Maeuse nachts", ["agent/test", "human"])],
626
+ ),
627
+ ),
628
+ EvalTask(
629
+ id="dup-case-whitespace",
630
+ name="duplicate merged regardless of case + whitespace",
631
+ ingests=[
632
+ ("Katzen jagen Maeuse nachts", {"source": "a"}),
633
+ (" katzen JAGEN maeuse NACHTS ", {"source": "b"}),
634
+ ],
635
+ oracle=EvalOracle(node_count=1, duplicate_merged=[("katzen jagen maeuse nachts", ["a", "b"])]),
636
+ ),
637
+ EvalTask(
638
+ id="retrieval-explain",
639
+ name="Retrieval explanations reconstruct scores and show accepted graph context",
640
+ ingests=[("alpha beta", {}), ("alpha gamma", {}), ("omega psi", {})],
641
+ actions=[_link_same_as("alpha beta", "alpha gamma")],
642
+ oracle=EvalOracle(explanations=[
643
+ ExplanationExpectation(query="alpha", node_text="alpha beta",
644
+ related_texts=["alpha gamma"]),
645
+ ExplanationExpectation(query="alpha", node_text="omega psi", retrieved=False),
646
+ ]),
647
+ ),
648
+ EvalTask(
649
+ id="dup-disabled",
650
+ name="allow_duplicates=True creates a second node",
651
+ ingests=[
652
+ ("Katzen jagen Maeuse nachts", {}),
653
+ ("Katzen jagen Maeuse nachts", {"allow_duplicates": True}),
654
+ ],
655
+ oracle=EvalOracle(node_count=2),
656
+ ),
657
+ EvalTask(
658
+ id="no-false-positive",
659
+ name="unrelated texts are not deduplicated",
660
+ ingests=[
661
+ ("Katzen jagen Maeuse nachts", {}),
662
+ ("Rust Compiler borrow checker lifetime Regeln", {}),
663
+ ],
664
+ oracle=EvalOracle(node_count=2, nodes_present=[
665
+ "Katzen jagen Maeuse nachts",
666
+ "Rust Compiler borrow checker lifetime Regeln",
667
+ ]),
668
+ ),
669
+ EvalTask(
670
+ id="edge-similar",
671
+ name="similar nodes get connected by an edge (pending <0.95)",
672
+ ingests=[
673
+ ("katze hund tier futter", {}),
674
+ ("katze hund tier spiel", {}),
675
+ ],
676
+ oracle=EvalOracle(
677
+ node_count=2,
678
+ edges=[EdgeExpectation("katze hund tier futter", "katze hund tier spiel", "*", pending=True)],
679
+ ),
680
+ ),
681
+ EvalTask(
682
+ id="conf-auto-accept",
683
+ name="V2: Confidence >=0.95 → edge auto-accepted (pending=False)",
684
+ ingests=[
685
+ ("x x x x x y y y y y z z z z z", {}),
686
+ ("x x x x x y y y y y z z z z w", {"allow_duplicates": True}),
687
+ ],
688
+ oracle=EvalOracle(
689
+ node_count=2,
690
+ edges=[EdgeExpectation(
691
+ "x x x x x y y y y y z z z z z",
692
+ "x x x x x y y y y y z z z z w",
693
+ "*", pending=False, min_confidence=0.95,
694
+ )],
695
+ ),
696
+ ),
697
+ EvalTask(
698
+ id="edge-no-false-link",
699
+ name="unrelated nodes are NOT connected",
700
+ ingests=[
701
+ ("katze hund tier futter", {}),
702
+ ("quantenmechanik wellenfunktion schroedinger", {}),
703
+ ],
704
+ oracle=EvalOracle(
705
+ node_count=2,
706
+ no_edge=[EdgeExpectation("katze hund tier futter", "quantenmechanik wellenfunktion schroedinger", "*")],
707
+ ),
708
+ ),
709
+ EvalTask(
710
+ id="retrieval-hybrid",
711
+ name="V2: hybrid retrieval finds the matching node, not unrelated ones",
712
+ ingests=[
713
+ ("katze hund tier futter", {}),
714
+ ("quantenmechanik wellenfunktion schroedinger", {}),
715
+ ],
716
+ oracle=EvalOracle(
717
+ node_count=2,
718
+ retrieval=[RetrievalExpectation(
719
+ query="katze futter",
720
+ top=1,
721
+ includes=["katze hund tier futter"],
722
+ excludes=["quantenmechanik wellenfunktion schroedinger"],
723
+ )],
724
+ ),
725
+ ),
726
+ EvalTask(
727
+ id="retrieval-rerank-honored",
728
+ name="V2: rerank pass decides the final ranking (cross-encoder pipeline)",
729
+ ingests=[
730
+ ("aaa bbb ccc", {}),
731
+ ("ddd eee fff", {}),
732
+ ],
733
+ oracle=EvalOracle(
734
+ node_count=2,
735
+ retrieval=[RetrievalExpectation(
736
+ query="ddd eee fff",
737
+ top=1,
738
+ includes=["aaa bbb ccc"],
739
+ excludes=["ddd eee fff"],
740
+ )],
741
+ ),
742
+ # ReverseReranker is the deterministic stand-in for a cross-encoder:
743
+ # hybrid would rank "ddd eee fff" first; the rerank pass reverses that.
744
+ # Green only if retrieve() actually honors the reranker.
745
+ reranker=ReverseReranker(),
746
+ ),
747
+ EvalTask(
748
+ id="taxonomy-procedural",
749
+ name="procedural node carries type=procedural",
750
+ ingests=[
751
+ ("Wie ingestiere ich Research: ig ingest ...", {"ntype": "procedural"}),
752
+ ],
753
+ oracle=EvalOracle(node_count=1, nodes_present=["Wie ingestiere ich Research: ig ingest ..."]),
754
+ ),
755
+ EvalTask(
756
+ id="same-as-multilingual",
757
+ name="multilingual pair connected via manual same_as link",
758
+ ingests=[
759
+ ("Katzen jagen Maeuse", {}),
760
+ ("Cats hunt mice", {"allow_duplicates": True}),
761
+ ],
762
+ actions=[_link_same_as("Katzen jagen Maeuse", "Cats hunt mice")],
763
+ oracle=EvalOracle(
764
+ node_count=2,
765
+ edges=[EdgeExpectation("Katzen jagen Maeuse", "Cats hunt mice", "same_as")],
766
+ ),
767
+ ),
768
+ EvalTask(
769
+ id="hygiene-promote",
770
+ name="V2: dual buffer — consolidation promotes probation nodes to active",
771
+ ingests=[
772
+ ("katze hund tier futter", {}),
773
+ ("quantenmechanik wellenfunktion schroedinger", {}),
774
+ ],
775
+ actions=[lambda e: e.consolidate()],
776
+ oracle=EvalOracle(
777
+ node_count=2,
778
+ node_status={
779
+ "katze hund tier futter": "active",
780
+ "quantenmechanik wellenfunktion schroedinger": "active",
781
+ },
782
+ ),
783
+ ),
784
+ EvalTask(
785
+ id="hygiene-demote",
786
+ name="V2: graceful degradation — unused node gets tombstoned",
787
+ ingests=[
788
+ ("katze hund tier futter", {}),
789
+ ("quantenmechanik wellenfunktion schroedinger", {}),
790
+ ],
791
+ actions=[
792
+ lambda e: e.consolidate(),
793
+ lambda e: e.demote_forgotten(
794
+ lambda n: "tombstone" if "katze" in n.text else "record"
795
+ ),
796
+ ],
797
+ oracle=EvalOracle(
798
+ node_count=2,
799
+ node_status={
800
+ "katze hund tier futter": "tombstone",
801
+ "quantenmechanik wellenfunktion schroedinger": "active",
802
+ },
803
+ ),
804
+ ),
805
+ EvalTask(
806
+ id="intent-contradiction",
807
+ name="V2: contradictory statement -> 'contradicts' edge auto-detected",
808
+ ingests=[
809
+ ("Die Erde ist eine Scheibe", {}),
810
+ ("Die Erde ist keine Scheibe", {}),
811
+ ],
812
+ oracle=EvalOracle(
813
+ node_count=2,
814
+ edges=[EdgeExpectation("Die Erde ist eine Scheibe", "Die Erde ist keine Scheibe", "contradicts")],
815
+ ),
816
+ ),
817
+ EvalTask(
818
+ id="intent-supersedes",
819
+ name="V2: newer statement supersedes older → 'supersedes' edge",
820
+ ingests=[
821
+ ("API v1 wird verwendet", {}),
822
+ ("API v2 ersetzt v1", {}),
823
+ ],
824
+ oracle=EvalOracle(
825
+ node_count=2,
826
+ edges=[EdgeExpectation("API v1 wird verwendet", "API v2 ersetzt v1", "supersedes")],
827
+ ),
828
+ ),
829
+ EvalTask(
830
+ id="admit-rule-no-relations",
831
+ name="V2: admit rule — node without relations stays in probation (admit_required)",
832
+ ingests=[
833
+ ("xyzvw abcdefgh ijklmnop", {}),
834
+ ],
835
+ actions=[lambda e: e.consolidate(admit_required=True)],
836
+ oracle=EvalOracle(
837
+ node_count=1,
838
+ node_status={"xyzvw abcdefgh ijklmnop": "probation"},
839
+ ),
840
+ ),
841
+ EvalTask(
842
+ id="admit-rule-with-relations",
843
+ name="V2: admit rule — node WITH declared relations becomes active",
844
+ ingests=[
845
+ ("aaa bbb ccc", {}),
846
+ ("aaa bbb ccc ddd", {"allow_duplicates": True,
847
+ "relations": [("aaa bbb ccc", "extends")]}),
848
+ ],
849
+ actions=[lambda e: e.consolidate(admit_required=True)],
850
+ oracle=EvalOracle(
851
+ node_count=2,
852
+ node_status={
853
+ "aaa bbb ccc": "active",
854
+ "aaa bbb ccc ddd": "active",
855
+ },
856
+ ),
857
+ ),
858
+ # GOLDEN (flip 2026-09-10, was `roadmap-confidence-floor`) — Tier-3 self-extension:
859
+ # configurable confidence floor for auto edge suggestions (per-call env kwarg,
860
+ # IG_EDGE_CONF_FLOOR, default 0.0 = no behavior change). Suggestions below the
861
+ # floor are dropped instead of landing pending — protects autonomous cycles
862
+ # from a flood of low-confidence edges. (HashEmbedder measurement: the two
863
+ # texts sit at cos≈0.653 → pending band 0.45–0.95, below floor 0.95.)
864
+ EvalTask(
865
+ id="roadmap-confidence-floor",
866
+ name="Confidence floor rejects weak auto edge suggestions",
867
+ ingests=[
868
+ ("Agentenplanung zerlegt langfristige Aufgaben in hierarchische "
869
+ "Teilziele und prueft Zwischenergebnisse gegen den Zielzustand.", {}),
870
+ ("Agentenplanung in Multi-Agent-Systemen verteilt hierarchische "
871
+ "Teilziele und prueft Zwischenergebnisse gegenseitig.",
872
+ {"env": {"IG_EDGE_CONF_FLOOR": "0.95"}}),
873
+ ],
874
+ oracle=EvalOracle(
875
+ node_count=2,
876
+ # With floor: the 0.653 suggestion is rejected (no similar edge).
877
+ edges=[],
878
+ no_edge=[EdgeExpectation(
879
+ source="Agentenplanung in Multi-Agent-Systemen verteilt",
880
+ target="Agentenplanung zerlegt langfristige Aufgaben",
881
+ kind="similar")],
882
+ ),
883
+ ),
884
+
885
+ # Report #3 (2026-09-15): topology communities — two topical clusters form
886
+ # two communities with a structural gap. Registered RED, implemented, then
887
+ # flipped (pass^3 + full suite green).
888
+ EvalTask(
889
+ id="roadmap-communities-two-clusters",
890
+ name="Topology: two topical clusters form two communities with a structural gap",
891
+ ingests=[
892
+ ("alpha beta gamma delta", {}),
893
+ ("alpha beta gamma epsilon", {}),
894
+ ("alpha beta gamma zeta", {}),
895
+ ("omega psi chi phi", {}),
896
+ ("omega psi chi kappa", {}),
897
+ ("omega psi chi lambda", {}),
898
+ ],
899
+ oracle=EvalOracle(
900
+ node_count=6,
901
+ communities=[CommunityExpectation(
902
+ together=[
903
+ ["alpha beta gamma delta", "alpha beta gamma epsilon",
904
+ "alpha beta gamma zeta"],
905
+ ["omega psi chi phi", "omega psi chi kappa",
906
+ "omega psi chi lambda"],
907
+ ],
908
+ apart=[("alpha beta gamma delta", "omega psi chi phi")],
909
+ gap=[("alpha beta gamma delta", "omega psi chi phi")],
910
+ min_size=2,
911
+ )],
912
+ ),
913
+ ),
914
+ # Report #7 (2026-09-15): BRAIN_REPORT digest renders non-empty, sectioned,
915
+ # content-asserted output. Registered RED, implemented, then flipped.
916
+ EvalTask(
917
+ id="roadmap-brain-report",
918
+ name="BRAIN_REPORT renders non-empty, sectioned, content-asserted output",
919
+ ingests=[
920
+ ("Die Erde ist eine Scheibe", {}),
921
+ ("Die Erde ist keine Scheibe", {}),
922
+ ],
923
+ oracle=EvalOracle(
924
+ node_count=2,
925
+ report_contains=["BRAIN_REPORT", "Intent review queue",
926
+ "Die Erde ist eine Scheibe"],
927
+ ),
928
+ ),
929
+ # Report #1 (2026-09-15): graph traversal over live edges (flipped). MEASURED under
930
+ # HashEmbedder: the three agent-memory texts sit at cos 0.875/0.875/0.75
931
+ # (similar/similar/extends — inside the 0.75/0.45 bands), the gardening
932
+ # text at 0.13-0.27 (no edges). The MCP surface itself is a read-only
933
+ # projection and CANNOT be an EvalTask (the harness verifies brain
934
+ # end-state only) — same judgement as Late Chunking above; traversal is
935
+ # the measurable new capability underneath it.
936
+ EvalTask(
937
+ id="roadmap-neighbors",
938
+ name="graph traversal reaches hub neighbors at hop 1, isolates stay out",
939
+ ingests=[
940
+ ("agent memory systems store knowledge graphs for retrieval", {}),
941
+ ("agent memory systems store knowledge graphs for retrieval and search", {}),
942
+ ("agent memory systems store knowledge graphs for retrieval but slower", {}),
943
+ ("unrelated note about gardening tomatoes and weather", {}),
944
+ ],
945
+ oracle=EvalOracle(
946
+ node_count=4,
947
+ neighborhoods=[
948
+ NeighborhoodExpectation(
949
+ node_text="agent memory systems store knowledge graphs for retrieval",
950
+ expected_neighbor_texts=[
951
+ "agent memory systems store knowledge graphs for retrieval and search",
952
+ "agent memory systems store knowledge graphs for retrieval but slower",
953
+ ],
954
+ absent_neighbor_texts=[
955
+ "unrelated note about gardening tomatoes and weather",
956
+ ],
957
+ hops=1,
958
+ ),
959
+ ],
960
+ ),
961
+ ),
962
+ # Intent fan-out dam (2026-09-18): registered RED, implemented, flipped.
963
+ # Intent edges carry confidence=None, so the confidence bands can never
964
+ # judge them; the marker heuristic is their only producer and mass-fires in
965
+ # a homogeneous brain — live measurement before the dam: 168 intent edges
966
+ # ever created, 66 invalidated again (39 % false), one source holding 10
967
+ # auto-accepted `contradicts`, 26 false edges added in one cycle.
968
+ # MEASURED fixture (HashEmbedder, real engine path): the marker text fires 6
969
+ # `supersedes` edges at cos 0.866/0.722 (threshold 0.45) and the maximum
970
+ # pairwise cosine is 0.866 < dedupe 0.92, so the case fails on the CAP, not
971
+ # on a missing edge.
972
+ EvalTask(
973
+ id="roadmap-intent-fanout-cap",
974
+ name="Intent edges: auto-accepted fan-out per source is capped, the rest stay pending",
975
+ ingests=[
976
+ ("alpha beta gamma delta training pipeline", {}),
977
+ ("alpha beta gamma epsilon training pipeline", {}),
978
+ ("alpha beta gamma zeta training pipeline", {}),
979
+ ("alpha beta gamma eta training pipeline", {}),
980
+ ("alpha beta gamma theta training pipeline", {}),
981
+ ("alpha beta gamma iota training pipeline", {}),
982
+ ("alpha beta gamma kappa training pipeline ersetzt delta", {}),
983
+ ],
984
+ oracle=EvalOracle(
985
+ node_count=7,
986
+ max_auto_intent_per_source=2,
987
+ ),
988
+ ),
989
+ # Welle A (2026-09-18): the two signals the consolidation half of a memory
990
+ # system runs on — registered RED, implemented, flipped in one session.
991
+ # `roadmap-edge-origin`: a maintenance pass may rewrite heuristic edges but
992
+ # never user-authored ones, so the provenance has to be stored, not inferred
993
+ # from the text (the 96-edge backlog cleanup was exactly that inference by
994
+ # hand). `roadmap-recall-tracking`: promotion/decay needs actual use —
995
+ # recall_count + distinct query fingerprints, folded from a local ledger so
996
+ # the read path stays cheap and git-clean.
997
+ EvalTask(
998
+ id="roadmap-edge-origin",
999
+ name="Edges carry their provenance: suggester kNN vs manual link",
1000
+ ingests=[
1001
+ ("agent memory systems store knowledge graphs for retrieval", {}),
1002
+ ("agent memory systems store knowledge graphs for retrieval and search", {}),
1003
+ ("Retrieval-Augmented Generation connects a model to external documents", {}),
1004
+ ("Graph RAG combines vector search with multi-hop traversal", {}),
1005
+ ],
1006
+ actions=[_link_same_as(
1007
+ "Retrieval-Augmented Generation connects a model to external documents",
1008
+ "Graph RAG combines vector search with multi-hop traversal")],
1009
+ oracle=EvalOracle(
1010
+ node_count=4,
1011
+ edges=[
1012
+ # MEASURED (neighbors fixture): these two sit at cos 0.875, i.e.
1013
+ # the `similar` band — the suggester produces the edge.
1014
+ EdgeExpectation(
1015
+ source="agent memory systems store knowledge graphs for retrieval and search",
1016
+ target="agent memory systems store knowledge graphs for retrieval",
1017
+ kind="similar", origin="suggester"),
1018
+ EdgeExpectation(
1019
+ source="Retrieval-Augmented Generation connects a model to external documents",
1020
+ target="Graph RAG combines vector search with multi-hop traversal",
1021
+ kind="same_as", origin="manual"),
1022
+ ],
1023
+ ),
1024
+ ),
1025
+ EvalTask(
1026
+ id="roadmap-recall-tracking",
1027
+ name="Recall tracking: retrieved nodes carry recall counts, untouched ones stay at 0",
1028
+ ingests=[
1029
+ ("alpha beta gamma delta training pipeline", {}),
1030
+ ("omega psi chi phi gardening tomatoes", {}),
1031
+ ],
1032
+ actions=[
1033
+ lambda e: retrieve(e, "alpha beta gamma delta training pipeline", k=1, track=True),
1034
+ lambda e: retrieve(e, "alpha beta gamma delta training", k=1, track=True),
1035
+ lambda e: aggregate_recalls(e.brain),
1036
+ ],
1037
+ oracle=EvalOracle(
1038
+ node_count=2,
1039
+ node_recalls={
1040
+ "alpha beta gamma delta training pipeline": 2,
1041
+ "omega psi chi phi gardening tomatoes": 0,
1042
+ },
1043
+ ),
1044
+ ),
1045
+ # Welle B (2026-09-18): the consolidation half — the dream pass. Measured
1046
+ # before building: recall-gated promotion had 0 eligible nodes, a degree gate
1047
+ # >= 3 matched 99 % of nodes, the corpus was 27 days old (so no staleness
1048
+ # gate can fire), and the 97 near-dup pairs in the review band are
1049
+ # demonstrably related-but-distinct — so promotion/decay/auto-merge are NOT
1050
+ # part of the pass yet. What IS measurable today: the deterministic refresh
1051
+ # and the distillation of communities (30 communities >= 10 nodes cover 94 %
1052
+ # of the live brain).
1053
+ EvalTask(
1054
+ id="roadmap-dream-refresh",
1055
+ name="Dream refresh re-derives drifted suggester kinds, leaves manual edges alone",
1056
+ ingests=[
1057
+ ("agent memory systems store knowledge graphs for retrieval", {}),
1058
+ ("agent memory systems store knowledge graphs for retrieval and search", {}),
1059
+ ("Retrieval-Augmented Generation connects a model to external documents", {}),
1060
+ ("Graph RAG combines vector search with multi-hop traversal", {}),
1061
+ # 5th ingest declares a relation explicitly -> origin="manual". Its
1062
+ # cosine is in the `similar` band, so a pass that ignored provenance
1063
+ # would "correct" a user-authored edge.
1064
+ ("agent memory systems store knowledge graphs for retrieval and ranking",
1065
+ {"relations": [("agent memory systems store knowledge graphs for retrieval",
1066
+ "extends")]}),
1067
+ ],
1068
+ actions=[
1069
+ # drift the suggester edge (cos 0.875 => must be `similar` again)
1070
+ _force_kind("agent memory systems store knowledge graphs for retrieval and search",
1071
+ "agent memory systems store knowledge graphs for retrieval",
1072
+ "extends"),
1073
+ _dream_refresh,
1074
+ ],
1075
+ oracle=EvalOracle(
1076
+ node_count=5,
1077
+ edges=[
1078
+ EdgeExpectation(
1079
+ source="agent memory systems store knowledge graphs for retrieval and search",
1080
+ target="agent memory systems store knowledge graphs for retrieval",
1081
+ kind="similar", origin="suggester"),
1082
+ EdgeExpectation(
1083
+ source="agent memory systems store knowledge graphs for retrieval and ranking",
1084
+ target="agent memory systems store knowledge graphs for retrieval",
1085
+ kind="extends", origin="manual"),
1086
+ ],
1087
+ ),
1088
+ ),
1089
+ EvalTask(
1090
+ id="roadmap-dream-distill",
1091
+ name="Dream pass distills each community into one consolidator summary node",
1092
+ ingests=[
1093
+ ("alpha beta gamma delta", {}),
1094
+ ("alpha beta gamma epsilon", {}),
1095
+ ("alpha beta gamma zeta", {}),
1096
+ ("omega psi chi phi", {}),
1097
+ ("omega psi chi kappa", {}),
1098
+ ("omega psi chi lambda", {}),
1099
+ ],
1100
+ actions=[_dream_distill],
1101
+ oracle=EvalOracle(
1102
+ # 6 members + 2 summary nodes (one per cluster)
1103
+ node_count=8,
1104
+ # 2 summaries x 3 members, all `origin="consolidator"`
1105
+ min_edges_by_origin={"consolidator": 6},
1106
+ ),
1107
+ ),
1108
+ # Welle C (2026-09-18): the agent-facing write path. An agent that consults
1109
+ # the brain must be able to leave something in it — under its own provenance
1110
+ # (`source="agent"` / `origin="agent"`), never destructively (`forget`
1111
+ # tombstones and invalidates, it never deletes), and only when the operator
1112
+ # opts in (`ig mcp --write`; the MCP default stays read-only).
1113
+ EvalTask(
1114
+ id="roadmap-agent-memory",
1115
+ name="Agent write path: remember marks provenance, forget tombstones without deleting",
1116
+ ingests=[
1117
+ ("alpha beta gamma delta", {}),
1118
+ ("omega psi chi phi", {}),
1119
+ ],
1120
+ actions=[
1121
+ _agent_remember("a note an agent decided to keep", "alpha beta gamma delta"),
1122
+ _agent_forget("omega psi chi phi", reason="superseded by the newer note"),
1123
+ ],
1124
+ oracle=EvalOracle(
1125
+ # 2 ingests + 1 remembered; the forgotten node is NEVER deleted
1126
+ node_count=3,
1127
+ nodes_present=["a note an agent decided to keep", "omega psi chi phi"],
1128
+ node_status={"omega psi chi phi": "tombstone"},
1129
+ # the agent-declared relation carries agent provenance
1130
+ min_edges_by_origin={"agent": 1},
1131
+ ),
1132
+ ),
1133
+ # Welle B/2 (2026-09-18): the status lifecycle — promotion and decay driven by
1134
+ # the recall signal. Gates are data-derived (see graph_engine/dream.py): the
1135
+ # case pins them explicitly so it stays readable.
1136
+ EvalTask(
1137
+ id="roadmap-dream-lifecycle",
1138
+ name="Lifecycle pass promotes what is used, demotes what is not, deletes nothing",
1139
+ ingests=[
1140
+ ("alpha beta gamma delta", {}),
1141
+ ("omega psi chi phi", {}),
1142
+ ("kappa lambda my tau", {}),
1143
+ ],
1144
+ actions=[
1145
+ _link_same_as("alpha beta gamma delta", "omega psi chi phi"),
1146
+ _link_same_as("alpha beta gamma delta", "kappa lambda my tau"),
1147
+ # alpha (degree 2) and kappa (degree 1) are recalled; omega is not
1148
+ _record_recall("what is used", ["alpha beta gamma delta", "kappa lambda my tau"]),
1149
+ _dream_refresh, # folds the ledger into recall_count
1150
+ # stale_days=0 so the decay gate can fire on a fresh fixture — the
1151
+ # live default is 30 days (a month of silence).
1152
+ _dream_lifecycle(),
1153
+ ],
1154
+ oracle=EvalOracle(
1155
+ # nothing is ever deleted, not even a demoted node
1156
+ node_count=3,
1157
+ node_status={
1158
+ "alpha beta gamma delta": "active", # used + connected
1159
+ "omega psi chi phi": "stale", # unused + weak + old
1160
+ "kappa lambda my tau": "probation", # used, but not connected
1161
+ },
1162
+ ),
1163
+ ),
1164
+ EvalTask(
1165
+ id="episodic-auto-consolidation",
1166
+ name="Used episodes yield one semantic fact with both sources; repeats do no work",
1167
+ ingests=[],
1168
+ actions=[
1169
+ _seed_episodic_consolidation,
1170
+ _record_recall("reply preferences", ["User requested brief replies"]),
1171
+ _dream_refresh,
1172
+ _consolidate_episodic,
1173
+ _consolidate_episodic,
1174
+ ],
1175
+ oracle=EvalOracle(
1176
+ node_count=4,
1177
+ nodes_present=["User requested brief replies", "User prefers concise answers",
1178
+ "A button was clicked"],
1179
+ node_status={"User prefers concise answers": "probation"},
1180
+ edges=[EdgeExpectation("User prefers concise answers",
1181
+ "User requested brief replies", "extends", pending=False)],
1182
+ no_edge=[EdgeExpectation("User prefers concise answers", "A button was clicked", "*")],
1183
+ min_edges_by_origin={"consolidator": 2},
1184
+ ),
1185
+ ),
1186
+
1187
+ ]
1188
+
1189
+
1190
+
1191
+ # ---------------------------------------------------------------------------
1192
+ # Roadmap cases — desired future behavior (turns green once implemented)
1193
+ # ---------------------------------------------------------------------------
1194
+
1195
+ ROADMAP_CASES: list[EvalTask] = [
1196
+ # Admit-rule enforcement (V2#3) is implemented → GOLDEN_SET
1197
+ # (`admit-rule-no-relations`, `admit-rule-with-relations`).
1198
+ # Late chunking (V2#1) deliberately stays NO eval case: as long as there is
1199
+ # no real chunking layer (the engine embeds the whole node text), it cannot
1200
+ # be specified as an end-state oracle — a case would either turn
1201
+ # spuriously green (whole-text embedding satisfies it trivially) or red
1202
+ # for the wrong reasons. So it remains a documented roadmap note, not a case.
1203
+ #
1204
+ # Cross-encoder reranking (V2#1) is implemented → GOLDEN_SET
1205
+ # (`retrieval-rerank-honored`).
1206
+ ]