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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hrr-memory",
3
- "version": "0.4.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
- 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
-
34
- export class HRRMemory {
35
- constructor(d = 2048) {
36
- this.d = d;
37
- this.symbols = new SymbolTable(d);
38
- this.buckets = new Map();
39
- this.routing = new Map();
40
- this._relIndex = new Map(); // "subject\0relation" → Set<bucket_id>
41
- }
42
-
43
- _activeBucket(subject) {
44
- const key = subject.toLowerCase().trim();
45
- const ids = this.routing.get(key);
46
- if (ids) {
47
- const lastId = ids[ids.length - 1];
48
- const last = this.buckets.get(lastId);
49
- if (!last.isFull) return last;
50
- const newId = key + '#' + ids.length;
51
- const nb = new Bucket(newId, this.d);
52
- this.buckets.set(newId, nb);
53
- ids.push(newId);
54
- return nb;
55
- }
56
- const b = new Bucket(key, this.d);
57
- this.buckets.set(key, b);
58
- this.routing.set(key, [key]);
59
- return b;
60
- }
61
-
62
- _subjectBuckets(subject) {
63
- const ids = this.routing.get(subject.toLowerCase().trim()) || [];
64
- return ids.map(id => this.buckets.get(id)).filter(Boolean);
65
- }
66
-
67
- store(subject, relation, object) {
68
- const triple = {
69
- subject: subject.toLowerCase().trim(),
70
- relation: relation.toLowerCase().trim(),
71
- object: object.toLowerCase().trim(),
72
- };
73
- for (const b of this._subjectBuckets(subject)) {
74
- if (b.triples.some(t =>
75
- t.subject === triple.subject &&
76
- t.relation === triple.relation &&
77
- t.object === triple.object
78
- )) return false;
79
- }
80
- const s = this.symbols.get(subject);
81
- const r = this.symbols.get(relation);
82
- const o = this.symbols.get(object);
83
- const association = bind(bind(s, r), o);
84
- const bucket = this._activeBucket(subject);
85
- bucket.storeVector(association, triple);
86
-
87
- // Update relation → shard index
88
- const indexKey = triple.subject + '\0' + triple.relation;
89
- let set = this._relIndex.get(indexKey);
90
- if (!set) { set = new Set(); this._relIndex.set(indexKey, set); }
91
- set.add(bucket.name);
92
-
93
- return true;
94
- }
95
-
96
- forget(subject, relation, object) {
97
- const s = subject.toLowerCase().trim();
98
- const r = relation.toLowerCase().trim();
99
- const o = object.toLowerCase().trim();
100
- const ids = this.routing.get(s);
101
- if (!ids) return false;
102
- for (let i = 0; i < ids.length; i++) {
103
- const bucket = this.buckets.get(ids[i]);
104
- const idx = bucket.triples.findIndex(t =>
105
- t.subject === s && t.relation === r && t.object === o
106
- );
107
- if (idx === -1) continue;
108
- bucket.triples.splice(idx, 1);
109
- bucket.rebuild(this.symbols);
110
-
111
- // Update relation index: remove bucket if it no longer has this relation
112
- const indexKey = s + '\0' + r;
113
- const set = this._relIndex.get(indexKey);
114
- if (set) {
115
- const stillHas = bucket.triples.some(t => t.relation === r);
116
- if (!stillHas) {
117
- set.delete(ids[i]);
118
- if (set.size === 0) this._relIndex.delete(indexKey);
119
- }
120
- }
121
-
122
- if (bucket.count === 0 && ids[i].includes('#')) {
123
- this.buckets.delete(ids[i]);
124
- ids.splice(i, 1);
125
- }
126
- return true;
127
- }
128
- return false;
129
- }
130
-
131
- query(subject, relation) {
132
- const s = subject.toLowerCase().trim();
133
- const r = relation.toLowerCase().trim();
134
-
135
- // Fast-path: use relation index to scan only relevant shards
136
- const indexKey = s + '\0' + r;
137
- const indexed = this._relIndex.get(indexKey);
138
- const buckets = indexed
139
- ? [...indexed].map(id => this.buckets.get(id)).filter(Boolean)
140
- : this._subjectBuckets(subject); // fallback for pre-index data
141
-
142
- if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
143
- const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
144
- let bestName = null, bestScore = -1, bestBucket = null;
145
- for (const bucket of buckets) {
146
- if (bucket.count === 0) continue;
147
- const result = unbind(probe, bucket.memory);
148
- for (const t of bucket.triples) {
149
- const score = similarity(result, this.symbols.get(t.object));
150
- if (score > bestScore) {
151
- bestScore = score;
152
- bestName = t.object;
153
- bestBucket = bucket.name;
154
- }
155
- }
156
- }
157
- return {
158
- match: bestName,
159
- score: Math.round(bestScore * 1000) / 1000,
160
- confident: bestScore > 0.1,
161
- bucket: bestBucket,
162
- };
163
- }
164
-
165
- querySubject(subject) {
166
- const key = subject.toLowerCase().trim();
167
- const facts = [];
168
- for (const bucket of this._subjectBuckets(subject)) {
169
- for (const t of bucket.triples) {
170
- if (t.subject === key) facts.push({ relation: t.relation, object: t.object });
171
- }
172
- }
173
- return facts;
174
- }
175
-
176
- search(relation, object) {
177
- const results = [];
178
- const rel = relation ? relation.toLowerCase().trim() : null;
179
- const obj = object ? object.toLowerCase().trim() : null;
180
- for (const [_, bucket] of this.buckets) {
181
- for (const t of bucket.triples) {
182
- if (rel && t.relation !== rel) continue;
183
- if (obj && t.object !== obj) continue;
184
- results.push(t);
185
- }
186
- }
187
- return results;
188
- }
189
-
190
- ask(question) {
191
- const parts = question.toLowerCase().trim()
192
- .replace(/[?.,!]/g, '')
193
- .replace(/'s\b/g, '')
194
- .replace(/-/g, '_')
195
- .split(/\s+/)
196
- .filter(w => !STOP_WORDS.has(w) && w.length > 0);
197
- for (let i = 0; i < parts.length - 1; i++) {
198
- const result = this.query(parts[i], parts[i + 1]);
199
- if (result.confident) return { type: 'direct', ...result, subject: parts[i], relation: parts[i + 1] };
200
- }
201
- for (const word of parts) {
202
- const facts = this.querySubject(word);
203
- if (facts.length > 0) return { type: 'subject', subject: word, facts };
204
- }
205
- for (const word of parts) {
206
- const results = this.search(null, word);
207
- if (results.length > 0) return { type: 'search', term: word, results };
208
- }
209
- return { type: 'miss', query: question };
210
- }
211
-
212
- stats() {
213
- let totalFacts = 0;
214
- const bucketInfo = [];
215
- for (const [_, b] of this.buckets) {
216
- totalFacts += b.count;
217
- bucketInfo.push({ name: b.name, facts: b.count, full: b.isFull });
218
- }
219
- const symBytes = this.symbols.size * this.d * 4;
220
- const bktBytes = this.buckets.size * this.d * 4;
221
- return {
222
- dimensions: this.d,
223
- maxBucketSize: MAX_BUCKET_SIZE,
224
- symbols: this.symbols.size,
225
- buckets: this.buckets.size,
226
- subjects: this.routing.size,
227
- totalFacts,
228
- ramBytes: symBytes + bktBytes,
229
- ramMB: Math.round((symBytes + bktBytes) / 1024 / 1024 * 10) / 10,
230
- perBucket: bucketInfo,
231
- };
232
- }
233
-
234
- toJSON() {
235
- const buckets = {};
236
- for (const [k, v] of this.buckets) buckets[k] = v.toJSON();
237
- const routing = {};
238
- for (const [k, v] of this.routing) routing[k] = v;
239
- return { version: 3, d: this.d, symbols: this.symbols.toJSON(), buckets, routing };
240
- }
241
-
242
- static fromJSON(data) {
243
- const d = data.d || 2048;
244
- const mem = new HRRMemory(d);
245
- mem.symbols = SymbolTable.fromJSON(data.symbols || {}, d);
246
- for (const [k, v] of Object.entries(data.buckets || {})) {
247
- mem.buckets.set(k, Bucket.fromJSON(v, d));
248
- }
249
- for (const [k, v] of Object.entries(data.routing || {})) {
250
- mem.routing.set(k, Array.isArray(v) ? v : [v]);
251
- }
252
-
253
- // Rebuild relation index from deserialized triples
254
- for (const [bucketId, bucket] of mem.buckets) {
255
- for (const t of bucket.triples) {
256
- const indexKey = t.subject + '\0' + t.relation;
257
- let set = mem._relIndex.get(indexKey);
258
- if (!set) { set = new Set(); mem._relIndex.set(indexKey, set); }
259
- set.add(bucketId);
260
- }
261
- }
262
-
263
- return mem;
264
- }
265
-
266
- save(filePath) {
267
- writeFileSync(filePath, JSON.stringify(this.toJSON()));
268
- }
269
-
270
- static load(filePath, d = 2048) {
271
- if (!existsSync(filePath)) return new HRRMemory(d);
272
- try { return HRRMemory.fromJSON(JSON.parse(readFileSync(filePath, 'utf8'))); }
273
- catch { return new HRRMemory(d); }
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
+ }
@@ -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
 
@@ -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
+ }