555e282cc4
pi-agent-plugin checks / lint (push) Has been cancelled
pi-agent-plugin checks / test (20) (push) Has been cancelled
pi-agent-plugin checks / test (22) (push) Has been cancelled
pi-agent-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / check_changes (push) Has been cancelled
TypeScript SDK CI / changelog_check (push) Has been cancelled
ci / changelog_check (push) Has been cancelled
ci / check_changes (push) Has been cancelled
ci / build_mem0 (3.10) (push) Has been cancelled
ci / build_mem0 (3.11) (push) Has been cancelled
ci / build_mem0 (3.12) (push) Has been cancelled
CLI Node CI / lint (push) Has been cancelled
CLI Node CI / test (20) (push) Has been cancelled
CLI Node CI / test (22) (push) Has been cancelled
CLI Node CI / build (push) Has been cancelled
CLI Python CI / lint (push) Has been cancelled
CLI Python CI / test (3.10) (push) Has been cancelled
CLI Python CI / test (3.11) (push) Has been cancelled
CLI Python CI / test (3.12) (push) Has been cancelled
CLI Python CI / build (push) Has been cancelled
openclaw checks / lint (push) Has been cancelled
openclaw checks / test (20) (push) Has been cancelled
openclaw checks / test (22) (push) Has been cancelled
openclaw checks / build (push) Has been cancelled
opencode-plugin checks / build (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / build_ts_sdk (22) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (20) (push) Has been cancelled
TypeScript SDK CI / integration_ts_sdk (22) (push) Has been cancelled
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""Tests for Redis vector store update() — embedding corruption fix.
|
|
|
|
Regression tests for #4336: when update() is called with vector=None
|
|
(metadata-only update), np.array(None) silently creates a 4-byte scalar,
|
|
overwriting the real embedding. The fix skips the embedding field entirely
|
|
when vector is None.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import numpy as np
|
|
import pytz
|
|
|
|
|
|
def _make_redis_db():
|
|
"""Create a RedisDB instance with mocked internals, bypassing __init__
|
|
to avoid the redis module name collision with mem0.vector_stores.redis."""
|
|
from mem0.vector_stores.redis import RedisDB
|
|
|
|
db = RedisDB.__new__(RedisDB)
|
|
mock_index = MagicMock()
|
|
db.index = mock_index
|
|
db.schema = {"index": {"prefix": "mem0:test"}}
|
|
return db, mock_index
|
|
|
|
|
|
def test_update_with_none_vector_preserves_embedding():
|
|
"""update() with vector=None should not include embedding in the data."""
|
|
db, mock_index = _make_redis_db()
|
|
|
|
payload = {
|
|
"hash": "test_hash",
|
|
"data": "updated_data",
|
|
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
|
|
"updated_at": datetime.now(pytz.timezone("UTC")).isoformat(),
|
|
"user_id": "test_user",
|
|
}
|
|
|
|
db.update(vector_id="test_id", vector=None, payload=payload)
|
|
|
|
mock_index.load.assert_called_once()
|
|
call_kwargs = mock_index.load.call_args
|
|
data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0]
|
|
assert "embedding" not in data_dict, (
|
|
"embedding should not be in data when vector is None"
|
|
)
|
|
assert data_dict["memory_id"] == "test_id"
|
|
|
|
|
|
def test_update_with_vector_includes_embedding():
|
|
"""update() with a real vector should include embedding in the data."""
|
|
db, mock_index = _make_redis_db()
|
|
|
|
vector = np.random.rand(1536).tolist()
|
|
payload = {
|
|
"hash": "test_hash",
|
|
"data": "updated_data",
|
|
"created_at": datetime.now(pytz.timezone("UTC")).isoformat(),
|
|
"updated_at": datetime.now(pytz.timezone("UTC")).isoformat(),
|
|
"user_id": "test_user",
|
|
}
|
|
|
|
db.update(vector_id="test_id", vector=vector, payload=payload)
|
|
|
|
mock_index.load.assert_called_once()
|
|
call_kwargs = mock_index.load.call_args
|
|
data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0]
|
|
assert "embedding" in data_dict, (
|
|
"embedding should be in data when vector is provided"
|
|
)
|
|
expected_bytes = np.array(vector, dtype=np.float32).tobytes()
|
|
assert data_dict["embedding"] == expected_bytes
|
|
|
|
|
|
def test_search_with_none_filters_does_not_crash():
|
|
"""search() with the base-class default filters=None must run an unfiltered
|
|
query, not raise. Regression: filters.items() on None raised AttributeError
|
|
and reduce() over an empty list raised TypeError. keyword_search() in the
|
|
same file already guards this; search() did not."""
|
|
db, mock_index = _make_redis_db()
|
|
mock_index.query.return_value = []
|
|
|
|
assert db.search("query", [0.1, 0.2, 0.3, 0.4], top_k=5, filters=None) == []
|
|
mock_index.query.assert_called_once()
|
|
|
|
|
|
def test_search_with_empty_or_all_none_filters_does_not_crash():
|
|
db, mock_index = _make_redis_db()
|
|
mock_index.query.return_value = []
|
|
|
|
assert db.search("query", [0.1, 0.2, 0.3, 0.4], filters={}) == []
|
|
assert db.search("query", [0.1, 0.2, 0.3, 0.4], filters={"user_id": None}) == []
|
|
|
|
|
|
def test_list_with_none_filters_matches_all():
|
|
"""list() with no filters must issue a match-all ('*') query, not raise."""
|
|
db, mock_index = _make_redis_db()
|
|
mock_index.search.return_value = MagicMock(docs=[])
|
|
|
|
db.list(filters=None)
|
|
|
|
query = mock_index.search.call_args[0][0]
|
|
assert query.query_string() == "*"
|
|
|
|
|
|
def test_list_with_filter_builds_query():
|
|
"""A real filter is still translated into a tag query (no regression)."""
|
|
db, mock_index = _make_redis_db()
|
|
mock_index.search.return_value = MagicMock(docs=[])
|
|
|
|
db.list(filters={"user_id": "alice"})
|
|
|
|
query = mock_index.search.call_args[0][0]
|
|
assert query.query_string() == "@user_id:{alice}"
|
|
|
|
|
|
def test_create_col_keeps_distinct_dims_across_instances():
|
|
"""Building the schema for two collections with different embedding
|
|
dimensions must keep them distinct. Regression: DEFAULT_FIELDS.copy() is a
|
|
shallow copy, so the shared "embedding" field dict was mutated in place and
|
|
the second create_col() clobbered the first one's dims (and the module
|
|
global)."""
|
|
import mem0.vector_stores.redis as redis_module
|
|
|
|
db, _ = _make_redis_db()
|
|
db.client = MagicMock()
|
|
|
|
captured = []
|
|
|
|
def capture_schema(schema):
|
|
captured.append(schema)
|
|
return MagicMock()
|
|
|
|
with patch.object(redis_module.SearchIndex, "from_dict", side_effect=capture_schema):
|
|
db.create_col(name="col_384", vector_size=384)
|
|
db.create_col(name="col_1536", vector_size=1536)
|
|
|
|
assert captured[0]["fields"][-1]["attrs"]["dims"] == 384
|
|
assert captured[1]["fields"][-1]["attrs"]["dims"] == 1536
|
|
# the module-level default must never be mutated by building a schema
|
|
assert "dims" not in redis_module.DEFAULT_FIELDS[-1]["attrs"]
|
|
|
|
|
|
def test_init_keeps_distinct_dims_from_module_global():
|
|
"""__init__ must stamp the requested dims into the index schema without
|
|
mutating the shared module-level DEFAULT_FIELDS. The create_col test above
|
|
covers the other deepcopy site; all other tests build RedisDB via __new__
|
|
and so skip __init__, leaving this call site otherwise uncovered."""
|
|
import mem0.vector_stores.redis as redis_module
|
|
from mem0.vector_stores.redis import RedisDB
|
|
|
|
captured = []
|
|
|
|
def capture_schema(schema):
|
|
captured.append(schema)
|
|
return MagicMock()
|
|
|
|
with (
|
|
patch("mem0.vector_stores.redis.redis.Redis.from_url", return_value=MagicMock()),
|
|
patch.object(redis_module.SearchIndex, "from_dict", side_effect=capture_schema),
|
|
):
|
|
RedisDB("redis://localhost:6379", "col_384", 384)
|
|
|
|
assert captured[0]["fields"][-1]["attrs"]["dims"] == 384
|
|
# the module-level default must never be mutated by constructing an instance
|
|
assert "dims" not in redis_module.DEFAULT_FIELDS[-1]["attrs"]
|
|
|
|
|
|
def test_get_returns_none_for_missing_id():
|
|
"""get() must return None for a missing id.
|
|
|
|
redisvl's SearchIndex.fetch() returns None when the id is not found, so the
|
|
old code raised ``TypeError: 'NoneType' object is not subscriptable`` on
|
|
``result["hash"]``. Every other vector store returns None for a missing id,
|
|
and Memory relies on it (``if existing_memory is None``), so get() must too.
|
|
"""
|
|
db, mock_index = _make_redis_db()
|
|
mock_index.fetch.return_value = None
|
|
|
|
assert db.get("missing_id") is None
|
|
mock_index.fetch.assert_called_once_with("missing_id")
|
|
|
|
|
|
def test_insert_entity_payload_without_hash_and_created_at():
|
|
"""insert() must not crash on entity payloads that lack hash/created_at."""
|
|
db, mock_index = _make_redis_db()
|
|
|
|
entity_payload = {
|
|
"data": "OpenAI",
|
|
"entity_type": "organization",
|
|
"linked_memory_ids": ["mem-1"],
|
|
"user_id": "test_user",
|
|
}
|
|
|
|
db.insert(
|
|
vectors=[[0.1, 0.2, 0.3]],
|
|
payloads=[entity_payload],
|
|
ids=["entity-1"],
|
|
)
|
|
|
|
mock_index.load.assert_called_once()
|
|
data = mock_index.load.call_args[0][0]
|
|
assert data[0]["memory_id"] == "entity-1"
|
|
assert data[0]["memory"] == "OpenAI"
|
|
assert data[0]["hash"] == ""
|
|
assert data[0]["created_at"] == 0
|
|
|
|
|
|
def test_update_entity_payload_without_hash_and_timestamps():
|
|
"""update() must not crash on entity payloads that lack hash/created_at/updated_at."""
|
|
db, mock_index = _make_redis_db()
|
|
|
|
entity_payload = {
|
|
"data": "OpenAI",
|
|
"entity_type": "organization",
|
|
"linked_memory_ids": ["mem-1"],
|
|
"user_id": "test_user",
|
|
}
|
|
|
|
db.update(vector_id="entity-1", vector=[0.1, 0.2, 0.3], payload=entity_payload)
|
|
|
|
mock_index.load.assert_called_once()
|
|
call_kwargs = mock_index.load.call_args
|
|
data_dict = call_kwargs[1]["data"][0] if "data" in call_kwargs[1] else call_kwargs[0][0][0]
|
|
assert data_dict["memory_id"] == "entity-1"
|
|
assert data_dict["memory"] == "OpenAI"
|
|
assert data_dict["hash"] == ""
|
|
assert data_dict["created_at"] == 0
|
|
assert data_dict["updated_at"] == 0
|