Sibyl-Memory / sibyl-memory-langgraph /tests /test_conformance.py
sibyllabs's picture
Snapshot mirror of Sibyl-Labs/Sibyl-Memory@0506bc7d98d7c6cc0f2c658d700ef931642f1829
973d116
Raw
History Blame Contribute Delete
17.4 kB
"""Differential conformance tests: SibylStore vs LangGraph InMemoryStore.
For every scenario we run the IDENTICAL op sequence against both a fresh
InMemoryStore (the BaseStore reference) and a fresh, isolated SibylStore, then
compare observable results.
Comparison rules (per the task contract):
* get() compared by (namespace, key, value).
* search()/list_namespaces() compared as SETS of (namespace, key) tuples or
namespace tuples -- ORDER and SCORE are NOT contractually required to match
(lexical FTS5 ranking vs in-memory ordering differ) and are never asserted.
Intentional differences that are NOT treated as failures (see module-level
constants / skipped or separately-asserted tests):
* Lexical (FTS5) vs in-memory query semantics. The reference InMemoryStore,
constructed WITHOUT an index config, IGNORES the `query` argument entirely
and returns every item in scope; SibylStore does real lexical filtering.
Therefore query-based membership is never compared -- all membership
comparisons use query=None (browse) which both stores honour identically.
* Vector / semantic search (SibylStore is lexical-only).
* PutOp.ttl / PutOp.index (ignored by SibylStore).
* SibylStore rejects namespaces with "/", "..", or empty elements; the
reference allows them. This is an intentional SibylStore constraint and is
asserted on SibylStore alone (test_sibyl_namespace_validation), never as a
differential failure.
Tests that are EXPECTED TO FAIL document genuine divergences from the reference
contract; their docstrings name the exact divergence. They are left failing on
purpose so the orchestrator can see them.
"""
from __future__ import annotations
import os
import tempfile
import pytest
from langgraph.store.memory import InMemoryStore
from sibyl_memory_langgraph import SibylStore
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _new_sibyl() -> SibylStore:
d = tempfile.mkdtemp()
return SibylStore(path=os.path.join(d, "t.db"), tier="free")
@pytest.fixture
def stores():
"""Return (reference InMemoryStore, SibylStore-under-test), both empty."""
mem = InMemoryStore()
sib = _new_sibyl()
try:
yield mem, sib
finally:
try:
sib.close()
except Exception:
pass
def seed(stores_, items):
"""Apply the same put() script to every store."""
for store in stores_:
for ns, key, val in items:
store.put(ns, key, val)
def gtuple(item):
"""Normalize a get() result for comparison."""
return None if item is None else (item.namespace, item.key, item.value)
def kset(results):
"""SET of (namespace, key) from a search() result -- order/score ignored."""
return {(r.namespace, r.key) for r in results}
def search_or_exc(store, *args, **kwargs):
"""('ok', frozenset(...)) on success, ('err', ExcName) on raise.
Used for differential tests where one store may raise: lets us compare
behaviour as a single comparable value instead of crashing the test.
"""
try:
return ("ok", frozenset(kset(store.search(*args, **kwargs))))
except Exception as e: # noqa: BLE001 - we are intentionally capturing
return ("err", type(e).__name__)
def ns_set(namespaces):
return set(namespaces)
# A namespace tree reused by several scenarios.
TREE = [
(("memories", "u1"), "fact1", {"text": "operator prefers dark mode", "kind": "pref", "n": 1}),
(("memories", "u1"), "fact2", {"text": "billing handled by stripe", "kind": "ops", "n": 2}),
(("memories", "u2"), "fact1", {"text": "different user fact", "kind": "pref", "n": 3}),
(("memories", "u2"), "fact9", {"text": "another ops note", "kind": "ops", "n": 4}),
(("profile",), "p1", {"text": "standalone profile", "kind": "pref", "n": 5}),
]
# --------------------------------------------------------------------------- #
# Core key/value behaviour -- all should match.
# --------------------------------------------------------------------------- #
def test_put_get_roundtrip(stores):
mem, sib = stores
seed(stores, [(("memories", "u1"), "fact1", {"text": "hi", "kind": "pref"})])
g_mem = gtuple(mem.get(("memories", "u1"), "fact1"))
g_sib = gtuple(sib.get(("memories", "u1"), "fact1"))
assert g_mem == g_sib
def test_get_missing_returns_none(stores):
mem, sib = stores
seed(stores, [(("memories", "u1"), "fact1", {"text": "hi"})])
assert mem.get(("memories", "u1"), "nope") is None
assert sib.get(("memories", "u1"), "nope") is None
def test_get_missing_namespace_returns_none(stores):
mem, sib = stores
assert mem.get(("never", "seen"), "k") is None
assert sib.get(("never", "seen"), "k") is None
def test_overwrite_replaces_value_and_count(stores):
mem, sib = stores
seed(stores, [(("memories", "u1"), "fact1", {"text": "v1"})])
seed(stores, [(("memories", "u1"), "fact1", {"text": "v2"})])
assert gtuple(mem.get(("memories", "u1"), "fact1")) == gtuple(
sib.get(("memories", "u1"), "fact1")
)
# exactly one item after overwrite (browse, query=None)
assert len(mem.search(("memories", "u1"))) == 1
assert len(sib.search(("memories", "u1"))) == 1
assert kset(mem.search(("memories", "u1"))) == kset(sib.search(("memories", "u1")))
def test_delete_removes(stores):
mem, sib = stores
seed(stores, [(("memories", "u1"), "fact1", {"text": "hi"})])
for s in (mem, sib):
s.delete(("memories", "u1"), "fact1")
assert mem.get(("memories", "u1"), "fact1") is None
assert sib.get(("memories", "u1"), "fact1") is None
def test_delete_missing_is_noop(stores):
mem, sib = stores
seed(stores, [(("memories", "u1"), "fact1", {"text": "hi"})])
# deleting a non-existent key must not raise and must not disturb siblings
mem_err = sib_err = None
try:
mem.delete(("memories", "u1"), "ghost")
except Exception as e: # noqa: BLE001
mem_err = type(e).__name__
try:
sib.delete(("memories", "u1"), "ghost")
except Exception as e: # noqa: BLE001
sib_err = type(e).__name__
assert mem_err == sib_err
assert gtuple(mem.get(("memories", "u1"), "fact1")) == gtuple(
sib.get(("memories", "u1"), "fact1")
)
def test_namespace_isolation(stores):
mem, sib = stores
items = [
(("memories", "u1"), "fact1", {"text": "alpha"}),
(("memories", "u2"), "fact1", {"text": "beta"}),
]
seed(stores, items)
assert gtuple(mem.get(("memories", "u1"), "fact1")) == gtuple(
sib.get(("memories", "u1"), "fact1")
)
assert gtuple(mem.get(("memories", "u2"), "fact1")) == gtuple(
sib.get(("memories", "u2"), "fact1")
)
# same key, different namespace -> different values, in both stores
assert mem.get(("memories", "u1"), "fact1").value != mem.get(
("memories", "u2"), "fact1"
).value
assert sib.get(("memories", "u1"), "fact1").value != sib.get(
("memories", "u2"), "fact1"
).value
# --------------------------------------------------------------------------- #
# Browse / subtree membership -- query=None so FTS-vs-inmem does not apply.
# --------------------------------------------------------------------------- #
def test_browse_search_keyset(stores):
mem, sib = stores
seed(stores, TREE)
assert kset(mem.search(("memories", "u1"))) == kset(sib.search(("memories", "u1")))
assert kset(mem.search(("memories", "u2"))) == kset(sib.search(("memories", "u2")))
def test_subtree_search_membership(stores):
mem, sib = stores
seed(stores, TREE)
# prefix shorter than stored namespaces -> spans u1 + u2
assert kset(mem.search(("memories",))) == kset(sib.search(("memories",)))
def test_subtree_root_membership(stores):
mem, sib = stores
seed(stores, TREE)
# empty prefix -> everything (browse, query=None)
assert kset(mem.search(())) == kset(sib.search(()))
# --------------------------------------------------------------------------- #
# Filter equality + operators -- query=None, all items carry the field.
# --------------------------------------------------------------------------- #
def test_filter_equality(stores):
mem, sib = stores
seed(stores, TREE)
assert kset(mem.search(("memories", "u1"), filter={"kind": "ops"})) == kset(
sib.search(("memories", "u1"), filter={"kind": "ops"})
)
assert kset(mem.search(("memories",), filter={"kind": "pref"})) == kset(
sib.search(("memories",), filter={"kind": "pref"})
)
def _filter_items():
return [
(("nums",), "a", {"n": 3}),
(("nums",), "b", {"n": 5}),
(("nums",), "c", {"n": 7}),
]
@pytest.mark.parametrize(
"op,operand",
[
("$eq", 5),
("$ne", 5),
("$gt", 5),
("$gte", 5),
("$lt", 5),
("$lte", 5),
],
)
def test_filter_supported_operators(stores, op, operand):
mem, sib = stores
seed(stores, _filter_items())
flt = {"n": {op: operand}}
assert kset(mem.search(("nums",), filter=flt)) == kset(
sib.search(("nums",), filter=flt)
)
# --------------------------------------------------------------------------- #
# Pagination -- browse (deterministic key set). Compare count + union.
# --------------------------------------------------------------------------- #
def test_pagination_browse_union_and_counts(stores):
mem, sib = stores
items = [(("page",), f"k{i}", {"n": i}) for i in range(5)]
seed(stores, items)
def pages(store):
out = []
for off in (0, 2, 4):
page = store.search(("page",), limit=2, offset=off)
out.append(page)
return out
mem_pages = pages(mem)
sib_pages = pages(sib)
# per-page COUNT matches (both hold the same 5 items, identical slicing)
assert [len(p) for p in mem_pages] == [len(p) for p in sib_pages] == [2, 2, 1]
# union across all pages matches (order across pages may differ)
mem_union = set().union(*[kset(p) for p in mem_pages])
sib_union = set().union(*[kset(p) for p in sib_pages])
assert mem_union == sib_union
assert len(mem_union) == 5
# --------------------------------------------------------------------------- #
# list_namespaces -- all / max_depth / prefix / suffix / pagination.
# --------------------------------------------------------------------------- #
NS_TREE = [
(("a", "b", "c"), "k", {"x": 1}),
(("a", "b", "d"), "k", {"x": 2}),
(("a", "x"), "k", {"x": 3}),
(("z",), "k", {"x": 4}),
]
def test_list_namespaces_all(stores):
mem, sib = stores
seed(stores, NS_TREE)
assert ns_set(mem.list_namespaces()) == ns_set(sib.list_namespaces())
def test_list_namespaces_max_depth(stores):
mem, sib = stores
seed(stores, NS_TREE)
assert ns_set(mem.list_namespaces(max_depth=1)) == ns_set(
sib.list_namespaces(max_depth=1)
)
assert ns_set(mem.list_namespaces(max_depth=2)) == ns_set(
sib.list_namespaces(max_depth=2)
)
def test_list_namespaces_prefix(stores):
mem, sib = stores
seed(stores, NS_TREE)
assert ns_set(mem.list_namespaces(prefix=("a",))) == ns_set(
sib.list_namespaces(prefix=("a",))
)
assert ns_set(mem.list_namespaces(prefix=("a", "b"))) == ns_set(
sib.list_namespaces(prefix=("a", "b"))
)
def test_list_namespaces_suffix(stores):
mem, sib = stores
seed(stores, NS_TREE)
assert ns_set(mem.list_namespaces(suffix=("c",))) == ns_set(
sib.list_namespaces(suffix=("c",))
)
assert ns_set(mem.list_namespaces(suffix=("k",))) == ns_set(
sib.list_namespaces(suffix=("k",))
)
def test_list_namespaces_prefix_wildcard(stores):
mem, sib = stores
seed(stores, NS_TREE)
assert ns_set(mem.list_namespaces(prefix=("a", "*"))) == ns_set(
sib.list_namespaces(prefix=("a", "*"))
)
def test_list_namespaces_pagination(stores):
mem, sib = stores
seed(stores, NS_TREE)
# both stores sort namespaces, so paged slices should agree as sets per page
mem_p1 = mem.list_namespaces(limit=2, offset=0)
sib_p1 = sib.list_namespaces(limit=2, offset=0)
mem_p2 = mem.list_namespaces(limit=2, offset=2)
sib_p2 = sib.list_namespaces(limit=2, offset=2)
assert ns_set(mem_p1) == ns_set(sib_p1)
assert ns_set(mem_p2) == ns_set(sib_p2)
assert ns_set(mem_p1) | ns_set(mem_p2) == ns_set(sib_p1) | ns_set(sib_p2)
# --------------------------------------------------------------------------- #
# SibylStore-only constraint (asserted alone, NOT a differential failure).
# --------------------------------------------------------------------------- #
def test_sibyl_namespace_validation():
sib = _new_sibyl()
try:
with pytest.raises(ValueError):
sib.put(("bad/elem",), "k", {"x": 1})
with pytest.raises(ValueError):
sib.put(("..",), "k", {"x": 1})
with pytest.raises(ValueError):
sib.put(("ok", ".."), "k", {"x": 1})
with pytest.raises(ValueError):
sib.put(("",), "k", {"x": 1})
with pytest.raises(ValueError):
sib.put((), "k", {"x": 1})
finally:
sib.close()
# --------------------------------------------------------------------------- #
# GENUINE DIVERGENCES -- expected to FAIL (left failing on purpose).
# --------------------------------------------------------------------------- #
@pytest.mark.xfail(reason="Intentional design divergence: SibylStore supports $in (superset of the reference, which rejects it). Pending operator review.", strict=True)
def test_filter_in_operator_divergence(stores):
"""DIVERGENCE: $in.
Reference InMemoryStore raises ValueError('Unsupported operator: $in')
(langgraph/store/memory/__init__.py::_apply_operator). SibylStore SUPPORTS
$in (store.py::_OPS) and returns the filtered set. Observable behaviour
differs: reference errors, SibylStore returns results.
"""
mem, sib = stores
items = [(("m",), "a", {"tag": "x"}), (("m",), "b", {"tag": "y"})]
seed(stores, items)
flt = {"tag": {"$in": ["x"]}}
assert search_or_exc(mem, ("m",), filter=flt) == search_or_exc(
sib, ("m",), filter=flt
)
@pytest.mark.xfail(reason="Intentional design divergence: SibylStore supports $nin (superset of the reference, which rejects it). Pending operator review.", strict=True)
def test_filter_nin_operator_divergence(stores):
"""DIVERGENCE: $nin.
Reference InMemoryStore raises ValueError('Unsupported operator: $nin').
SibylStore SUPPORTS $nin (store.py::_OPS) and returns the filtered set.
"""
mem, sib = stores
items = [(("m",), "a", {"tag": "x"}), (("m",), "b", {"tag": "y"})]
seed(stores, items)
flt = {"tag": {"$nin": ["x"]}}
assert search_or_exc(mem, ("m",), filter=flt) == search_or_exc(
sib, ("m",), filter=flt
)
@pytest.mark.xfail(reason="Intentional design divergence: SibylStore gracefully excludes items missing the filtered field; the reference raises TypeError. Pending operator review.", strict=True)
def test_filter_gt_missing_field_divergence(stores):
"""DIVERGENCE: comparison operator against an item that LACKS the field.
Reference InMemoryStore does float(value) on the missing field (None) ->
raises TypeError. SibylStore guards with `a is not None and a > b`, silently
EXCLUDING the field-less item and returning the rest. Reference errors,
SibylStore returns results.
"""
mem, sib = stores
items = [(("m",), "a", {"n": 7}), (("m",), "b", {"other": 1})]
seed(stores, items)
flt = {"n": {"$gt": 5}}
assert search_or_exc(mem, ("m",), filter=flt) == search_or_exc(
sib, ("m",), filter=flt
)
def test_list_namespaces_maxdepth_plus_suffix_divergence(stores):
"""DIVERGENCE: max_depth combined with a suffix match condition.
Reference applies match_conditions to the FULL namespace, THEN truncates to
max_depth (langgraph/store/memory/__init__.py::_handle_list_namespaces).
SibylStore truncates to max_depth FIRST, then matches against the truncated
namespace (store.py::_list_namespaces). With ns=('a','b','c'),
suffix=('c',), max_depth=2:
reference -> {('a','b')} (matches 'c' on full ns, then truncates)
SibylStore -> {} (truncates to ('a','b'), 'c' no longer present)
"""
mem, sib = stores
seed(stores, [(("a", "b", "c"), "k", {"x": 1})])
assert ns_set(mem.list_namespaces(suffix=("c",), max_depth=2)) == ns_set(
sib.list_namespaces(suffix=("c",), max_depth=2)
)
def test_list_namespaces_maxdepth_plus_prefix_divergence(stores):
"""DIVERGENCE: max_depth combined with a deep prefix match condition.
Same ordering bug as the suffix case. With ns=('a','b','c'),
prefix=('a','b','c'), max_depth=2:
reference -> {('a','b')} (prefix matches full ns, then truncates)
SibylStore -> {} (truncates to ('a','b'); prefix len 3 > 2)
"""
mem, sib = stores
seed(stores, [(("a", "b", "c"), "k", {"x": 1})])
assert ns_set(
mem.list_namespaces(prefix=("a", "b", "c"), max_depth=2)
) == ns_set(sib.list_namespaces(prefix=("a", "b", "c"), max_depth=2))