Skip to content

Commit 3cb2942

Browse files
committed
fix(consolidation): eliminate duplicate observations
Consolidation produced duplicate observations two ways: (1) the cross-encoder reranker demoted a near-identical existing observation below the recall budget, hiding it from the LLM; (2) even when shown the twin, the model emitted an identical CREATE anyway (often while also UPDATE-ing it). Changes: - recall: add a 'rerank' flag; consolidation recall ranks by RRF (rerank=False) so a near-identical existing observation stays near the top instead of being demoted by the cross-encoder. Default recall path is unchanged. - consolidation: drop a CREATE whose normalized text exactly matches an observation shown to the LLM, or the text of an UPDATE issued in the same response (exact match => no information loss). See _duplicate_create_target; dropped creates are logged at WARNING. - add a 'reason' field to consolidation create/update/delete actions so the model explains its choices (surfaced when a duplicate create is dropped). - tests/eval: deterministic unit test for the dedup guard (test_consolidation_dedup.py); the duplicate-observation rate is tracked by a new quality benchmark (hindsight-dev/benchmarks/obs) over a synthetic herb-garden transcript and the real PII-free hermes session that surfaced the bug, reusing the obs-dedup tool — instead of a flaky real-LLM pytest.
1 parent 92bd42c commit 3cb2942

10 files changed

Lines changed: 599 additions & 16 deletions

File tree

hindsight-api-slim/hindsight_api/engine/consolidation/consolidator.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,32 @@
4646
logger = logging.getLogger(__name__)
4747

4848

