Skip to content

Commit ec8c4c3

Browse files
fix: finalize 1.4.5 consolidation hardening (#113)
Carry bounded consolidation clusters across maintenance windows, filter closed pending sources, bound raw scans, and strengthen ranking evaluation coverage.
1 parent 26a7344 commit ec8c4c3

5 files changed

Lines changed: 462 additions & 15 deletions

File tree

engraphis/core/consolidate.py

Lines changed: 158 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
# Bound the population that reaches the quadratic fallback clustering pass while
6161
# allowing the storage scan to page in smaller batches and skip pending rows.
6262
DISTILL_CLUSTER_LIMIT = 2000
63+
# Carry a small, bounded set of incomplete clusters into the next sweep. This keeps
64+
# interleaved subjects from being permanently split across rotating windows without
65+
# turning a maintenance pass into an unbounded full-table scan.
66+
DISTILL_PENDING_LIMIT = 512
6367
# Cursor name for the bounded episodic sweep; the value is scoped by workspace/repo.
6468
DISTILL_CURSOR_NAME = "episodic-consolidation"
6569

@@ -203,29 +207,54 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
203207
batch_size: int, prompt_only: bool = False,
204208
max_records: Optional[int] = None,
205209
exclude_relation: Optional[str] = None,
206-
start_after_id: str = "") -> tuple[list[MemoryRecord], str]:
210+
start_after_id: str = "", overlap: int = 0,
211+
advance_records: Optional[int] = None,
212+
) -> tuple[list[MemoryRecord], str]:
207213
"""Read one bounded keyset window and return its next persistent cursor.
208214
209215
``Store.list_memories_page`` orders by id. When a bounded window reaches the
210216
end, the empty cursor deliberately makes the *next* sweep wrap to the start;
211217
this rotates maintenance over all eligible rows without materializing or
212-
clustering the full population on every run.
218+
clustering the full population on every run. ``overlap`` retains a bounded
219+
suffix of the raw keyset window for the next sweep, which keeps clusters that
220+
straddle a maintenance boundary intact. ``advance_records`` is a raw-row
221+
progress floor used when filtering leaves fewer than ``max_records`` eligible
222+
rows; it prevents a bounded sweep from pinning its cursor on an excluded page.
213223
"""
214224
size = max(1, int(batch_size))
215225
cap = None if max_records is None else max(0, int(max_records))
216-
if cap == 0:
226+
advance_cap = (
227+
None if advance_records is None else max(0, int(advance_records))
228+
)
229+
if cap == 0 or advance_cap == 0:
217230
return [], str(start_after_id or "")
218231
after_id = str(start_after_id or "")
219232
records: list[MemoryRecord] = []
233+
window_ids: list[str] = []
220234
scoped = _replace(flt, mtypes=mtypes)
221235
next_cursor = ""
236+
237+
def cursor_for_window() -> str:
238+
retained = min(max(0, int(overlap)), max(0, len(window_ids) - 1))
239+
return window_ids[len(window_ids) - retained - 1]
240+
222241
while True:
223-
page = store.list_memories_page(scoped, after_id=after_id, limit=size)
242+
if advance_cap is not None and len(window_ids) >= advance_cap:
243+
next_cursor = cursor_for_window()
244+
break
245+
remaining = None if cap is None else max(1, cap - len(records))
246+
page_limit = size if remaining is None else min(size, remaining)
247+
if advance_cap is not None:
248+
page_limit = min(page_limit, advance_cap - len(window_ids))
249+
page = store.list_memories_page(
250+
scoped, after_id=after_id, limit=page_limit,
251+
)
224252
if not page:
225253
# The persisted cursor was at the end of the keyspace. Start the next
226254
# sweep from the beginning instead of retrying an empty tail forever.
227255
break
228256
next_after = page[-1].id
257+
window_ids.extend(memory.id for memory in page)
229258
page_size = len(page)
230259
if exclude_relation:
231260
excluded = _linked_memory_ids(
@@ -240,12 +269,22 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
240269
else:
241270
records.extend(page)
242271
if cap is not None and len(records) >= cap:
243-
next_cursor = next_after
272+
next_cursor = cursor_for_window()
273+
break
274+
if advance_cap is not None and len(window_ids) >= advance_cap:
275+
next_cursor = cursor_for_window()
244276
break
245-
if next_after == after_id or page_size < size:
277+
if next_after == after_id or page_size < page_limit:
246278
# End-of-keyspace: clear the cursor for the next invocation.
247279
break
248280
after_id = next_after
281+
if (
282+
not next_cursor
283+
and advance_cap is not None
284+
and window_ids
285+
and len(window_ids) >= max(1, advance_cap - max(0, int(overlap)))
286+
):
287+
next_cursor = cursor_for_window()
249288
records.sort(
250289
key=lambda memory: (
251290
memory.ingested_at if memory.ingested_at is not None else float("-inf"),
@@ -256,6 +295,90 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
256295
return records[:cap] if cap is not None else records, next_cursor
257296

258297

298+
def _decode_distill_cursor(value: str) -> tuple[str, list[str]]:
299+
"""Read a legacy keyset cursor or the current cursor-plus-candidates state."""
300+
raw = str(value or "")
301+
if not raw.startswith("{"):
302+
return raw, []
303+
try:
304+
state = json.loads(raw)
305+
except (TypeError, ValueError):
306+
return raw, []
307+
if not isinstance(state, dict):
308+
return raw, []
309+
cursor = state.get("cursor")
310+
pending = state.get("pending")
311+
if not isinstance(cursor, str) or not isinstance(pending, list):
312+
return raw, []
313+
ids = list(dict.fromkeys(
314+
str(memory_id) for memory_id in pending if str(memory_id or "")
315+
))
316+
return cursor, ids[:DISTILL_PENDING_LIMIT]
317+
318+
319+
def _encode_distill_cursor(cursor: str, pending_ids: list[str]) -> str:
320+
"""Persist bounded partial-cluster candidates alongside the scan cursor."""
321+
normalized = list(dict.fromkeys(
322+
str(memory_id) for memory_id in pending_ids if str(memory_id or "")
323+
))[:DISTILL_PENDING_LIMIT]
324+
if not normalized:
325+
return str(cursor or "")
326+
return json.dumps(
327+
{"cursor": str(cursor or ""), "pending": normalized},
328+
ensure_ascii=False,
329+
separators=(",", ":"),
330+
)
331+
332+
333+
def _load_distill_candidates(
334+
store, flt: SearchFilter, pending_ids: list[str], *, now: float,
335+
) -> list[MemoryRecord]:
336+
"""Reload prior partial-cluster sources, dropping deleted or ineligible rows."""
337+
from engraphis.core.store import memory_matches_filter
338+
339+
if not pending_ids:
340+
return []
341+
records: list[MemoryRecord] = []
342+
for memory_id in dict.fromkeys(pending_ids):
343+
memory = store.get_memory(memory_id)
344+
if (
345+
memory is not None
346+
and memory.mtype == MemoryType.EPISODIC
347+
and memory_matches_filter(memory, flt, at=now)
348+
and prompt_eligible(memory.provenance, memory.metadata)
349+
):
350+
records.append(memory)
351+
if not records:
352+
return []
353+
linked = _linked_memory_ids(
354+
store, [memory.id for memory in records], relation="consolidates",
355+
)
356+
return [memory for memory in records if memory.id not in linked]
357+
358+
359+
def _pending_distill_ids(clusters: list[list[MemoryRecord]], *, min_cluster: int) -> list[str]:
360+
"""Select bounded candidates whose cluster needs a later sweep to complete.
361+
362+
Multi-record partial clusters are preferred because they carry positive evidence
363+
that a subject is recurring. Singleton records with an explicit subject key are
364+
retained too; unrelated singleton noise is intentionally not allowed to consume
365+
the whole carry-over budget.
366+
"""
367+
incomplete = [cluster for cluster in clusters if 0 < len(cluster) < min_cluster]
368+
prioritized = [
369+
cluster for cluster in incomplete
370+
if len(cluster) > 1 or any(memory.subject_key for memory in cluster)
371+
]
372+
selected: list[str] = []
373+
for cluster in prioritized:
374+
for memory in cluster[-max(1, min_cluster - 1):]:
375+
if memory.id not in selected:
376+
selected.append(memory.id)
377+
if len(selected) >= DISTILL_PENDING_LIMIT:
378+
return selected
379+
return selected
380+
381+
259382
def _scan_memories(store, flt: SearchFilter, *, mtypes: list[MemoryType],
260383
batch_size: int, prompt_only: bool = False,
261384
max_records: Optional[int] = None,
@@ -687,19 +810,30 @@ def consolidate(engine, *, workspace_id: str, repo_id: Optional[str] = None,
687810
except Exception as exc:
688811
report["errors"].append(_error_entry(retry_cluster, exc))
689812

690-
distill_cursor = store.get_maintenance_cursor(
813+
distill_state = store.get_maintenance_cursor(
691814
workspace_id, repo_id, DISTILL_CURSOR_NAME,
692815
)
816+
distill_cursor, pending_ids = _decode_distill_cursor(distill_state)
817+
distill_overlap = max(0, int(min_cluster) - 1)
693818
episodic, next_distill_cursor = _scan_memory_window(
694819
store, flt, mtypes=[MemoryType.EPISODIC],
695820
batch_size=DISTILL_SCAN_LIMIT, prompt_only=True,
696-
max_records=DISTILL_CLUSTER_LIMIT,
821+
max_records=DISTILL_CLUSTER_LIMIT + distill_overlap,
697822
exclude_relation="consolidates",
698823
start_after_id=distill_cursor,
824+
overlap=distill_overlap,
825+
advance_records=DISTILL_CLUSTER_LIMIT + distill_overlap,
699826
)
700-
if not dry_run:
701-
store.set_maintenance_cursor(
702-
workspace_id, repo_id, DISTILL_CURSOR_NAME, next_distill_cursor,
827+
prior_candidates = _load_distill_candidates(store, flt, pending_ids, now=now)
828+
if prior_candidates:
829+
by_id = {memory.id: memory for memory in [*prior_candidates, *episodic]}
830+
episodic = sorted(
831+
by_id.values(),
832+
key=lambda memory: (
833+
memory.ingested_at if memory.ingested_at is not None else float("-inf"),
834+
memory.id,
835+
),
836+
reverse=True,
703837
)
704838
# A digest inherits its owner from its first source. Cluster only records that have
705839
# the exact same owner, otherwise a workspace sweep could write one repo's digest with
@@ -711,6 +845,14 @@ def consolidate(engine, *, workspace_id: str, repo_id: Optional[str] = None,
711845
owner_memories, threshold=subject_jaccard, store=store, flt=flt,
712846
)
713847
]
848+
if not dry_run:
849+
store.set_maintenance_cursor(
850+
workspace_id, repo_id, DISTILL_CURSOR_NAME,
851+
_encode_distill_cursor(
852+
next_distill_cursor,
853+
_pending_distill_ids(clusters, min_cluster=min_cluster),
854+
),
855+
)
714856

715857
if structured:
716858
report["structured"] = {"enabled": True, "attempted": 0, "succeeded": 0,
@@ -1424,11 +1566,15 @@ def consolidate_profiles(engine, *, workspace_id: str, repo_id: Optional[str] =
14241566
profile_cursor = store.get_maintenance_cursor(
14251567
workspace_id, repo_id, PROFILE_CURSOR_NAME,
14261568
)
1569+
profile_overlap = max(0, int(min_mentions) - 1)
14271570
profile_memories, next_profile_cursor = _scan_memory_window(
14281571
store, flt, mtypes=DURABLE_TYPES,
14291572
batch_size=PROFILE_SCAN_LIMIT, prompt_only=True,
1430-
max_records=PROFILE_MEMORY_LIMIT, exclude_relation=PROFILE_RELATION,
1573+
max_records=PROFILE_MEMORY_LIMIT + profile_overlap,
1574+
exclude_relation=PROFILE_RELATION,
14311575
start_after_id=profile_cursor,
1576+
overlap=profile_overlap,
1577+
advance_records=PROFILE_MEMORY_LIMIT + profile_overlap,
14321578
)
14331579
if not dry_run:
14341580
store.set_maintenance_cursor(

eval/consolidation_ranking.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@
1111
from __future__ import annotations
1212

1313
import json
14+
import math
1415
from pathlib import Path
1516
from typing import Any
1617

1718
from engraphis.core.consolidate import consolidate
1819
from engraphis.core.engine import MemoryEngine
1920
from engraphis.core.interfaces import MemoryType, SearchFilter
21+
from engraphis.core.recall import CONSOLIDATION_BONUS
2022

2123

2224
DATASET = Path(__file__).with_name("datasets") / "consolidation_ranking.jsonl"
@@ -40,6 +42,24 @@ def load_cases(path: Path = DATASET) -> list[dict[str, Any]]:
4042
context = case.get("context", [])
4143
if not isinstance(context, list):
4244
raise ValueError(f"case {case['id']} context must be a list")
45+
probes = case.get("bonus_probes", [])
46+
if not isinstance(probes, list):
47+
raise ValueError(f"case {case['id']} bonus_probes must be a list")
48+
for probe in probes:
49+
if (
50+
not isinstance(probe, dict)
51+
or not isinstance(probe.get("id"), str)
52+
or probe.get("expected") not in {"source", "digest"}
53+
):
54+
raise ValueError(f"case {case['id']} has an invalid bonus probe")
55+
for field in ("source_score", "digest_score"):
56+
value = probe.get(field)
57+
if (
58+
not isinstance(value, (int, float))
59+
or isinstance(value, bool)
60+
or not math.isfinite(float(value))
61+
):
62+
raise ValueError(f"case {case['id']} has an invalid probe {field}")
4363
expected = case.get("expected")
4464
if not isinstance(expected, (str, dict)):
4565
raise ValueError(f"case {case['id']} needs an expected ranking target")
@@ -100,6 +120,7 @@ def evaluate_case(case: dict[str, Any]) -> dict[str, Any]:
100120
context_ids[tag] = engine.remember(
101121
str(item["text"]), workspace_id=workspace_id, repo_id=repo_id,
102122
mtype=_memory_type(item.get("mtype"), MemoryType.SEMANTIC),
123+
importance=float(item.get("importance", 0.0)),
103124
resolve_conflicts=False,
104125
)
105126
report = consolidate(engine, workspace_id=workspace_id, repo_id=repo_id)
@@ -163,11 +184,42 @@ def rank(ids: list[str], target: str) -> int | None:
163184
}
164185

165186

187+
def _evaluate_bonus_probe(probe: dict[str, Any]) -> dict[str, Any]:
188+
"""Exercise the exact post-normalization bonus at a controlled boundary."""
189+
baseline_scores = {
190+
"source": float(probe["source_score"]),
191+
"digest": float(probe["digest_score"]),
192+
}
193+
policy_scores = dict(baseline_scores)
194+
policy_scores["digest"] += CONSOLIDATION_BONUS
195+
baseline = sorted(baseline_scores, key=lambda kind: (-baseline_scores[kind], kind))
196+
policy = sorted(policy_scores, key=lambda kind: (-policy_scores[kind], kind))
197+
expected = str(probe["expected"])
198+
return {
199+
"id": probe["id"],
200+
"expected": expected,
201+
"baseline_top": baseline[0],
202+
"policy_top": policy[0],
203+
"baseline_hit_at_1": baseline[0] == expected,
204+
"policy_hit_at_1": policy[0] == expected,
205+
"ranking_changed": baseline != policy,
206+
}
207+
208+
166209
def evaluate(path: Path = DATASET) -> dict[str, Any]:
167210
"""Return ranking preference and raw-evidence retention metrics."""
168-
results = [evaluate_case(case) for case in load_cases(path)]
211+
cases = load_cases(path)
212+
results = [evaluate_case(case) for case in cases]
213+
bonus_probes = [
214+
_evaluate_bonus_probe(probe)
215+
for case in cases for probe in case.get("bonus_probes", [])
216+
]
169217
summary = [item for item in results if item["expected_role"] == "digest"]
170218
details = [item for item in results if item["expected_role"] == "raw"]
219+
source_regressions = [
220+
item["id"] for item in bonus_probes
221+
if item["expected"] == "source" and not item["policy_hit_at_1"]
222+
]
171223
return {
172224
"cases": len(results),
173225
"summary_digest_top1_rate": (
@@ -178,7 +230,31 @@ def evaluate(path: Path = DATASET) -> dict[str, Any]:
178230
sum(item["baseline_top"] == item["expected_id"] for item in summary)
179231
/ len(summary)
180232
),
181-
"ranking_changed_rate": sum(item["ranking_changed"] for item in results) / len(results),
233+
"production_trace_ranking_changed_rate": (
234+
sum(item["ranking_changed"] for item in results) / len(results)
235+
),
236+
"bonus_probe_count": len(bonus_probes),
237+
"ranking_changed_rate": (
238+
sum(item["ranking_changed"] for item in bonus_probes) / len(bonus_probes)
239+
if bonus_probes else 0.0
240+
),
241+
"bonus_probe_digest_top1_rate": (
242+
sum(item["policy_hit_at_1"] for item in bonus_probes
243+
if item["expected"] == "digest")
244+
/ max(1, sum(item["expected"] == "digest" for item in bonus_probes))
245+
),
246+
"baseline_bonus_probe_digest_top1_rate": (
247+
sum(item["baseline_hit_at_1"] for item in bonus_probes
248+
if item["expected"] == "digest")
249+
/ max(1, sum(item["expected"] == "digest" for item in bonus_probes))
250+
),
251+
"bonus_probe_source_top1_rate": (
252+
sum(item["policy_hit_at_1"] for item in bonus_probes
253+
if item["expected"] == "source")
254+
/ max(1, sum(item["expected"] == "source" for item in bonus_probes))
255+
),
256+
"bonus_probe_source_regressions": source_regressions,
257+
"bonus_probes": bonus_probes,
182258
"expected_hit_at_k": sum(item["expected_hit_at_k"] for item in results) / len(results),
183259
"raw_detail_hit_at_k": (
184260
sum(item["expected_hit_at_k"] for item in details) / len(details)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Consolidated digest ranking: summary preference and raw-detail retention.
2-
{"id":"summary_digest","query":"flaky network integration build failure","expected":"digest","k":5,"cluster":[{"text":"Build failed on the flaky network integration test in CI run 101."},{"text":"Build failed on the flaky network integration test in CI run 202."},{"text":"Build failed on the flaky network integration test in CI run 303."}],"context":[{"tag":"unrelated","text":"The office kitchen orders sourdough every Friday.","mtype":"semantic"}]}
2+
{"id":"summary_digest","query":"flaky network integration build failure","expected":"digest","k":5,"cluster":[{"text":"Build failed on the flaky network integration test in CI run 101."},{"text":"Build failed on the flaky network integration test in CI run 202."},{"text":"Build failed on the flaky network integration test in CI run 303."}],"context":[{"tag":"unrelated","text":"build failure","mtype":"working","importance":0.5}],"bonus_probes":[{"id":"near-boundary-digest","source_score":0.72,"digest_score":0.70,"expected":"digest"},{"id":"source-margin","source_score":0.80,"digest_score":0.70,"expected":"source"}]}
33
{"id":"specific_raw_evidence","query":"What rollback procedure follows a failed API canary?","expected":{"tag":"rollback","role":"raw"},"k":5,"cluster":[{"text":"The API deployment failed a canary health check during the morning release."},{"text":"The API deployment failed a canary health check during the afternoon release."},{"text":"The API deployment failed a canary health check during the evening release."}],"context":[{"tag":"rollback","text":"The exact rollback command is kubectl rollout undo deployment/api after a canary failure.","mtype":"procedural"}]}

0 commit comments

Comments
 (0)