Files
roboco/tests/unit/services/optimal_brain/test_learnings_id_collision.py
T

80 lines
3.1 KiB
Python
Raw Normal View History

"""Distinct lessons must get distinct ``learning_id``s so re-ingest of one
does not delete the other's chunks.
The memory distiller emits lessons with a fixed ``Problem: …`` opening shape.
Two lessons whose first 100 chars match but whose bodies differ used to collide
on ``learning_id = f"lrn-{md5(content[:100])[:12]}"``; ``replace_on_reingest``
then routed both to the same source URI, so the second ingest's
``replace_chunks`` DELETE wiped the first lesson's chunks — silent data loss.
The fix hashes the FULL content (wider hex slice) so distinct bodies get
distinct ids and each keeps its chunks.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.services.optimal_brain.indexes.learnings import (
LearningsIndexPlugin,
RecordLearningParams,
)
from roboco.services.optimal_brain.text_chunker import Chunk
def _wire(plugin: object, source: str) -> AsyncMock:
"""Attach mock store/chunker/embedder; return the store mock."""
chunk = Chunk(text="x" * 250, source=source, metadata={})
store = AsyncMock()
embedder = MagicMock()
embedder.aembed_chunks = AsyncMock(return_value=[chunk])
object.__setattr__(plugin, "_store", store)
object.__setattr__(
plugin, "_chunker", MagicMock(chunk_document=MagicMock(return_value=[chunk]))
)
object.__setattr__(plugin, "_embedder", embedder)
object.__setattr__(plugin, "_initialized", True)
return store
def _learning_id_from_source(source: str) -> str:
return source.rsplit("/", 1)[-1]
@pytest.mark.asyncio
async def test_distinct_bodies_with_shared_prefix_get_distinct_ids() -> None:
"""Two lessons whose first 100 chars are identical (the fixed
``Problem: …`` prefix) but whose bodies differ must produce DIFFERENT
``learning_id``s — so both sources are distinct and ``replace_chunks``
of the second does NOT delete the first's chunks."""
prefix = "Problem: " + "x" * 91 # exactly 100 chars
content_a = prefix + "\nApproach: do A. " + "A" * 300
content_b = prefix + "\nApproach: do B. " + "B" * 300
assert content_a[:100] == content_b[:100]
assert content_a != content_b
plugin = LearningsIndexPlugin()
store = _wire(plugin, "roboco://learnings/lrn-x")
store.replace_chunks = AsyncMock()
await plugin.record_learning(
RecordLearningParams(content=content_a, category="error_handling")
)
await plugin.record_learning(
RecordLearningParams(content=content_b, category="error_handling")
)
_EXPECTED_INGESTS = 2
assert store.replace_chunks.await_count == _EXPECTED_INGESTS
sources = [call.args[0] for call in store.replace_chunks.await_args_list]
id_a = _learning_id_from_source(sources[0])
id_b = _learning_id_from_source(sources[1])
assert id_a != id_b, (
f"distinct lessons collapsed to the same learning_id {id_a!r} "
"— second ingest would delete the first's chunks"
)
assert sources[0] != sources[1]
# The second ingest must NOT delete the first's source: replace_chunks
# deletes only by its own source, and the two sources differ.
store.delete_by_source.assert_not_awaited()