hrr-memory 0.3.2 → 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/src/memory.js CHANGED
@@ -24,6 +24,7 @@ import { readFileSync, writeFileSync, existsSync } from 'fs';
24
24
  import { bind, unbind, similarity } from './ops.js';
25
25
  import { SymbolTable } from './symbols.js';
26
26
  import { Bucket, MAX_BUCKET_SIZE } from './bucket.js';
27
+ import { buildAdjacency, dijkstra, neighbors as getNeighbors } from './traversal.js';
27
28
 
28
29
  const STOP_WORDS = new Set([
29
30
  'what', 'is', 'the', 'a', 'an', 'does', 'do', 'where', 'who', 'how',
@@ -37,6 +38,9 @@ export class HRRMemory {
37
38
  this.symbols = new SymbolTable(d);
38
39
  this.buckets = new Map();
39
40
  this.routing = new Map();
41
+ this._relIndex = new Map(); // "subject\0relation" → Set<bucket_id>
42
+ this._adjDirty = true;
43
+ this._adjacency = null;
40
44
  }
41
45
 
42
46
  _activeBucket(subject) {
@@ -80,7 +84,18 @@ export class HRRMemory {
80
84
  const r = this.symbols.get(relation);
81
85
  const o = this.symbols.get(object);
82
86
  const association = bind(bind(s, r), o);
83
- this._activeBucket(subject).storeVector(association, triple);
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
+
84
99
  return true;
85
100
  }
86
101
 
@@ -98,17 +113,42 @@ export class HRRMemory {
98
113
  if (idx === -1) continue;
99
114
  bucket.triples.splice(idx, 1);
100
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
+
101
128
  if (bucket.count === 0 && ids[i].includes('#')) {
102
129
  this.buckets.delete(ids[i]);
103
130
  ids.splice(i, 1);
104
131
  }
132
+
133
+ // Invalidate adjacency cache
134
+ this._adjDirty = true;
135
+
105
136
  return true;
106
137
  }
107
138
  return false;
108
139
  }
109
140
 
110
141
  query(subject, relation) {
111
- const buckets = this._subjectBuckets(subject);
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
+
112
152
  if (buckets.length === 0) return { match: null, score: 0, confident: false, bucket: null };
113
153
  const probe = bind(this.symbols.get(subject), this.symbols.get(relation));
114
154
  let bestName = null, bestScore = -1, bestBucket = null;
@@ -179,6 +219,42 @@ export class HRRMemory {
179
219
  return { type: 'miss', query: question };
180
220
  }
181
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
+
182
258
  stats() {
183
259
  let totalFacts = 0;
184
260
  const bucketInfo = [];
@@ -219,6 +295,17 @@ export class HRRMemory {
219
295
  for (const [k, v] of Object.entries(data.routing || {})) {
220
296
  mem.routing.set(k, Array.isArray(v) ? v : [v]);
221
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
+
222
309
  return mem;
223
310
  }
224
311
 
@@ -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/ops.js CHANGED
@@ -1,107 +1,107 @@
1
- /**
2
- * Core HRR operations — circular convolution, correlation, similarity.
3
- * All vectors are Float32Array. FFT computed in Float64 for precision.
4
- */
5
-
6
- /** Generate a random unit vector of dimension d (Gaussian, normalized) */
7
- export function randomVector(d) {
8
- const v = new Float32Array(d);
9
- for (let i = 0; i < d; i += 2) {
10
- const u1 = Math.random(), u2 = Math.random();
11
- const r = Math.sqrt(-2 * Math.log(u1));
12
- v[i] = r * Math.cos(2 * Math.PI * u2);
13
- if (i + 1 < d) v[i + 1] = r * Math.sin(2 * Math.PI * u2);
14
- }
15
- return normalize(v);
16
- }
17
-
18
- /** Normalize vector to unit length */
19
- export function normalize(v) {
20
- let norm = 0;
21
- for (let i = 0; i < v.length; i++) norm += v[i] * v[i];
22
- norm = Math.sqrt(norm);
23
- if (norm === 0) return v;
24
- const out = new Float32Array(v.length);
25
- for (let i = 0; i < v.length; i++) out[i] = v[i] / norm;
26
- return out;
27
- }
28
-
29
- /** Cosine similarity between two vectors */
30
- export function similarity(a, b) {
31
- let dot = 0, na = 0, nb = 0;
32
- for (let i = 0; i < a.length; i++) {
33
- dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i];
34
- }
35
- return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
36
- }
37
-
38
- // ── FFT (Cooley-Tukey radix-2) ──
39
-
40
- function fft(re, im, inverse) {
41
- const n = re.length;
42
- for (let i = 1, j = 0; i < n; i++) {
43
- let bit = n >> 1;
44
- for (; j & bit; bit >>= 1) j ^= bit;
45
- j ^= bit;
46
- if (i < j) {
47
- [re[i], re[j]] = [re[j], re[i]];
48
- [im[i], im[j]] = [im[j], im[i]];
49
- }
50
- }
51
- for (let len = 2; len <= n; len <<= 1) {
52
- const ang = (inverse ? -1 : 1) * 2 * Math.PI / len;
53
- const wRe = Math.cos(ang), wIm = Math.sin(ang);
54
- for (let i = 0; i < n; i += len) {
55
- let curRe = 1, curIm = 0;
56
- for (let j = 0; j < len / 2; j++) {
57
- const uRe = re[i+j], uIm = im[i+j];
58
- const vRe = re[i+j+len/2]*curRe - im[i+j+len/2]*curIm;
59
- const vIm = re[i+j+len/2]*curIm + im[i+j+len/2]*curRe;
60
- re[i+j] = uRe+vRe; im[i+j] = uIm+vIm;
61
- re[i+j+len/2] = uRe-vRe; im[i+j+len/2] = uIm-vIm;
62
- const nr = curRe*wRe - curIm*wIm;
63
- curIm = curRe*wIm + curIm*wRe;
64
- curRe = nr;
65
- }
66
- }
67
- }
68
- if (inverse) {
69
- for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
70
- }
71
- }
72
-
73
- function nextPow2(n) { let p = 1; while (p < n) p <<= 1; return p; }
74
-
75
- /**
76
- * Circular convolution (binding): a ⊛ b
77
- * Creates an association between two vectors.
78
- * Computed in Float64, returned as Float32.
79
- */
80
- export function bind(a, b) {
81
- const d = a.length, n = nextPow2(d);
82
- const aR = new Float64Array(n), aI = new Float64Array(n);
83
- const bR = new Float64Array(n), bI = new Float64Array(n);
84
- for (let i = 0; i < d; i++) { aR[i] = a[i]; bR[i] = b[i]; }
85
- fft(aR, aI, false); fft(bR, bI, false);
86
- const cR = new Float64Array(n), cI = new Float64Array(n);
87
- for (let i = 0; i < n; i++) {
88
- cR[i] = aR[i]*bR[i] - aI[i]*bI[i];
89
- cI[i] = aR[i]*bI[i] + aI[i]*bR[i];
90
- }
91
- fft(cR, cI, true);
92
- const out = new Float32Array(d);
93
- for (let i = 0; i < d; i++) out[i] = cR[i];
94
- return out;
95
- }
96
-
97
- /**
98
- * Circular correlation (unbinding): retrieve from association.
99
- * unbind(key, memory) ≈ the value that was bound with key.
100
- */
101
- export function unbind(key, memory) {
102
- const d = key.length;
103
- const inv = new Float32Array(d);
104
- inv[0] = key[0];
105
- for (let i = 1; i < d; i++) inv[i] = key[d - i];
106
- return bind(inv, memory);
107
- }
1
+ /**
2
+ * Core HRR operations — circular convolution, correlation, similarity.
3
+ * All vectors are Float32Array. FFT computed in Float64 for precision.
4
+ */
5
+
6
+ /** Generate a random unit vector of dimension d (Gaussian, normalized) */
7
+ export function randomVector(d) {
8
+ const v = new Float32Array(d);
9
+ for (let i = 0; i < d; i += 2) {
10
+ const u1 = Math.random(), u2 = Math.random();
11
+ const r = Math.sqrt(-2 * Math.log(u1));
12
+ v[i] = r * Math.cos(2 * Math.PI * u2);
13
+ if (i + 1 < d) v[i + 1] = r * Math.sin(2 * Math.PI * u2);
14
+ }
15
+ return normalize(v);
16
+ }
17
+
18
+ /** Normalize vector to unit length */
19
+ export function normalize(v) {
20
+ let norm = 0;
21
+ for (let i = 0; i < v.length; i++) norm += v[i] * v[i];
22
+ norm = Math.sqrt(norm);
23
+ if (norm === 0) return v;
24
+ const out = new Float32Array(v.length);
25
+ for (let i = 0; i < v.length; i++) out[i] = v[i] / norm;
26
+ return out;
27
+ }
28
+
29
+ /** Cosine similarity between two vectors */
30
+ export function similarity(a, b) {
31
+ let dot = 0, na = 0, nb = 0;
32
+ for (let i = 0; i < a.length; i++) {
33
+ dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i];
34
+ }
35
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
36
+ }
37
+
38
+ // ── FFT (Cooley-Tukey radix-2) ──
39
+
40
+ function fft(re, im, inverse) {
41
+ const n = re.length;
42
+ for (let i = 1, j = 0; i < n; i++) {
43
+ let bit = n >> 1;
44
+ for (; j & bit; bit >>= 1) j ^= bit;
45
+ j ^= bit;
46
+ if (i < j) {
47
+ [re[i], re[j]] = [re[j], re[i]];
48
+ [im[i], im[j]] = [im[j], im[i]];
49
+ }
50
+ }
51
+ for (let len = 2; len <= n; len <<= 1) {
52
+ const ang = (inverse ? -1 : 1) * 2 * Math.PI / len;
53
+ const wRe = Math.cos(ang), wIm = Math.sin(ang);
54
+ for (let i = 0; i < n; i += len) {
55
+ let curRe = 1, curIm = 0;
56
+ for (let j = 0; j < len / 2; j++) {
57
+ const uRe = re[i+j], uIm = im[i+j];
58
+ const vRe = re[i+j+len/2]*curRe - im[i+j+len/2]*curIm;
59
+ const vIm = re[i+j+len/2]*curIm + im[i+j+len/2]*curRe;
60
+ re[i+j] = uRe+vRe; im[i+j] = uIm+vIm;
61
+ re[i+j+len/2] = uRe-vRe; im[i+j+len/2] = uIm-vIm;
62
+ const nr = curRe*wRe - curIm*wIm;
63
+ curIm = curRe*wIm + curIm*wRe;
64
+ curRe = nr;
65
+ }
66
+ }
67
+ }
68
+ if (inverse) {
69
+ for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
70
+ }
71
+ }
72
+
73
+ function nextPow2(n) { let p = 1; while (p < n) p <<= 1; return p; }
74
+
75
+ /**
76
+ * Circular convolution (binding): a ⊛ b
77
+ * Creates an association between two vectors.
78
+ * Computed in Float64, returned as Float32.
79
+ */
80
+ export function bind(a, b) {
81
+ const d = a.length, n = nextPow2(d);
82
+ const aR = new Float64Array(n), aI = new Float64Array(n);
83
+ const bR = new Float64Array(n), bI = new Float64Array(n);
84
+ for (let i = 0; i < d; i++) { aR[i] = a[i]; bR[i] = b[i]; }
85
+ fft(aR, aI, false); fft(bR, bI, false);
86
+ const cR = new Float64Array(n), cI = new Float64Array(n);
87
+ for (let i = 0; i < n; i++) {
88
+ cR[i] = aR[i]*bR[i] - aI[i]*bI[i];
89
+ cI[i] = aR[i]*bI[i] + aI[i]*bR[i];
90
+ }
91
+ fft(cR, cI, true);
92
+ const out = new Float32Array(d);
93
+ for (let i = 0; i < d; i++) out[i] = cR[i];
94
+ return out;
95
+ }
96
+
97
+ /**
98
+ * Circular correlation (unbinding): retrieve from association.
99
+ * unbind(key, memory) ≈ the value that was bound with key.
100
+ */
101
+ export function unbind(key, memory) {
102
+ const d = key.length;
103
+ const inv = new Float32Array(d);
104
+ inv[0] = key[0];
105
+ for (let i = 1; i < d; i++) inv[i] = key[d - i];
106
+ return bind(inv, memory);
107
+ }
package/src/symbols.js CHANGED
@@ -1,49 +1,49 @@
1
- /**
2
- * Shared symbol table — maps string names to random vectors.
3
- * Shared across all buckets to save memory.
4
- */
5
-
6
- import { randomVector, similarity } from './ops.js';
7
-
8
- export class SymbolTable {
9
- constructor(d = 2048) {
10
- this.d = d;
11
- this.symbols = new Map();
12
- }
13
-
14
- get(name) {
15
- const key = name.toLowerCase().trim();
16
- if (!this.symbols.has(key)) this.symbols.set(key, randomVector(this.d));
17
- return this.symbols.get(key);
18
- }
19
-
20
- has(name) { return this.symbols.has(name.toLowerCase().trim()); }
21
-
22
- get size() { return this.symbols.size; }
23
-
24
- get bytes() { return this.symbols.size * this.d * 4; }
25
-
26
- nearest(vec, candidates = null, topK = 1) {
27
- const source = candidates || this.symbols;
28
- const results = [];
29
- for (const [name, svec] of source) {
30
- results.push({ name, score: similarity(vec, svec) });
31
- }
32
- results.sort((a, b) => b.score - a.score);
33
- return topK === 1 ? results[0] || null : results.slice(0, topK);
34
- }
35
-
36
- toJSON() {
37
- const out = {};
38
- for (const [k, v] of this.symbols) out[k] = Array.from(v);
39
- return out;
40
- }
41
-
42
- static fromJSON(data, d) {
43
- const st = new SymbolTable(d);
44
- for (const [k, v] of Object.entries(data)) {
45
- st.symbols.set(k, new Float32Array(v));
46
- }
47
- return st;
48
- }
49
- }
1
+ /**
2
+ * Shared symbol table — maps string names to random vectors.
3
+ * Shared across all buckets to save memory.
4
+ */
5
+
6
+ import { randomVector, similarity } from './ops.js';
7
+
8
+ export class SymbolTable {
9
+ constructor(d = 2048) {
10
+ this.d = d;
11
+ this.symbols = new Map();
12
+ }
13
+
14
+ get(name) {
15
+ const key = name.toLowerCase().trim();
16
+ if (!this.symbols.has(key)) this.symbols.set(key, randomVector(this.d));
17
+ return this.symbols.get(key);
18
+ }
19
+
20
+ has(name) { return this.symbols.has(name.toLowerCase().trim()); }
21
+
22
+ get size() { return this.symbols.size; }
23
+
24
+ get bytes() { return this.symbols.size * this.d * 4; }
25
+
26
+ nearest(vec, candidates = null, topK = 1) {
27
+ const source = candidates || this.symbols;
28
+ const results = [];
29
+ for (const [name, svec] of source) {
30
+ results.push({ name, score: similarity(vec, svec) });
31
+ }
32
+ results.sort((a, b) => b.score - a.score);
33
+ return topK === 1 ? results[0] || null : results.slice(0, topK);
34
+ }
35
+
36
+ toJSON() {
37
+ const out = {};
38
+ for (const [k, v] of this.symbols) out[k] = Array.from(v);
39
+ return out;
40
+ }
41
+
42
+ static fromJSON(data, d) {
43
+ const st = new SymbolTable(d);
44
+ for (const [k, v] of Object.entries(data)) {
45
+ st.symbols.set(k, new Float32Array(v));
46
+ }
47
+ return st;
48
+ }
49
+ }
@@ -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
+ }