Skip to content

Commit 0a046f9

Browse files
authored
feat(consolidation): add "shared" observation_scopes keyword (#2202)
* feat(consolidation): add "shared" observation_scopes keyword Add a "shared" value for observation_scopes that resolves to a single global, untagged scope ([[]]). Memories consolidate into one observation regardless of their tags, while the tags stay on the source facts for recall filtering. This is the supported way to deduplicate observations across volatile per-call provenance tags (e.g. per-session ids): with combined/per_tag, a unique session tag puts every retain in its own scope, so near-identical facts never dedup and accumulate one observation per session. "shared" keeps recall and the dedup probe on the same (empty) scope, fixing consolidation quality rather than only the duplicate count. - consolidator: _resolve_obs_tags_list -> [[]], _resolve_write_scopes -> [frozenset()] - API/engine type literals + OpenAPI + regenerated Python/TS/Go/Rust clients - CP client type kept in sync - docs: retain.mdx 'shared' section (+ shared vs [[]] vs [] caveat), observations.mdx dedup pointer; regenerated hindsight-docs skill mirror - tests: unit scope-resolution + e2e parallel-consolidation scope correctness * chore(opencode): apply prettier formatting to plugin.test.ts Pre-existing lint drift unrelated to this PR — CI's verify-generated-files job reformats all integrations (LINT_ALL_INTEGRATIONS) and flagged this file. Folding the one-line reflow in here to get the gate green.
1 parent 156a543 commit 0a046f9

20 files changed

Lines changed: 152 additions & 23 deletions

File tree

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,13 +545,16 @@ def coerce_tags(cls, v):
545545
return [v]
546546
return v
547547

548-
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
548+
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = Field(
549549
default=None,
550550
title="ObservationScopes",
551551
description=(
552552
"How to scope observations during consolidation. "
553553
"'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. "
554554
"'combined' (default) runs a single pass with all tags together. "
555+
"'shared' runs a single pass over one global, untagged scope, so memories consolidate together "
556+
"regardless of their tags — useful for deduplicating across volatile per-call provenance tags "
557+
"(e.g. per-session ids) while keeping those tags on the source facts. "
555558
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
556559
),
557560
)

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,15 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
335335
336336
Returns ``None`` for the default ``combined``-mode single pass (caller uses
337337
the memory's own tags). Returns a list[list[str]] when the memory requested
338-
multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list).
338+
multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an
339+
explicit list).
340+
341+
``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged)
342+
scope. The created observation carries no tags and recall/dedup match it with
343+
``tags_match="any"``, so every memory consolidates into one shared observation
344+
regardless of its own tags. Use it to deduplicate across volatile per-call
345+
provenance tags (e.g. per-session ids) without dropping those tags from the
346+
source facts.
339347
"""
340348
parsed = _parse_observation_scopes(memory)
341349
tags = list(memory.get("tags") or [])
@@ -346,6 +354,8 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None:
346354
if not tags:
347355
return None
348356
return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
357+
if parsed == "shared":
358+
return [[]]
349359
if parsed == "combined" or parsed is None:
350360
return None
351361
return parsed # explicit list[list[str]]
@@ -362,6 +372,7 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
362372
- ``combined`` / ``None`` -> ``[frozenset(memory.tags)]``
363373
- ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]``
364374
- ``all_combinations`` -> one frozenset per nonempty subset of tags
375+
- ``shared`` -> ``[frozenset()]`` (the single untagged scope)
365376
- explicit ``list[list[str]]`` -> one frozenset per declared scope
366377
367378
Empty-tag memories collapse to a single ``frozenset()`` in all modes so they
@@ -376,6 +387,8 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]:
376387
if not tags:
377388
return [frozenset()]
378389
return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)]
390+
if parsed == "shared":
391+
return [frozenset()]
379392
if parsed == "combined" or parsed is None:
380393
return [frozenset(tags)]
381394
return [frozenset(s) for s in parsed] # explicit list[list[str]]

hindsight-api-slim/hindsight_api/engine/retain/types.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ class RetainContentDict(TypedDict, total=False):
2424
tags: Visibility scope tags for this content item (optional)
2525
observation_scopes: How to scope observations for consolidation (optional).
2626
"per_tag" runs one pass per individual tag; "combined" (default) runs a
27-
single pass with all tags; a list[list[str]] specifies exact passes.
27+
single pass with all tags; "shared" runs a single pass over one global,
28+
untagged scope so memories consolidate together regardless of tags;
29+
a list[list[str]] specifies exact passes.
2830
update_mode: How to handle existing documents with the same document_id (optional).
2931
"replace" (default) deletes old data and reprocesses. "append" concatenates
3032
new content to the existing document and reprocesses.
@@ -38,7 +40,7 @@ class RetainContentDict(TypedDict, total=False):
3840
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
3941
tags: list[str] # Visibility scope tags
4042
observation_scopes: (
41-
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
43+
Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
4244
) # Observation scopes for consolidation
4345
update_mode: Literal["replace", "append"]
4446

@@ -57,7 +59,7 @@ class RetainContent:
5759
metadata: dict[str, str] = field(default_factory=dict)
5860
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
5961
tags: list[str] = field(default_factory=list) # Visibility scope tags
60-
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
62+
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
6163
None # Observation scopes
6264
)
6365

@@ -124,7 +126,7 @@ class ExtractedFact:
124126
mentioned_at: datetime | None = None
125127
metadata: dict[str, str] = field(default_factory=dict)
126128
tags: list[str] = field(default_factory=list) # Visibility scope tags
127-
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = (
129+
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = (
128130
None # Observation scopes
129131
)
130132

@@ -176,7 +178,7 @@ class ProcessedFact:
176178
tags: list[str] = field(default_factory=list)
177179

178180
# Observation scopes for consolidation
179-
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None
181+
observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = None
180182

181183
@property
182184
def is_duplicate(self) -> bool:

hindsight-api-slim/hindsight_api/engine/transfer/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
# Bump when the archive layout changes in a backward-incompatible way.
2323
SCHEMA_VERSION = 1
2424

25-
ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
25+
ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]]
2626

2727

2828
class TransferCausalRelation(BaseModel):

hindsight-api-slim/tests/test_consolidation_scope_parallelism.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,42 @@ async def test_combined_mode_parallel_writes_to_memory_tag_set(memory: MemoryEng
178178
await memory.delete_bank(bank_id, request_context=request_context)
179179

180180

181+
@pytest.mark.asyncio
182+
async def test_shared_mode_parallel_writes_only_untagged_scope(memory: MemoryEngine, request_context):
183+
"""shared → every memory writes to the single untagged scope, ignoring its
184+
own tags. Three memories with disjoint tags therefore all consolidate into
185+
the same global scope (the per-session-tag dedup use case) instead of one
186+
isolated observation per tag."""
187+
bank_id = f"test-shared-{uuid.uuid4().hex[:8]}"
188+
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
189+
try:
190+
async with memory._pool.acquire() as conn:
191+
await _insert_memory(conn, bank_id, "Alice likes tea", ["session:s1"], "shared")
192+
await _insert_memory(conn, bank_id, "Bob bikes daily", ["session:s2"], "shared")
193+
await _insert_memory(conn, bank_id, "Carol reads books", ["session:s3"], "shared")
194+
195+
wrapper, _ = _mock_llm_one_obs_per_fact()
196+
original_llm = memory._consolidation_llm_config
197+
memory._consolidation_llm_config = wrapper
198+
try:
199+
with (
200+
_override_config(memory, consolidation_llm_parallelism=3, consolidation_llm_batch_size=1),
201+
patch.object(memory, "submit_async_consolidation"),
202+
):
203+
result = await run_consolidation_job(
204+
memory_engine=memory, bank_id=bank_id, request_context=request_context
205+
)
206+
finally:
207+
memory._consolidation_llm_config = original_llm
208+
209+
assert result["status"] == "completed"
210+
tag_sets = await _fetch_observation_tag_sets(memory, bank_id)
211+
# Every observation lands at the untagged scope — none carries a session tag.
212+
assert tag_sets and all(t == frozenset() for t in tag_sets), tag_sets
213+
finally:
214+
await memory.delete_bank(bank_id, request_context=request_context)
215+
216+
181217
@pytest.mark.asyncio
182218
async def test_per_tag_mode_parallel_writes_one_observation_per_tag(memory: MemoryEngine, request_context):
183219
"""per_tag with tags [a, b] → two observations, tagged [a] and [b] respectively.

