fleet-memory/hindsight-api/tests/test_consolidation.py
Nicolò Boschi 9db64ecda3
feat: revisit mental models, directives and reflections (#179)
* chore: run benchmarks with reflect mode

* chore: run benchmarks with reflect mode

* fixes

* new mm

* bunch of fixes

* initial commit

* fixes

* fixes

* fixes

* fix: sometimes memories gets extracted in the wrong language
2026-01-22 17:13:16 +01:00

1587 lines
60 KiB
Python

"""Integration tests for the consolidation engine.
These tests exercise the real consolidation implementation with actual database operations.
Note: Consolidation runs automatically after retain via SyncTaskBackend in tests.
"""
import uuid
from unittest.mock import patch
import pytest
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.reflect.tools import (
tool_recall,
tool_search_mental_models,
tool_search_reflections,
)
@pytest.fixture(autouse=True)
def enable_mental_models():
"""Enable mental models for all tests in this module."""
from hindsight_api.config import get_config
config = get_config()
original_value = config.enable_mental_models
config.enable_mental_models = True
yield
config.enable_mental_models = original_value
class TestConsolidationIntegration:
"""Integration tests for consolidation with real database.
These tests verify that consolidation creates mental models correctly.
Since we use SyncTaskBackend in tests, consolidation runs synchronously
after retain completes.
"""
@pytest.mark.asyncio
async def test_consolidation_creates_mental_model_after_retain(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation creates a mental model after retain."""
bank_id = f"test-consolidation-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation runs automatically after
await memory.retain_async(
bank_id=bank_id,
content="Peter loves hiking in the mountains every weekend.",
request_context=request_context,
)
# Verify mental model exists in memory_units
# (consolidation already ran as part of retain via SyncTaskBackend)
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, proof_count, fact_type
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
# Mental model may or may not be created depending on LLM relevance judgment
# The important thing is no errors occurred
if mental_models:
mm = mental_models[0]
assert mm["proof_count"] >= 1
assert mm["fact_type"] == "mental_model"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_processes_multiple_memories(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation processes multiple related memories."""
bank_id = f"test-consolidation-multi-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain first memory
await memory.retain_async(
bank_id=bank_id,
content="Peter enjoys hiking on mountain trails.",
request_context=request_context,
)
# Retain a second related memory
await memory.retain_async(
bank_id=bank_id,
content="Peter went hiking in the Alps last weekend and loved it.",
request_context=request_context,
)
# Check mental models after both retains
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, proof_count
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY proof_count DESC
""",
bank_id,
)
# Should have at least one mental model
# If the LLM determined both memories support the same model,
# proof_count might be > 1
if mental_models:
# Verify structure is correct
assert all(mm["text"] for mm in mental_models)
assert all(mm["proof_count"] >= 1 for mm in mental_models)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_no_new_memories(self, memory: MemoryEngine, request_context):
"""Test that consolidation handles case when no new memories exist."""
bank_id = f"test-consolidation-empty-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Run consolidation without any memories
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "no_new_memories"
assert result["memories_processed"] == 0
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_respects_last_consolidated_at(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation only processes memories created after last_consolidated_at."""
bank_id = f"test-consolidation-timestamp-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation runs automatically
await memory.retain_async(
bank_id=bank_id,
content="Alice works at a technology company.",
request_context=request_context,
)
# Run consolidation again - should have no new memories
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
# Should report no new memories since consolidation already ran
assert result["status"] == "no_new_memories"
assert result["memories_processed"] == 0
# Add a new memory
await memory.retain_async(
bank_id=bank_id,
content="Alice got promoted to senior engineer.",
request_context=request_context,
)
# Run consolidation again - should also have no new memories
# because consolidation ran automatically after the second retain
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "no_new_memories"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_copies_entity_links(self, memory: MemoryEngine, request_context):
"""Test that mental models inherit entity links from source memories."""
bank_id = f"test-consolidation-entities-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory with a named entity
await memory.retain_async(
bank_id=bank_id,
content="John Smith is the CEO of Acme Corporation.",
request_context=request_context,
)
# Check mental model and its entity links
async with memory._pool.acquire() as conn:
mental_model = await conn.fetchrow(
"""
SELECT id
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
LIMIT 1
""",
bank_id,
)
if mental_model:
# Check if entity links were copied
entity_links = await conn.fetch(
"""
SELECT entity_id
FROM unit_entities
WHERE unit_id = $1
""",
mental_model["id"],
)
# Mental model should have inherited entity links from source memory
# (may be empty if no entities were extracted, which is fine)
assert entity_links is not None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_mental_models_included_in_recall(
self, memory: MemoryEngine, request_context
):
"""Test that mental models created by consolidation are returned in recall."""
bank_id = f"test-consolidation-recall-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation runs automatically
await memory.retain_async(
bank_id=bank_id,
content="Sarah is an expert Python programmer who specializes in machine learning.",
request_context=request_context,
)
# Recall with mental models included
recall_result = await memory.recall_async(
bank_id=bank_id,
query="What does Sarah do?",
fact_type=["world", "experience", "mental_model"],
request_context=request_context,
)
# Mental models come back as regular results with fact_type='mental_model'
assert hasattr(recall_result, "results")
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_creates_memory_links(self, memory: MemoryEngine, request_context):
"""Test that mental models get bidirectional links to their source memories."""
bank_id = f"test-consolidation-links-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation runs automatically
await memory.retain_async(
bank_id=bank_id,
content="Maria works as a software engineer at Microsoft.",
request_context=request_context,
)
# Check memory_links between mental model and source memory
async with memory._pool.acquire() as conn:
mental_model = await conn.fetchrow(
"""
SELECT id, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
LIMIT 1
""",
bank_id,
)
if mental_model and mental_model["source_memory_ids"]:
source_memory_id = mental_model["source_memory_ids"][0]
# Check that bidirectional links exist
link_from_memory = await conn.fetchrow(
"""
SELECT * FROM memory_links
WHERE from_unit_id = $1 AND to_unit_id = $2
""",
source_memory_id,
mental_model["id"],
)
link_to_memory = await conn.fetchrow(
"""
SELECT * FROM memory_links
WHERE from_unit_id = $1 AND to_unit_id = $2
""",
mental_model["id"],
source_memory_id,
)
# Both directions should have links
assert link_from_memory is not None, "Expected link from source memory to mental model"
assert link_to_memory is not None, "Expected link from mental model to source memory"
assert link_from_memory["link_type"] == "semantic"
assert link_to_memory["link_type"] == "semantic"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_merges_only_redundant_facts(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation only merges truly redundant facts.
Mental models should be fine-grained (almost 1:1 with memories).
Only merge when facts are truly redundant (saying the same thing differently)
or when one directly updates another (e.g., location change).
Given:
- "Nicolò lives in Italy"
- "Nicolò moved to the US recently" (updates the living location)
The second fact should UPDATE the first, not create a separate model.
But unrelated facts like "Nicolò works at Vectorize" should stay separate.
"""
bank_id = f"test-consolidation-merge-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory about living location
await memory.retain_async(
bank_id=bank_id,
content="Nicolò lives in Italy.",
request_context=request_context,
)
# Retain an unrelated memory (different topic - should NOT merge)
await memory.retain_async(
bank_id=bank_id,
content="Nicolò works at Vectorize as an engineer.",
request_context=request_context,
)
# Check mental models - should have 2 separate models
async with memory._pool.acquire() as conn:
mm_before = await conn.fetch(
"""
SELECT id, text FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
# Add a memory that UPDATES the living location (should merge with first)
await memory.retain_async(
bank_id=bank_id,
content="Nicolò recently moved to the United States.",
request_context=request_context,
)
# Check mental models after consolidation
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, proof_count, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY created_at
""",
bank_id,
)
# Key assertions:
# 1. Consolidation ran without errors
# 2. Mental models exist
assert len(mental_models) >= 1, "Expected at least one mental model"
# The work-related fact should remain separate from location facts
# (LLM behavior varies, so we check structure rather than exact count)
for mm in mental_models:
assert mm["text"], "Mental model should have text"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_keeps_different_people_separate(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation NEVER merges facts about different people.
Each person's facts should stay in separate mental models.
"""
bank_id = f"test-consolidation-people-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Add facts about different people
await memory.retain_async(
bank_id=bank_id,
content="John lives in New York.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Mary lives in Boston.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Bob works at Google.",
request_context=request_context,
)
# Check mental models - should have separate models for each person
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
# Should have multiple mental models (one per person/fact)
# Not everything merged into one
assert len(mental_models) >= 2, (
f"Expected multiple mental models for different people, got {len(mental_models)}"
)
# No single mental model should mention multiple different people
# (This is a structural check - each model should be focused)
for mm in mental_models:
text = mm["text"].lower()
people_mentioned = sum([
1 for name in ["john", "mary", "bob"]
if name in text
])
assert people_mentioned <= 1, (
f"Mental model should not merge different people: {mm['text']}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_merges_contradictions(
self, memory: MemoryEngine, request_context
):
"""Test that contradictions about the same topic are merged with history.
When facts contradict each other (same person, same topic, opposite info),
they should be merged into ONE mental model that captures the change.
Example:
- "Nicolò loves pizza"
- "Nicolò hates pizza"
→ Should become: "Nicolò used to love pizza but now hates it" (or similar)
"""
bank_id = f"test-consolidation-contradict-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Add initial fact
await memory.retain_async(
bank_id=bank_id,
content="Nicolò loves pizza.",
request_context=request_context,
)
# Check we have one mental model
async with memory._pool.acquire() as conn:
mm_before = await conn.fetch(
"""
SELECT id, text FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
count_before = len(mm_before)
# Add contradicting fact (same person, same topic, opposite sentiment)
await memory.retain_async(
bank_id=bank_id,
content="Nicolò hates pizza.",
request_context=request_context,
)
# Check mental models after consolidation
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, source_memory_ids, history
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
# Key assertion: Should NOT have more mental models than before
# The contradiction should be merged, not create a new model
assert len(mental_models) <= count_before, (
f"Contradiction should merge, not create new model. "
f"Before: {count_before}, After: {len(mental_models)}. "
f"Models: {[mm['text'] for mm in mental_models]}"
)
# The merged model should capture both sentiments or the change
if mental_models:
merged_text = mental_models[0]["text"].lower()
# Should mention the change or both states
has_history = (
("used to" in merged_text or "now" in merged_text or "but" in merged_text)
or ("love" in merged_text and "hate" in merged_text)
or (len(mental_models[0]["source_memory_ids"] or []) > 1)
)
assert has_history, (
f"Merged model should capture the change. Got: {mental_models[0]['text']}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestConsolidationDisabled:
"""Test consolidation when disabled via config."""
@pytest.mark.asyncio
async def test_consolidation_returns_disabled_status(
self, memory: MemoryEngine, request_context
):
"""Test that consolidation returns disabled status when enable_mental_models is False."""
from unittest.mock import patch
bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Disable mental models via config
with patch("hindsight_api.config.get_config") as mock_config:
mock_config.return_value.enable_mental_models = False
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
assert result["status"] == "disabled"
assert result["bank_id"] == bank_id
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestRecallMentalModelFactType:
"""Test recall with mental_model as a fact type."""
@pytest.mark.asyncio
async def test_recall_with_mental_model_fact_type(
self, memory: MemoryEngine, request_context
):
"""Test that mental_model can be used as a fact type in recall.
When mental_model is in the types list, the recall should:
1. Return mental models in the results field with fact_type='mental_model'
2. Not raise validation errors for None context fields
"""
bank_id = f"test-recall-mm-type-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation runs automatically
await memory.retain_async(
bank_id=bank_id,
content="Alex is a data scientist who specializes in deep learning and neural networks.",
request_context=request_context,
)
# Recall with mental_model in types
recall_result = await memory.recall_async(
bank_id=bank_id,
query="What does Alex do?",
fact_type=["mental_model"],
request_context=request_context,
)
# Mental models come back as regular results with fact_type='mental_model'
assert recall_result is not None
assert recall_result.results is not None
# Check that results include mental models
if recall_result.results:
for mm in recall_result.results:
assert mm.id is not None
assert mm.text is not None
assert mm.fact_type == "mental_model"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recall_with_mixed_fact_types_including_mental_model(
self, memory: MemoryEngine, request_context
):
"""Test recall with mental_model alongside world and experience types."""
bank_id = f"test-recall-mixed-types-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain memories - consolidation runs automatically
await memory.retain_async(
bank_id=bank_id,
content="Jordan is a professional musician who plays guitar in a rock band.",
request_context=request_context,
)
# Recall with all types including mental_model
recall_result = await memory.recall_async(
bank_id=bank_id,
query="What does Jordan do?",
fact_type=["world", "experience", "mental_model"],
enable_trace=True,
request_context=request_context,
)
# Should return results without errors
assert recall_result is not None
# Should have results from world/experience facts
assert recall_result.results is not None
# Mental models come back as regular results with fact_type='mental_model'
# when mental_model is included in fact_type parameter
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recall_mental_model_only_with_trace(
self, memory: MemoryEngine, request_context
):
"""Test that recall with only mental_model type and trace enabled works.
This specifically tests the tracer handling of mental models with None context.
"""
bank_id = f"test-recall-mm-trace-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain memory - consolidation creates mental model
await memory.retain_async(
bank_id=bank_id,
content="Chris works as a product manager at a startup focused on AI applications.",
request_context=request_context,
)
# Recall with mental_model only and trace enabled
# This tests the fix for the None context validation error
recall_result = await memory.recall_async(
bank_id=bank_id,
query="Where does Chris work?",
fact_type=["mental_model"],
enable_trace=True,
request_context=request_context,
)
# Should complete without validation errors
assert recall_result is not None
# Trace should be populated
assert recall_result.trace is not None or recall_result.mental_models is not None
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestConsolidationTagRouting:
"""Test tag routing during consolidation.
Tag routing rules:
- Same scope (tags match): update existing mental model
- Fact scoped, model global (untagged): update global (it absorbs all)
- Different scopes (non-overlapping tags): create untagged cross-scope insight
- No match: create with fact's tags
"""
async def _retain_with_tags(
self,
memory: MemoryEngine,
bank_id: str,
content: str,
tags: list[str],
request_context,
):
"""Helper to retain content with tags using retain_batch_async."""
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content}],
document_tags=tags,
request_context=request_context,
)
@pytest.mark.asyncio
async def test_same_scope_updates_model(
self, memory: MemoryEngine, request_context
):
"""Test that a tagged fact updates a mental model with the same tags.
Given:
- Memory with tags=['alice']: "Alice likes coffee"
- New memory with tags=['alice']: "Alice prefers espresso"
Expected:
- Mental model with tags=['alice'] is updated to reflect both facts
"""
bank_id = f"test-tag-same-scope-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain first memory with tags
await self._retain_with_tags(
memory, bank_id, "Alice likes coffee.", ["alice"], request_context
)
# Check mental model has correct tags
async with memory._pool.acquire() as conn:
mm_before = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
count_before = len(mm_before)
if mm_before:
assert "alice" in (mm_before[0]["tags"] or []), (
f"Expected mental model to have 'alice' tag, got: {mm_before[0]['tags']}"
)
# Retain related memory with same tags
await self._retain_with_tags(
memory, bank_id, "Alice prefers espresso over regular coffee.", ["alice"], request_context
)
# Check mental models - should NOT have increased (same scope update)
async with memory._pool.acquire() as conn:
mm_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
# Count of mental models should stay same or decrease (merge)
assert len(mm_after) <= count_before + 1, (
f"Same scope fact should update existing model, not create new. "
f"Before: {count_before}, After: {len(mm_after)}"
)
# The model(s) should still have alice tag
for mm in mm_after:
if "coffee" in mm["text"].lower() or "espresso" in mm["text"].lower():
assert "alice" in (mm["tags"] or []), (
f"Updated model should keep 'alice' tag: {mm['text']}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_scoped_fact_updates_global_model(
self, memory: MemoryEngine, request_context
):
"""Test that a scoped fact can update an untagged (global) mental model.
Given:
- Untagged memory: "Pizza is a popular food"
- New memory with tags=['history']: "Pizza originated in Naples"
Expected:
- The global mental model is updated (global absorbs all scopes)
"""
bank_id = f"test-tag-global-absorb-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain untagged (global) memory
await memory.retain_async(
bank_id=bank_id,
content="Pizza is a popular Italian food.",
request_context=request_context,
)
# Check untagged mental model exists
async with memory._pool.acquire() as conn:
mm_before = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
count_before = len(mm_before)
# Should be untagged or have empty tags
if mm_before:
assert not mm_before[0]["tags"] or len(mm_before[0]["tags"]) == 0, (
f"Expected untagged model, got: {mm_before[0]['tags']}"
)
# Retain scoped memory that relates to the global topic
await self._retain_with_tags(
memory, bank_id, "Pizza originated in Naples.", ["history"], request_context
)
# Check - global model should be updated OR new scoped model created
async with memory._pool.acquire() as conn:
mm_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY created_at
""",
bank_id,
)
# At least one model should exist
assert len(mm_after) >= 1, "Expected at least one mental model"
# Check that global model was updated (source_memory_ids increased)
# OR new model was created with appropriate tags
global_models = [m for m in mm_after if not m["tags"] or len(m["tags"]) == 0]
scoped_models = [m for m in mm_after if m["tags"] and len(m["tags"]) > 0]
# Either global was updated or scoped was created
assert len(global_models) >= 1 or len(scoped_models) >= 1, (
"Expected either global model update or scoped model creation"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_cross_scope_creates_untagged(
self, memory: MemoryEngine, request_context
):
"""Test that cross-scope related facts create untagged (global) insights.
Given:
- Memory with tags=['alice']: "Alice recommends the Thai restaurant"
- Memory with tags=['bob']: "Bob tried the Thai restaurant Alice mentioned"
Expected:
- A new untagged mental model capturing the cross-scope insight
"""
bank_id = f"test-tag-cross-scope-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain Alice's scoped memory
await self._retain_with_tags(
memory, bank_id,
"Alice recommends the Thai restaurant on Main Street.",
["alice"], request_context
)
# Check Alice's mental model exists with correct tags
async with memory._pool.acquire() as conn:
mm_alice = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
count_before = len(mm_alice)
# Retain Bob's memory that relates to Alice's topic (cross-scope)
await self._retain_with_tags(
memory, bank_id,
"Bob visited the Thai restaurant on Main Street and loved it.",
["bob"], request_context
)
# Check mental models
async with memory._pool.acquire() as conn:
mm_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY created_at
""",
bank_id,
)
# Should have multiple models (alice's, bob's, potentially global)
assert len(mm_after) >= 2, (
f"Expected at least 2 mental models for different scopes, got {len(mm_after)}"
)
# Check we have models with different tags (alice, bob, or untagged)
tag_sets = [frozenset(m["tags"] or []) for m in mm_after]
# Should NOT merge alice and bob into same model
models_with_both = [
m for m in mm_after
if m["tags"] and "alice" in m["tags"] and "bob" in m["tags"]
]
assert len(models_with_both) == 0, (
"Should not merge different scopes into one model with both tags"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_no_match_creates_with_fact_tags(
self, memory: MemoryEngine, request_context
):
"""Test that a new fact with no matching models creates a model with fact's tags.
Given:
- Empty bank
- Memory with tags=['project_x']: "Project X uses Python"
Expected:
- Mental model created with tags=['project_x']
"""
bank_id = f"test-tag-new-scoped-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain tagged memory (no existing mental models)
await self._retain_with_tags(
memory, bank_id,
"Project X uses Python for its backend services.",
["project_x"], request_context
)
# Check mental model was created with correct tags
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, tags FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
assert len(mental_models) >= 1, "Expected mental model to be created"
# The model should have the fact's tags
mm = mental_models[0]
assert mm["tags"] is not None, "Mental model should have tags"
assert "project_x" in mm["tags"], (
f"Mental model should have 'project_x' tag, got: {mm['tags']}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_untagged_fact_can_update_scoped_model(
self, memory: MemoryEngine, request_context
):
"""Test that an untagged fact can update a scoped mental model.
Given:
- Memory with tags=['alice']: "Alice works on machine learning"
- Untagged memory: "Machine learning involves neural networks"
Expected:
- The scoped model may be updated with the global insight
- OR a global model is created
"""
bank_id = f"test-tag-untagged-update-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain scoped memory
await self._retain_with_tags(
memory, bank_id,
"Alice works on machine learning projects.",
["alice"], request_context
)
# Retain untagged memory on same topic
await memory.retain_async(
bank_id=bank_id,
content="Machine learning involves training neural networks.",
request_context=request_context,
)
# Check mental models
async with memory._pool.acquire() as conn:
mental_models = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY created_at
""",
bank_id,
)
# Should have at least one model
assert len(mental_models) >= 1, "Expected at least one mental model"
# Either alice's model was updated OR a global model was created
# This is valid LLM behavior - just verify no errors and structure is correct
for mm in mental_models:
assert mm["text"], "Mental model should have text"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_tag_filtering_in_recall(
self, memory: MemoryEngine, request_context
):
"""Test that mental models respect tag filtering during recall.
Mental models should be filtered by tags just like memories.
"""
bank_id = f"test-tag-recall-filter-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain memories with different tags
await self._retain_with_tags(
memory, bank_id,
"Alice works as a software engineer.",
["alice"], request_context
)
await self._retain_with_tags(
memory, bank_id,
"Bob works as a product manager.",
["bob"], request_context
)
# Recall with alice tag only
recall_result = await memory.recall_async(
bank_id=bank_id,
query="What does everyone do for work?",
tags=["alice"],
tags_match="any_strict", # Only alice's data
fact_type=["world", "experience", "mental_model"],
request_context=request_context,
)
# Results should only include alice-tagged content
# Mental models are now regular results with fact_type='mental_model'
mental_models = [r for r in recall_result.results if r.fact_type == "mental_model"]
for mm in mental_models:
# Mental model should be alice-scoped or global (untagged)
# Not bob-scoped
mm_tags = mm.tags or []
assert "bob" not in mm_tags, (
f"Recall with tags=['alice'] should not return bob's models: {mm.text}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_multiple_actions_from_single_fact(
self, memory: MemoryEngine, request_context
):
"""Test that one fact can trigger multiple consolidation actions.
Given:
- Global model: "Coffee is a popular beverage"
- Alice's model: "Alice drinks coffee every morning"
- New fact with tags=['alice']: "Alice switched to decaf coffee"
Expected:
- Update Alice's scoped model (same scope)
- Potentially update global model too (global absorbs all)
"""
bank_id = f"test-tag-multi-action-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create global model
await memory.retain_async(
bank_id=bank_id,
content="Coffee is a popular beverage worldwide.",
request_context=request_context,
)
# Create alice's scoped model
await self._retain_with_tags(
memory, bank_id,
"Alice drinks coffee every morning.",
["alice"], request_context
)
# Check models before
async with memory._pool.acquire() as conn:
mm_before = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
count_before = len(mm_before)
# Add fact that could relate to both
await self._retain_with_tags(
memory, bank_id,
"Alice switched to decaf coffee for health reasons.",
["alice"], request_context
)
# Check models after
async with memory._pool.acquire() as conn:
mm_after = await conn.fetch(
"""
SELECT id, text, tags, source_memory_ids, proof_count FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
ORDER BY created_at
""",
bank_id,
)
# Should have processed without errors
assert len(mm_after) >= 1, "Expected at least one mental model"
# Check that consolidation worked (either updates or maintains structure)
# The key is no errors and proper tag handling
for mm in mm_after:
assert mm["text"], "Mental model should have text"
# Tags should be consistent (not mixing alice and bob, etc.)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_inherits_dates_from_source_memory(
self, memory: MemoryEngine, request_context
):
"""Test that mental models inherit occurred_start and event_date from source memories.
When a mental model is created, it should inherit the temporal information
from the source memory that triggered its creation, not use the current time.
"""
from datetime import datetime, timezone
bank_id = f"test-consolidation-dates-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Create a specific date in the past for testing
past_date = datetime(2023, 6, 15, 10, 30, 0, tzinfo=timezone.utc)
# First, create a memory unit directly with a specific date
async with memory._pool.acquire() as conn:
memory_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (
id, bank_id, text, fact_type, occurred_start, event_date, created_at
)
VALUES ($1, $2, $3, 'experience', $4, $4, now())
""",
memory_id,
bank_id,
"Sarah went to Paris for vacation and loved the Eiffel Tower.",
past_date,
)
# Run consolidation manually
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
result = await run_consolidation_job(
memory_engine=memory,
bank_id=bank_id,
request_context=request_context,
)
# Verify consolidation processed the memory
assert result["status"] == "completed"
assert result["memories_processed"] >= 1
# Check that mental model inherited the date from source memory
async with memory._pool.acquire() as conn:
mental_model = await conn.fetchrow(
"""
SELECT id, text, occurred_start, event_date, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
LIMIT 1
""",
bank_id,
)
if mental_model:
# Mental model should have inherited the date from the source memory
mm_occurred = mental_model["occurred_start"]
mm_event_date = mental_model["event_date"]
# Dates should match the source memory's date (2023-06-15), not today
assert mm_occurred is not None, "Mental model should have occurred_start"
assert mm_event_date is not None, "Mental model should have event_date"
# The date should be from 2023, not today
assert mm_occurred.year == 2023, (
f"Expected occurred_start year 2023, got {mm_occurred.year}. "
"Mental model should inherit date from source memory."
)
assert mm_occurred.month == 6, f"Expected month 6, got {mm_occurred.month}"
assert mm_occurred.day == 15, f"Expected day 15, got {mm_occurred.day}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelDrillDown:
"""Test that reflect agent can drill down from mental models to source memories."""
@pytest.mark.asyncio
async def test_search_mental_models_returns_source_memory_ids(
self, memory: MemoryEngine, request_context
):
"""Test that search_mental_models returns source_memory_ids for drill-down.
This verifies the agent can:
1. Find a mental model
2. Access its source_memory_ids
3. Use those IDs to expand/recall for more details
"""
from hindsight_api.engine.reflect.tools import tool_search_mental_models, tool_expand
bank_id = f"test-mm-drilldown-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Store memories with specific details that get summarized in mental model
await memory.retain_async(
bank_id=bank_id,
content="Sarah works at TechCorp as a senior software engineer since March 2020.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Sarah's employee ID at TechCorp is EMP-12345.",
request_context=request_context,
)
# Search for mental models
result = await tool_search_mental_models(
memory_engine=memory,
bank_id=bank_id,
query="Sarah TechCorp",
request_context=request_context,
)
assert result["count"] > 0, "Expected at least one mental model"
# Verify source_memory_ids and proof_count are present
mm = result["mental_models"][0]
assert "source_memory_ids" in mm, "Mental model should have source_memory_ids"
assert "proof_count" in mm, "Mental model should have proof_count"
assert mm["proof_count"] >= 1, "proof_count should be at least 1"
# If source_memory_ids exist, verify they can be used with expand
if mm["source_memory_ids"]:
assert len(mm["source_memory_ids"]) >= 1, "Should have at least one source memory"
# Use expand tool to get source memory details
async with memory._pool.acquire() as conn:
expand_result = await tool_expand(
conn=conn,
bank_id=bank_id,
memory_ids=mm["source_memory_ids"][:2], # Take first 2
depth="chunk",
)
assert "results" in expand_result
assert len(expand_result["results"]) > 0, "Expand should return source memories"
# Verify we get the original detailed information
all_text = " ".join(r["memory"]["text"] for r in expand_result["results"] if "memory" in r)
# The expanded memories should contain details not necessarily in the mental model
assert "Sarah" in all_text or "TechCorp" in all_text, (
f"Expanded memories should contain source details. Got: {all_text}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_mental_model_source_ids_match_contributing_memories(
self, memory: MemoryEngine, request_context
):
"""Test that source_memory_ids actually point to the memories that built the mental model."""
bank_id = f"test-mm-source-ids-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Store two related memories
await memory.retain_async(
bank_id=bank_id,
content="Project Phoenix was started by the engineering team in January 2024.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Project Phoenix achieved 99.9% uptime in its first quarter.",
request_context=request_context,
)
# Get the mental model with source_memory_ids
async with memory._pool.acquire() as conn:
mm_rows = await conn.fetch(
"""
SELECT id, text, proof_count, source_memory_ids
FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
if mm_rows:
mm = mm_rows[0]
source_ids = mm["source_memory_ids"] or []
# Verify source_memory_ids point to actual memories
if source_ids:
async with memory._pool.acquire() as conn:
source_memories = await conn.fetch(
"""
SELECT id, text FROM memory_units
WHERE id = ANY($1) AND fact_type IN ('world', 'experience')
""",
source_ids,
)
# Should have found the source memories
assert len(source_memories) >= 1, (
f"source_memory_ids should point to valid memories. "
f"IDs: {source_ids}, Found: {len(source_memories)}"
)
# The source memories should contain our original content
source_texts = [m["text"].lower() for m in source_memories]
has_phoenix = any("phoenix" in t for t in source_texts)
assert has_phoenix, f"Source memories should contain original content. Got: {source_texts}"
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
class TestHierarchicalRetrieval:
"""Test the reflect agent's hierarchical retrieval tools.
The hierarchy is:
1. search_reflections - User-curated summaries (highest quality)
2. search_mental_models - Auto-consolidated knowledge
3. recall - Raw facts as ground truth
When a reflection matches the query, it should be used first.
"""
@pytest.mark.asyncio
async def test_reflection_takes_priority_over_mental_model(
self, memory: MemoryEngine, request_context
):
"""Test that reflections are found and would be used before mental models.
Given:
- A memory about "John's favorite color is blue"
- A mental model created from that memory (via consolidation)
- A reflection manually created about John
When searching, the reflection should be found first.
"""
bank_id = f"test-hierarchy-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation creates a mental model
await memory.retain_async(
bank_id=bank_id,
content="John's favorite color is blue and he likes painting.",
request_context=request_context,
)
# Verify mental model was created
async with memory._pool.acquire() as conn:
mm_count = await conn.fetchval(
"""
SELECT COUNT(*) FROM memory_units
WHERE bank_id = $1 AND fact_type = 'mental_model'
""",
bank_id,
)
assert mm_count >= 1, "Consolidation should have created a mental model"
# Create a reflection about John (higher quality, user-curated)
reflection = await memory.create_reflection(
bank_id=bank_id,
name="John's Preferences",
source_query="What are John's preferences?",
content="John is an artist who loves the color blue. He has been painting for 10 years and prefers watercolors.",
tags=[],
request_context=request_context,
)
assert reflection["id"] is not None
# Search reflections - should find our reflection
async with memory._pool.acquire() as conn:
query_embedding = memory.embeddings.encode(["What does John like?"])[0]
reflection_result = await tool_search_reflections(
conn=conn,
bank_id=bank_id,
query="What does John like?",
query_embedding=query_embedding,
max_results=5,
)
# Reflection should be found
assert reflection_result["count"] >= 1, "Reflection should be found"
found_reflection = reflection_result["reflections"][0]
assert "John" in found_reflection["content"] or "blue" in found_reflection["content"]
# Search mental models - should also find something
mm_result = await tool_search_mental_models(
memory_engine=memory,
bank_id=bank_id,
query="What does John like?",
request_context=request_context,
max_tokens=5000,
)
assert mm_result["count"] >= 1, "Mental model should also be found"
# Verify the reflection has higher quality content (more detail)
reflection_content = found_reflection["content"]
mm_content = mm_result["mental_models"][0]["text"]
# The reflection should contain the richer, user-curated content
assert "watercolors" in reflection_content or "10 years" in reflection_content, (
f"Reflection should have the rich user-curated content. Got: {reflection_content}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_fallback_to_mental_model_when_no_reflection(
self, memory: MemoryEngine, request_context
):
"""Test that mental models are used when no reflection matches.
Given:
- A memory about "Sarah works at Google"
- A mental model created from that memory
- NO reflection about Sarah
When searching, mental models should provide the information.
"""
bank_id = f"test-hierarchy-fallback-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain a memory - consolidation creates a mental model
await memory.retain_async(
bank_id=bank_id,
content="Sarah works at Google as a software engineer.",
request_context=request_context,
)
# Search reflections - should find nothing
async with memory._pool.acquire() as conn:
query_embedding = memory.embeddings.encode(["Where does Sarah work?"])[0]
reflection_result = await tool_search_reflections(
conn=conn,
bank_id=bank_id,
query="Where does Sarah work?",
query_embedding=query_embedding,
max_results=5,
)
# No reflections exist
assert reflection_result["count"] == 0, "No reflections should exist"
# Search mental models - should find the consolidated knowledge
mm_result = await tool_search_mental_models(
memory_engine=memory,
bank_id=bank_id,
query="Where does Sarah work?",
request_context=request_context,
max_tokens=5000,
)
# Mental model should be found
assert mm_result["count"] >= 1, "Mental model should be found when no reflection exists"
mm_text = mm_result["mental_models"][0]["text"].lower()
assert "sarah" in mm_text or "google" in mm_text, (
f"Mental model should contain info about Sarah. Got: {mm_text}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_fallback_to_recall_for_fresh_data(
self, memory: MemoryEngine, request_context
):
"""Test that recall provides raw facts when needed for verification.
This tests the drill-down capability: when mental models are stale or
need verification, recall provides the original source facts.
"""
bank_id = f"test-hierarchy-recall-{uuid.uuid4().hex[:8]}"
# Create the bank
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
# Retain some specific memories
await memory.retain_async(
bank_id=bank_id,
content="The quarterly revenue was $1.5M in Q3 2024.",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="The quarterly revenue was $2.1M in Q4 2024.",
request_context=request_context,
)
# Use recall to get the raw facts
recall_result = await tool_recall(
memory_engine=memory,
bank_id=bank_id,
query="What was the quarterly revenue?",
request_context=request_context,
max_tokens=2048,
max_results=10,
)
# Should have raw facts with specific numbers
assert recall_result["count"] >= 1, "Recall should find the raw facts"
# Check that we get the actual numbers from the original memories
all_memory_text = " ".join([m["text"] for m in recall_result["memories"]])
assert "$1.5M" in all_memory_text or "$2.1M" in all_memory_text, (
f"Recall should return raw facts with specific data. Got: {all_memory_text}"
)
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)