hrr-memory 0.1.0
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.
- package/README.md +64 -0
- package/package.json +35 -0
- package/src/bucket.js +45 -0
- package/src/index.js +18 -0
- package/src/memory.js +258 -0
- package/src/ops.js +107 -0
- package/src/symbols.js +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# hrr-memory
|
|
2
|
+
|
|
3
|
+
**Your AI agent already has RAG. Now give it instant fact recall.**
|
|
4
|
+
|
|
5
|
+
hrr-memory stores structured facts as `(subject, relation, object)` triples and retrieves them in under 2 milliseconds — no vector database, no embeddings API, no dependencies. It complements RAG, not replaces it.
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img src="assets/hrr-diagram.svg" alt="How HRR Memory Works" width="800" />
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install github:Joncik91/hrr-memory
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 30-Second Demo
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { HRRMemory } from 'hrr-memory';
|
|
21
|
+
|
|
22
|
+
const mem = new HRRMemory();
|
|
23
|
+
|
|
24
|
+
mem.store('alice', 'lives_in', 'paris');
|
|
25
|
+
mem.store('alice', 'works_at', 'acme');
|
|
26
|
+
mem.store('bob', 'lives_in', 'tokyo');
|
|
27
|
+
|
|
28
|
+
mem.query('alice', 'lives_in'); // → { match: 'paris', confident: true }
|
|
29
|
+
mem.query('bob', 'lives_in'); // → { match: 'tokyo', confident: true }
|
|
30
|
+
|
|
31
|
+
mem.querySubject('alice');
|
|
32
|
+
// → [{ relation: 'lives_in', object: 'paris' },
|
|
33
|
+
// { relation: 'works_at', object: 'acme' }]
|
|
34
|
+
|
|
35
|
+
mem.save('memory.json');
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Why Not Just RAG?
|
|
39
|
+
|
|
40
|
+
| Query | RAG | hrr-memory |
|
|
41
|
+
|-------|-----|------------|
|
|
42
|
+
| "What is Alice's timezone?" | Returns paragraphs, maybe | Returns `cet` in <1ms |
|
|
43
|
+
| "Find notes about deployment" | Ranked chunks | Can't — not a triple |
|
|
44
|
+
|
|
45
|
+
Use both. HRR for structured facts, RAG for semantic search. [See the architecture guide →](docs/architecture.md)
|
|
46
|
+
|
|
47
|
+
## Performance
|
|
48
|
+
|
|
49
|
+
| Facts | Accuracy | Query | RAM |
|
|
50
|
+
|-------|----------|-------|-----|
|
|
51
|
+
| 100 | 100% | <1ms | 0.1 MB |
|
|
52
|
+
| 1,000 | 100% | 1.5ms | 4 MB |
|
|
53
|
+
| 10,000 | 100% | 1.8ms | 86 MB |
|
|
54
|
+
|
|
55
|
+
## Documentation
|
|
56
|
+
|
|
57
|
+
- **[Getting Started](docs/getting-started.md)** — installation, first facts, persistence
|
|
58
|
+
- **[API Reference](docs/api.md)** — every method, parameter, and return type
|
|
59
|
+
- **[Architecture](docs/architecture.md)** — how HRR works, auto-sharding, RAG integration
|
|
60
|
+
- **[Performance](docs/performance.md)** — benchmarks, scaling limits, tuning
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hrr-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Holographic Reduced Representations for structured agent memory. Zero dependencies. Complements RAG with algebraic fact queries.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test test/",
|
|
15
|
+
"bench": "node bench/benchmark.js"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"hrr",
|
|
19
|
+
"holographic",
|
|
20
|
+
"memory",
|
|
21
|
+
"agent",
|
|
22
|
+
"ai",
|
|
23
|
+
"rag",
|
|
24
|
+
"vector",
|
|
25
|
+
"structured-memory",
|
|
26
|
+
"fact-store",
|
|
27
|
+
"llm"
|
|
28
|
+
],
|
|
29
|
+
"author": "Jounes",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "https://github.com/joncik/hrr-memory"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/bucket.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bucket — a single HRR memory vector with fixed capacity.
|
|
3
|
+
* Auto-splits are managed by HRRMemory, not by the bucket itself.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const MAX_BUCKET_SIZE = 25;
|
|
7
|
+
|
|
8
|
+
export class Bucket {
|
|
9
|
+
constructor(name, d = 2048) {
|
|
10
|
+
this.name = name;
|
|
11
|
+
this.d = d;
|
|
12
|
+
this.memory = new Float32Array(d);
|
|
13
|
+
this.count = 0;
|
|
14
|
+
this.triples = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Add a pre-computed association vector */
|
|
18
|
+
storeVector(association, triple) {
|
|
19
|
+
for (let i = 0; i < this.d; i++) this.memory[i] += association[i];
|
|
20
|
+
this.count++;
|
|
21
|
+
this.triples.push(triple);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether the bucket has reached max capacity */
|
|
25
|
+
get isFull() { return this.count >= MAX_BUCKET_SIZE; }
|
|
26
|
+
|
|
27
|
+
/** Serialize */
|
|
28
|
+
toJSON() {
|
|
29
|
+
return {
|
|
30
|
+
name: this.name,
|
|
31
|
+
memory: Array.from(this.memory),
|
|
32
|
+
count: this.count,
|
|
33
|
+
triples: this.triples,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Deserialize */
|
|
38
|
+
static fromJSON(data, d) {
|
|
39
|
+
const b = new Bucket(data.name, d);
|
|
40
|
+
b.memory = new Float32Array(data.memory);
|
|
41
|
+
b.count = data.count || 0;
|
|
42
|
+
b.triples = data.triples || [];
|
|
43
|
+
return b;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hrr-memory — Holographic Reduced Representations for structured agent memory.
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies. Pure JS. Float32 storage. Auto-sharding.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* import { HRRMemory } from 'hrr-memory';
|
|
8
|
+
* const mem = new HRRMemory();
|
|
9
|
+
* mem.store('alice', 'lives_in', 'paris');
|
|
10
|
+
* mem.query('alice', 'lives_in'); // → { match: 'paris', score: 0.3, confident: true }
|
|
11
|
+
*
|
|
12
|
+
* Based on Plate (1994): "Distributed Representations and Nested Compositional Structure"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { HRRMemory } from './memory.js';
|
|
16
|
+
export { SymbolTable } from './symbols.js';
|
|
17
|
+
export { Bucket } from './bucket.js';
|
|
18
|
+
export { bind, unbind, similarity, randomVector, normalize } from './ops.js';
|
package/src/memory.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HRRMemory — auto-sharded holographic memory store.
|
|
3
|
+
*
|
|
4
|
+
* Stores (subject, relation, object) triples in sharded buckets.
|
|
5
|
+
* Each bucket holds max 25 facts. When full, a new overflow bucket
|
|
6
|
+
* is created automatically. Queries scan all buckets for a subject.
|
|
7
|
+
*
|
|
8
|
+
* Symbol vectors are shared across all buckets.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
12
|
+
import { bind, unbind, similarity } from './ops.js';
|
|
13
|
+
import { SymbolTable } from './symbols.js';
|
|
14
|
+
import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
|
|
15
|
+
|
|
16
|
+
export class HRRMemory {
|
|
17
|
+
/**
|
|
18
|
+
* Create a new HRR memory store.
|
|
19
|
+
* @param {number} d - Vector dimensions (default 2048). Higher = more capacity per bucket.
|
|
20
|
+
*/
|
|
21
|
+
constructor(d = 2048) {
|
|
22
|
+
this.d = d;
|
|
23
|
+
this.symbols = new SymbolTable(d);
|
|
24
|
+
this.buckets = new Map();
|
|
25
|
+
this.routing = new Map(); // subject → [bucket_id, ...]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Bucket management ──────────────────────────────
|
|
29
|
+
|
|
30
|
+
/** Get the active (non-full) bucket for a subject, splitting if needed */
|
|
31
|
+
_activeBucket(subject) {
|
|
32
|
+
const key = subject.toLowerCase().trim();
|
|
33
|
+
const ids = this.routing.get(key);
|
|
34
|
+
|
|
35
|
+
if (ids) {
|
|
36
|
+
const lastId = ids[ids.length - 1];
|
|
37
|
+
const last = this.buckets.get(lastId);
|
|
38
|
+
if (!last.isFull) return last;
|
|
39
|
+
|
|
40
|
+
// Overflow: create new bucket
|
|
41
|
+
const newId = key + '#' + ids.length;
|
|
42
|
+
const nb = new Bucket(newId, this.d);
|
|
43
|
+
this.buckets.set(newId, nb);
|
|
44
|
+
ids.push(newId);
|
|
45
|
+
return nb;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// First bucket for this subject
|
|
49
|
+
const b = new Bucket(key, this.d);
|
|
50
|
+
this.buckets.set(key, b);
|
|
51
|
+
this.routing.set(key, [key]);
|
|
52
|
+
return b;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Get all buckets for a subject */
|
|
56
|
+
_subjectBuckets(subject) {
|
|
57
|
+
const ids = this.routing.get(subject.toLowerCase().trim()) || [];
|
|
58
|
+
return ids.map(id => this.buckets.get(id)).filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Store ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Store a fact as a (subject, relation, object) triple.
|
|
65
|
+
* @param {string} subject - The entity (e.g., 'alice')
|
|
66
|
+
* @param {string} relation - The attribute (e.g., 'lives_in')
|
|
67
|
+
* @param {string} object - The value (e.g., 'paris')
|
|
68
|
+
* @returns {boolean} true if stored, false if duplicate
|
|
69
|
+
*/
|
|
70
|
+
store(subject, relation, object) {
|
|
71
|
+
const triple = {
|
|
72
|
+
subject: subject.toLowerCase().trim(),
|
|
73
|
+
relation: relation.toLowerCase().trim(),
|
|
74
|
+
object: object.toLowerCase().trim(),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Dedup across all subject buckets
|
|
78
|
+
for (const b of this._subjectBuckets(subject)) {
|
|
79
|
+
if (b.triples.some(t =>
|
|
80
|
+
t.subject === triple.subject &&
|
|
81
|
+
t.relation === triple.relation &&
|
|
82
|
+
t.object === triple.object
|
|
83
|
+
)) return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const s = this.symbols.get(subject);
|
|
87
|
+
const r = this.symbols.get(relation);
|
|
88
|
+
const o = this.symbols.get(object);
|
|
89
|
+
const association = bind(bind(s, r), o);
|
|
90
|
+
|
|
91
|
+
this._activeBucket(subject).storeVector(association, triple);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Query ──────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Query: given subject and relation, retrieve the object.
|
|
99
|
+
* @param {string} subject
|
|
100
|
+
* @param {string} relation
|
|
101
|
+
* @returns {{ match: string|null, score: number, confident: boolean, bucket: string|null }}
|
|
102
|
+
*/
|
|
103
|
+
query(subject, relation) {
|
|
104
|
+
const buckets = this._subjectBuckets(subject);
|
|
105
|
+
if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
|
|
106
|
+
|
|
107
|
+
const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
|
|
108
|
+
let bestName = null, bestScore = -1, bestBucket = null;
|
|
109
|
+
|
|
110
|
+
for (const bucket of buckets) {
|
|
111
|
+
if (bucket.count === 0) continue;
|
|
112
|
+
const result = unbind(probe, bucket.memory);
|
|
113
|
+
|
|
114
|
+
// Optimized: only scan object symbols in this bucket
|
|
115
|
+
for (const t of bucket.triples) {
|
|
116
|
+
const score = similarity(result, this.symbols.get(t.object));
|
|
117
|
+
if (score > bestScore) {
|
|
118
|
+
bestScore = score;
|
|
119
|
+
bestName = t.object;
|
|
120
|
+
bestBucket = bucket.name;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
match: bestName,
|
|
127
|
+
score: Math.round(bestScore * 1000) / 1000,
|
|
128
|
+
confident: bestScore > 0.1,
|
|
129
|
+
bucket: bestBucket,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get all known facts about a subject (symbolic, exact).
|
|
135
|
+
* @param {string} subject
|
|
136
|
+
* @returns {Array<{ relation: string, object: string }>}
|
|
137
|
+
*/
|
|
138
|
+
querySubject(subject) {
|
|
139
|
+
const key = subject.toLowerCase().trim();
|
|
140
|
+
const facts = [];
|
|
141
|
+
for (const bucket of this._subjectBuckets(subject)) {
|
|
142
|
+
for (const t of bucket.triples) {
|
|
143
|
+
if (t.subject === key) facts.push({ relation: t.relation, object: t.object });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return facts;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Search across all buckets for triples matching a relation and/or object.
|
|
151
|
+
* @param {string|null} relation - Filter by relation (null = any)
|
|
152
|
+
* @param {string|null} object - Filter by object value (null = any)
|
|
153
|
+
* @returns {Array<{ subject: string, relation: string, object: string }>}
|
|
154
|
+
*/
|
|
155
|
+
search(relation, object) {
|
|
156
|
+
const results = [];
|
|
157
|
+
const rel = relation ? relation.toLowerCase().trim() : null;
|
|
158
|
+
const obj = object ? object.toLowerCase().trim() : null;
|
|
159
|
+
for (const [_, bucket] of this.buckets) {
|
|
160
|
+
for (const t of bucket.triples) {
|
|
161
|
+
if (rel && t.relation !== rel) continue;
|
|
162
|
+
if (obj && t.object !== obj) continue;
|
|
163
|
+
results.push(t);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Free-form query: tries subject+relation, then subject lookup, then cross-bucket search.
|
|
171
|
+
* @param {string} question
|
|
172
|
+
*/
|
|
173
|
+
ask(question) {
|
|
174
|
+
const parts = question.toLowerCase().trim().replace(/[?.,!]/g, '').split(/\s+/);
|
|
175
|
+
|
|
176
|
+
// Try consecutive word pairs as subject+relation
|
|
177
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
178
|
+
const result = this.query(parts[i], parts[i + 1]);
|
|
179
|
+
if (result.confident) return { type: 'direct', ...result, subject: parts[i], relation: parts[i + 1] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Try each word as a subject
|
|
183
|
+
for (const word of parts) {
|
|
184
|
+
const facts = this.querySubject(word);
|
|
185
|
+
if (facts.length > 0) return { type: 'subject', subject: word, facts };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Search across all buckets for any matching object
|
|
189
|
+
for (const word of parts) {
|
|
190
|
+
const results = this.search(null, word);
|
|
191
|
+
if (results.length > 0) return { type: 'search', term: word, results };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { type: 'miss', query: question };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Stats ──────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/** Get memory statistics */
|
|
200
|
+
stats() {
|
|
201
|
+
let totalFacts = 0;
|
|
202
|
+
const bucketInfo = [];
|
|
203
|
+
for (const [_, b] of this.buckets) {
|
|
204
|
+
totalFacts += b.count;
|
|
205
|
+
bucketInfo.push({ name: b.name, facts: b.count, full: b.isFull });
|
|
206
|
+
}
|
|
207
|
+
const symBytes = this.symbols.size * this.d * 4;
|
|
208
|
+
const bktBytes = this.buckets.size * this.d * 4;
|
|
209
|
+
return {
|
|
210
|
+
dimensions: this.d,
|
|
211
|
+
maxBucketSize: MAX_BUCKET_SIZE,
|
|
212
|
+
symbols: this.symbols.size,
|
|
213
|
+
buckets: this.buckets.size,
|
|
214
|
+
subjects: this.routing.size,
|
|
215
|
+
totalFacts,
|
|
216
|
+
ramBytes: symBytes + bktBytes,
|
|
217
|
+
ramMB: Math.round((symBytes + bktBytes) / 1024 / 1024 * 10) / 10,
|
|
218
|
+
perBucket: bucketInfo,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Persistence ────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/** Serialize to JSON */
|
|
225
|
+
toJSON() {
|
|
226
|
+
const buckets = {};
|
|
227
|
+
for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
|
|
228
|
+
const routing = {};
|
|
229
|
+
for (const [k, v] of this.routing) routing[k] = v;
|
|
230
|
+
return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Deserialize from JSON */
|
|
234
|
+
static fromJSON(data) {
|
|
235
|
+
const d = data.d || 2048;
|
|
236
|
+
const mem = new HRRMemory(d);
|
|
237
|
+
mem.symbols = SymbolTable.fromJSON(data.symbols || {}, d);
|
|
238
|
+
for (const [k, v] of Object.entries(data.buckets || {})) {
|
|
239
|
+
mem.buckets.set(k, Bucket.fromJSON(v, d));
|
|
240
|
+
}
|
|
241
|
+
for (const [k, v] of Object.entries(data.routing || {})) {
|
|
242
|
+
mem.routing.set(k, Array.isArray(v) ? v : [v]);
|
|
243
|
+
}
|
|
244
|
+
return mem;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Save to a JSON file */
|
|
248
|
+
save(filePath) {
|
|
249
|
+
writeFileSync(filePath, JSON.stringify(this.toJSON()));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Load from a JSON file (returns new empty store if file doesn't exist) */
|
|
253
|
+
static load(filePath, d = 2048) {
|
|
254
|
+
if (!existsSync(filePath)) return new HRRMemory(d);
|
|
255
|
+
try { return HRRMemory.fromJSON(JSON.parse(readFileSync(filePath, 'utf8'))); }
|
|
256
|
+
catch { return new HRRMemory(d); }
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/ops.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core HRR operations — circular convolution, correlation, similarity.
|
|
3
|
+
* All vectors are Float32Array. FFT computed in Float64 for precision.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Generate a random unit vector of dimension d (Gaussian, normalized) */
|
|
7
|
+
export function randomVector(d) {
|
|
8
|
+
const v = new Float32Array(d);
|
|
9
|
+
for (let i = 0; i < d; i += 2) {
|
|
10
|
+
const u1 = Math.random(), u2 = Math.random();
|
|
11
|
+
const r = Math.sqrt(-2 * Math.log(u1));
|
|
12
|
+
v[i] = r * Math.cos(2 * Math.PI * u2);
|
|
13
|
+
if (i + 1 < d) v[i + 1] = r * Math.sin(2 * Math.PI * u2);
|
|
14
|
+
}
|
|
15
|
+
return normalize(v);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Normalize vector to unit length */
|
|
19
|
+
export function normalize(v) {
|
|
20
|
+
let norm = 0;
|
|
21
|
+
for (let i = 0; i < v.length; i++) norm += v[i] * v[i];
|
|
22
|
+
norm = Math.sqrt(norm);
|
|
23
|
+
if (norm === 0) return v;
|
|
24
|
+
const out = new Float32Array(v.length);
|
|
25
|
+
for (let i = 0; i < v.length; i++) out[i] = v[i] / norm;
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Cosine similarity between two vectors */
|
|
30
|
+
export function similarity(a, b) {
|
|
31
|
+
let dot = 0, na = 0, nb = 0;
|
|
32
|
+
for (let i = 0; i < a.length; i++) {
|
|
33
|
+
dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i];
|
|
34
|
+
}
|
|
35
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── FFT (Cooley-Tukey radix-2) ──
|
|
39
|
+
|
|
40
|
+
function fft(re, im, inverse) {
|
|
41
|
+
const n = re.length;
|
|
42
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
43
|
+
let bit = n >> 1;
|
|
44
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
45
|
+
j ^= bit;
|
|
46
|
+
if (i < j) {
|
|
47
|
+
[re[i], re[j]] = [re[j], re[i]];
|
|
48
|
+
[im[i], im[j]] = [im[j], im[i]];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
52
|
+
const ang = (inverse ? -1 : 1) * 2 * Math.PI / len;
|
|
53
|
+
const wRe = Math.cos(ang), wIm = Math.sin(ang);
|
|
54
|
+
for (let i = 0; i < n; i += len) {
|
|
55
|
+
let curRe = 1, curIm = 0;
|
|
56
|
+
for (let j = 0; j < len / 2; j++) {
|
|
57
|
+
const uRe = re[i+j], uIm = im[i+j];
|
|
58
|
+
const vRe = re[i+j+len/2]*curRe - im[i+j+len/2]*curIm;
|
|
59
|
+
const vIm = re[i+j+len/2]*curIm + im[i+j+len/2]*curRe;
|
|
60
|
+
re[i+j] = uRe+vRe; im[i+j] = uIm+vIm;
|
|
61
|
+
re[i+j+len/2] = uRe-vRe; im[i+j+len/2] = uIm-vIm;
|
|
62
|
+
const nr = curRe*wRe - curIm*wIm;
|
|
63
|
+
curIm = curRe*wIm + curIm*wRe;
|
|
64
|
+
curRe = nr;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (inverse) {
|
|
69
|
+
for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function nextPow2(n) { let p = 1; while (p < n) p <<= 1; return p; }
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Circular convolution (binding): a ⊛ b
|
|
77
|
+
* Creates an association between two vectors.
|
|
78
|
+
* Computed in Float64, returned as Float32.
|
|
79
|
+
*/
|
|
80
|
+
export function bind(a, b) {
|
|
81
|
+
const d = a.length, n = nextPow2(d);
|
|
82
|
+
const aR = new Float64Array(n), aI = new Float64Array(n);
|
|
83
|
+
const bR = new Float64Array(n), bI = new Float64Array(n);
|
|
84
|
+
for (let i = 0; i < d; i++) { aR[i] = a[i]; bR[i] = b[i]; }
|
|
85
|
+
fft(aR, aI, false); fft(bR, bI, false);
|
|
86
|
+
const cR = new Float64Array(n), cI = new Float64Array(n);
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
cR[i] = aR[i]*bR[i] - aI[i]*bI[i];
|
|
89
|
+
cI[i] = aR[i]*bI[i] + aI[i]*bR[i];
|
|
90
|
+
}
|
|
91
|
+
fft(cR, cI, true);
|
|
92
|
+
const out = new Float32Array(d);
|
|
93
|
+
for (let i = 0; i < d; i++) out[i] = cR[i];
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Circular correlation (unbinding): retrieve from association.
|
|
99
|
+
* unbind(key, memory) ≈ the value that was bound with key.
|
|
100
|
+
*/
|
|
101
|
+
export function unbind(key, memory) {
|
|
102
|
+
const d = key.length;
|
|
103
|
+
const inv = new Float32Array(d);
|
|
104
|
+
inv[0] = key[0];
|
|
105
|
+
for (let i = 1; i < d; i++) inv[i] = key[d - i];
|
|
106
|
+
return bind(inv, memory);
|
|
107
|
+
}
|
package/src/symbols.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared symbol table — maps string names to random vectors.
|
|
3
|
+
* Shared across all buckets to save memory.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomVector, similarity } from './ops.js';
|
|
7
|
+
|
|
8
|
+
export class SymbolTable {
|
|
9
|
+
constructor(d = 2048) {
|
|
10
|
+
this.d = d;
|
|
11
|
+
this.symbols = new Map();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Get or create a vector for a symbol name */
|
|
15
|
+
get(name) {
|
|
16
|
+
const key = name.toLowerCase().trim();
|
|
17
|
+
if (!this.symbols.has(key)) this.symbols.set(key, randomVector(this.d));
|
|
18
|
+
return this.symbols.get(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Check if a symbol exists */
|
|
22
|
+
has(name) { return this.symbols.has(name.toLowerCase().trim()); }
|
|
23
|
+
|
|
24
|
+
/** Number of symbols */
|
|
25
|
+
get size() { return this.symbols.size; }
|
|
26
|
+
|
|
27
|
+
/** Memory footprint in bytes */
|
|
28
|
+
get bytes() { return this.symbols.size * this.d * 4; }
|
|
29
|
+
|
|
30
|
+
/** Find the nearest symbol(s) to a result vector */
|
|
31
|
+
nearest(vec, candidates = null, topK = 1) {
|
|
32
|
+
const source = candidates || this.symbols;
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const [name, svec] of source) {
|
|
35
|
+
results.push({ name, score: similarity(vec, svec) });
|
|
36
|
+
}
|
|
37
|
+
results.sort((a, b) => b.score - a.score);
|
|
38
|
+
return topK === 1 ? results[0] || null : results.slice(0, topK);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Serialize to plain object */
|
|
42
|
+
toJSON() {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [k, v] of this.symbols) out[k] = Array.from(v);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Deserialize from plain object */
|
|
49
|
+
static fromJSON(data, d) {
|
|
50
|
+
const st = new SymbolTable(d);
|
|
51
|
+
for (const [k, v] of Object.entries(data)) {
|
|
52
|
+
st.symbols.set(k, new Float32Array(v));
|
|
53
|
+
}
|
|
54
|
+
return st;
|
|
55
|
+
}
|
|
56
|
+
}
|