wavemind 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wavemind/__init__.py +22 -0
- wavemind/__main__.py +5 -0
- wavemind/api.py +177 -0
- wavemind/benchmark.py +67 -0
- wavemind/cli.py +245 -0
- wavemind/core.py +428 -0
- wavemind/encoders.py +127 -0
- wavemind/importers.py +136 -0
- wavemind/indexes.py +214 -0
- wavemind/storage.py +222 -0
- wavemind-2.0.0.dist-info/METADATA +134 -0
- wavemind-2.0.0.dist-info/RECORD +16 -0
- wavemind-2.0.0.dist-info/WHEEL +5 -0
- wavemind-2.0.0.dist-info/entry_points.txt +2 -0
- wavemind-2.0.0.dist-info/licenses/LICENSE +21 -0
- wavemind-2.0.0.dist-info/top_level.txt +1 -0
wavemind/core.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .encoders import FieldProjector, HashingTextEncoder, TextVectorEncoder
|
|
12
|
+
from .indexes import NumpyVectorIndex, create_vector_index
|
|
13
|
+
from .storage import MemoryRecord, SQLiteMemoryStore
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WaveField:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
width: int = 128,
|
|
20
|
+
height: int = 128,
|
|
21
|
+
layers: int = 6,
|
|
22
|
+
radius: int = 1,
|
|
23
|
+
decay: float = 0.965,
|
|
24
|
+
speed: float = 0.14,
|
|
25
|
+
nonlin: float = 0.04,
|
|
26
|
+
threshold_nl: float = 3e-4,
|
|
27
|
+
stable_threshold: float = 8e-5,
|
|
28
|
+
):
|
|
29
|
+
self.W = width
|
|
30
|
+
self.H = height
|
|
31
|
+
self.L = layers
|
|
32
|
+
self.radius = radius
|
|
33
|
+
self.decay = decay
|
|
34
|
+
self.speed = speed
|
|
35
|
+
self.nonlin = nonlin
|
|
36
|
+
self.threshold_nl = threshold_nl
|
|
37
|
+
self.stable_threshold = stable_threshold
|
|
38
|
+
self.state = np.zeros((height, width, layers), dtype=np.float32)
|
|
39
|
+
|
|
40
|
+
def feed(self, pattern: np.ndarray, strength: float = 1.0) -> None:
|
|
41
|
+
h = min(self.H, pattern.shape[0])
|
|
42
|
+
w = min(self.W, pattern.shape[1])
|
|
43
|
+
noise = np.random.uniform(0.94, 1.06, (h, w, self.L)).astype(np.float32)
|
|
44
|
+
self.state[:h, :w] += pattern[:h, :w, np.newaxis] * noise * strength
|
|
45
|
+
|
|
46
|
+
def forget(self, pattern: np.ndarray, strength: float = 0.5) -> None:
|
|
47
|
+
h = min(self.H, pattern.shape[0])
|
|
48
|
+
w = min(self.W, pattern.shape[1])
|
|
49
|
+
self.state[:h, :w] -= pattern[:h, :w, np.newaxis] * strength
|
|
50
|
+
np.clip(self.state, -12.0, 12.0, out=self.state)
|
|
51
|
+
|
|
52
|
+
def evolve(self, steps: int = 1) -> None:
|
|
53
|
+
rad = self.radius
|
|
54
|
+
for _ in range(steps):
|
|
55
|
+
state = self.state
|
|
56
|
+
neighbours = np.zeros_like(state)
|
|
57
|
+
count = 0
|
|
58
|
+
for dy in range(-rad, rad + 1):
|
|
59
|
+
for dx in range(-rad, rad + 1):
|
|
60
|
+
if dx == 0 and dy == 0:
|
|
61
|
+
continue
|
|
62
|
+
neighbours += np.roll(np.roll(state, dy, axis=0), dx, axis=1)
|
|
63
|
+
count += 1
|
|
64
|
+
average = neighbours / count
|
|
65
|
+
diff = average - state
|
|
66
|
+
diff = np.where(np.abs(diff) < self.threshold_nl, 0.0, diff)
|
|
67
|
+
diff = diff * self.speed - self.nonlin * (state ** 2) * diff
|
|
68
|
+
self.state = (state + diff) * self.decay
|
|
69
|
+
|
|
70
|
+
def field_resonance(self, pattern: np.ndarray) -> float:
|
|
71
|
+
h = min(self.H, pattern.shape[0])
|
|
72
|
+
w = min(self.W, pattern.shape[1])
|
|
73
|
+
field_mag = np.sum(np.abs(self.state[:h, :w]), axis=2)
|
|
74
|
+
pat = pattern[:h, :w]
|
|
75
|
+
denom = (np.linalg.norm(field_mag) * np.linalg.norm(pat)) + 1e-9
|
|
76
|
+
return float(np.dot(field_mag.flatten(), pat.flatten()) / denom)
|
|
77
|
+
|
|
78
|
+
def energy(self) -> float:
|
|
79
|
+
return float(np.sum(self.state ** 2))
|
|
80
|
+
|
|
81
|
+
def detect_clusters(self) -> list[list[tuple[int, int]]]:
|
|
82
|
+
magnitude = np.sum(np.abs(self.state), axis=2)
|
|
83
|
+
active = magnitude > self.stable_threshold
|
|
84
|
+
visited = np.zeros((self.H, self.W), dtype=bool)
|
|
85
|
+
clusters = []
|
|
86
|
+
ys, xs = np.where(active)
|
|
87
|
+
for y0, x0 in zip(ys.tolist(), xs.tolist()):
|
|
88
|
+
if visited[y0, x0]:
|
|
89
|
+
continue
|
|
90
|
+
cluster = []
|
|
91
|
+
stack = [(x0, y0)]
|
|
92
|
+
visited[y0, x0] = True
|
|
93
|
+
while stack:
|
|
94
|
+
cx, cy = stack.pop()
|
|
95
|
+
cluster.append((cx, cy))
|
|
96
|
+
for dy in (-1, 0, 1):
|
|
97
|
+
for dx in (-1, 0, 1):
|
|
98
|
+
if dx == 0 and dy == 0:
|
|
99
|
+
continue
|
|
100
|
+
nx, ny = cx + dx, cy + dy
|
|
101
|
+
if 0 <= nx < self.W and 0 <= ny < self.H:
|
|
102
|
+
if not visited[ny, nx] and active[ny, nx]:
|
|
103
|
+
visited[ny, nx] = True
|
|
104
|
+
stack.append((nx, ny))
|
|
105
|
+
clusters.append(cluster)
|
|
106
|
+
return clusters
|
|
107
|
+
|
|
108
|
+
def reset(self) -> None:
|
|
109
|
+
self.state[:] = 0.0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class QueryResult:
|
|
114
|
+
id: int
|
|
115
|
+
text: str
|
|
116
|
+
score: float
|
|
117
|
+
vector_score: float
|
|
118
|
+
field_score: float
|
|
119
|
+
namespace: str
|
|
120
|
+
tags: tuple[str, ...] = ()
|
|
121
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class WaveMind:
|
|
125
|
+
def __init__(
|
|
126
|
+
self,
|
|
127
|
+
db_path: str | Path | None = None,
|
|
128
|
+
width: int = 128,
|
|
129
|
+
height: int = 128,
|
|
130
|
+
layers: int = 6,
|
|
131
|
+
encoder: TextVectorEncoder | None = None,
|
|
132
|
+
index_kind: str = "numpy",
|
|
133
|
+
score_threshold: float = 0.0,
|
|
134
|
+
evolve_on_feed: int = 6,
|
|
135
|
+
vector_weight: float = 0.94,
|
|
136
|
+
field_weight: float = 0.04,
|
|
137
|
+
priority_weight: float = 0.02,
|
|
138
|
+
lexical_weight: float = 0.20,
|
|
139
|
+
short_query_lexical_weight: float = 2.0,
|
|
140
|
+
rerank_k: int = 10,
|
|
141
|
+
field_disable_after: int = 1000,
|
|
142
|
+
persist_access_on_query: bool = False,
|
|
143
|
+
query_feedback_strength: float = 0.0,
|
|
144
|
+
):
|
|
145
|
+
self.encoder = encoder or HashingTextEncoder(vector_dim=384)
|
|
146
|
+
self.projector = FieldProjector(width, height, self.encoder.vector_dim)
|
|
147
|
+
self.field = WaveField(width=width, height=height, layers=layers)
|
|
148
|
+
self.store = SQLiteMemoryStore(db_path)
|
|
149
|
+
self.index = create_vector_index(index_kind, self.encoder.vector_dim)
|
|
150
|
+
self.score_threshold = float(score_threshold)
|
|
151
|
+
self._evolve_n = int(evolve_on_feed)
|
|
152
|
+
self.vector_weight = float(vector_weight)
|
|
153
|
+
self.field_weight = float(field_weight)
|
|
154
|
+
self.priority_weight = float(priority_weight)
|
|
155
|
+
self.lexical_weight = float(lexical_weight)
|
|
156
|
+
self.short_query_lexical_weight = float(short_query_lexical_weight)
|
|
157
|
+
self.rerank_k = int(rerank_k)
|
|
158
|
+
self.field_disable_after = int(field_disable_after)
|
|
159
|
+
self.persist_access_on_query = bool(persist_access_on_query)
|
|
160
|
+
self.query_feedback_strength = float(query_feedback_strength)
|
|
161
|
+
self._records_by_id: dict[int, MemoryRecord] = {}
|
|
162
|
+
self._namespace_ids: dict[str, set[int]] = {}
|
|
163
|
+
self._token_ids: dict[str, set[int]] = {}
|
|
164
|
+
self._field_magnitude = np.zeros((height, width), dtype=np.float32)
|
|
165
|
+
self._field_magnitude_norm = 0.0
|
|
166
|
+
self.load()
|
|
167
|
+
|
|
168
|
+
def remember(
|
|
169
|
+
self,
|
|
170
|
+
text: str,
|
|
171
|
+
namespace: str = "default",
|
|
172
|
+
tags: Iterable[str] | None = None,
|
|
173
|
+
ttl_seconds: float | None = None,
|
|
174
|
+
metadata: dict[str, Any] | None = None,
|
|
175
|
+
priority: float = 1.0,
|
|
176
|
+
strength: float = 1.0,
|
|
177
|
+
) -> int:
|
|
178
|
+
vector = self.encoder.encode_vector(text)
|
|
179
|
+
pattern = self.projector.to_pattern(vector)
|
|
180
|
+
expires_at = time.time() + ttl_seconds if ttl_seconds is not None else None
|
|
181
|
+
record = MemoryRecord(
|
|
182
|
+
text=text,
|
|
183
|
+
namespace=namespace,
|
|
184
|
+
tags=tuple(tags or ()),
|
|
185
|
+
metadata=metadata or {},
|
|
186
|
+
vector=vector,
|
|
187
|
+
pattern=pattern,
|
|
188
|
+
expires_at=expires_at,
|
|
189
|
+
priority=priority,
|
|
190
|
+
)
|
|
191
|
+
id = self.store.insert(record)
|
|
192
|
+
record.id = id
|
|
193
|
+
self._cache_record(record)
|
|
194
|
+
self.index.add(id, vector)
|
|
195
|
+
self.field.feed(pattern, strength=strength * priority)
|
|
196
|
+
self.field.evolve(self._evolve_n)
|
|
197
|
+
self._refresh_field_magnitude()
|
|
198
|
+
return id
|
|
199
|
+
|
|
200
|
+
def query(
|
|
201
|
+
self,
|
|
202
|
+
text: str,
|
|
203
|
+
namespace: str = "default",
|
|
204
|
+
top_k: int = 3,
|
|
205
|
+
tags: Iterable[str] | None = None,
|
|
206
|
+
min_score: float | None = None,
|
|
207
|
+
) -> list[QueryResult]:
|
|
208
|
+
allowed_ids = self._allowed_ids(namespace=namespace, tags=tags)
|
|
209
|
+
if not allowed_ids:
|
|
210
|
+
return []
|
|
211
|
+
|
|
212
|
+
query_vector = self.encoder.encode_vector(text)
|
|
213
|
+
|
|
214
|
+
vector_top_k = max(top_k, self.rerank_k)
|
|
215
|
+
candidates = self.index.search(
|
|
216
|
+
query_vector,
|
|
217
|
+
top_k=vector_top_k,
|
|
218
|
+
allowed_ids=allowed_ids,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
threshold = self.score_threshold if min_score is None else float(min_score)
|
|
222
|
+
query_tokens = self._tokens(text)
|
|
223
|
+
field_weight = self._effective_field_weight(len(allowed_ids))
|
|
224
|
+
lexical_weight = self._effective_lexical_weight(query_tokens)
|
|
225
|
+
candidate_scores = {candidate.id: candidate.score for candidate in candidates}
|
|
226
|
+
for id in self._lexical_candidate_ids(query_tokens, allowed_ids):
|
|
227
|
+
if id not in candidate_scores:
|
|
228
|
+
record = self._records_by_id[id]
|
|
229
|
+
candidate_scores[id] = float(np.dot(query_vector, record.vector))
|
|
230
|
+
|
|
231
|
+
results: list[QueryResult] = []
|
|
232
|
+
for candidate_id, vector_score in candidate_scores.items():
|
|
233
|
+
record = self._records_by_id[candidate_id]
|
|
234
|
+
field_score = self._field_resonance(record.pattern) if field_weight > 0 else 0.0
|
|
235
|
+
priority_score = min(1.0, max(0.0, record.priority / 10.0))
|
|
236
|
+
lexical_score = self._lexical_match(query_tokens, record.text)
|
|
237
|
+
score = (
|
|
238
|
+
self.vector_weight * vector_score
|
|
239
|
+
+ field_weight * field_score
|
|
240
|
+
+ self.priority_weight * priority_score
|
|
241
|
+
+ lexical_weight * lexical_score
|
|
242
|
+
)
|
|
243
|
+
if score < threshold:
|
|
244
|
+
continue
|
|
245
|
+
results.append(
|
|
246
|
+
QueryResult(
|
|
247
|
+
id=int(record.id),
|
|
248
|
+
text=record.text,
|
|
249
|
+
score=float(score),
|
|
250
|
+
vector_score=float(vector_score),
|
|
251
|
+
field_score=float(field_score),
|
|
252
|
+
namespace=record.namespace,
|
|
253
|
+
tags=record.tags,
|
|
254
|
+
metadata=record.metadata,
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
results.sort(key=lambda item: item.score, reverse=True)
|
|
259
|
+
selected = results[:top_k]
|
|
260
|
+
for result in selected:
|
|
261
|
+
record = self._records_by_id[result.id]
|
|
262
|
+
record.access_count += 1
|
|
263
|
+
record.priority += 0.05
|
|
264
|
+
if self.persist_access_on_query:
|
|
265
|
+
self.store.touch(result.id)
|
|
266
|
+
if self.query_feedback_strength > 0:
|
|
267
|
+
self.field.feed(record.pattern, strength=self.query_feedback_strength)
|
|
268
|
+
if selected and self.query_feedback_strength > 0:
|
|
269
|
+
self.field.evolve(1)
|
|
270
|
+
self._refresh_field_magnitude()
|
|
271
|
+
return selected
|
|
272
|
+
|
|
273
|
+
def forget(
|
|
274
|
+
self,
|
|
275
|
+
id: int | None = None,
|
|
276
|
+
text: str | None = None,
|
|
277
|
+
namespace: str | None = None,
|
|
278
|
+
) -> int:
|
|
279
|
+
records = self.store.delete(id=id, text=text, namespace=namespace)
|
|
280
|
+
for record in records:
|
|
281
|
+
if record.id is not None:
|
|
282
|
+
self.index.remove(record.id)
|
|
283
|
+
self._uncache_record(record.id)
|
|
284
|
+
self.field.forget(record.pattern, strength=0.7)
|
|
285
|
+
if records:
|
|
286
|
+
self.field.evolve(4)
|
|
287
|
+
self._refresh_field_magnitude()
|
|
288
|
+
return len(records)
|
|
289
|
+
|
|
290
|
+
def save(self, backup_path: str | Path | None = None) -> Path | None:
|
|
291
|
+
self.store.conn.commit()
|
|
292
|
+
if backup_path is not None:
|
|
293
|
+
return self.store.backup(backup_path)
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
def load(self) -> None:
|
|
297
|
+
records = self.store.list(include_expired=False)
|
|
298
|
+
self._build_cache(records)
|
|
299
|
+
self.index.build(records)
|
|
300
|
+
self.field.reset()
|
|
301
|
+
for record in records:
|
|
302
|
+
self.field.feed(record.pattern, strength=max(0.1, record.priority))
|
|
303
|
+
if records:
|
|
304
|
+
self.field.evolve(self._evolve_n)
|
|
305
|
+
self._refresh_field_magnitude()
|
|
306
|
+
|
|
307
|
+
def purge_expired(self) -> int:
|
|
308
|
+
purged = self.store.purge_expired()
|
|
309
|
+
if purged:
|
|
310
|
+
self.load()
|
|
311
|
+
return purged
|
|
312
|
+
|
|
313
|
+
def consolidate(self, steps: int = 40) -> None:
|
|
314
|
+
self.field.evolve(steps)
|
|
315
|
+
self._refresh_field_magnitude()
|
|
316
|
+
|
|
317
|
+
def stats(self, namespace: str | None = None) -> dict[str, Any]:
|
|
318
|
+
active = self.store.list(namespace=namespace, include_expired=False)
|
|
319
|
+
all_records = self.store.list(namespace=namespace, include_expired=True)
|
|
320
|
+
expired = [record for record in all_records if record.is_expired]
|
|
321
|
+
clusters = self.field.detect_clusters()
|
|
322
|
+
return {
|
|
323
|
+
"active_memories": len(active),
|
|
324
|
+
"expired_memories": len(expired),
|
|
325
|
+
"total_memories": len(all_records),
|
|
326
|
+
"field_energy": round(self.field.energy(), 6),
|
|
327
|
+
"clusters": len(clusters),
|
|
328
|
+
"field_shape": f"{self.field.H}x{self.field.W}x{self.field.L}",
|
|
329
|
+
"index": getattr(self.index, "name", type(self.index).__name__),
|
|
330
|
+
"vector_dim": self.encoder.vector_dim,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def memory(self) -> list[tuple[str, np.ndarray]]:
|
|
335
|
+
return [(record.text, record.pattern) for record in self._records_by_id.values()]
|
|
336
|
+
|
|
337
|
+
def _build_cache(self, records: Iterable[MemoryRecord]) -> None:
|
|
338
|
+
self._records_by_id.clear()
|
|
339
|
+
self._namespace_ids.clear()
|
|
340
|
+
self._token_ids.clear()
|
|
341
|
+
for record in records:
|
|
342
|
+
self._cache_record(record)
|
|
343
|
+
|
|
344
|
+
def _cache_record(self, record: MemoryRecord) -> None:
|
|
345
|
+
if record.id is None:
|
|
346
|
+
return
|
|
347
|
+
id = int(record.id)
|
|
348
|
+
self._records_by_id[id] = record
|
|
349
|
+
self._namespace_ids.setdefault(record.namespace, set()).add(id)
|
|
350
|
+
for token in self._tokens(record.text):
|
|
351
|
+
self._token_ids.setdefault(token, set()).add(id)
|
|
352
|
+
|
|
353
|
+
def _uncache_record(self, id: int) -> None:
|
|
354
|
+
record = self._records_by_id.pop(int(id), None)
|
|
355
|
+
if record is None:
|
|
356
|
+
return
|
|
357
|
+
ids = self._namespace_ids.get(record.namespace)
|
|
358
|
+
if ids is not None:
|
|
359
|
+
ids.discard(int(id))
|
|
360
|
+
if not ids:
|
|
361
|
+
self._namespace_ids.pop(record.namespace, None)
|
|
362
|
+
for token in self._tokens(record.text):
|
|
363
|
+
token_ids = self._token_ids.get(token)
|
|
364
|
+
if token_ids is None:
|
|
365
|
+
continue
|
|
366
|
+
token_ids.discard(int(id))
|
|
367
|
+
if not token_ids:
|
|
368
|
+
self._token_ids.pop(token, None)
|
|
369
|
+
|
|
370
|
+
def _allowed_ids(
|
|
371
|
+
self,
|
|
372
|
+
namespace: str,
|
|
373
|
+
tags: Iterable[str] | None = None,
|
|
374
|
+
) -> set[int]:
|
|
375
|
+
ids = set(self._namespace_ids.get(namespace, set()))
|
|
376
|
+
required_tags = set(tags or ())
|
|
377
|
+
if not ids:
|
|
378
|
+
return set()
|
|
379
|
+
allowed = set()
|
|
380
|
+
for id in ids:
|
|
381
|
+
record = self._records_by_id[id]
|
|
382
|
+
if record.is_expired:
|
|
383
|
+
continue
|
|
384
|
+
if required_tags and not required_tags.issubset(set(record.tags)):
|
|
385
|
+
continue
|
|
386
|
+
allowed.add(id)
|
|
387
|
+
return allowed
|
|
388
|
+
|
|
389
|
+
def _refresh_field_magnitude(self) -> None:
|
|
390
|
+
self._field_magnitude = np.sum(np.abs(self.field.state), axis=2)
|
|
391
|
+
self._field_magnitude_norm = float(np.linalg.norm(self._field_magnitude))
|
|
392
|
+
|
|
393
|
+
def _field_resonance(self, pattern: np.ndarray) -> float:
|
|
394
|
+
denom = (self._field_magnitude_norm * float(np.linalg.norm(pattern))) + 1e-9
|
|
395
|
+
return float(np.dot(self._field_magnitude.ravel(), pattern.ravel()) / denom)
|
|
396
|
+
|
|
397
|
+
def _effective_field_weight(self, allowed_count: int) -> float:
|
|
398
|
+
if self.field_disable_after > 0 and allowed_count > self.field_disable_after:
|
|
399
|
+
return 0.0
|
|
400
|
+
return self.field_weight
|
|
401
|
+
|
|
402
|
+
def _effective_lexical_weight(self, query_tokens: tuple[str, ...]) -> float:
|
|
403
|
+
if 0 < len(query_tokens) <= 2:
|
|
404
|
+
return self.short_query_lexical_weight
|
|
405
|
+
return self.lexical_weight
|
|
406
|
+
|
|
407
|
+
def _tokens(self, text: str) -> tuple[str, ...]:
|
|
408
|
+
return tuple(
|
|
409
|
+
token.replace("ё", "е")
|
|
410
|
+
for token in re.findall(r"[\w]+", text.lower(), flags=re.UNICODE)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
def _lexical_match(self, query_tokens: tuple[str, ...], text: str) -> float:
|
|
414
|
+
if not query_tokens:
|
|
415
|
+
return 0.0
|
|
416
|
+
text_tokens = set(self._tokens(text))
|
|
417
|
+
matched = sum(1 for token in query_tokens if token in text_tokens)
|
|
418
|
+
return matched / len(query_tokens)
|
|
419
|
+
|
|
420
|
+
def _lexical_candidate_ids(
|
|
421
|
+
self,
|
|
422
|
+
query_tokens: tuple[str, ...],
|
|
423
|
+
allowed_ids: set[int],
|
|
424
|
+
) -> set[int]:
|
|
425
|
+
candidate_ids: set[int] = set()
|
|
426
|
+
for token in query_tokens:
|
|
427
|
+
candidate_ids.update(self._token_ids.get(token, set()))
|
|
428
|
+
return candidate_ids & allowed_ids
|
wavemind/encoders.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TextVectorEncoder(Protocol):
|
|
12
|
+
vector_dim: int
|
|
13
|
+
|
|
14
|
+
def encode_vector(self, text: str) -> np.ndarray:
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _l2_normalize(vector: np.ndarray) -> np.ndarray:
|
|
19
|
+
vector = np.asarray(vector, dtype=np.float32)
|
|
20
|
+
norm = float(np.linalg.norm(vector))
|
|
21
|
+
if norm <= 1e-12:
|
|
22
|
+
return vector.astype(np.float32)
|
|
23
|
+
return (vector / norm).astype(np.float32)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class HashingTextEncoder:
|
|
28
|
+
vector_dim: int = 384
|
|
29
|
+
token_weight: float = 4.0
|
|
30
|
+
char_ngram_weight: float = 0.10
|
|
31
|
+
|
|
32
|
+
def encode_vector(self, text: str) -> np.ndarray:
|
|
33
|
+
text = text.lower().strip()
|
|
34
|
+
vector = np.zeros(self.vector_dim, dtype=np.float32)
|
|
35
|
+
if not text:
|
|
36
|
+
return vector
|
|
37
|
+
|
|
38
|
+
tokens = re.findall(r"[\w]+", text, flags=re.UNICODE)
|
|
39
|
+
for token in tokens:
|
|
40
|
+
self._add_feature(vector, f"tok:{token}", self.token_weight)
|
|
41
|
+
|
|
42
|
+
compact = re.sub(r"\s+", " ", text)
|
|
43
|
+
for size in (2, 3):
|
|
44
|
+
for i in range(max(0, len(compact) - size + 1)):
|
|
45
|
+
self._add_feature(
|
|
46
|
+
vector,
|
|
47
|
+
f"ch{size}:{compact[i:i + size]}",
|
|
48
|
+
self.char_ngram_weight,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return _l2_normalize(vector)
|
|
52
|
+
|
|
53
|
+
def _add_feature(self, vector: np.ndarray, feature: str, weight: float) -> None:
|
|
54
|
+
digest = hashlib.blake2b(feature.encode("utf-8"), digest_size=16).digest()
|
|
55
|
+
bucket = int.from_bytes(digest[:8], "little") % self.vector_dim
|
|
56
|
+
sign = 1.0 if digest[8] & 1 else -1.0
|
|
57
|
+
vector[bucket] += sign * weight
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SentenceTransformerTextEncoder:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
model_name: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
|
64
|
+
model=None,
|
|
65
|
+
):
|
|
66
|
+
if model is None:
|
|
67
|
+
try:
|
|
68
|
+
from sentence_transformers import SentenceTransformer
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"Install sentence-transformers to use SentenceTransformerTextEncoder"
|
|
72
|
+
) from exc
|
|
73
|
+
model = SentenceTransformer(model_name)
|
|
74
|
+
|
|
75
|
+
self.model_name = model_name
|
|
76
|
+
self.model = model
|
|
77
|
+
if hasattr(model, "get_embedding_dimension"):
|
|
78
|
+
self.vector_dim = int(model.get_embedding_dimension())
|
|
79
|
+
elif hasattr(model, "get_sentence_embedding_dimension"):
|
|
80
|
+
self.vector_dim = int(model.get_sentence_embedding_dimension())
|
|
81
|
+
else:
|
|
82
|
+
self.vector_dim = 768
|
|
83
|
+
|
|
84
|
+
def encode_vector(self, text: str) -> np.ndarray:
|
|
85
|
+
encoded = self.model.encode([text], normalize_embeddings=True)
|
|
86
|
+
vector = np.asarray(encoded[0], dtype=np.float32)
|
|
87
|
+
return _l2_normalize(vector)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FieldProjector:
|
|
91
|
+
def __init__(self, width: int, height: int, vector_dim: int, seed: int = 1729):
|
|
92
|
+
self.width = int(width)
|
|
93
|
+
self.height = int(height)
|
|
94
|
+
self.vector_dim = int(vector_dim)
|
|
95
|
+
rng = np.random.default_rng(seed + self.width * 31 + self.height * 17 + self.vector_dim)
|
|
96
|
+
self._matrix = rng.normal(
|
|
97
|
+
loc=0.0,
|
|
98
|
+
scale=1.0 / max(1, self.vector_dim) ** 0.5,
|
|
99
|
+
size=(self.height * self.width, self.vector_dim),
|
|
100
|
+
).astype(np.float32)
|
|
101
|
+
|
|
102
|
+
def to_pattern(self, vector: np.ndarray) -> np.ndarray:
|
|
103
|
+
vector = _l2_normalize(vector)
|
|
104
|
+
projected = self._matrix @ vector
|
|
105
|
+
projected = np.maximum(projected, 0.0)
|
|
106
|
+
if not np.any(projected):
|
|
107
|
+
projected = np.abs(self._matrix @ vector)
|
|
108
|
+
pattern = projected.reshape(self.height, self.width).astype(np.float32)
|
|
109
|
+
return _l2_normalize(pattern)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
TextEncoder = HashingTextEncoder
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def create_text_encoder(
|
|
116
|
+
kind: str = "hash",
|
|
117
|
+
vector_dim: int = 384,
|
|
118
|
+
model_name: str = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2",
|
|
119
|
+
) -> TextVectorEncoder:
|
|
120
|
+
kind = (kind or "hash").lower()
|
|
121
|
+
if kind == "hash":
|
|
122
|
+
return HashingTextEncoder(vector_dim=vector_dim)
|
|
123
|
+
if kind in {"sentence", "sentence-transformers", "transformer"}:
|
|
124
|
+
return SentenceTransformerTextEncoder(model_name=model_name)
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Unknown encoder kind: {kind}. Choose an explicit encoder: hash or sentence."
|
|
127
|
+
)
|
wavemind/importers.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def segment_text(text: str, max_chars: int = 1000, overlap: int = 120) -> list[str]:
|
|
10
|
+
text = text.replace("\r\n", "\n").strip()
|
|
11
|
+
if not text:
|
|
12
|
+
return []
|
|
13
|
+
paragraphs = [part.strip() for part in re.split(r"\n\s*\n", text) if part.strip()]
|
|
14
|
+
chunks: list[str] = []
|
|
15
|
+
for paragraph in paragraphs:
|
|
16
|
+
if len(paragraph) <= max_chars:
|
|
17
|
+
chunks.append(paragraph)
|
|
18
|
+
continue
|
|
19
|
+
start = 0
|
|
20
|
+
while start < len(paragraph):
|
|
21
|
+
end = min(len(paragraph), start + max_chars)
|
|
22
|
+
chunk = paragraph[start:end].strip()
|
|
23
|
+
if chunk:
|
|
24
|
+
chunks.append(chunk)
|
|
25
|
+
if end == len(paragraph):
|
|
26
|
+
break
|
|
27
|
+
start = max(0, end - overlap)
|
|
28
|
+
return chunks
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def import_txt(
|
|
32
|
+
path: str | Path,
|
|
33
|
+
mind,
|
|
34
|
+
namespace: str = "default",
|
|
35
|
+
tags: Iterable[str] | None = None,
|
|
36
|
+
max_chars: int = 1000,
|
|
37
|
+
overlap: int = 120,
|
|
38
|
+
) -> list[int]:
|
|
39
|
+
path = Path(path)
|
|
40
|
+
text = path.read_text(encoding="utf-8")
|
|
41
|
+
ids = []
|
|
42
|
+
for chunk in segment_text(text, max_chars=max_chars, overlap=overlap):
|
|
43
|
+
ids.append(
|
|
44
|
+
mind.remember(
|
|
45
|
+
chunk,
|
|
46
|
+
namespace=namespace,
|
|
47
|
+
tags=tags or (),
|
|
48
|
+
metadata={"source": str(path), "format": "txt"},
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return ids
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def import_json(
|
|
55
|
+
path: str | Path,
|
|
56
|
+
mind,
|
|
57
|
+
namespace: str = "default",
|
|
58
|
+
tags: Iterable[str] | None = None,
|
|
59
|
+
) -> list[int]:
|
|
60
|
+
path = Path(path)
|
|
61
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
if isinstance(data, dict):
|
|
63
|
+
items = data.get("items", [data])
|
|
64
|
+
else:
|
|
65
|
+
items = data
|
|
66
|
+
|
|
67
|
+
ids = []
|
|
68
|
+
for item in items:
|
|
69
|
+
if isinstance(item, str):
|
|
70
|
+
text = item
|
|
71
|
+
item_tags = set(tags or ())
|
|
72
|
+
metadata = {"source": str(path), "format": "json"}
|
|
73
|
+
elif isinstance(item, dict):
|
|
74
|
+
text = str(item.get("text", "")).strip()
|
|
75
|
+
item_tags = set(tags or ()) | set(item.get("tags", []))
|
|
76
|
+
metadata = {
|
|
77
|
+
key: value
|
|
78
|
+
for key, value in item.items()
|
|
79
|
+
if key not in {"text", "tags"}
|
|
80
|
+
}
|
|
81
|
+
metadata.update({"source": str(path), "format": "json"})
|
|
82
|
+
else:
|
|
83
|
+
continue
|
|
84
|
+
if not text:
|
|
85
|
+
continue
|
|
86
|
+
ids.append(mind.remember(text, namespace=namespace, tags=sorted(item_tags), metadata=metadata))
|
|
87
|
+
return ids
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def import_pdf(
|
|
91
|
+
path: str | Path,
|
|
92
|
+
mind,
|
|
93
|
+
namespace: str = "default",
|
|
94
|
+
tags: Iterable[str] | None = None,
|
|
95
|
+
max_chars: int = 1000,
|
|
96
|
+
overlap: int = 120,
|
|
97
|
+
) -> list[int]:
|
|
98
|
+
try:
|
|
99
|
+
from pypdf import PdfReader
|
|
100
|
+
except ImportError as exc:
|
|
101
|
+
raise ImportError("Install pypdf to import PDF files") from exc
|
|
102
|
+
|
|
103
|
+
path = Path(path)
|
|
104
|
+
reader = PdfReader(str(path))
|
|
105
|
+
text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
|
|
106
|
+
ids = []
|
|
107
|
+
for chunk in segment_text(text, max_chars=max_chars, overlap=overlap):
|
|
108
|
+
ids.append(
|
|
109
|
+
mind.remember(
|
|
110
|
+
chunk,
|
|
111
|
+
namespace=namespace,
|
|
112
|
+
tags=tags or (),
|
|
113
|
+
metadata={"source": str(path), "format": "pdf"},
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return ids
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def import_path(
|
|
120
|
+
path: str | Path,
|
|
121
|
+
mind,
|
|
122
|
+
namespace: str = "default",
|
|
123
|
+
tags: Iterable[str] | None = None,
|
|
124
|
+
max_chars: int = 1000,
|
|
125
|
+
overlap: int = 120,
|
|
126
|
+
) -> list[int]:
|
|
127
|
+
path = Path(path)
|
|
128
|
+
suffix = path.suffix.lower()
|
|
129
|
+
if suffix == ".txt":
|
|
130
|
+
return import_txt(path, mind, namespace=namespace, tags=tags, max_chars=max_chars, overlap=overlap)
|
|
131
|
+
if suffix == ".json":
|
|
132
|
+
return import_json(path, mind, namespace=namespace, tags=tags)
|
|
133
|
+
if suffix == ".pdf":
|
|
134
|
+
return import_pdf(path, mind, namespace=namespace, tags=tags, max_chars=max_chars, overlap=overlap)
|
|
135
|
+
raise ValueError(f"Unsupported import format: {suffix}")
|
|
136
|
+
|