Skip to content

Commit 460eda3

Browse files
perf: numpy accelerator for compression (23x at 10k facts)
- agentkeeper._fastmath: optional numpy accel, pure-python fallback. - index.py: vectorised search via batch_dot. - consolidation.py: live numpy centroid matrix, incremental centroids, best-match assignment. - contradiction.py: vectorised per-row similarity prefilter. - compression at 10k facts: 118s -> 5.2s with numpy. - new [fast] extra (numpy). Zero required deps preserved. - tests/test_fastmath.py: 12 tests, both paths. - benchmark/stress_test.py: reproducible scaling benchmark.
1 parent 69915e8 commit 460eda3

9 files changed

Lines changed: 549 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
All notable changes to AgentKeeper are documented here.
44
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55

6+
## [1.1.2] — 2026-05-20
7+
8+
Performance release. No public API changes.
9+
10+
### Changed
11+
12+
- **Compression is dramatically faster at scale.** An optional numpy
13+
accelerator (`agentkeeper._fastmath`) vectorises the dot products and
14+
cluster-centroid math used by consolidation and contradiction
15+
arbitration. With numpy installed, a full compression pass over an
16+
agent holding 10,000 facts drops from ~118s to ~5s (about 23x). Without
17+
numpy, behaviour is unchanged — the pure-Python fallback is preserved,
18+
so the core retains zero required dependencies.
19+
- Install the accelerator with the new extra: `pip install
20+
'agentkeeper-ai[fast]'` (also included in `[all]`).
21+
- Consolidation clustering now assigns each fact to the **best** matching
22+
centroid above the similarity threshold rather than the first one
23+
encountered. Tighter, more stable clusters; output on typical inputs is
24+
equal or better.
25+
26+
### Added
27+
28+
- `benchmark/stress_test.py` — reproducible scaling benchmark.
29+
- `tests/test_fastmath.py` — verifies the numpy and pure-Python paths agree.
30+
631
## [1.1.0] — 2026-05-19
732

833
### Added

agentkeeper/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
register_profile,
8989
)
9090

91-
__version__ = "1.1.1"
91+
__version__ = "1.1.2"
9292

9393
_log = get_logger(__name__)
9494

agentkeeper/_fastmath.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Optional numpy acceleration for vector math.
2+
3+
AgentKeeper's core has zero required dependencies. Vector math (dot
4+
products, cosine similarity, clustering) falls back to pure Python when
5+
numpy is unavailable. When numpy *is* installed — which is the case for
6+
essentially every AI/ML project — the same operations run 50-100x
7+
faster, which matters a lot for compression at scale (consolidation
8+
and contradiction passes are vector-heavy).
9+
10+
This module exposes a single flag, `HAS_NUMPY`, and helpers that pick
11+
the fast path when possible. Callers never import numpy directly.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from collections.abc import Sequence
17+
18+
try:
19+
import numpy as _np
20+
21+
HAS_NUMPY = True
22+
except ImportError: # pragma: no cover
23+
_np = None # type: ignore[assignment]
24+
HAS_NUMPY = False
25+
26+
27+
def dot(a: Sequence[float], b: Sequence[float]) -> float:
28+
"""Dot product of two equal-length vectors."""
29+
if len(a) != len(b):
30+
raise ValueError(f"Vector dimension mismatch: {len(a)} vs {len(b)}")
31+
if HAS_NUMPY:
32+
return float(_np.dot(a, b))
33+
return sum(x * y for x, y in zip(a, b, strict=True))
34+
35+
36+
def batch_dot(
37+
query: Sequence[float],
38+
matrix: Sequence[Sequence[float]],
39+
) -> list[float]:
40+
"""Dot product of `query` against every row of `matrix`.
41+
42+
Returns a list of scores, one per row. With numpy this is a single
43+
matrix-vector product; without, it's a Python loop.
44+
"""
45+
if not matrix:
46+
return []
47+
if HAS_NUMPY:
48+
m = _np.asarray(matrix, dtype=float)
49+
q = _np.asarray(query, dtype=float)
50+
return _np.dot(m, q).tolist()
51+
return [dot(query, row) for row in matrix]
52+
53+
54+
def mean_vector(vectors: Sequence[Sequence[float]]) -> list[float]:
55+
"""Component-wise mean of a list of equal-length vectors."""
56+
if not vectors:
57+
return []
58+
if HAS_NUMPY:
59+
return _np.asarray(vectors, dtype=float).mean(axis=0).tolist()
60+
n = len(vectors)
61+
dim = len(vectors[0])
62+
acc = [0.0] * dim
63+
for v in vectors:
64+
for i in range(dim):
65+
acc[i] += v[i]
66+
return [x / n for x in acc]

