hrr-memory 0.1.1 → 0.2.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/package.json +5 -3
- package/src/bucket.js +18 -0
- package/src/memory.js +93 -8
- package/types/index.d.ts +122 -0
package/package.json
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hrr-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Holographic Reduced Representations for structured agent memory. Zero dependencies. Complements RAG with algebraic fact queries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js"
|
|
9
9
|
},
|
|
10
|
+
"types": "types/index.d.ts",
|
|
10
11
|
"files": [
|
|
11
|
-
"src/"
|
|
12
|
+
"src/",
|
|
13
|
+
"types/"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
|
-
"test": "node --test test/",
|
|
16
|
+
"test": "node --test test/memory.test.js",
|
|
15
17
|
"bench": "node bench/benchmark.js"
|
|
16
18
|
},
|
|
17
19
|
"keywords": [
|
package/src/bucket.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Auto-splits are managed by HRRMemory, not by the bucket itself.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { bind } from './ops.js';
|
|
7
|
+
|
|
6
8
|
export const MAX_BUCKET_SIZE = 25;
|
|
7
9
|
|
|
8
10
|
export class Bucket {
|
|
@@ -24,6 +26,22 @@ export class Bucket {
|
|
|
24
26
|
/** Whether the bucket has reached max capacity */
|
|
25
27
|
get isFull() { return this.count >= MAX_BUCKET_SIZE; }
|
|
26
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild the memory vector from remaining triples.
|
|
31
|
+
* Called after removing a triple — can't subtract cleanly due to superposition noise.
|
|
32
|
+
*/
|
|
33
|
+
rebuild(symbols) {
|
|
34
|
+
this.memory = new Float32Array(this.d);
|
|
35
|
+
for (const t of this.triples) {
|
|
36
|
+
const s = symbols.get(t.subject);
|
|
37
|
+
const r = symbols.get(t.relation);
|
|
38
|
+
const o = symbols.get(t.object);
|
|
39
|
+
const association = bind(bind(s, r), o);
|
|
40
|
+
for (let i = 0; i < this.d; i++) this.memory[i] += association[i];
|
|
41
|
+
}
|
|
42
|
+
this.count = this.triples.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
/** Serialize */
|
|
28
46
|
toJSON() {
|
|
29
47
|
return {
|
package/src/memory.js
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
* is created automatically. Queries scan all buckets for a subject.
|
|
7
7
|
*
|
|
8
8
|
* Symbol vectors are shared across all buckets.
|
|
9
|
+
* All values are lowercased on store. If you need case-sensitive values,
|
|
10
|
+
* normalize them yourself before calling store().
|
|
11
|
+
*
|
|
12
|
+
* @typedef {{ match: string|null, score: number, confident: boolean, bucket: string|null }} QueryResult
|
|
13
|
+
* @typedef {{ relation: string, object: string }} Fact
|
|
14
|
+
* @typedef {{ subject: string, relation: string, object: string }} Triple
|
|
15
|
+
* @typedef {{ type: 'direct', match: string, score: number, confident: boolean, subject: string, relation: string, bucket: string|null }} DirectAskResult
|
|
16
|
+
* @typedef {{ type: 'subject', subject: string, facts: Fact[] }} SubjectAskResult
|
|
17
|
+
* @typedef {{ type: 'search', term: string, results: Triple[] }} SearchAskResult
|
|
18
|
+
* @typedef {{ type: 'miss', query: string }} MissAskResult
|
|
19
|
+
* @typedef {DirectAskResult | SubjectAskResult | SearchAskResult | MissAskResult} AskResult
|
|
20
|
+
* @typedef {{ dimensions: number, maxBucketSize: number, symbols: number, buckets: number, subjects: number, totalFacts: number, ramBytes: number, ramMB: number, perBucket: Array<{name: string, facts: number, full: boolean}> }} Stats
|
|
9
21
|
*/
|
|
10
22
|
|
|
11
23
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
@@ -13,6 +25,12 @@ import { bind, unbind, similarity } from './ops.js';
|
|
|
13
25
|
import { SymbolTable } from './symbols.js';
|
|
14
26
|
import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
|
|
15
27
|
|
|
28
|
+
const STOP_WORDS = new Set([
|
|
29
|
+
'what', 'is', 'the', 'a', 'an', 'does', 'do', 'where', 'who', 'how',
|
|
30
|
+
'which', 'of', 'for', 'in', 'at', 'to', 'my', 'your', 'their', 'has',
|
|
31
|
+
'have', 'was', 'were', 'are', 'been',
|
|
32
|
+
]);
|
|
33
|
+
|
|
16
34
|
export class HRRMemory {
|
|
17
35
|
/**
|
|
18
36
|
* Create a new HRR memory store.
|
|
@@ -92,6 +110,44 @@ export class HRRMemory {
|
|
|
92
110
|
return true;
|
|
93
111
|
}
|
|
94
112
|
|
|
113
|
+
// ── Forget ─────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove a fact from memory.
|
|
117
|
+
* Rebuilds the affected bucket's memory vector from remaining triples.
|
|
118
|
+
* @param {string} subject
|
|
119
|
+
* @param {string} relation
|
|
120
|
+
* @param {string} object
|
|
121
|
+
* @returns {boolean} true if found and removed, false if not found
|
|
122
|
+
*/
|
|
123
|
+
forget(subject, relation, object) {
|
|
124
|
+
const s = subject.toLowerCase().trim();
|
|
125
|
+
const r = relation.toLowerCase().trim();
|
|
126
|
+
const o = object.toLowerCase().trim();
|
|
127
|
+
|
|
128
|
+
const ids = this.routing.get(s);
|
|
129
|
+
if (!ids) return false;
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < ids.length; i++) {
|
|
132
|
+
const bucket = this.buckets.get(ids[i]);
|
|
133
|
+
const idx = bucket.triples.findIndex(t =>
|
|
134
|
+
t.subject === s && t.relation === r && t.object === o
|
|
135
|
+
);
|
|
136
|
+
if (idx === -1) continue;
|
|
137
|
+
|
|
138
|
+
bucket.triples.splice(idx, 1);
|
|
139
|
+
bucket.rebuild(this.symbols);
|
|
140
|
+
|
|
141
|
+
// Clean up empty overflow buckets
|
|
142
|
+
if (bucket.count === 0 && ids[i].includes('#')) {
|
|
143
|
+
this.buckets.delete(ids[i]);
|
|
144
|
+
ids.splice(i, 1);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
95
151
|
// ── Query ──────────────────────────────────────────
|
|
96
152
|
|
|
97
153
|
/**
|
|
@@ -167,11 +223,22 @@ export class HRRMemory {
|
|
|
167
223
|
}
|
|
168
224
|
|
|
169
225
|
/**
|
|
170
|
-
* Free-form query:
|
|
171
|
-
*
|
|
226
|
+
* Free-form query: strips stop words and possessives, then tries subject+relation pairs,
|
|
227
|
+
* subject lookup, and cross-bucket search in that order.
|
|
228
|
+
* @param {string} question - Natural language question (e.g., "What is alice's timezone?")
|
|
229
|
+
* @returns {AskResult} One of: DirectAskResult, SubjectAskResult, SearchAskResult, or MissAskResult
|
|
230
|
+
* @example
|
|
231
|
+
* mem.store('alice', 'timezone', 'cet');
|
|
232
|
+
* mem.ask("What is alice's timezone?"); // → { type: 'direct', match: 'cet', ... }
|
|
233
|
+
* mem.ask('alice'); // → { type: 'subject', facts: [...] }
|
|
172
234
|
*/
|
|
173
235
|
ask(question) {
|
|
174
|
-
const parts = question.toLowerCase().trim()
|
|
236
|
+
const parts = question.toLowerCase().trim()
|
|
237
|
+
.replace(/[?.,!]/g, '')
|
|
238
|
+
.replace(/'s\b/g, '') // possessive: alice's → alice
|
|
239
|
+
.replace(/-/g, '_') // lives-in → lives_in
|
|
240
|
+
.split(/\s+/)
|
|
241
|
+
.filter(w => !STOP_WORDS.has(w) && w.length > 0);
|
|
175
242
|
|
|
176
243
|
// Try consecutive word pairs as subject+relation
|
|
177
244
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
@@ -196,7 +263,10 @@ export class HRRMemory {
|
|
|
196
263
|
|
|
197
264
|
// ── Stats ──────────────────────────────────────────
|
|
198
265
|
|
|
199
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* Get memory statistics: dimensions, bucket count, total facts, RAM usage.
|
|
268
|
+
* @returns {Stats}
|
|
269
|
+
*/
|
|
200
270
|
stats() {
|
|
201
271
|
let totalFacts = 0;
|
|
202
272
|
const bucketInfo = [];
|
|
@@ -221,7 +291,10 @@ export class HRRMemory {
|
|
|
221
291
|
|
|
222
292
|
// ── Persistence ────────────────────────────────────
|
|
223
293
|
|
|
224
|
-
/**
|
|
294
|
+
/**
|
|
295
|
+
* Serialize the entire memory store to a plain object.
|
|
296
|
+
* @returns {{ version: number, d: number, symbols: object, buckets: object, routing: object }}
|
|
297
|
+
*/
|
|
225
298
|
toJSON() {
|
|
226
299
|
const buckets = {};
|
|
227
300
|
for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
|
|
@@ -230,7 +303,11 @@ export class HRRMemory {
|
|
|
230
303
|
return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
|
|
231
304
|
}
|
|
232
305
|
|
|
233
|
-
/**
|
|
306
|
+
/**
|
|
307
|
+
* Deserialize from a plain object (as produced by toJSON).
|
|
308
|
+
* @param {object} data - Serialized memory data
|
|
309
|
+
* @returns {HRRMemory}
|
|
310
|
+
*/
|
|
234
311
|
static fromJSON(data) {
|
|
235
312
|
const d = data.d || 2048;
|
|
236
313
|
const mem = new HRRMemory(d);
|
|
@@ -244,12 +321,20 @@ export class HRRMemory {
|
|
|
244
321
|
return mem;
|
|
245
322
|
}
|
|
246
323
|
|
|
247
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* Save the memory store to a JSON file.
|
|
326
|
+
* @param {string} filePath - Path to write the JSON file
|
|
327
|
+
*/
|
|
248
328
|
save(filePath) {
|
|
249
329
|
writeFileSync(filePath, JSON.stringify(this.toJSON()));
|
|
250
330
|
}
|
|
251
331
|
|
|
252
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* Load a memory store from a JSON file. Returns a new empty store if the file doesn't exist.
|
|
334
|
+
* @param {string} filePath - Path to the JSON file
|
|
335
|
+
* @param {number} [d=2048] - Vector dimensions (used only if creating new store)
|
|
336
|
+
* @returns {HRRMemory}
|
|
337
|
+
*/
|
|
253
338
|
static load(filePath, d = 2048) {
|
|
254
339
|
if (!existsSync(filePath)) return new HRRMemory(d);
|
|
255
340
|
try { return HRRMemory.fromJSON(JSON.parse(readFileSync(filePath, 'utf8'))); }
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export interface QueryResult {
|
|
2
|
+
match: string | null;
|
|
3
|
+
score: number;
|
|
4
|
+
confident: boolean;
|
|
5
|
+
bucket: string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Fact {
|
|
9
|
+
relation: string;
|
|
10
|
+
object: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Triple {
|
|
14
|
+
subject: string;
|
|
15
|
+
relation: string;
|
|
16
|
+
object: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DirectAskResult {
|
|
20
|
+
type: 'direct';
|
|
21
|
+
match: string;
|
|
22
|
+
score: number;
|
|
23
|
+
confident: boolean;
|
|
24
|
+
subject: string;
|
|
25
|
+
relation: string;
|
|
26
|
+
bucket: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SubjectAskResult {
|
|
30
|
+
type: 'subject';
|
|
31
|
+
subject: string;
|
|
32
|
+
facts: Fact[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SearchAskResult {
|
|
36
|
+
type: 'search';
|
|
37
|
+
term: string;
|
|
38
|
+
results: Triple[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MissAskResult {
|
|
42
|
+
type: 'miss';
|
|
43
|
+
query: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AskResult = DirectAskResult | SubjectAskResult | SearchAskResult | MissAskResult;
|
|
47
|
+
|
|
48
|
+
export interface BucketStats {
|
|
49
|
+
name: string;
|
|
50
|
+
facts: number;
|
|
51
|
+
full: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Stats {
|
|
55
|
+
dimensions: number;
|
|
56
|
+
maxBucketSize: number;
|
|
57
|
+
symbols: number;
|
|
58
|
+
buckets: number;
|
|
59
|
+
subjects: number;
|
|
60
|
+
totalFacts: number;
|
|
61
|
+
ramBytes: number;
|
|
62
|
+
ramMB: number;
|
|
63
|
+
perBucket: BucketStats[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SerializedMemory {
|
|
67
|
+
version: number;
|
|
68
|
+
d: number;
|
|
69
|
+
symbols: Record<string, number[]>;
|
|
70
|
+
buckets: Record<string, unknown>;
|
|
71
|
+
routing: Record<string, string[]>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class HRRMemory {
|
|
75
|
+
constructor(dimensions?: number);
|
|
76
|
+
|
|
77
|
+
store(subject: string, relation: string, object: string): boolean;
|
|
78
|
+
forget(subject: string, relation: string, object: string): boolean;
|
|
79
|
+
query(subject: string, relation: string): QueryResult;
|
|
80
|
+
querySubject(subject: string): Fact[];
|
|
81
|
+
search(relation: string | null, object: string | null): Triple[];
|
|
82
|
+
ask(question: string): AskResult;
|
|
83
|
+
stats(): Stats;
|
|
84
|
+
|
|
85
|
+
save(filePath: string): void;
|
|
86
|
+
static load(filePath: string, dimensions?: number): HRRMemory;
|
|
87
|
+
|
|
88
|
+
toJSON(): SerializedMemory;
|
|
89
|
+
static fromJSON(data: SerializedMemory): HRRMemory;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export class SymbolTable {
|
|
93
|
+
constructor(dimensions?: number);
|
|
94
|
+
get(name: string): Float32Array;
|
|
95
|
+
has(name: string): boolean;
|
|
96
|
+
readonly size: number;
|
|
97
|
+
readonly bytes: number;
|
|
98
|
+
nearest(
|
|
99
|
+
vec: Float32Array,
|
|
100
|
+
candidates?: Map<string, Float32Array> | null,
|
|
101
|
+
topK?: number
|
|
102
|
+
): { name: string; score: number } | { name: string; score: number }[] | null;
|
|
103
|
+
toJSON(): Record<string, number[]>;
|
|
104
|
+
static fromJSON(data: Record<string, number[]>, dimensions: number): SymbolTable;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class Bucket {
|
|
108
|
+
constructor(name: string, dimensions?: number);
|
|
109
|
+
readonly name: string;
|
|
110
|
+
readonly count: number;
|
|
111
|
+
readonly isFull: boolean;
|
|
112
|
+
storeVector(association: Float32Array, triple: Triple): void;
|
|
113
|
+
rebuild(symbols: SymbolTable): void;
|
|
114
|
+
toJSON(): unknown;
|
|
115
|
+
static fromJSON(data: unknown, dimensions: number): Bucket;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function bind(a: Float32Array, b: Float32Array): Float32Array;
|
|
119
|
+
export function unbind(key: Float32Array, memory: Float32Array): Float32Array;
|
|
120
|
+
export function similarity(a: Float32Array, b: Float32Array): number;
|
|
121
|
+
export function randomVector(dimensions: number): Float32Array;
|
|
122
|
+
export function normalize(v: Float32Array): Float32Array;
|