Skip to content

Commit 613a699

Browse files
authored
fix(consolidation): eliminate duplicate observations via interleave dedup recall (#1907)
Round-robin interleave fusion for consolidation dedup recall (guarantees the semantic-#1 'twin' a slot so the LLM updates instead of duplicating), unified 'reranking' strategy param (cross_encoder/rrf/interleave), case-sensitive exact-dup guard, obs-dedup tool + benchmark wired into the perf dashboard (English dataset). Near-dup observation rate 4% -> 0% on the English hermes transcript (1/10 and 1/4), coverage 89% -> 94%, no false merges.
1 parent 2834192 commit 613a699

24 files changed

Lines changed: 1756 additions & 25 deletions

File tree

.github/workflows/perf-test.yml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ on:
3434
description: "Skip LoComo job"
3535
type: boolean
3636
default: false
37+
obs_skip:
38+
description: "Skip observation-dedup benchmark job"
39+
type: boolean
40+
default: false
41+
obs_dataset:
42+
description: "Obs benchmark dataset substring (blank = English hermes transcript)."
43+
type: string
44+
default: ""
45+
obs_fraction:
46+
description: "Obs benchmark fraction (0-1] of each document to run."
47+
type: string
48+
default: "1.0"
3749
ref:
3850
description: "Git ref to test (branch, tag, or SHA). Defaults to main."
3951
type: string
@@ -199,3 +211,86 @@ jobs:
199211
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
200212
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
201213
run: ./scripts/benchmarks/publish-locomo-results.sh hindsight-dev/benchmarks/locomo/results/benchmark_results.json
214+
215+
obs:
216+
# Observation-dedup quality benchmark: ingests a transcript, drains consolidation
217+
# (serial SyncTaskBackend + embedded pg0 — no external DB / worker), and reports the
218+
# near-duplicate observation rate. Real LLM via VertexAI, mirroring the LoComo job.
219+
if: inputs.obs_skip != true
220+
runs-on: ubuntu-latest
221+
env:
222+
HINDSIGHT_API_LLM_PROVIDER: vertexai
223+
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
224+
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
225+
HINDSIGHT_API_ENABLE_OBSERVATIONS: "true"
226+
steps:
227+
- uses: actions/checkout@v6
228+
with:
229+
ref: ${{ inputs.ref || github.ref }}
230+
231+
- name: Setup GCP credentials
232+
run: |
233+
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
234+
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
235+
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
236+
237+
- name: Install uv
238+
uses: astral-sh/setup-uv@v7
239+
with:
240+
enable-cache: true
241+
prune-cache: false
242+
243+
- name: Set up Python
244+
uses: actions/setup-python@v6
245+
with:
246+
python-version-file: ".python-version"
247+
248+
- name: Cache HuggingFace models
249+
uses: actions/cache@v5
250+
with:
251+
path: ~/.cache/huggingface
252+
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
253+
restore-keys: |
254+
${{ runner.os }}-huggingface-
255+
256+
- name: Pre-download models
257+
working-directory: ./hindsight-api-slim
258+
run: |
259+
uv run --frozen --all-extras --index-strategy unsafe-best-match python -c "
260+
from sentence_transformers import SentenceTransformer
261+
print('Downloading embedding model...')
262+
SentenceTransformer('BAAI/bge-small-en-v1.5')
263+
print('Model downloaded successfully')
264+
"
265+
266+
- name: Install hindsight-dev dependencies
267+
run: |
268+
cd hindsight-dev && uv sync --frozen --all-extras --index-strategy unsafe-best-match
269+
270+
- name: Run obs benchmark
271+
# Default to the English hermes transcript at full fraction — a clean, deterministic
272+
# consolidation-dedup signal (the Chinese variant adds a cross-lingual embedding
273+
# confound). Override dataset/fraction via workflow_dispatch.
274+
run: |
275+
DATASET="${{ inputs.obs_dataset }}"
276+
if [ -z "$DATASET" ]; then DATASET="hermes_session_2026-05-15_en"; fi
277+
FRACTION="${{ inputs.obs_fraction }}"
278+
if [ -z "$FRACTION" ]; then FRACTION="1.0"; fi
279+
cd hindsight-dev
280+
uv run python -m benchmarks.obs.obs_benchmark \
281+
--dataset "$DATASET" --fraction "$FRACTION" --wipe-bank --output obs-results.json
282+
283+
- name: Upload obs results
284+
if: always()
285+
uses: actions/upload-artifact@v7
286+
with:
287+
name: obs-results-${{ github.sha }}
288+
path: hindsight-dev/obs-results.json
289+
retention-days: 90
290+
291+
- name: Publish obs to dashboard
292+
if: success() && (github.event_name == 'schedule' || github.event_name == 'push' || github.event_name == 'workflow_dispatch')
293+
env:
294+
PERF_DASHBOARD_TOKEN: ${{ secrets.PERF_DASHBOARD_TOKEN }}
295+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
296+
run: ./scripts/benchmarks/publish-obs-results.sh hindsight-dev/obs-results.json

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,38 @@
5353
logger = logging.getLogger(__name__)
5454

5555

56+
def _norm_obs_text(text: str) -> str:
57+
"""Whitespace-normalised observation text for exact-duplicate matching.
58+
59+
Collapses runs of whitespace only; case is preserved. The reconciliation guard
60+
drops a CREATE on the premise that an exact-text match loses no information — but
61+
case-folding would also drop a create differing only in case (e.g. "TLS" vs "tls"),
62+
which *does* lose information, so we match case-sensitively.
63+
"""
64+
return " ".join((text or "").split()).strip()
65+
66+
67+
def _duplicate_create_target(
68+
create_text: str,
69+
shown_obs_by_text: "dict[str, MemoryFact]",
70+
update_texts: set[str],
71+
) -> str | None:
72+
"""Return a human label for what ``create_text`` duplicates, or None if novel.
73+
74+
A CREATE is a duplicate when its normalised text matches an observation that was
75+
already shown to the LLM, or the text of an UPDATE issued in the same response
76+
(the model occasionally UPDATEs the twin to text X and also CREATEs X). Exact-text
77+
match means no information is lost by dropping the CREATE.
78+
"""
79+
norm = _norm_obs_text(create_text)
80+
matched = shown_obs_by_text.get(norm)
81+
if matched is not None:
82+
return f"shown observation {str(matched.id)[:8]}"
83+
if norm in update_texts:
84+
return "an UPDATE in this response"
85+
return None
86+
87+
5688
@dataclass
5789
class _BatchDeltas:
5890
"""Per-LLM-batch deltas, merged into the job's running stats after dispatch.
@@ -174,6 +206,9 @@ async def _filter_live_source_memories(
174206
class _CreateAction(BaseModel):
175207
text: str
176208
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
209+
# One-sentence justification from the LLM (why CREATE vs UPDATE). Diagnostic
210+
# only — surfaced in the consolidation trace to explain duplicate creates.
211+
reason: str = ""
177212

178213
@field_validator("text", mode="before")
179214
@classmethod
@@ -185,6 +220,7 @@ class _UpdateAction(BaseModel):
185220
text: str
186221
observation_id: str # UUID of the existing observation to update
187222
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
223+
reason: str = "" # LLM's one-sentence justification (diagnostic only)
188224

189225
@field_validator("text", mode="before")
190226
@classmethod
@@ -194,6 +230,7 @@ def sanitize_text(cls, v: str) -> str:
194230

195231
class _DeleteAction(BaseModel):
196232
observation_id: str # UUID of the observation to remove
233+
reason: str = "" # LLM's one-sentence justification (diagnostic only)
197234

198235

199236
class _ConsolidationBatchResponse(BaseModel):
@@ -1142,16 +1179,44 @@ async def _process_memory_batch(
11421179
for m in source_mems:
11431180
per_memory_updated.add(str(m["id"]))
11441181

1182+
# Deterministic dedup guard: map the observations the LLM was SHOWN by their
1183+
# normalised text. The model intermittently emits a CREATE whose text is identical
1184+
# to an observation already in its context (over-aggregation / incoherence — it even
1185+
# UPDATEs the twin and creates a sibling). When that happens we drop the duplicate
1186+
# CREATE instead of inserting a redundant row. No extra LLM/embedding cost — the
1187+
# match is exact text against the in-memory set.
1188+
shown_obs_by_text = {_norm_obs_text(o.text): o for o in union_observations}
1189+
# Also collapse a CREATE that reproduces the text of an UPDATE issued in the SAME
1190+
# response (the model occasionally UPDATEs the twin to text X and also CREATEs X).
1191+
update_texts = {_norm_obs_text(u.text) for u in llm_result.updates if u.text}
1192+
11451193
for create in llm_result.creates:
11461194
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
11471195
if not source_mems:
11481196
continue
11491197
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
1198+
create_source_ids = [m["id"] for m in source_mems]
1199+
1200+
# Reconcile against observations shown to the LLM: an exact-text match means
1201+
# this CREATE reproduces verbatim an observation the model already had in context.
1202+
# Since that observation already carries this exact text, drop the duplicate CREATE
1203+
# — no row is inserted, nothing is lost. We deliberately do NOT also UPDATE the twin
1204+
# here: the LLM frequently UPDATEd it earlier in this same batch, and a second update
1205+
# would run off the pre-LLM snapshot and clobber that change (see _dedupe_updates).
1206+
duplicate_of = _duplicate_create_target(create.text, shown_obs_by_text, update_texts)
1207+
if duplicate_of is not None:
1208+
logger.warning(
1209+
"[CONSOLIDATION] dropped duplicate observation CREATE — verbatim match of %s; llm_reason=%r",
1210+
duplicate_of,
1211+
create.reason or "(none given)",
1212+
)
1213+
continue
1214+
11501215
await _execute_create_action(
11511216
conn=conn,
11521217
memory_engine=memory_engine,
11531218
bank_id=bank_id,
1154-
source_memory_ids=[m["id"] for m in source_mems],
1219+
source_memory_ids=create_source_ids,
11551220
text=create.text,
11561221
source_fact_tags=agg.tags,
11571222
event_date=agg.event_date,
@@ -1442,6 +1507,13 @@ async def _find_related_observations(
14421507
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
14431508
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
14441509
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
1510+
# Round-robin interleave fusion (no cross-encoder): consolidation is looking
1511+
# for an existing near-identical observation to merge into. Both the
1512+
# cross-encoder (semantic #1 -> reranked #37) and RRF (semantic #1 -> outside
1513+
# the 512-token budget) were measured to bury that twin; interleave guarantees
1514+
# each retrieval arm's top hits a slot, so the semantic-#1 twin is always shown
1515+
# to the LLM, which then UPDATEs instead of creating a duplicate.
1516+
reranking="interleave",
14451517
_quiet=True, # Suppress logging
14461518
)
14471519
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

0 commit comments

Comments
 (0)