hindsight-api-slim/tests/test_consolidation_write_scopes.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ def test_empty_tags_collapses_to_untagged_scope(self):
9292
assert _resolve_write_scopes(memory) == [frozenset()]
9393

9494

95+
class TestResolveWriteScopesShared:
96+
def test_collapses_to_single_untagged_scope_regardless_of_tags(self):
97+
# "shared" ignores the memory's own tags and writes to one global scope,
98+
# so every memory deduplicates against the same observation.
99+
memory = {"tags": ["alice", "session"], "observation_scopes": _as_json_string("shared")}
100+
assert _resolve_write_scopes(memory) == [frozenset()]
101+
102+
def test_empty_tags_also_untagged_scope(self):
103+
memory = {"tags": [], "observation_scopes": _as_json_string("shared")}
104+
assert _resolve_write_scopes(memory) == [frozenset()]
105+
106+
95107
class TestResolveWriteScopesExplicitList:
96108
def test_uses_declared_scopes_verbatim(self):
97109
memory = {
@@ -166,6 +178,12 @@ def test_explicit_list_passthrough(self):
166178
memory = {"tags": ["a", "b"], "observation_scopes": json.dumps(spec)}
167179
assert _resolve_obs_tags_list(memory) == spec
168180

181+
def test_shared_returns_single_empty_scope(self):
182+
# One pass over the empty (untagged) scope; the memory's own tags are
183+
# ignored so cross-tag memories consolidate into one observation.
184+
memory = {"tags": ["a", "b"], "observation_scopes": json.dumps("shared")}
185+
assert _resolve_obs_tags_list(memory) == [[]]
186+
169187

170188
# ---------------------------------------------------------------------------
171189
# Agreement between obs_tags_list (dispatch) and write_scopes (locks)
@@ -185,6 +203,7 @@ class TestDispatchLockAgreement:
185203
{"tags": ["a", "b", "c"], "observation_scopes": json.dumps("combined")},
186204
{"tags": ["a", "b"], "observation_scopes": json.dumps("per_tag")},
187205
{"tags": ["a", "b", "c"], "observation_scopes": json.dumps("all_combinations")},
206+
{"tags": ["a", "b"], "observation_scopes": json.dumps("shared")},
188207
{"tags": ["a", "b"], "observation_scopes": json.dumps([["a"], ["b"], ["a", "b"]])},
189208
{"tags": ["a"], "observation_scopes": json.dumps([["a"], ["x"]])},
190209
# Pre-parsed Python shape (defensive — covers callers that hand the

hindsight-clients/go/api/openapi.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8166,6 +8166,7 @@ components:
81668166
- per_tag
81678167
- combined
81688168
- all_combinations
8169+
- shared
81698170
type: string
81708171
- items:
81718172
items:
@@ -8175,8 +8176,11 @@ components:
81758176
description: "How to scope observations during consolidation. 'per_tag' runs\
81768177
\ one consolidation pass per individual tag, creating separate observations\
81778178
\ for each tag. 'combined' (default) runs a single pass with all tags together.\
8178-
\ A list of tag lists runs one pass per inner list, giving full control over\
8179-
\ which combinations to use."
8179+
\ 'shared' runs a single pass over one global, untagged scope, so memories\
8180+
\ consolidate together regardless of their tags — useful for deduplicating\
8181+
\ across volatile per-call provenance tags (e.g. per-session ids) while keeping\
8182+
\ those tags on the source facts. A list of tag lists runs one pass per inner\
8183+
\ list, giving full control over which combinations to use."
81808184
nullable: true
81818185
title: ObservationScopes
81828186
MentalModelTrigger_Input_tag_groups_inner:

hindsight-clients/go/model_observation_scopes.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

hindsight-clients/python/hindsight_client_api/models/observation_scopes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
class ObservationScopes(BaseModel):
2929
"""
30-
How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
30+
How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. 'shared' runs a single pass over one global, untagged scope, so memories consolidate together regardless of their tags — useful for deduplicating across volatile per-call provenance tags (e.g. per-session ids) while keeping those tags on the source facts. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
3131
"""
3232

3333
# data type: str

hindsight-clients/typescript/generated/types.gen.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2235,9 +2235,15 @@ export type MemoryItem = {
22352235
/**
22362236
* ObservationScopes
22372237
*
2238-
* How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
2239-
*/
2240-
observation_scopes?: "per_tag" | "combined" | "all_combinations" | Array<Array<string>> | null;
2238+
* How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. 'shared' runs a single pass over one global, untagged scope, so memories consolidate together regardless of their tags — useful for deduplicating across volatile per-call provenance tags (e.g. per-session ids) while keeping those tags on the source facts. A list of tag lists runs one pass per inner list, giving full control over which combinations to use.
2239+
*/
2240+
observation_scopes?:
2241+
| "per_tag"
2242+
| "combined"
2243+
| "all_combinations"
2244+
| "shared"
2245+
| Array<Array<string>>
2246+
| null;
22412247
/**
22422248
* Strategy
22432249
*

0 commit comments

Comments
 (0)