hrr-memory 0.4.0 → 0.5.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 +2 -2
- package/src/index.js +1 -0
- package/src/memory.js +321 -275
- package/src/observation.js +2 -0
- package/src/traversal.js +151 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hrr-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Holographic Reduced Representations for structured agent memory. Structured facts, temporal awareness, conflict detection, and belief synthesis. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"types/"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node --test test/memory.test.js test/timeline.test.js test/conflict.test.js test/prompt.test.js test/integration.test.js",
|
|
16
|
+
"test": "node --test test/memory.test.js test/timeline.test.js test/conflict.test.js test/prompt.test.js test/integration.test.js test/traversal.test.js",
|
|
17
17
|
"bench": "node bench/benchmark.js",
|
|
18
18
|
"bench:obs": "node bench/benchmark-obs.js"
|
|
19
19
|
},
|
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { HRRMemory } from './memory.js';
|
|
|
17
17
|
export { SymbolTable } from './symbols.js';
|
|
18
18
|
export { Bucket } from './bucket.js';
|
|
19
19
|
export { bind, unbind, similarity, randomVector, normalize } from './ops.js';
|
|
20
|
+
export { buildAdjacency, dijkstra, neighbors } from './traversal.js';
|
|
20
21
|
|
|
21
22
|
// Observation layer
|
|
22
23
|
export { ObservationMemory, ObservationParseError } from './observation.js';
|
package/src/memory.js
CHANGED
|
@@ -1,275 +1,321 @@
|
|
|
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
|
-
* 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
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
24
|
-
import { bind, unbind, similarity } from './ops.js';
|
|
25
|
-
import { SymbolTable } from './symbols.js';
|
|
26
|
-
import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
this.
|
|
38
|
-
this.
|
|
39
|
-
this.
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
ids.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
bucket.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
for (const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
+
* 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
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
24
|
+
import { bind, unbind, similarity } from './ops.js';
|
|
25
|
+
import { SymbolTable } from './symbols.js';
|
|
26
|
+
import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
|
|
27
|
+
import { buildAdjacency, dijkstra, neighbors as getNeighbors } from './traversal.js';
|
|
28
|
+
|
|
29
|
+
const STOP_WORDS = new Set([
|
|
30
|
+
'what', 'is', 'the', 'a', 'an', 'does', 'do', 'where', 'who', 'how',
|
|
31
|
+
'which', 'of', 'for', 'in', 'at', 'to', 'my', 'your', 'their', 'has',
|
|
32
|
+
'have', 'was', 'were', 'are', 'been',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export class HRRMemory {
|
|
36
|
+
constructor(d = 2048) {
|
|
37
|
+
this.d = d;
|
|
38
|
+
this.symbols = new SymbolTable(d);
|
|
39
|
+
this.buckets = new Map();
|
|
40
|
+
this.routing = new Map();
|
|
41
|
+
this._relIndex = new Map(); // "subject\0relation" → Set<bucket_id>
|
|
42
|
+
this._adjDirty = true;
|
|
43
|
+
this._adjacency = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_activeBucket(subject) {
|
|
47
|
+
const key = subject.toLowerCase().trim();
|
|
48
|
+
const ids = this.routing.get(key);
|
|
49
|
+
if (ids) {
|
|
50
|
+
const lastId = ids[ids.length - 1];
|
|
51
|
+
const last = this.buckets.get(lastId);
|
|
52
|
+
if (!last.isFull) return last;
|
|
53
|
+
const newId = key + '#' + ids.length;
|
|
54
|
+
const nb = new Bucket(newId, this.d);
|
|
55
|
+
this.buckets.set(newId, nb);
|
|
56
|
+
ids.push(newId);
|
|
57
|
+
return nb;
|
|
58
|
+
}
|
|
59
|
+
const b = new Bucket(key, this.d);
|
|
60
|
+
this.buckets.set(key, b);
|
|
61
|
+
this.routing.set(key, [key]);
|
|
62
|
+
return b;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_subjectBuckets(subject) {
|
|
66
|
+
const ids = this.routing.get(subject.toLowerCase().trim()) || [];
|
|
67
|
+
return ids.map(id => this.buckets.get(id)).filter(Boolean);
|
|
68
|
+
}
|
|
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
|
+
for (const b of this._subjectBuckets(subject)) {
|
|
77
|
+
if (b.triples.some(t =>
|
|
78
|
+
t.subject === triple.subject &&
|
|
79
|
+
t.relation === triple.relation &&
|
|
80
|
+
t.object === triple.object
|
|
81
|
+
)) return false;
|
|
82
|
+
}
|
|
83
|
+
const s = this.symbols.get(subject);
|
|
84
|
+
const r = this.symbols.get(relation);
|
|
85
|
+
const o = this.symbols.get(object);
|
|
86
|
+
const association = bind(bind(s, r), o);
|
|
87
|
+
const bucket = this._activeBucket(subject);
|
|
88
|
+
bucket.storeVector(association, triple);
|
|
89
|
+
|
|
90
|
+
// Invalidate adjacency cache
|
|
91
|
+
this._adjDirty = true;
|
|
92
|
+
|
|
93
|
+
// Update relation → shard index
|
|
94
|
+
const indexKey = triple.subject + '\0' + triple.relation;
|
|
95
|
+
let set = this._relIndex.get(indexKey);
|
|
96
|
+
if (!set) { set = new Set(); this._relIndex.set(indexKey, set); }
|
|
97
|
+
set.add(bucket.name);
|
|
98
|
+
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
forget(subject, relation, object) {
|
|
103
|
+
const s = subject.toLowerCase().trim();
|
|
104
|
+
const r = relation.toLowerCase().trim();
|
|
105
|
+
const o = object.toLowerCase().trim();
|
|
106
|
+
const ids = this.routing.get(s);
|
|
107
|
+
if (!ids) return false;
|
|
108
|
+
for (let i = 0; i < ids.length; i++) {
|
|
109
|
+
const bucket = this.buckets.get(ids[i]);
|
|
110
|
+
const idx = bucket.triples.findIndex(t =>
|
|
111
|
+
t.subject === s && t.relation === r && t.object === o
|
|
112
|
+
);
|
|
113
|
+
if (idx === -1) continue;
|
|
114
|
+
bucket.triples.splice(idx, 1);
|
|
115
|
+
bucket.rebuild(this.symbols);
|
|
116
|
+
|
|
117
|
+
// Update relation index: remove bucket if it no longer has this relation
|
|
118
|
+
const indexKey = s + '\0' + r;
|
|
119
|
+
const set = this._relIndex.get(indexKey);
|
|
120
|
+
if (set) {
|
|
121
|
+
const stillHas = bucket.triples.some(t => t.relation === r);
|
|
122
|
+
if (!stillHas) {
|
|
123
|
+
set.delete(ids[i]);
|
|
124
|
+
if (set.size === 0) this._relIndex.delete(indexKey);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (bucket.count === 0 && ids[i].includes('#')) {
|
|
129
|
+
this.buckets.delete(ids[i]);
|
|
130
|
+
ids.splice(i, 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Invalidate adjacency cache
|
|
134
|
+
this._adjDirty = true;
|
|
135
|
+
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
query(subject, relation) {
|
|
142
|
+
const s = subject.toLowerCase().trim();
|
|
143
|
+
const r = relation.toLowerCase().trim();
|
|
144
|
+
|
|
145
|
+
// Fast-path: use relation index to scan only relevant shards
|
|
146
|
+
const indexKey = s + '\0' + r;
|
|
147
|
+
const indexed = this._relIndex.get(indexKey);
|
|
148
|
+
const buckets = indexed
|
|
149
|
+
? [...indexed].map(id => this.buckets.get(id)).filter(Boolean)
|
|
150
|
+
: this._subjectBuckets(subject); // fallback for pre-index data
|
|
151
|
+
|
|
152
|
+
if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
|
|
153
|
+
const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
|
|
154
|
+
let bestName = null, bestScore = -1, bestBucket = null;
|
|
155
|
+
for (const bucket of buckets) {
|
|
156
|
+
if (bucket.count === 0) continue;
|
|
157
|
+
const result = unbind(probe, bucket.memory);
|
|
158
|
+
for (const t of bucket.triples) {
|
|
159
|
+
const score = similarity(result, this.symbols.get(t.object));
|
|
160
|
+
if (score > bestScore) {
|
|
161
|
+
bestScore = score;
|
|
162
|
+
bestName = t.object;
|
|
163
|
+
bestBucket = bucket.name;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
match: bestName,
|
|
169
|
+
score: Math.round(bestScore * 1000) / 1000,
|
|
170
|
+
confident: bestScore > 0.1,
|
|
171
|
+
bucket: bestBucket,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
querySubject(subject) {
|
|
176
|
+
const key = subject.toLowerCase().trim();
|
|
177
|
+
const facts = [];
|
|
178
|
+
for (const bucket of this._subjectBuckets(subject)) {
|
|
179
|
+
for (const t of bucket.triples) {
|
|
180
|
+
if (t.subject === key) facts.push({ relation: t.relation, object: t.object });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return facts;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
search(relation, object) {
|
|
187
|
+
const results = [];
|
|
188
|
+
const rel = relation ? relation.toLowerCase().trim() : null;
|
|
189
|
+
const obj = object ? object.toLowerCase().trim() : null;
|
|
190
|
+
for (const [_, bucket] of this.buckets) {
|
|
191
|
+
for (const t of bucket.triples) {
|
|
192
|
+
if (rel && t.relation !== rel) continue;
|
|
193
|
+
if (obj && t.object !== obj) continue;
|
|
194
|
+
results.push(t);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return results;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
ask(question) {
|
|
201
|
+
const parts = question.toLowerCase().trim()
|
|
202
|
+
.replace(/[?.,!]/g, '')
|
|
203
|
+
.replace(/'s\b/g, '')
|
|
204
|
+
.replace(/-/g, '_')
|
|
205
|
+
.split(/\s+/)
|
|
206
|
+
.filter(w => !STOP_WORDS.has(w) && w.length > 0);
|
|
207
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
208
|
+
const result = this.query(parts[i], parts[i + 1]);
|
|
209
|
+
if (result.confident) return { type: 'direct', ...result, subject: parts[i], relation: parts[i + 1] };
|
|
210
|
+
}
|
|
211
|
+
for (const word of parts) {
|
|
212
|
+
const facts = this.querySubject(word);
|
|
213
|
+
if (facts.length > 0) return { type: 'subject', subject: word, facts };
|
|
214
|
+
}
|
|
215
|
+
for (const word of parts) {
|
|
216
|
+
const results = this.search(null, word);
|
|
217
|
+
if (results.length > 0) return { type: 'search', term: word, results };
|
|
218
|
+
}
|
|
219
|
+
return { type: 'miss', query: question };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Graph Traversal ──────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build or return cached adjacency list.
|
|
226
|
+
* @returns {Map<string, Array<{relation: string, object: string, weight: number}>>}
|
|
227
|
+
*/
|
|
228
|
+
_getAdjacency() {
|
|
229
|
+
if (this._adjDirty || !this._adjacency) {
|
|
230
|
+
this._adjacency = buildAdjacency(this);
|
|
231
|
+
this._adjDirty = false;
|
|
232
|
+
}
|
|
233
|
+
return this._adjacency;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Find shortest path between two entities using Dijkstra's algorithm.
|
|
238
|
+
* @param {string} from - Starting entity
|
|
239
|
+
* @param {string} to - Target entity
|
|
240
|
+
* @param {{ maxHops?: number, relations?: string[] }} [options]
|
|
241
|
+
* @returns {{ found: boolean, path: Array<{subject: string, relation: string, object: string, weight: number}>, totalWeight: number, hops: number } | null}
|
|
242
|
+
*/
|
|
243
|
+
traverse(from, to, options = {}) {
|
|
244
|
+
const adj = this._getAdjacency();
|
|
245
|
+
return dijkstra(adj, from, to, options);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Get direct neighbors of a subject in the knowledge graph.
|
|
250
|
+
* @param {string} subject
|
|
251
|
+
* @returns {Array<{relation: string, object: string, weight: number}>}
|
|
252
|
+
*/
|
|
253
|
+
neighbors(subject) {
|
|
254
|
+
const adj = this._getAdjacency();
|
|
255
|
+
return getNeighbors(adj, subject);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
stats() {
|
|
259
|
+
let totalFacts = 0;
|
|
260
|
+
const bucketInfo = [];
|
|
261
|
+
for (const [_, b] of this.buckets) {
|
|
262
|
+
totalFacts += b.count;
|
|
263
|
+
bucketInfo.push({ name: b.name, facts: b.count, full: b.isFull });
|
|
264
|
+
}
|
|
265
|
+
const symBytes = this.symbols.size * this.d * 4;
|
|
266
|
+
const bktBytes = this.buckets.size * this.d * 4;
|
|
267
|
+
return {
|
|
268
|
+
dimensions: this.d,
|
|
269
|
+
maxBucketSize: MAX_BUCKET_SIZE,
|
|
270
|
+
symbols: this.symbols.size,
|
|
271
|
+
buckets: this.buckets.size,
|
|
272
|
+
subjects: this.routing.size,
|
|
273
|
+
totalFacts,
|
|
274
|
+
ramBytes: symBytes + bktBytes,
|
|
275
|
+
ramMB: Math.round((symBytes + bktBytes) / 1024 / 1024 * 10) / 10,
|
|
276
|
+
perBucket: bucketInfo,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
toJSON() {
|
|
281
|
+
const buckets = {};
|
|
282
|
+
for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
|
|
283
|
+
const routing = {};
|
|
284
|
+
for (const [k, v] of this.routing) routing[k] = v;
|
|
285
|
+
return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
static fromJSON(data) {
|
|
289
|
+
const d = data.d || 2048;
|
|
290
|
+
const mem = new HRRMemory(d);
|
|
291
|
+
mem.symbols = SymbolTable.fromJSON(data.symbols || {}, d);
|
|
292
|
+
for (const [k, v] of Object.entries(data.buckets || {})) {
|
|
293
|
+
mem.buckets.set(k, Bucket.fromJSON(v, d));
|
|
294
|
+
}
|
|
295
|
+
for (const [k, v] of Object.entries(data.routing || {})) {
|
|
296
|
+
mem.routing.set(k, Array.isArray(v) ? v : [v]);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Rebuild relation index from deserialized triples
|
|
300
|
+
for (const [bucketId, bucket] of mem.buckets) {
|
|
301
|
+
for (const t of bucket.triples) {
|
|
302
|
+
const indexKey = t.subject + '\0' + t.relation;
|
|
303
|
+
let set = mem._relIndex.get(indexKey);
|
|
304
|
+
if (!set) { set = new Set(); mem._relIndex.set(indexKey, set); }
|
|
305
|
+
set.add(bucketId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return mem;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
save(filePath) {
|
|
313
|
+
writeFileSync(filePath, JSON.stringify(this.toJSON()));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
static load(filePath, d = 2048) {
|
|
317
|
+
if (!existsSync(filePath)) return new HRRMemory(d);
|
|
318
|
+
try { return HRRMemory.fromJSON(JSON.parse(readFileSync(filePath, 'utf8'))); }
|
|
319
|
+
catch { return new HRRMemory(d); }
|
|
320
|
+
}
|
|
321
|
+
}
|
package/src/observation.js
CHANGED
|
@@ -49,6 +49,8 @@ export class ObservationMemory {
|
|
|
49
49
|
search(relation, object) { return this._hrr.search(relation, object); }
|
|
50
50
|
ask(question) { return this._hrr.ask(question); }
|
|
51
51
|
stats() { return this._hrr.stats(); }
|
|
52
|
+
traverse(from, to, options) { return this._hrr.traverse(from, to, options); }
|
|
53
|
+
neighbors(subject) { return this._hrr.neighbors(subject); }
|
|
52
54
|
|
|
53
55
|
// ── Store (async) ──────────────────────────────────
|
|
54
56
|
|
package/src/traversal.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph traversal for HRR Memory using Dijkstra's algorithm.
|
|
3
|
+
*
|
|
4
|
+
* Builds an adjacency list from stored triples and finds shortest paths
|
|
5
|
+
* between entities. Edge weights are derived from HRR confidence scores:
|
|
6
|
+
* weight = 1 - score, so high-confidence edges are cheaper to traverse.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build adjacency list from all triples in HRRMemory.
|
|
11
|
+
* @param {import('./memory.js').HRRMemory} hrr
|
|
12
|
+
* @returns {Map<string, Array<{relation: string, object: string, weight: number}>>}
|
|
13
|
+
*/
|
|
14
|
+
export function buildAdjacency(hrr) {
|
|
15
|
+
const adj = new Map();
|
|
16
|
+
|
|
17
|
+
for (const bucket of hrr.buckets.values()) {
|
|
18
|
+
for (const t of bucket.triples) {
|
|
19
|
+
// Forward edge: subject → object
|
|
20
|
+
if (!adj.has(t.subject)) adj.set(t.subject, []);
|
|
21
|
+
adj.get(t.subject).push({
|
|
22
|
+
relation: t.relation,
|
|
23
|
+
object: t.object,
|
|
24
|
+
weight: edgeWeight(hrr, t.subject, t.relation),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Ensure object exists as a node (even if it has no outgoing edges)
|
|
28
|
+
if (!adj.has(t.object)) adj.set(t.object, []);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return adj;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute edge weight from HRR query confidence.
|
|
37
|
+
* Falls back to 0.5 (neutral) if query returns no confidence.
|
|
38
|
+
*/
|
|
39
|
+
function edgeWeight(hrr, subject, relation) {
|
|
40
|
+
const result = hrr.query(subject, relation);
|
|
41
|
+
if (result && result.confident) {
|
|
42
|
+
return Math.max(0, 1 - result.score);
|
|
43
|
+
}
|
|
44
|
+
// For low-confidence edges, still use score but with a floor
|
|
45
|
+
if (result && result.score > 0) {
|
|
46
|
+
return 1 - result.score;
|
|
47
|
+
}
|
|
48
|
+
return 0.5;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dijkstra's shortest path between two entities.
|
|
53
|
+
*
|
|
54
|
+
* @param {Map<string, Array<{relation: string, object: string, weight: number}>>} adjacency
|
|
55
|
+
* @param {string} from - Starting entity
|
|
56
|
+
* @param {string} to - Target entity
|
|
57
|
+
* @param {object} [options]
|
|
58
|
+
* @param {number} [options.maxHops=10] - Maximum hops to search
|
|
59
|
+
* @param {string[]} [options.relations] - Only follow these relation types
|
|
60
|
+
* @returns {{ found: boolean, path: Array<{subject: string, relation: string, object: string, weight: number}>, totalWeight: number, hops: number } | null}
|
|
61
|
+
*/
|
|
62
|
+
export function dijkstra(adjacency, from, to, options = {}) {
|
|
63
|
+
const maxHops = options.maxHops ?? 10;
|
|
64
|
+
const relFilter = options.relations
|
|
65
|
+
? new Set(options.relations.map(r => r.toLowerCase().trim()))
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
if (!adjacency.has(from) || !adjacency.has(to)) return null;
|
|
69
|
+
if (from === to) return { found: true, path: [], totalWeight: 0, hops: 0 };
|
|
70
|
+
|
|
71
|
+
// dist.get(node) = best known total weight to reach node
|
|
72
|
+
const dist = new Map();
|
|
73
|
+
// prev.get(node) = { from, relation, weight } describing the edge used
|
|
74
|
+
const prev = new Map();
|
|
75
|
+
// hopCount.get(node) = number of hops to reach node
|
|
76
|
+
const hopCount = new Map();
|
|
77
|
+
|
|
78
|
+
// Simple priority queue using sorted array (sufficient for knowledge graph sizes)
|
|
79
|
+
const pq = [];
|
|
80
|
+
|
|
81
|
+
dist.set(from, 0);
|
|
82
|
+
hopCount.set(from, 0);
|
|
83
|
+
pq.push({ node: from, cost: 0 });
|
|
84
|
+
|
|
85
|
+
while (pq.length > 0) {
|
|
86
|
+
// Extract minimum
|
|
87
|
+
pq.sort((a, b) => a.cost - b.cost);
|
|
88
|
+
const { node: current, cost: currentCost } = pq.shift();
|
|
89
|
+
|
|
90
|
+
// Skip stale entries
|
|
91
|
+
if (currentCost > (dist.get(current) ?? Infinity)) continue;
|
|
92
|
+
|
|
93
|
+
// Found target
|
|
94
|
+
if (current === to) break;
|
|
95
|
+
|
|
96
|
+
// Check hop limit
|
|
97
|
+
const hops = hopCount.get(current) ?? 0;
|
|
98
|
+
if (hops >= maxHops) continue;
|
|
99
|
+
|
|
100
|
+
// Explore neighbors
|
|
101
|
+
const neighbors = adjacency.get(current) || [];
|
|
102
|
+
for (const edge of neighbors) {
|
|
103
|
+
if (relFilter && !relFilter.has(edge.relation)) continue;
|
|
104
|
+
|
|
105
|
+
const newDist = currentCost + edge.weight;
|
|
106
|
+
const prevDist = dist.get(edge.object) ?? Infinity;
|
|
107
|
+
|
|
108
|
+
if (newDist < prevDist) {
|
|
109
|
+
dist.set(edge.object, newDist);
|
|
110
|
+
prev.set(edge.object, { from: current, relation: edge.relation, weight: edge.weight });
|
|
111
|
+
hopCount.set(edge.object, hops + 1);
|
|
112
|
+
pq.push({ node: edge.object, cost: newDist });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!dist.has(to) || dist.get(to) === Infinity) return null;
|
|
118
|
+
|
|
119
|
+
// Reconstruct path
|
|
120
|
+
const path = [];
|
|
121
|
+
let current = to;
|
|
122
|
+
while (current !== from) {
|
|
123
|
+
const step = prev.get(current);
|
|
124
|
+
if (!step) return null;
|
|
125
|
+
path.unshift({
|
|
126
|
+
subject: step.from,
|
|
127
|
+
relation: step.relation,
|
|
128
|
+
object: current,
|
|
129
|
+
weight: step.weight,
|
|
130
|
+
});
|
|
131
|
+
current = step.from;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
found: true,
|
|
136
|
+
path,
|
|
137
|
+
totalWeight: Math.round(dist.get(to) * 1000) / 1000,
|
|
138
|
+
hops: path.length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get direct neighbors of a subject.
|
|
144
|
+
*
|
|
145
|
+
* @param {Map<string, Array<{relation: string, object: string, weight: number}>>} adjacency
|
|
146
|
+
* @param {string} subject
|
|
147
|
+
* @returns {Array<{relation: string, object: string, weight: number}>}
|
|
148
|
+
*/
|
|
149
|
+
export function neighbors(adjacency, subject) {
|
|
150
|
+
return adjacency.get(subject.toLowerCase().trim()) || [];
|
|
151
|
+
}
|