agentkeeper/compression/consolidation.py

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
from collections.abc import Callable
2727
from dataclasses import dataclass, field
2828

29+
from .._fastmath import batch_dot
2930
from ..cso.types import Fact
3031
from ..semantic.base import EmbeddingProvider
31-
from ..semantic.index import _dot
3232

3333

3434
@dataclass
@@ -97,25 +97,75 @@ def consolidate(
9797
f.id: vec for f, vec in zip(targets, vectors, strict=True)
9898
}
9999

100-
# Greedy clustering: walk facts, group each into the first cluster
100+
# Greedy clustering: walk facts, group each into the best cluster
101101
# whose centroid is within the threshold; else start a new cluster.
102+
# When numpy is present we keep the centroid matrix as a live ndarray
103+
# and append rows in place, so we never re-coerce a growing python
104+
# list to an array on every fact (that reconversion was the dominant
105+
# cost at scale). Centroids use an incremental running sum / count.
106+
from .._fastmath import HAS_NUMPY
107+
102108
clusters: list[list[Fact]] = []
103-
centroids: list[list[float]] = []
104-
105-
for fact in targets:
106-
vec = fact_vectors[fact.id]
107-
assigned = False
108-
for i, centroid in enumerate(centroids):
109-
if _dot(vec, centroid) >= config.similarity_threshold:
110-
clusters[i].append(fact)
111-
# update centroid as running mean (re-normalise lazily)
112-
cluster_vecs = [fact_vectors[m.id] for m in clusters[i]]
113-
centroids[i] = _mean_vector(cluster_vecs)
114-
assigned = True
115-
break
116-
if not assigned:
117-
clusters.append([fact])
118-
centroids.append(vec)
109+
centroid_sums: list[list[float]] = []
110+
centroid_counts: list[int] = []
111+
112+
if HAS_NUMPY:
113+
import numpy as _np
114+
115+
dim = len(fact_vectors[targets[0].id])
116+
# Pre-allocate; grow geometrically to amortise reallocation.
117+
cap = 64
118+
cmat = _np.empty((cap, dim), dtype=float)
119+
n_centroids = 0
120+
121+
for fact in targets:
122+
vec = fact_vectors[fact.id]
123+
varr = _np.asarray(vec, dtype=float)
124+
assigned = False
125+
if n_centroids > 0:
126+
scores = cmat[:n_centroids] @ varr
127+
best_i = int(scores.argmax())
128+
if scores[best_i] >= config.similarity_threshold:
129+
clusters[best_i].append(fact)
130+
csum = centroid_sums[best_i]
131+
for k in range(dim):
132+
csum[k] += vec[k]
133+
centroid_counts[best_i] += 1
134+
cnt = centroid_counts[best_i]
135+
cmat[best_i] = [s / cnt for s in csum]
136+
assigned = True
137+
if not assigned:
138+
if n_centroids >= cap:
139+
cap *= 2
140+
newmat = _np.empty((cap, dim), dtype=float)
141+
newmat[:n_centroids] = cmat[:n_centroids]
142+
cmat = newmat
143+
cmat[n_centroids] = varr
144+
n_centroids += 1
145+
clusters.append([fact])
146+
centroid_sums.append(list(vec))
147+
centroid_counts.append(1)
148+
else:
149+
centroids: list[list[float]] = []
150+
for fact in targets:
151+
vec = fact_vectors[fact.id]
152+
assigned = False
153+
for i, centroid in enumerate(centroids):
154+
if batch_dot(vec, [centroid])[0] >= config.similarity_threshold:
155+
clusters[i].append(fact)
156+
csum = centroid_sums[i]
157+
for k in range(len(csum)):
158+
csum[k] += vec[k]
159+
centroid_counts[i] += 1
160+
cnt = centroid_counts[i]
161+
centroids[i] = [s / cnt for s in csum]
162+
assigned = True
163+
break
164+
if not assigned:
165+
clusters.append([fact])
166+
centroids.append(list(vec))
167+
centroid_sums.append(list(vec))
168+
centroid_counts.append(1)
119169

