mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
483 lines
21 KiB
Python
483 lines
21 KiB
Python
"""Tests for structured memory storage backend operations."""
|
|
|
|
|
|
class TestCreateAndGet:
|
|
def test_create_requires_non_empty_description(self, backend):
|
|
import pytest
|
|
|
|
for description in (None, "", " "):
|
|
with pytest.raises(ValueError, match="description is required"):
|
|
backend.create_structured_memory(
|
|
"m1", "test_key", description, "general", "global", "", "data"
|
|
)
|
|
|
|
def test_create_and_get_by_id(self, backend):
|
|
backend.create_structured_memory("m1", "test_key", "desc", "general", "global", "", "data")
|
|
mem = backend.get_structured_memory("m1")
|
|
assert mem is not None
|
|
assert mem["name"] == "test_key"
|
|
assert mem["content"] == "data"
|
|
assert mem["type"] == "general"
|
|
|
|
def test_get_nonexistent(self, backend):
|
|
assert backend.get_structured_memory("nope") is None
|
|
|
|
def test_get_by_name(self, backend):
|
|
backend.create_structured_memory("m1", "mykey", "d", "general", "global", "", "val")
|
|
mem = backend.get_structured_memory_by_name("mykey", "global", "")
|
|
assert mem is not None
|
|
assert mem["memory_id"] == "m1"
|
|
|
|
def test_get_by_name_scoped(self, backend):
|
|
backend.create_structured_memory("m1", "key", "d", "general", "global", "", "g")
|
|
backend.create_structured_memory("m2", "key", "d", "general", "workstream", "ws1", "w")
|
|
g = backend.get_structured_memory_by_name("key", "global", "")
|
|
w = backend.get_structured_memory_by_name("key", "workstream", "ws1")
|
|
assert g["content"] == "g"
|
|
assert w["content"] == "w"
|
|
|
|
|
|
class TestSaveUpsert:
|
|
"""``save_structured_memory`` upserts by (name, scope, scope_id).
|
|
|
|
A second save of the same key must UPDATE in place, not surface the
|
|
``uq_smem_name_scope`` unique-constraint violation. These run on whichever
|
|
backend ``--storage-backend`` selects, so the PostgreSQL path is covered in
|
|
CI -- the session-level memory tests only exercise SQLite (via ``tmp_db``),
|
|
which is where this path previously had no cross-backend coverage.
|
|
"""
|
|
|
|
def test_duplicate_create_raises_integrity_error(self, backend):
|
|
"""The unique constraint the upsert's ON CONFLICT targets actually fires."""
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
|
|
backend.create_structured_memory("m1", "dup", "Test memory", "general", "global", "", "a")
|
|
with pytest.raises(sa.exc.IntegrityError):
|
|
backend.create_structured_memory(
|
|
"m2", "dup", "Test memory", "general", "global", "", "b"
|
|
)
|
|
|
|
def test_save_same_key_updates_in_place(self, backend):
|
|
from turnstone.core.memory import save_structured_memory
|
|
|
|
row1, was_update1 = save_structured_memory(
|
|
"upsert_key", "v1", description="first description", scope="global"
|
|
)
|
|
assert row1 and was_update1 is False # inserted
|
|
|
|
row2, was_update2 = save_structured_memory(
|
|
"upsert_key", "v2", description="updated description", scope="global"
|
|
)
|
|
assert row2 and was_update2 is True # updated in place
|
|
assert row2["memory_id"] == row1["memory_id"] # same row, not a duplicate
|
|
assert row2["content"] == "v2"
|
|
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
|
assert names.count("upsert_key") == 1
|
|
|
|
def test_save_same_key_requires_and_updates_description(self, backend):
|
|
from turnstone.core.memory import save_structured_memory, save_structured_memory_strict
|
|
|
|
save_structured_memory(
|
|
"meta_key", "c1", description="orig desc", mem_type="fact", scope="global"
|
|
)
|
|
# Every update must describe the revised memory; type can still be omitted.
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="description is required"):
|
|
save_structured_memory_strict("meta_key", "c2", description=None, scope="global")
|
|
save_structured_memory("meta_key", "c2", description="revised description", scope="global")
|
|
row = backend.get_structured_memory_by_name("meta_key", "global", "")
|
|
assert row["content"] == "c2"
|
|
assert row["description"] == "revised description"
|
|
assert row["type"] == "fact"
|
|
|
|
def test_upsert_method_updates_in_place_no_raise(self, backend):
|
|
"""The atomic storage primitive updates in place on a key conflict and
|
|
returns (row, was_update) carrying the existing row's id -- no
|
|
IntegrityError (which a second create_structured_memory would raise)."""
|
|
backend.create_structured_memory("m1", "k", "desc", "fact", "global", "", "v1")
|
|
row, was_update = backend.upsert_structured_memory(
|
|
"m2", "k", "newdesc", "note", "global", "", "v2"
|
|
)
|
|
assert was_update is True
|
|
assert row["memory_id"] == "m1" # existing row id, not the supplied "m2"
|
|
assert row["content"] == "v2"
|
|
assert row["description"] == "newdesc"
|
|
assert row["type"] == "note"
|
|
names = [r["name"] for r in backend.list_structured_memories(scope="global")]
|
|
assert names.count("k") == 1
|
|
|
|
def test_upsert_requires_description_and_preserves_omitted_type(self, backend):
|
|
"""Description is mandatory; an omitted type keeps the stored value."""
|
|
import pytest
|
|
|
|
backend.create_structured_memory("m1", "k", "keepdesc", "fact", "global", "", "v1")
|
|
with pytest.raises(ValueError, match="description is required"):
|
|
backend.upsert_structured_memory("m2", "k", None, None, "global", "", "v2")
|
|
with pytest.raises(ValueError, match="description is required"):
|
|
backend.upsert_structured_memory("m2", "k", " ", None, "global", "", "v2")
|
|
|
|
row, _ = backend.upsert_structured_memory(
|
|
"m2", "k", "new description", None, "global", "", "v2"
|
|
)
|
|
assert row["content"] == "v2"
|
|
assert row["description"] == "new description"
|
|
assert row["type"] == "fact"
|
|
row2, _ = backend.upsert_structured_memory(
|
|
"m3", "k", "final description", "general", "global", "", "v3"
|
|
)
|
|
assert row2["description"] == "final description"
|
|
assert row2["type"] == "general"
|
|
|
|
def test_active_project_guard_accepts_only_active_project(self, backend):
|
|
import pytest
|
|
|
|
backend.create_project("active", "Active", "u1")
|
|
row, was_update = backend.upsert_structured_memory(
|
|
"m1",
|
|
"guarded",
|
|
"guarded description",
|
|
None,
|
|
"project",
|
|
"active",
|
|
"value",
|
|
require_active_project=True,
|
|
)
|
|
assert row["scope_id"] == "active"
|
|
assert was_update is False
|
|
|
|
backend.create_project("archived", "Archived", "u1", state="archived")
|
|
for project_id in ("archived", "missing"):
|
|
with pytest.raises(ValueError, match="missing, archived"):
|
|
backend.upsert_structured_memory(
|
|
f"m-{project_id}",
|
|
"guarded",
|
|
"guarded description",
|
|
None,
|
|
"project",
|
|
project_id,
|
|
"value",
|
|
require_active_project=True,
|
|
)
|
|
assert backend.get_structured_memory_by_name("guarded", "project", project_id) is None
|
|
|
|
def test_active_project_guard_rejects_non_project_scope(self, backend):
|
|
import pytest
|
|
|
|
with pytest.raises(ValueError, match="requires project scope"):
|
|
backend.upsert_structured_memory(
|
|
"m1",
|
|
"guarded",
|
|
"guarded description",
|
|
None,
|
|
"global",
|
|
"",
|
|
"value",
|
|
require_active_project=True,
|
|
)
|
|
|
|
|
|
class TestDelete:
|
|
def test_delete_existing(self, backend):
|
|
backend.create_structured_memory("m1", "k", "d", "general", "global", "", "data")
|
|
assert backend.delete_structured_memory("k", "global", "")
|
|
assert backend.get_structured_memory("m1") is None
|
|
|
|
def test_delete_nonexistent(self, backend):
|
|
assert not backend.delete_structured_memory("nope", "global", "")
|
|
|
|
def test_delete_scoped(self, backend):
|
|
backend.create_structured_memory("m1", "k", "d", "general", "workstream", "ws1", "data")
|
|
assert not backend.delete_structured_memory("k", "global", "")
|
|
assert backend.delete_structured_memory("k", "workstream", "ws1")
|
|
|
|
def test_delete_returning_is_atomic_and_truthful(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "k", "description", "reference", "user", "u1", "data"
|
|
)
|
|
|
|
deleted = backend.delete_structured_memory_returning("k", "user", "u1")
|
|
|
|
assert deleted is not None
|
|
assert deleted["memory_id"] == "m1"
|
|
assert deleted["description"] == "description"
|
|
assert deleted["type"] == "reference"
|
|
assert backend.get_structured_memory("m1") is None
|
|
assert backend.delete_structured_memory_returning("k", "user", "u1") is None
|
|
|
|
def test_delete_by_id_returning_is_atomic_and_truthful(self, backend):
|
|
backend.create_structured_memory("m1", "k", "Test memory", "general", "global", "", "data")
|
|
|
|
deleted = backend.delete_structured_memory_by_id_returning("m1")
|
|
|
|
assert deleted is not None
|
|
assert deleted["name"] == "k"
|
|
assert backend.get_structured_memory("m1") is None
|
|
assert backend.delete_structured_memory_by_id_returning("m1") is None
|
|
|
|
|
|
class TestFindScopes:
|
|
def test_finds_only_requested_same_name_scopes(self, backend):
|
|
backend.create_structured_memory("m1", "same", "Test memory", "general", "global", "", "g")
|
|
backend.create_structured_memory(
|
|
"m2", "same", "Test memory", "general", "user", "u1", "own"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m3", "same", "Test memory", "general", "user", "victim", "secret"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m4", "other", "Test memory", "general", "workstream", "ws1", "other"
|
|
)
|
|
|
|
found = backend.find_structured_memory_scopes(
|
|
"same", [("global", ""), ("user", "u1"), ("workstream", "ws1")]
|
|
)
|
|
|
|
assert set(found) == {("global", ""), ("user", "u1")}
|
|
|
|
|
|
class TestList:
|
|
def test_list_all(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
|
|
mems = backend.list_structured_memories()
|
|
assert len(mems) == 2
|
|
|
|
def test_list_by_type(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory("m2", "b", "Test memory", "user", "global", "", "2")
|
|
mems = backend.list_structured_memories(mem_type="user")
|
|
assert len(mems) == 1
|
|
assert mems[0]["name"] == "b"
|
|
|
|
def test_list_by_scope(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory(
|
|
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
|
|
)
|
|
mems = backend.list_structured_memories(scope="workstream")
|
|
assert len(mems) == 1
|
|
|
|
def test_list_respects_limit(self, backend):
|
|
for i in range(10):
|
|
backend.create_structured_memory(
|
|
f"m{i}", f"k{i}", "Test memory", "general", "global", "", f"{i}"
|
|
)
|
|
mems = backend.list_structured_memories(limit=3)
|
|
assert len(mems) == 3
|
|
|
|
|
|
class TestSearch:
|
|
def test_search_by_name(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "database_config", "Test memory", "general", "global", "", "pg"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "api_key", "Test memory", "general", "global", "", "secret"
|
|
)
|
|
results = backend.search_structured_memories("database")
|
|
assert len(results) == 1
|
|
assert results[0]["name"] == "database_config"
|
|
|
|
def test_search_by_content(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "a", "Test memory", "general", "global", "", "postgresql host"
|
|
)
|
|
results = backend.search_structured_memories("postgresql")
|
|
assert len(results) == 1
|
|
|
|
def test_search_empty_lists_all(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
|
|
results = backend.search_structured_memories("")
|
|
assert len(results) == 2
|
|
|
|
|
|
class TestCount:
|
|
def test_count_all(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory("m2", "b", "Test memory", "general", "global", "", "2")
|
|
assert backend.count_structured_memories() == 2
|
|
|
|
def test_count_by_scope(self, backend):
|
|
backend.create_structured_memory("m1", "a", "Test memory", "general", "global", "", "1")
|
|
backend.create_structured_memory(
|
|
"m2", "b", "Test memory", "general", "workstream", "ws1", "2"
|
|
)
|
|
assert backend.count_structured_memories(scope="global") == 1
|
|
assert backend.count_structured_memories(scope="workstream") == 1
|
|
|
|
|
|
class TestSearchOrOfTerms:
|
|
"""Verify that multi-word search uses OR-of-terms (any term matches → row included)."""
|
|
|
|
def test_single_matching_term_in_multi_word_query(self, backend):
|
|
"""Memory with content 'apple' found when query is 'apple banana cherry'."""
|
|
backend.create_structured_memory(
|
|
"m1", "apple_mem", "Test memory", "general", "global", "", "apple"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "other_mem", "Test memory", "general", "global", "", "grape"
|
|
)
|
|
|
|
results = backend.search_structured_memories("apple banana cherry")
|
|
names = {r["name"] for r in results}
|
|
assert "apple_mem" in names # matches "apple" — OR-of-terms keeps it
|
|
assert "other_mem" not in names # "grape" matches nothing in the query
|
|
|
|
def test_partial_overlap_across_memories(self, backend):
|
|
"""Each memory matches one of three terms; all three are returned."""
|
|
backend.create_structured_memory(
|
|
"m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "beta_doc", "Test memory", "general", "global", "", "beta"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m3", "gamma_doc", "Test memory", "general", "global", "", "gamma"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m4", "unrelated", "Test memory", "general", "global", "", "delta"
|
|
)
|
|
|
|
results = backend.search_structured_memories("alpha beta gamma")
|
|
names = {r["name"] for r in results}
|
|
assert "alpha_doc" in names
|
|
assert "beta_doc" in names
|
|
assert "gamma_doc" in names
|
|
assert "unrelated" not in names # "delta" doesn't appear in the query
|
|
|
|
def test_scope_filter_preserved(self, backend):
|
|
"""OR-of-terms search still respects scope / scope_id filters."""
|
|
backend.create_structured_memory(
|
|
"m1", "ws1_note", "Test memory", "general", "workstream", "ws1", "info"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "ws2_note", "Test memory", "general", "workstream", "ws2", "info"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m3", "global_note", "Test memory", "general", "global", "", "info"
|
|
)
|
|
|
|
results = backend.search_structured_memories("info", scope="workstream", scope_id="ws1")
|
|
names = {r["name"] for r in results}
|
|
assert "ws1_note" in names
|
|
assert "ws2_note" not in names
|
|
assert "global_note" not in names
|
|
|
|
def test_term_cap_normalizes_unbounded_query(self, backend):
|
|
"""A multi-KB query collapses to <= MAX terms (de-dupe + length filter)."""
|
|
backend.create_structured_memory(
|
|
"m1", "alpha_doc", "Test memory", "general", "global", "", "alpha"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "other_doc", "Test memory", "general", "global", "", "irrelevant"
|
|
)
|
|
|
|
# Build a noisy query: same word repeated, plus 1-char tokens that
|
|
# the normalizer drops, plus the actual signal "alpha".
|
|
noisy = " ".join(["x"] * 100 + ["alpha"] * 50)
|
|
results = backend.search_structured_memories(noisy)
|
|
names = {r["name"] for r in results}
|
|
assert "alpha_doc" in names
|
|
|
|
|
|
class TestVisibleStructuredMemories:
|
|
"""Single-query union helpers used by the composition path."""
|
|
|
|
def test_list_visible_unions_global_workstream_user(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "g_note", "Test memory", "general", "global", "", "g"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "ws_note", "Test memory", "general", "workstream", "ws1", "w"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m3", "u_note", "Test memory", "general", "user", "u1", "u"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m4", "other_ws", "Test memory", "general", "workstream", "ws2", "x"
|
|
)
|
|
|
|
scopes = [("global", ""), ("workstream", "ws1"), ("user", "u1")]
|
|
rows = backend.list_visible_structured_memories(scopes)
|
|
names = {r["name"] for r in rows}
|
|
assert names == {"g_note", "ws_note", "u_note"} # ws2 excluded
|
|
|
|
def test_search_visible_unions_scopes_and_terms(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "g_alpha", "Test memory", "general", "global", "", "alpha"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m2", "ws_beta", "Test memory", "general", "workstream", "ws1", "beta"
|
|
)
|
|
backend.create_structured_memory(
|
|
"m3", "ws_other", "Test memory", "general", "workstream", "ws2", "alpha"
|
|
)
|
|
|
|
scopes = [("global", ""), ("workstream", "ws1")]
|
|
rows = backend.search_visible_structured_memories("alpha beta", scopes)
|
|
names = {r["name"] for r in rows}
|
|
assert "g_alpha" in names # global, matches "alpha"
|
|
assert "ws_beta" in names # ws1, matches "beta"
|
|
assert "ws_other" not in names # ws2 -> outside visibility
|
|
|
|
def test_visible_helpers_handle_empty_scopes(self, backend):
|
|
backend.create_structured_memory(
|
|
"m1", "anything", "Test memory", "general", "global", "", "x"
|
|
)
|
|
assert backend.list_visible_structured_memories([]) == []
|
|
assert backend.search_visible_structured_memories("x", []) == []
|
|
|
|
|
|
class TestStableOrderingOnTimestampTies:
|
|
"""When two memories share an `updated` timestamp, secondary sort on
|
|
memory_id keeps the order deterministic across calls.
|
|
|
|
`updated` is second-precision, and touch_structured_memories() can bump
|
|
a batch to identical timestamps — without a tie-breaker BM25 input
|
|
order shuffles run-to-run, busting the LLM-side prompt cache.
|
|
"""
|
|
|
|
def _seed_with_shared_timestamp(self, backend):
|
|
# Create three memories then force their `updated` columns equal —
|
|
# mirrors the real-world case where a touch_structured_memories
|
|
# batch lands them in the same second.
|
|
for mid in ("zebra_id", "apple_id", "mango_id"):
|
|
backend.create_structured_memory(
|
|
mid, f"name_{mid}", "Test memory", "general", "global", "", "shared content"
|
|
)
|
|
import sqlalchemy as sa
|
|
|
|
with backend._conn() as conn:
|
|
conn.execute(sa.text("UPDATE structured_memories SET updated = '2024-01-01T00:00:00'"))
|
|
conn.commit()
|
|
|
|
def test_list_stable_order_under_tied_updated(self, backend):
|
|
self._seed_with_shared_timestamp(backend)
|
|
first = [r["memory_id"] for r in backend.list_structured_memories()]
|
|
second = [r["memory_id"] for r in backend.list_structured_memories()]
|
|
# Deterministic across calls AND sorted by memory_id ASC for ties
|
|
assert first == second
|
|
assert first == ["apple_id", "mango_id", "zebra_id"]
|
|
|
|
def test_search_stable_order_under_tied_updated(self, backend):
|
|
self._seed_with_shared_timestamp(backend)
|
|
first = [r["memory_id"] for r in backend.search_structured_memories("shared")]
|
|
second = [r["memory_id"] for r in backend.search_structured_memories("shared")]
|
|
assert first == second
|
|
assert first == ["apple_id", "mango_id", "zebra_id"]
|
|
|
|
def test_visible_search_stable_order_under_tied_updated(self, backend):
|
|
self._seed_with_shared_timestamp(backend)
|
|
scopes = [("global", "")]
|
|
first = [
|
|
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
|
|
]
|
|
second = [
|
|
r["memory_id"] for r in backend.search_visible_structured_memories("shared", scopes)
|
|
]
|
|
assert first == second
|
|
assert first == ["apple_id", "mango_id", "zebra_id"]
|