hrr-memory 0.4.0 → 0.5.1
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 +43 -0
- 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/README.md
CHANGED
|
@@ -105,6 +105,25 @@ mem.addObservation({
|
|
|
105
105
|
});
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
+
## Graph Traversal
|
|
109
|
+
|
|
110
|
+
Dijkstra's shortest path between entities, with edge weights derived from HRR confidence scores.
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
mem.store('alice', 'works_at', 'acme');
|
|
114
|
+
mem.store('acme', 'uses', 'aws');
|
|
115
|
+
mem.store('aws', 'runs', 'ec2');
|
|
116
|
+
|
|
117
|
+
mem.traverse('alice', 'ec2');
|
|
118
|
+
// { found: true, hops: 3, totalWeight: 0.12,
|
|
119
|
+
// path: [{ relation: 'works_at', subject: 'alice', object: 'acme' }, ...] }
|
|
120
|
+
|
|
121
|
+
mem.neighbors('acme');
|
|
122
|
+
// [{ relation: 'uses', object: 'aws', weight: 0.04 }, ...]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Adjacency is lazily built and cached — rebuilt automatically after `store()` or `forget()`.
|
|
126
|
+
|
|
108
127
|
## Performance
|
|
109
128
|
|
|
110
129
|
| Facts | Accuracy | Query time | RAM |
|
|
@@ -128,6 +147,8 @@ Auto-sharding kicks in at 25 facts per subject. Accuracy stays at 100% across sh
|
|
|
128
147
|
| `search(r?, o?)` | `Triple[]` | Find triples by relation and/or object. |
|
|
129
148
|
| `ask(question)` | `AskResult` | Natural language query with stop-word handling. |
|
|
130
149
|
| `stats()` | `Stats` | Memory usage, bucket info, fact counts. |
|
|
150
|
+
| `traverse(from, to, opts?)` | `PathResult` | Dijkstra shortest path between entities. |
|
|
151
|
+
| `neighbors(subject)` | `Neighbor[]` | Directly connected entities. |
|
|
131
152
|
| `save(path)` | `void` | Persist to JSON file. |
|
|
132
153
|
| `HRRMemory.load(path)` | `HRRMemory` | Load from JSON file. |
|
|
133
154
|
|
|
@@ -149,6 +170,28 @@ All HRRMemory methods are delegated (query, querySubject, search, ask, stats).
|
|
|
149
170
|
| `save(hrrPath, obsPath)` | `void` | Persist both stores. |
|
|
150
171
|
| `ObservationMemory.load(hrrPath, obsPath, opts)` | `ObservationMemory` | Load both stores. |
|
|
151
172
|
|
|
173
|
+
### Graph Traversal
|
|
174
|
+
|
|
175
|
+
Find paths through the knowledge graph using Dijkstra's algorithm. Edge weights are derived from HRR confidence (`1 - score`), so high-confidence connections are preferred.
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
mem.store('sherlock', 'depends_on', 'postgres');
|
|
179
|
+
mem.store('postgres', 'runs_on', 'docker');
|
|
180
|
+
mem.store('docker', 'uses', 'linux');
|
|
181
|
+
|
|
182
|
+
mem.traverse('sherlock', 'linux');
|
|
183
|
+
// { found: true, hops: 3, totalWeight: 0.12, path: [...] }
|
|
184
|
+
|
|
185
|
+
mem.neighbors('sherlock');
|
|
186
|
+
// [{ relation: 'depends_on', object: 'postgres', weight: 0.03 }]
|
|
187
|
+
|
|
188
|
+
// Limit search depth or filter by relation type
|
|
189
|
+
mem.traverse('sherlock', 'linux', { maxHops: 5 });
|
|
190
|
+
mem.traverse('sherlock', 'linux', { relations: ['depends_on', 'runs_on'] });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Adjacency is lazily built and cached, automatically invalidated on `store`/`forget`.
|
|
194
|
+
|
|
152
195
|
### Standalone Components
|
|
153
196
|
|
|
154
197
|
Each layer works independently:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hrr-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
+
}
|