Skip to content

Commit 4d4dac2

Browse files
committed
Update version to 0.2.0 and enhance README.md with new features
- Bump version in pyproject.toml and __init__.py to 0.2.0. - Update README.md to reflect the addition of vector indexing in `create_memory` and improved search functionality with volatility re-ranking. - Introduce new tests for vector index functionality in CI workflow. - Enhance MemoryLayer to support vector index integration for improved memory retrieval performance.
1 parent 87be70b commit 4d4dac2

8 files changed

Lines changed: 497 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
- name: Run client tests
2929
run: python tests/test_client.py
3030

31+
- name: Run vector index tests
32+
run: python tests/test_vector_index.py
33+
3134
- name: Contradiction demo smoke test
3235
run: python examples/contradiction_demo.py
3336

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,27 @@ system = f"What you know about this user:\n{context}"
7272

7373
| Method | Description |
7474
|---|---|
75-
| `create_memory(db, user_id)` | Factory with auto-detected embeddings |
75+
| `create_memory(db, user_id)` | Factory with auto-detected embeddings + vector index |
7676
| `Memory.add(text \| messages)` | Store a fact; slot-aware linking updates related memories |
77-
| `Memory.search(query, limit=5)` | Ranked memories (relevance + freshness) |
77+
| `Memory.search(query, limit=5)` | ANN candidates + volatility re-rank (relevance + freshness) |
7878
| `Memory.get_all()` | All active memories for this user |
7979
| `Memory.delete(id)` | Remove one memory |
8080
| `Memory.clear()` | Wipe user namespace |
8181

8282
Advanced: `mem.layer` exposes `MemoryLayer` for low-level `observe()` / `write()`.
8383

84+
`create_memory(..., vector_index="auto")` enables a SQLite embedding index when an
85+
embedder is present (`"off"` restores full-scan retrieval). VoltMem always applies
86+
volatility re-ranking on top of vector candidates — not raw ANN results.
87+
88+
```mermaid
89+
flowchart LR
90+
Q[search query] --> E[embed query]
91+
E --> V[vector index: top candidates]
92+
V --> S[SQLite: load memory records]
93+
S --> R[volatility re-rank → current truth]
94+
```
95+
8496
---
8597

8698
## Why VoltMem

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "voltmem"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Current-truth memory for LLM agents — protect stable facts, update volatile ones."
99
readme = "README.md"
1010
license = "MIT"