49+
def _norm_obs_text(text: str) -> str:
50+
"""Whitespace/case-normalised observation text for exact-duplicate matching."""
51+
return " ".join((text or "").split()).strip().lower()
52+
53+
54+
def _duplicate_create_target(
55+
create_text: str,
56+
shown_obs_by_text: "dict[str, MemoryFact]",
57+
update_texts: set[str],
58+
) -> str | None:
59+
"""Return a human label for what ``create_text`` duplicates, or None if novel.
60+
61+
A CREATE is a duplicate when its normalised text matches an observation that was
62+
already shown to the LLM, or the text of an UPDATE issued in the same response
63+
(the model occasionally UPDATEs the twin to text X and also CREATEs X). Exact-text
64+
match means no information is lost by dropping the CREATE.
65+
"""
66+
norm = _norm_obs_text(create_text)
67+
matched = shown_obs_by_text.get(norm)
68+
if matched is not None:
69+
return f"shown observation {str(matched.id)[:8]}"
70+
if norm in update_texts:
71+
return "an UPDATE in this response"
72+
return None
73+
74+
4975
@dataclass
5076
class _BatchDeltas:
5177
"""Per-LLM-batch deltas, merged into the job's running stats after dispatch.
@@ -167,6 +193,9 @@ async def _filter_live_source_memories(
167193
class _CreateAction(BaseModel):
168194
text: str
169195
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
196+
# One-sentence justification from the LLM (why CREATE vs UPDATE). Diagnostic
197+
# only — surfaced in the consolidation trace to explain duplicate creates.
198+
reason: str = ""
170199

171200
@field_validator("text", mode="before")
172201
@classmethod
@@ -178,6 +207,7 @@ class _UpdateAction(BaseModel):
178207
text: str
179208
observation_id: str # UUID of the existing observation to update
180209
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
210+
reason: str = "" # LLM's one-sentence justification (diagnostic only)
181211

182212
@field_validator("text", mode="before")
183213
@classmethod
@@ -187,6 +217,7 @@ def sanitize_text(cls, v: str) -> str:
187217

188218
class _DeleteAction(BaseModel):
189219
observation_id: str # UUID of the observation to remove
220+
reason: str = "" # LLM's one-sentence justification (diagnostic only)
190221

191222

192223
class _ConsolidationBatchResponse(BaseModel):
@@ -1104,16 +1135,44 @@ async def _process_memory_batch(
11041135
for m in source_mems:
11051136
per_memory_updated.add(str(m["id"]))
11061137

1138+
# Deterministic dedup guard: map the observations the LLM was SHOWN by their
1139+
# normalised text. The model intermittently emits a CREATE whose text is identical
1140+
# to an observation already in its context (over-aggregation / incoherence — it even
1141+
# UPDATEs the twin and creates a sibling). When that happens we drop the duplicate
1142+
# CREATE instead of inserting a redundant row. No extra LLM/embedding cost — the
1143+
# match is exact text against the in-memory set.
1144+
shown_obs_by_text = {_norm_obs_text(o.text): o for o in union_observations}
1145+
# Also collapse a CREATE that reproduces the text of an UPDATE issued in the SAME
1146+
# response (the model occasionally UPDATEs the twin to text X and also CREATEs X).
1147+
update_texts = {_norm_obs_text(u.text) for u in llm_result.updates if u.text}
1148+
11071149
for create in llm_result.creates:
11081150
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
11091151
if not source_mems:
11101152
continue
11111153
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
1154+
create_source_ids = [m["id"] for m in source_mems]
1155+
1156+
# Reconcile against observations shown to the LLM: an exact-text match means
1157+
# this CREATE reproduces verbatim an observation the model already had in context.
1158+
# Since that observation already carries this exact text, drop the duplicate CREATE
1159+
# — no row is inserted, nothing is lost. We deliberately do NOT also UPDATE the twin
1160+
# here: the LLM frequently UPDATEd it earlier in this same batch, and a second update
1161+
# would run off the pre-LLM snapshot and clobber that change (see _dedupe_updates).
1162+
duplicate_of = _duplicate_create_target(create.text, shown_obs_by_text, update_texts)
1163+
if duplicate_of is not None:
1164+
logger.warning(
1165+
"[CONSOLIDATION] dropped duplicate observation CREATE — verbatim match of %s; llm_reason=%r",
1166+
duplicate_of,
1167+
create.reason or "(none given)",
1168+
)
1169+
continue
1170+
11121171
await _execute_create_action(
11131172
conn=conn,
11141173
memory_engine=memory_engine,
11151174
bank_id=bank_id,
1116-
source_memory_ids=[m["id"] for m in source_mems],
1175+
source_memory_ids=create_source_ids,
11171176
text=create.text,
11181177
source_fact_tags=agg.tags,
11191178
event_date=agg.event_date,
@@ -1398,6 +1457,12 @@ async def _find_related_observations(
13981457
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
13991458
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
14001459
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
1460+
# Rank by RRF, skipping the cross-encoder reranker: for consolidation we are
1461+
# looking for an existing near-identical observation to merge into, and the
1462+
# cross-encoder was observed to demote that twin far below the budget cutoff
1463+
# (semantic rank #1 -> reranked #37). RRF keeps strong lexical/semantic
1464+
# matches near the top so the LLM is shown the twin and UPDATEs it.
1465+
rerank=False,
14011466
_quiet=True, # Suppress logging
14021467
)
14031468
finally:

hindsight-api-slim/hindsight_api/engine/consolidation/prompts.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
6666
_OUTPUT_SECTION = """## OUTPUT FORMAT
6767
68-
Return a JSON object with three arrays: `creates`, `updates`, `deletes`.
68+
Return a JSON object with three arrays: `creates`, `updates`, `deletes`. Every entry must include a `reason`.
6969
7070
### Example 1 — Merging recurring claims into an existing observation
7171
@@ -79,7 +79,7 @@
7979
Expected output (one UPDATE, no creates — both new facts are additional evidence for the same canonical decision):
8080
8181
{{"creates": [],
82-
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
82+
"updates": [{{"text": "Donald named Athena's sovereignty as a foundational principle of the Janus architecture.", "observation_id": "11111111-1111-1111-1111-111111111111", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"], "reason": "Both new facts restate the same sovereignty decision already captured by obs 1111 — merged as evidence rather than creating siblings."}}],
8383
"deletes": []}}
8484
8585
### Example 2 — State change updates one observation; unrelated fact creates a new one
@@ -93,8 +93,8 @@
9393
9494
Expected output (UPDATE for the state change; CREATE for the unrelated work-hours facet):
9595
96-
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
97-
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"]}}],
96+
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"], "reason": "Work-hours is a distinct facet; no existing observation covers it, so CREATE."}}],
97+
"updates": [{{"text": "Alice owned a 2019 Honda Civic; sold it on March 15, 2025.", "observation_id": "22222222-2222-2222-2222-222222222222", "source_fact_ids": ["c3d4e5f6-a7b8-9012-cdef-123456789012"], "reason": "State change to the existing Honda Civic observation 2222 — UPDATE, not a new sibling."}}],
9898
"deletes": []}}
9999
100100
### Observation text rules
@@ -110,6 +110,7 @@
110110
- One create or update may reference multiple facts when they jointly support the observation.
111111
- **AT MOST ONE UPDATE PER `observation_id`**: if several new facts all update the same existing observation, emit a single `updates` entry that lists all contributing `source_fact_ids` and a single consolidated `text`. Never emit two `updates` entries with the same `observation_id` in one response — they would silently overwrite each other.
112112
- `deletes`: only when an observation is directly superseded or contradicted by new facts.
113+
- `reason`: REQUIRED on every create/update/delete — one sentence explaining the choice. For a CREATE, state which existing observation(s) you considered and why none matched (a near-identical existing observation means you should UPDATE, not CREATE). This is audited to catch duplicate creates.
113114
- Do NOT include `tags` — handled automatically.
114115
- Return `{{"creates": [], "updates": [], "deletes": []}}` if nothing durable is found."""
115116

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ def validate_sql_schema(sql: str) -> None:
242242
from .search import think_utils
243243
from .search.reranking import CrossEncoderReranker, apply_combined_scoring
244244
from .search.tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause
245+
from .search.types import ScoredResult
245246
from .task_backend import TaskBackend
246247
from .token_encoding import get_token_encoding
247248

@@ -3141,6 +3142,7 @@ async def recall_async(
31413142
created_before: datetime | None = None,
31423143
_connection_budget: int | None = None,
31433144
_quiet: bool = False,
3145+
rerank: bool = True,
31443146
) -> RecallResultModel:
31453147
"""
31463148
Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods).
@@ -3297,6 +3299,7 @@ async def recall_async(
32973299
include_source_facts=include_source_facts,
32983300
max_source_facts_tokens=max_source_facts_tokens,
32993301
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
3302+
rerank=rerank,
33003303
)
33013304
break # Success - exit retry loop
33023305
except Exception as e:
@@ -3428,6 +3431,7 @@ async def _search_with_retries(
34283431
include_source_facts: bool = False,
34293432
max_source_facts_tokens: int = 4096,
34303433
max_source_facts_tokens_per_observation: int = -1,
3434+
rerank: bool = True,
34313435
) -> RecallResultModel:
34323436
"""
34333437
Search implementation with modular retrieval and reranking.
@@ -3791,26 +3795,43 @@ def to_tuple_format(results):
37913795

37923796
scored_results: list = []
37933797
pre_filtered_count = 0
3798+
rerank_kind = "cross-encoder"
37943799
try:
3795-
# Ensure reranker is initialized (for lazy initialization mode)
3796-
await reranker_instance.ensure_initialized()
3797-
3798-
# Pre-filter candidates to reduce reranking cost (RRF already provides good ranking)
3799-
# This is especially important for remote rerankers with network latency
3800+
# Pre-filter candidates by RRF before the (optional) cross-encoder.
3801+
# RRF already provides good ranking; this caps cross-encoder cost.
38003802
reranker_max_candidates = get_config().reranker_max_candidates
38013803
if len(merged_candidates) > reranker_max_candidates:
3802-
# Sort by RRF score and take top candidates
38033804
merged_candidates.sort(key=lambda mc: mc.rrf_score, reverse=True)
38043805
pre_filtered_count = len(merged_candidates) - reranker_max_candidates
38053806
merged_candidates = merged_candidates[:reranker_max_candidates]
38063807

3807-
# Rerank using cross-encoder
3808-
scored_results = await reranker_instance.rerank(query, merged_candidates)
3808+
if rerank:
3809+
# Ensure reranker is initialized (for lazy initialization mode)
3810+
await reranker_instance.ensure_initialized()
3811+
scored_results = await reranker_instance.rerank(query, merged_candidates)
3812+
else:
3813+
# RRF-only: skip the cross-encoder and rank by RRF score. Used by
3814+
# consolidation recall, where the cross-encoder was observed to demote
3815+
# a near-identical existing observation (the dedup "twin") far below the
3816+
# budget cutoff (semantic rank #1 -> reranked #37), causing the LLM to
3817+
# never see it and create a duplicate. Passthrough ScoredResults keep the
3818+
# downstream recency/temporal boosts working, seeded from RRF rank.
3819+
rerank_kind = "rrf-passthrough"
3820+
scored_results = [
3821+
ScoredResult(
3822+
candidate=mc,
3823+
cross_encoder_score=0.0,
3824+
cross_encoder_score_normalized=0.0,
3825+
weight=0.0,
3826+
)
3827+
for mc in sorted(merged_candidates, key=lambda mc: mc.rrf_score, reverse=True)
3828+
]
38093829

38103830
step_duration = time.time() - step_start
38113831
pre_filter_note = f" (pre-filtered {pre_filtered_count})" if pre_filtered_count > 0 else ""
38123832
log_buffer.append(
3813-
f" [4] Reranking: {len(scored_results)} candidates scored in {step_duration:.3f}s{pre_filter_note}"
3833+
f" [4] Reranking [{rerank_kind}]: {len(scored_results)} candidates "
3834+
f"scored in {step_duration:.3f}s{pre_filter_note}"
38143835
)
38153836
finally:
38163837
rerank_span.set_attribute("hindsight.scored_count", len(scored_results))
@@ -3825,7 +3846,8 @@ def to_tuple_format(results):
38253846
# the slim/passthrough one that returns a constant score per pair.
38263847
if scored_results:
38273848
ce = reranker_instance.cross_encoder
3828-
is_passthrough = ce is not None and ce.provider_name == "rrf"
3849+
# RRF-only mode is passthrough by construction; so is a configured "rrf" CE.
3850+
is_passthrough = (not rerank) or (ce is not None and ce.provider_name == "rrf")
38293851
apply_combined_scoring(
38303852
scored_results,
38313853
now=_recall_scoring_now(question_date),
@@ -3845,7 +3867,7 @@ def to_tuple_format(results):
38453867
tracer.add_phase_metric(
38463868
"reranking",
38473869
step_duration,
3848-
{"reranker_type": "cross-encoder", "candidates_reranked": len(scored_results)},
3870+
{"reranker_type": rerank_kind, "candidates_reranked": len(scored_results)},
38493871
)
38503872

38513873
# Step 5: Truncate to thinking_budget * 2 for token filtering
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Deterministic unit tests for the consolidation duplicate-create guard.
2+
3+
These exercise the dedup decision directly (no LLM, no DB), so they reliably
4+
guard the fix in CI — unlike the real-LLM integration test, which only triggers
5+
the path stochastically.
6+
"""
7+
8+
from dataclasses import dataclass
9+
10+
from hindsight_api.engine.consolidation.consolidator import _duplicate_create_target, _norm_obs_text
11+
12+
13+
@dataclass
14+
class _FakeObs:
15+
id: str
16+
text: str
17+
18+
19+
def _shown(*observations: _FakeObs) -> dict[str, _FakeObs]:
20+
return {_norm_obs_text(o.text): o for o in observations}
21+
22+
23+
def test_norm_obs_text_collapses_whitespace_and_case() -> None:
24+
assert _norm_obs_text(" The User likes BASIL.\n") == "the user likes basil."
25+
assert _norm_obs_text(None) == ""
26+
27+
28+
def test_create_matching_shown_observation_is_duplicate() -> None:
29+
shown = _shown(_FakeObs(id="11111111-aaaa", text="User waters the herbs early in the morning."))
30+
# Same text with different whitespace/case still matches.
31+
target = _duplicate_create_target("user waters the herbs early in the morning.", shown, set())
32+
assert target is not None
33+
assert target.startswith("shown observation 11111111")
34+
35+
36+
def test_create_matching_inresponse_update_is_duplicate() -> None:
37+
update_texts = {_norm_obs_text("Mint is kept in its own separate bed.")}
38+
target = _duplicate_create_target("Mint is kept in its own separate bed.", {}, update_texts)
39+
assert target == "an UPDATE in this response"
40+
41+
42+
def test_novel_create_is_not_duplicate() -> None:
43+
shown = _shown(_FakeObs(id="22222222-bbbb", text="User waters the herbs early in the morning."))
44+
assert _duplicate_create_target("Rosemary is drought-tolerant.", shown, set()) is None
45+
assert _duplicate_create_target("", {}, set()) is None
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Observation duplication benchmark
2+
3+
Measures how many **duplicate observations** consolidation produces — a quality
4+
signal for the consolidation pipeline (complements the perf-focused
5+
`consolidation` benchmark).
6+
7+
For each document under `datasets/`, it ingests the content into a fresh bank,
8+
runs consolidation, then reuses the observation-dedup tool
9+
(`hindsight_dev.obs_dedup`) to score:
10+
11+
- **exact duplicates** — observations with identical (normalised) text in a scope
12+
- **near duplicates** — cosine-similarity clusters at thresholds (0.97, 0.92)
13+
14+
Headline metric: **duplication rate** = redundant observations / total. Lower is
15+
better.
16+
17+
## Run
18+
19+
```bash
20+
./scripts/benchmarks/run-obs.sh
21+
# or
22+
cd hindsight-dev && uv run python -m benchmarks.obs.obs_benchmark
23+
```
24+
25+
Requires a real LLM (set `HINDSIGHT_API_LLM_PROVIDER` / `_MODEL` / `_API_KEY`).
26+
Results are written to `benchmarks/results/obs_benchmark_<ts>.json`.
27+
28+
## Extending
29+
30+
Drop more `*.txt` transcripts into `datasets/` — one file per scenario. Keep them
31+
synthetic and PII-free. Documents that restate the same durable facts across many
32+
turns are the ones that stress consolidation's dedup behaviour.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Observation-quality benchmark: measures duplicate-observation rate after consolidation."""

0 commit comments

Comments
 (0)