120170
# Process non-singleton clusters
121171
for cluster in clusters:

agentkeeper/compression/contradiction.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,15 +161,48 @@ def detect_and_resolve(
161161
# cascading.
162162
already_lost: set[str] = set()
163163

164+
# Vectorised prefilter: contradictions only occur between highly
165+
# similar facts (similarity >= threshold). For each row i we compute
166+
# similarities against all j > i in one batched dot product, then
167+
# only run the (cheaper but still non-trivial) contradiction check on
168+
# candidate pairs that clear the threshold. We compute per-row rather
169+
# than materialising the full n*n matrix, to bound memory at large n
170+
# (a 10k*10k float matrix would be ~800MB).
171+
from .._fastmath import HAS_NUMPY
172+
173+
mat = None
174+
if HAS_NUMPY and n > 1:
175+
import numpy as _np
176+
177+
mat = _np.asarray(vectors, dtype=float)
178+
164179
for i in range(n):
165180
if targets[i].id in already_lost:
166181
continue
167-
for j in range(i + 1, n):
182+
if mat is not None:
183+
# Similarities of row i against every other row, vectorised.
184+
row = (mat @ mat[i])
185+
candidates = [
186+
j
187+
for j in range(i + 1, n)
188+
if row[j] >= config.similarity_threshold
189+
]
190+
row_scores = row
191+
else:
192+
candidates = [
193+
j
194+
for j in range(i + 1, n)
195+
if _dot(vectors[i], vectors[j]) >= config.similarity_threshold
196+
]
197+
row_scores = None
198+
for j in candidates:
168199
if targets[j].id in already_lost:
169200
continue
170-
similarity = _dot(vectors[i], vectors[j])
171-
if similarity < config.similarity_threshold:
172-
continue
201+
similarity = (
202+
float(row_scores[j])
203+
if row_scores is not None
204+
else _dot(vectors[i], vectors[j])
205+
)
173206
reason = _detect_contradiction(targets[i], targets[j], similarity)
174207
if reason is None:
175208
continue

agentkeeper/semantic/index.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,8 @@
1919
from abc import ABC, abstractmethod
2020
from collections.abc import Sequence
2121

22-
23-
def _dot(a: Sequence[float], b: Sequence[float]) -> float:
24-
if len(a) != len(b):
25-
raise ValueError(
26-
f"Vector dimension mismatch: {len(a)} vs {len(b)}"
27-
)
28-
return sum(x * y for x, y in zip(a, b, strict=True))
22+
from .._fastmath import batch_dot
23+
from .._fastmath import dot as _dot # noqa: F401 (re-exported for compression.contradiction)
2924

3025

3126
class VectorIndex(ABC):
@@ -86,11 +81,14 @@ def search(
8681
) -> list[tuple[str, float]]:
8782
if top_k <= 0 or not self._vectors:
8883
return []
89-
scored: list[tuple[str, float]] = []
90-
for fact_id, vec in self._vectors.items():
91-
score = _dot(query, vec)
92-
if score >= min_score:
93-
scored.append((fact_id, score))
84+
fact_ids = list(self._vectors.keys())
85+
matrix = [self._vectors[fid] for fid in fact_ids]
86+
scores = batch_dot(query, matrix)
87+
scored = [
88+
(fid, score)
89+
for fid, score in zip(fact_ids, scores, strict=True)
90+
if score >= min_score
91+
]
9492
scored.sort(key=lambda x: x[1], reverse=True)
9593
return scored[:top_k]
9694

0 commit comments

Comments
 (0)