tests/test_vector_index.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""
2+
Tests for vector index backends and MemoryLayer ANN retrieval.
3+
"""
4+
import sys
5+
from pathlib import Path
6+
7+
ROOT = Path(__file__).resolve().parents[1]
8+
sys.path.insert(0, str(ROOT))
9+
10+
from voltmem import MemoryLayer
11+
from voltmem.embeddings import _cosine
12+
from voltmem.vector_index import BruteForceVectorIndex, SqliteVectorIndex, cosine_similarity
13+
14+
15+
# ── deterministic toy embedder for parity tests ───────────────────────────────
16+
17+
_KEYWORDS = ("berlin", "paris", "concise", "stressed", "billing", "auth")
18+
19+
20+
def _toy_embed(text: str) -> list[float]:
21+
t = text.lower()
22+
vec = [1.0 if kw in t else 0.0 for kw in _KEYWORDS]
23+
return vec if any(vec) else [0.1] * len(_KEYWORDS)
24+
25+
26+
def _toy_similarity(a: str, b: str) -> float:
27+
return max(0.0, _cosine(_toy_embed(a), _toy_embed(b)))
28+
29+
30+
def _layer_with_index(mode: str = "brute"):
31+
return MemoryLayer(
32+
":memory:",
33+
similarity_fn=_toy_similarity,
34+
embed_fn=_toy_embed,
35+
vector_index=mode,
36+
namespace="test",
37+
)
38+
39+
40+
# ── index unit tests ──────────────────────────────────────────────────────────
41+
42+
def test_brute_force_search_orders_by_similarity():
43+
idx = BruteForceVectorIndex()
44+
q = [1.0, 0.0, 0.0]
45+
idx.upsert("a", "u1", "location", [1.0, 0.0, 0.0])
46+
idx.upsert("b", "u1", "location", [0.8, 0.2, 0.0])
47+
idx.upsert("c", "u1", "location", [0.0, 1.0, 0.0])
48+
hits = idx.search(q, "u1", top_k=2)
49+
assert [h[0] for h in hits] == ["a", "b"]
50+
51+
52+
def test_sqlite_index_namespace_isolation():
53+
idx = SqliteVectorIndex(":memory:")
54+
idx.upsert("a", "alice", "location", [1.0, 0.0])
55+
idx.upsert("b", "bob", "location", [0.0, 1.0])
56+
alice = idx.search([1.0, 0.0], "alice", top_k=5)
57+
bob = idx.search([1.0, 0.0], "bob", top_k=5)
58+
assert alice[0][0] == "a"
59+
assert bob[0][0] == "b"
60+
idx.close()
61+
62+
63+
def test_cosine_similarity_clamps_negative():
64+
assert cosine_similarity([1.0, 0.0], [-1.0, 0.0]) == 0.0
65+
66+
67+
# ── MemoryLayer integration ───────────────────────────────────────────────────
68+
69+
def test_retrieve_with_brute_index_matches_full_scan():
70+
with _layer_with_index("brute") as mem:
71+
mem.write("User lives in Berlin", domain="location")
72+
mem.write("User prefers concise answers", domain="core_preference")
73+
mem.write("User is stressed this week", domain="emotional_context")
74+
75+
indexed = mem.retrieve("concise communication", top_k=2)
76+
mem._vector_index = None
77+
full = mem.retrieve("concise communication", top_k=2)
78+
79+
assert [i.content for i in indexed.items] == [i.content for i in full.items]
80+
81+
82+
def test_retrieve_with_sqlite_index_matches_full_scan():
83+
with _layer_with_index("sqlite") as mem:
84+
mem.write("User lives in Berlin", domain="location")
85+
mem.write("User lives in Paris now", domain="location")
86+
mem.write("building the billing service", domain="current_project")
87+
88+
indexed = mem.retrieve("where does user live", top_k=1)
89+
mem._vector_index = None
90+
full = mem.retrieve("where does user live", top_k=1)
91+
92+
assert indexed.items[0].content == full.items[0].content
93+
94+
95+
def test_supersede_removes_old_vector():
96+
with _layer_with_index("brute") as mem:
97+
mem.write("User lives in Berlin", domain="location")
98+
mem.observe(
99+
"User lives in Paris now",
100+
domain="location",
101+
mismatch_magnitude=0.9,
102+
source="explicit_statement",
103+
)
104+
active = mem._active(domain="location")
105+
assert len(active) == 1
106+
assert "Paris" in active[0].content
107+
hits = mem._vector_index.search(_toy_embed("Berlin"), "test", top_k=5)
108+
for item_id, _ in hits:
109+
item = mem._store.get(item_id)
110+
assert item is None or "Berlin" not in item.content
111+
112+
113+
def test_remove_and_clear_drop_vectors():
114+
with _layer_with_index("sqlite") as mem:
115+
r = mem.write("User lives in Berlin", domain="location")
116+
mem.remove(r.item.id)
117+
hits = mem._vector_index.search(_toy_embed("Berlin"), "test", top_k=5)
118+
assert hits == []
119+
120+
mem.write("User lives in Paris", domain="location")
121+
mem.clear()
122+
hits = mem._vector_index.search(_toy_embed("Paris"), "test", top_k=5)
123+
assert hits == []
124+
125+
126+
def test_vector_index_off_preserves_behavior():
127+
with MemoryLayer(":memory:", vector_index="off") as mem:
128+
mem.remember("I live in Berlin")
129+
res = mem.remember("I live in Paris")
130+
assert res.action in ("audited", "logged_mismatch")
131+
assert len(mem._active(domain="location")) == 1
132+
133+
134+
if __name__ == "__main__":
135+
tests = [
136+
test_brute_force_search_orders_by_similarity,
137+
test_sqlite_index_namespace_isolation,
138+
test_cosine_similarity_clamps_negative,
139+
test_retrieve_with_brute_index_matches_full_scan,
140+
test_retrieve_with_sqlite_index_matches_full_scan,
141+
test_supersede_removes_old_vector,
142+
test_remove_and_clear_drop_vectors,
143+
test_vector_index_off_preserves_behavior,
144+
]
145+
passed = failed = 0
146+
for t in tests:
147+
try:
148+
t()
149+
print(f" PASS {t.__name__}")
150+
passed += 1
151+
except Exception as e:
152+
print(f" FAIL {t.__name__}: {e}")
153+
failed += 1
154+
print(f"\n{passed}/{passed + failed} tests passed")
155+
if failed:
156+
sys.exit(1)

voltmem/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@
3636
from .memory import MemoryLayer, WriteResult, RetrieveResult
3737
from .client import Memory, create_memory
3838
from .domains import MemoryItem, DOMAIN_VOLATILITY, SOURCE_RELIABILITY
39+
from .vector_index import (
40+
VectorIndex,
41+
BruteForceVectorIndex,
42+
SqliteVectorIndex,
43+
create_vector_index,
44+
)
3945
from .embeddings import EmbeddingSimilarity
4046
from .extract import HeuristicExtractor, LLMExtractor, HeuristicFactExtractor, LLMFactExtractor
4147
from .scoring import (
@@ -55,6 +61,10 @@
5561
"DOMAIN_VOLATILITY",
5662
"SOURCE_RELIABILITY",
5763
"EmbeddingSimilarity",
64+
"VectorIndex",
65+
"BruteForceVectorIndex",
66+
"SqliteVectorIndex",
67+
"create_vector_index",
5868
"HeuristicExtractor",
5969
"LLMExtractor",
6070
"HeuristicFactExtractor",
@@ -65,4 +75,4 @@
6575
"protection_weight",
6676
]
6777

68-
__version__ = "0.1.0"
78+
__version__ = "0.2.0"

voltmem/client.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def create_memory(
2323
verbose: bool = False,
2424
llm_extract: bool = False,
2525
llm_domain: bool = False,
26+
vector_index: str = "auto",
2627
**kwargs: Any,
2728
) -> "Memory":
2829
"""Create a production-ready memory instance with sensible defaults.
@@ -32,6 +33,9 @@ def create_memory(
3233
3334
Parameters
3435
----------
36+
vector_index : str
37+
``auto`` (sqlite index when embedder present), ``sqlite``, ``brute``,
38+
or ``off`` for full-scan retrieval.
3539
llm_extract : bool
3640
Use Ollama to extract atomic facts from conversation message lists.
3741
llm_domain : bool
@@ -42,13 +46,18 @@ def create_memory(
4246
if embeddings and similarity_fn is None:
4347
similarity_fn = EmbeddingSimilarity(verbose=verbose)
4448

49+
embed_fn = getattr(similarity_fn, "embed", None)
50+
4551
fact_extractor = LLMFactExtractor() if llm_extract else HeuristicFactExtractor()
4652

4753
layer_kwargs = dict(kwargs)
4854
if llm_domain:
4955
from .extract import LLMExtractor
5056
layer_kwargs["extractor"] = LLMExtractor()
5157

58+
layer_kwargs.setdefault("vector_index", vector_index)
59+
layer_kwargs.setdefault("embed_fn", embed_fn)
60+
5261
return Memory(
5362
user_id=user_id,
5463
db_path=db_path,
@@ -161,8 +170,7 @@ def delete(self, memory_id: str) -> bool:
161170
item = self._layer._store.get(memory_id)
162171
if not item or item.namespace != self.user_id:
163172
return False
164-
self._layer._store.delete(memory_id, namespace=self.user_id)
165-
return True
173+
return self._layer.remove(memory_id)
166174

167175
def clear(self) -> None:
168176
"""Remove all memories for this user."""

0 commit comments

Comments
 (0)