@fgv/ts-agent-memory 5.1.0-39 → 5.1.0-41
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/.rush/temp/{b82cf6bdece20481260e6bab946179eeec9d7b46.tar.log → cbbdbe09515171b4eba8f2592be2dace1e4e8142.tar.log} +14 -2
- package/.rush/temp/chunked-rush-logs/ts-agent-memory.build.chunks.jsonl +2 -2
- package/.rush/temp/operation/build/all.log +2 -2
- package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
- package/.rush/temp/operation/build/state.json +1 -1
- package/dist/packlets/converters/envelopeConverter.js +17 -3
- package/dist/packlets/converters/envelopeConverter.js.map +1 -1
- package/dist/packlets/index/memoryIndex.js +58 -10
- package/dist/packlets/index/memoryIndex.js.map +1 -1
- package/dist/packlets/ingest/cycleGuard.js +13 -6
- package/dist/packlets/ingest/cycleGuard.js.map +1 -1
- package/dist/packlets/ingest/hostStages.js.map +1 -1
- package/dist/packlets/ingest/model.js.map +1 -1
- package/dist/packlets/ingest/orchestrator.js +94 -49
- package/dist/packlets/ingest/orchestrator.js.map +1 -1
- package/dist/packlets/retrieve/hybridRetriever.js +13 -2
- package/dist/packlets/retrieve/hybridRetriever.js.map +1 -1
- package/dist/packlets/retrieve/linkTraversalRetriever.js +46 -57
- package/dist/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
- package/dist/packlets/retrieve/recencyRetriever.js +3 -3
- package/dist/packlets/retrieve/recencyRetriever.js.map +1 -1
- package/dist/packlets/retrieve/retriever.js +50 -7
- package/dist/packlets/retrieve/retriever.js.map +1 -1
- package/dist/packlets/retrieve/semanticRetriever.js +9 -3
- package/dist/packlets/retrieve/semanticRetriever.js.map +1 -1
- package/dist/packlets/retrieve/structuredFilterRetriever.js +3 -3
- package/dist/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
- package/dist/packlets/retrieve/tagRetriever.js +3 -3
- package/dist/packlets/retrieve/tagRetriever.js.map +1 -1
- package/dist/packlets/retrieve/temporalRetrievers.js +3 -3
- package/dist/packlets/retrieve/temporalRetrievers.js.map +1 -1
- package/dist/packlets/store/fileTreeMemoryStore.js +72 -16
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/tools/memoryTools.js +104 -21
- package/dist/packlets/tools/memoryTools.js.map +1 -1
- package/dist/packlets/types/envelope.js +13 -1
- package/dist/packlets/types/envelope.js.map +1 -1
- package/dist/packlets/vector/inMemoryCosineIndex.js +22 -17
- package/dist/packlets/vector/inMemoryCosineIndex.js.map +1 -1
- package/dist/packlets/vector/vectorIndex.js.map +1 -1
- package/dist/test/unit/converters/antagonistRoundTrip.test.js +3 -3
- package/dist/test/unit/converters/antagonistRoundTrip.test.js.map +1 -1
- package/dist/test/unit/converters/envelopeConverter.test.js +125 -8
- package/dist/test/unit/converters/envelopeConverter.test.js.map +1 -1
- package/dist/test/unit/index/memoryIndex.test.js +87 -25
- package/dist/test/unit/index/memoryIndex.test.js.map +1 -1
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js +20 -16
- package/dist/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -1
- package/dist/test/unit/ingest/cycleGuard.test.js +28 -1
- package/dist/test/unit/ingest/cycleGuard.test.js.map +1 -1
- package/dist/test/unit/ingest/orchestrator.test.js +187 -45
- package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/dist/test/unit/retrieve/linkTraversalRetriever.test.js +106 -31
- package/dist/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -1
- package/dist/test/unit/retrieve/retrievers.test.js +299 -37
- package/dist/test/unit/retrieve/retrievers.test.js.map +1 -1
- package/dist/test/unit/store/embedOnWrite.test.js +69 -12
- package/dist/test/unit/store/embedOnWrite.test.js.map +1 -1
- package/dist/test/unit/store/listScoped.test.js +109 -0
- package/dist/test/unit/store/listScoped.test.js.map +1 -0
- package/dist/test/unit/store/rankAxis.test.js +254 -0
- package/dist/test/unit/store/rankAxis.test.js.map +1 -0
- package/dist/test/unit/tools/memoryTools.test.js +280 -11
- package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
- package/dist/test/unit/types/writePolicy.test.js +9 -2
- package/dist/test/unit/types/writePolicy.test.js.map +1 -1
- package/dist/test/unit/vector/inMemoryCosineIndex.test.js +95 -35
- package/dist/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -1
- package/dist/test/unit/vector/vectorIndex.test.js +24 -15
- package/dist/test/unit/vector/vectorIndex.test.js.map +1 -1
- package/dist/ts-agent-memory.d.ts +384 -97
- package/etc/ts-agent-memory.api.md +64 -22
- package/lib/packlets/converters/envelopeConverter.d.ts +8 -1
- package/lib/packlets/converters/envelopeConverter.d.ts.map +1 -1
- package/lib/packlets/converters/envelopeConverter.js +18 -4
- package/lib/packlets/converters/envelopeConverter.js.map +1 -1
- package/lib/packlets/index/memoryIndex.d.ts +42 -10
- package/lib/packlets/index/memoryIndex.d.ts.map +1 -1
- package/lib/packlets/index/memoryIndex.js +58 -10
- package/lib/packlets/index/memoryIndex.js.map +1 -1
- package/lib/packlets/ingest/cycleGuard.d.ts +5 -5
- package/lib/packlets/ingest/cycleGuard.d.ts.map +1 -1
- package/lib/packlets/ingest/cycleGuard.js +13 -6
- package/lib/packlets/ingest/cycleGuard.js.map +1 -1
- package/lib/packlets/ingest/hostStages.d.ts +3 -3
- package/lib/packlets/ingest/hostStages.d.ts.map +1 -1
- package/lib/packlets/ingest/hostStages.js.map +1 -1
- package/lib/packlets/ingest/model.d.ts +25 -14
- package/lib/packlets/ingest/model.d.ts.map +1 -1
- package/lib/packlets/ingest/model.js.map +1 -1
- package/lib/packlets/ingest/orchestrator.d.ts +18 -4
- package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
- package/lib/packlets/ingest/orchestrator.js +93 -48
- package/lib/packlets/ingest/orchestrator.js.map +1 -1
- package/lib/packlets/retrieve/hybridRetriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/hybridRetriever.js +12 -1
- package/lib/packlets/retrieve/hybridRetriever.js.map +1 -1
- package/lib/packlets/retrieve/linkTraversalRetriever.d.ts +18 -23
- package/lib/packlets/retrieve/linkTraversalRetriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/linkTraversalRetriever.js +45 -56
- package/lib/packlets/retrieve/linkTraversalRetriever.js.map +1 -1
- package/lib/packlets/retrieve/recencyRetriever.js +2 -2
- package/lib/packlets/retrieve/recencyRetriever.js.map +1 -1
- package/lib/packlets/retrieve/retriever.d.ts +70 -11
- package/lib/packlets/retrieve/retriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/retriever.js +52 -7
- package/lib/packlets/retrieve/retriever.js.map +1 -1
- package/lib/packlets/retrieve/semanticRetriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/semanticRetriever.js +9 -3
- package/lib/packlets/retrieve/semanticRetriever.js.map +1 -1
- package/lib/packlets/retrieve/structuredFilterRetriever.js +2 -2
- package/lib/packlets/retrieve/structuredFilterRetriever.js.map +1 -1
- package/lib/packlets/retrieve/tagRetriever.js +2 -2
- package/lib/packlets/retrieve/tagRetriever.js.map +1 -1
- package/lib/packlets/retrieve/temporalRetrievers.js +3 -3
- package/lib/packlets/retrieve/temporalRetrievers.js.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +57 -3
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +72 -16
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/tools/memoryTools.d.ts +24 -0
- package/lib/packlets/tools/memoryTools.d.ts.map +1 -1
- package/lib/packlets/tools/memoryTools.js +104 -21
- package/lib/packlets/tools/memoryTools.js.map +1 -1
- package/lib/packlets/types/envelope.d.ts +61 -8
- package/lib/packlets/types/envelope.d.ts.map +1 -1
- package/lib/packlets/types/envelope.js +14 -0
- package/lib/packlets/types/envelope.js.map +1 -1
- package/lib/packlets/vector/inMemoryCosineIndex.d.ts +9 -5
- package/lib/packlets/vector/inMemoryCosineIndex.d.ts.map +1 -1
- package/lib/packlets/vector/inMemoryCosineIndex.js +22 -17
- package/lib/packlets/vector/inMemoryCosineIndex.js.map +1 -1
- package/lib/packlets/vector/vectorIndex.d.ts +46 -20
- package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
- package/lib/packlets/vector/vectorIndex.js.map +1 -1
- package/lib/test/unit/converters/antagonistRoundTrip.test.js +3 -3
- package/lib/test/unit/converters/antagonistRoundTrip.test.js.map +1 -1
- package/lib/test/unit/converters/envelopeConverter.test.js +124 -7
- package/lib/test/unit/converters/envelopeConverter.test.js.map +1 -1
- package/lib/test/unit/index/memoryIndex.test.js +86 -24
- package/lib/test/unit/index/memoryIndex.test.js.map +1 -1
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js +20 -16
- package/lib/test/unit/ingest/antagonistCycleAndParity.test.js.map +1 -1
- package/lib/test/unit/ingest/cycleGuard.test.js +28 -1
- package/lib/test/unit/ingest/cycleGuard.test.js.map +1 -1
- package/lib/test/unit/ingest/orchestrator.test.js +186 -44
- package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.js +106 -31
- package/lib/test/unit/retrieve/linkTraversalRetriever.test.js.map +1 -1
- package/lib/test/unit/retrieve/retrievers.test.js +298 -36
- package/lib/test/unit/retrieve/retrievers.test.js.map +1 -1
- package/lib/test/unit/store/embedOnWrite.test.js +68 -11
- package/lib/test/unit/store/embedOnWrite.test.js.map +1 -1
- package/lib/test/unit/store/listScoped.test.d.ts +2 -0
- package/lib/test/unit/store/listScoped.test.d.ts.map +1 -0
- package/lib/test/unit/store/listScoped.test.js +111 -0
- package/lib/test/unit/store/listScoped.test.js.map +1 -0
- package/lib/test/unit/store/rankAxis.test.d.ts +2 -0
- package/lib/test/unit/store/rankAxis.test.d.ts.map +1 -0
- package/lib/test/unit/store/rankAxis.test.js +256 -0
- package/lib/test/unit/store/rankAxis.test.js.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.js +280 -11
- package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
- package/lib/test/unit/types/writePolicy.test.js +9 -2
- package/lib/test/unit/types/writePolicy.test.js.map +1 -1
- package/lib/test/unit/vector/inMemoryCosineIndex.test.js +95 -35
- package/lib/test/unit/vector/inMemoryCosineIndex.test.js.map +1 -1
- package/lib/test/unit/vector/vectorIndex.test.js +24 -15
- package/lib/test/unit/vector/vectorIndex.test.js.map +1 -1
- package/package.json +7 -7
- package/rush-logs/ts-agent-memory.build.cache.log +1 -1
- package/rush-logs/ts-agent-memory.build.log +2 -2
- package/src/packlets/converters/envelopeConverter.ts +27 -4
- package/src/packlets/index/memoryIndex.ts +86 -22
- package/src/packlets/ingest/cycleGuard.ts +22 -11
- package/src/packlets/ingest/hostStages.ts +3 -3
- package/src/packlets/ingest/model.ts +25 -14
- package/src/packlets/ingest/orchestrator.ts +143 -67
- package/src/packlets/retrieve/hybridRetriever.ts +14 -1
- package/src/packlets/retrieve/linkTraversalRetriever.ts +51 -62
- package/src/packlets/retrieve/recencyRetriever.ts +3 -3
- package/src/packlets/retrieve/retriever.ts +97 -13
- package/src/packlets/retrieve/semanticRetriever.ts +10 -5
- package/src/packlets/retrieve/structuredFilterRetriever.ts +3 -3
- package/src/packlets/retrieve/tagRetriever.ts +3 -3
- package/src/packlets/retrieve/temporalRetrievers.ts +3 -3
- package/src/packlets/store/fileTreeMemoryStore.ts +117 -12
- package/src/packlets/tools/memoryTools.ts +152 -25
- package/src/packlets/types/envelope.ts +66 -8
- package/src/packlets/vector/inMemoryCosineIndex.ts +45 -22
- package/src/packlets/vector/vectorIndex.ts +47 -20
- package/src/test/unit/converters/antagonistRoundTrip.test.ts +3 -3
- package/src/test/unit/converters/envelopeConverter.test.ts +168 -11
- package/src/test/unit/index/memoryIndex.test.ts +99 -14
- package/src/test/unit/ingest/antagonistCycleAndParity.test.ts +23 -18
- package/src/test/unit/ingest/cycleGuard.test.ts +44 -2
- package/src/test/unit/ingest/orchestrator.test.ts +234 -41
- package/src/test/unit/retrieve/linkTraversalRetriever.test.ts +134 -35
- package/src/test/unit/retrieve/retrievers.test.ts +381 -25
- package/src/test/unit/store/embedOnWrite.test.ts +83 -11
- package/src/test/unit/store/listScoped.test.ts +138 -0
- package/src/test/unit/store/rankAxis.test.ts +349 -0
- package/src/test/unit/tools/memoryTools.test.ts +362 -13
- package/src/test/unit/types/writePolicy.test.ts +11 -2
- package/src/test/unit/vector/inMemoryCosineIndex.test.ts +115 -39
- package/src/test/unit/vector/vectorIndex.test.ts +33 -17
- package/temp/build/lint/_eslint-5eVG3S6w.json +41 -33
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +1080 -136
- package/temp/ts-agent-memory.api.md +64 -22
|
@@ -7,6 +7,7 @@ import '@fgv/ts-utils-jest';
|
|
|
7
7
|
import { Result, fail, succeed } from '@fgv/ts-utils';
|
|
8
8
|
import {
|
|
9
9
|
HybridRetriever,
|
|
10
|
+
IEdgeTarget,
|
|
10
11
|
IIndexedMemoryRecord,
|
|
11
12
|
IMemoryQuery,
|
|
12
13
|
IMemoryRecord,
|
|
@@ -26,9 +27,17 @@ import {
|
|
|
26
27
|
TagRetriever,
|
|
27
28
|
envelopeConverter,
|
|
28
29
|
guardRetrieverCapabilities,
|
|
29
|
-
limitRecords
|
|
30
|
+
limitRecords,
|
|
31
|
+
orderingCompare,
|
|
32
|
+
rankCompare,
|
|
33
|
+
recencyCompare
|
|
30
34
|
} from '../../../index';
|
|
31
35
|
|
|
36
|
+
/** A scope-qualified link-traversal seed for the retriever tests. */
|
|
37
|
+
function et(id: string, scope: string = 'knowledge'): IEdgeTarget {
|
|
38
|
+
return { scope: scope as MemoryScopeKey, id: id as MemoryId };
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
interface IRecordSpec {
|
|
33
42
|
readonly id: string;
|
|
34
43
|
readonly scope?: string;
|
|
@@ -36,6 +45,7 @@ interface IRecordSpec {
|
|
|
36
45
|
readonly tags?: ReadonlyArray<string>;
|
|
37
46
|
readonly updated?: number;
|
|
38
47
|
readonly seq?: number;
|
|
48
|
+
readonly rank?: number;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
function makeEntry(spec: IRecordSpec): IIndexedMemoryRecord {
|
|
@@ -51,6 +61,7 @@ function makeEntry(spec: IRecordSpec): IIndexedMemoryRecord {
|
|
|
51
61
|
updated: spec.updated ?? 0,
|
|
52
62
|
seq: spec.seq ?? 0,
|
|
53
63
|
contentHash: 'h',
|
|
64
|
+
...(spec.rank !== undefined ? { rank: spec.rank } : {}),
|
|
54
65
|
provenance: { source: 'agent' }
|
|
55
66
|
})
|
|
56
67
|
.orThrow(),
|
|
@@ -102,10 +113,10 @@ describe('retrieve helpers', () => {
|
|
|
102
113
|
});
|
|
103
114
|
|
|
104
115
|
test('fails loudly when a link-traversal axis is requested but unsupported', () => {
|
|
105
|
-
expect(guardRetrieverCapabilities({ linkedTo: 'x'
|
|
116
|
+
expect(guardRetrieverCapabilities({ linkedTo: et('x') }, noCaps)).toFailWith(
|
|
106
117
|
/link traversal requires a backlink index; none configured/i
|
|
107
118
|
);
|
|
108
|
-
expect(guardRetrieverCapabilities({ linkedFrom: 'x'
|
|
119
|
+
expect(guardRetrieverCapabilities({ linkedFrom: et('x') }, noCaps)).toFailWith(
|
|
109
120
|
/link traversal requires/i
|
|
110
121
|
);
|
|
111
122
|
expect(guardRetrieverCapabilities({ hops: 2 }, noCaps)).toFailWith(/link traversal requires/i);
|
|
@@ -113,7 +124,7 @@ describe('retrieve helpers', () => {
|
|
|
113
124
|
|
|
114
125
|
test('passes a link-traversal axis when the capability is supported', () => {
|
|
115
126
|
expect(
|
|
116
|
-
guardRetrieverCapabilities({ linkedTo: 'x'
|
|
127
|
+
guardRetrieverCapabilities({ linkedTo: et('x') }, { ...noCaps, supportsLinkTraversal: true })
|
|
117
128
|
).toSucceed();
|
|
118
129
|
});
|
|
119
130
|
});
|
|
@@ -135,6 +146,29 @@ describe('retrieve helpers', () => {
|
|
|
135
146
|
expect(limitRecords(records, 0)).toEqual([]);
|
|
136
147
|
expect(limitRecords(records, -1)).toEqual([]);
|
|
137
148
|
});
|
|
149
|
+
|
|
150
|
+
test('an absent offset is a no-op (byte-identical to today) — same reference returned', () => {
|
|
151
|
+
expect(limitRecords(records, undefined, undefined)).toBe(records);
|
|
152
|
+
expect(limitRecords(records, 2, undefined)).toHaveLength(2);
|
|
153
|
+
// A non-positive offset is guarded like a non-positive limit — no skip.
|
|
154
|
+
expect(limitRecords(records, undefined, 0)).toBe(records);
|
|
155
|
+
expect(ids(limitRecords(records, undefined, -5))).toEqual(['a', 'b', 'c']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('offset skips after ordering; { offset, limit } is a stable page window', () => {
|
|
159
|
+
expect(ids(limitRecords(records, undefined, 1))).toEqual(['b', 'c']);
|
|
160
|
+
expect(ids(limitRecords(records, 1, 1))).toEqual(['b']);
|
|
161
|
+
expect(ids(limitRecords(records, 2, 1))).toEqual(['b', 'c']);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('an offset past the end yields an empty page (never a throw)', () => {
|
|
165
|
+
expect(limitRecords(records, undefined, 3)).toEqual([]);
|
|
166
|
+
expect(limitRecords(records, 5, 99)).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('a non-positive limit still wins over an offset window', () => {
|
|
170
|
+
expect(limitRecords(records, 0, 1)).toEqual([]);
|
|
171
|
+
});
|
|
138
172
|
});
|
|
139
173
|
});
|
|
140
174
|
|
|
@@ -217,7 +251,266 @@ describe('RecencyRetriever', () => {
|
|
|
217
251
|
|
|
218
252
|
test('degrades loudly on a link-traversal request', async () => {
|
|
219
253
|
const r = RecencyRetriever.create(buildIndex([{ id: 'a' }])).orThrow();
|
|
220
|
-
expect(await r.retrieve({ linkedTo: 'a'
|
|
254
|
+
expect(await r.retrieve({ linkedTo: et('a') })).toFailWith(/link traversal requires/i);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('orderBy: rank axis', () => {
|
|
259
|
+
const a1 = makeEntry({ id: 'a', rank: 1, updated: 100, seq: 1 }).record;
|
|
260
|
+
const b3 = makeEntry({ id: 'b', rank: 3, updated: 100, seq: 2 }).record;
|
|
261
|
+
const c2 = makeEntry({ id: 'c', rank: 2, updated: 100, seq: 3 }).record;
|
|
262
|
+
const unranked = makeEntry({ id: 'z', updated: 999, seq: 4 }).record;
|
|
263
|
+
|
|
264
|
+
describe('rankCompare', () => {
|
|
265
|
+
test('orders by rank descending', () => {
|
|
266
|
+
expect(
|
|
267
|
+
[a1, b3, c2]
|
|
268
|
+
.slice()
|
|
269
|
+
.sort(rankCompare)
|
|
270
|
+
.map((r) => r.envelope.id)
|
|
271
|
+
).toEqual(['b', 'c', 'a']);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('places an absent-rank record last regardless of recency', () => {
|
|
275
|
+
// `unranked` has the newest `updated`, but an absent rank sorts after every ranked record.
|
|
276
|
+
expect(
|
|
277
|
+
[unranked, a1, b3]
|
|
278
|
+
.slice()
|
|
279
|
+
.sort(rankCompare)
|
|
280
|
+
.map((r) => r.envelope.id)
|
|
281
|
+
).toEqual(['b', 'a', 'z']);
|
|
282
|
+
// Symmetric: absent on the left side of the compare too.
|
|
283
|
+
expect(
|
|
284
|
+
[a1, unranked]
|
|
285
|
+
.slice()
|
|
286
|
+
.sort(rankCompare)
|
|
287
|
+
.map((r) => r.envelope.id)
|
|
288
|
+
).toEqual(['a', 'z']);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('breaks an equal-rank tie by recency', () => {
|
|
292
|
+
const b3b = makeEntry({ id: 'b2', rank: 3, updated: 50, seq: 9 }).record;
|
|
293
|
+
// Equal rank 3 → newer `updated` (b3 at 100) first.
|
|
294
|
+
expect(
|
|
295
|
+
[b3b, b3]
|
|
296
|
+
.slice()
|
|
297
|
+
.sort(rankCompare)
|
|
298
|
+
.map((r) => r.envelope.id)
|
|
299
|
+
).toEqual(['b', 'b2']);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('orderingCompare', () => {
|
|
304
|
+
test("returns recencyCompare for absent orderBy and 'recency'", () => {
|
|
305
|
+
expect(orderingCompare()).toBe(recencyCompare);
|
|
306
|
+
expect(orderingCompare('recency')).toBe(recencyCompare);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("returns rankCompare for 'rank'", () => {
|
|
310
|
+
expect(orderingCompare('rank')).toBe(rankCompare);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe.each([
|
|
315
|
+
['RecencyRetriever', (index: MemoryIndex): IMemoryRetriever => RecencyRetriever.create(index).orThrow()],
|
|
316
|
+
['TagRetriever', (index: MemoryIndex): IMemoryRetriever => TagRetriever.create(index).orThrow()],
|
|
317
|
+
[
|
|
318
|
+
'StructuredFilterRetriever',
|
|
319
|
+
(index: MemoryIndex): IMemoryRetriever => StructuredFilterRetriever.create(index).orThrow()
|
|
320
|
+
]
|
|
321
|
+
])('%s honors orderBy', (name, make) => {
|
|
322
|
+
// Tag / structured-filter need their axis present to return anything.
|
|
323
|
+
const axis: IMemoryQuery =
|
|
324
|
+
name === 'TagRetriever'
|
|
325
|
+
? { tag: 'x' as Tag }
|
|
326
|
+
: name === 'StructuredFilterRetriever'
|
|
327
|
+
? { filter: () => true }
|
|
328
|
+
: {};
|
|
329
|
+
|
|
330
|
+
function orderedIndex(): MemoryIndex {
|
|
331
|
+
return buildIndex([
|
|
332
|
+
{ id: 'a', rank: 1, tags: ['x'], updated: 100, seq: 1 },
|
|
333
|
+
{ id: 'b', rank: 3, tags: ['x'], updated: 100, seq: 2 },
|
|
334
|
+
{ id: 'c', rank: 2, tags: ['x'], updated: 100, seq: 3 },
|
|
335
|
+
{ id: 'z', tags: ['x'], updated: 999, seq: 4 }
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
test("orderBy 'rank' orders by rank descending, absent last", async () => {
|
|
340
|
+
const r = make(orderedIndex());
|
|
341
|
+
expect(await r.retrieve({ ...axis, orderBy: 'rank' })).toSucceedAndSatisfy(
|
|
342
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
343
|
+
expect(ids(records)).toEqual(['b', 'c', 'a', 'z']);
|
|
344
|
+
}
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('orderBy absent is unchanged recency order', async () => {
|
|
349
|
+
const r = make(orderedIndex());
|
|
350
|
+
expect(await r.retrieve({ ...axis })).toSucceedAndSatisfy(
|
|
351
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
352
|
+
// `z` has the newest `updated`; the rest tie on `updated` and break by
|
|
353
|
+
// seq descending (c=3, b=2, a=1) — recency order regardless of rank.
|
|
354
|
+
expect(ids(records)).toEqual(['z', 'c', 'b', 'a']);
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('rank ordering composes with { limit, offset } for a bounded top-M page', async () => {
|
|
360
|
+
const r = make(orderedIndex());
|
|
361
|
+
expect(await r.retrieve({ ...axis, orderBy: 'rank', limit: 1, offset: 1 })).toSucceedAndSatisfy(
|
|
362
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
363
|
+
expect(ids(records)).toEqual(['c']);
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("HybridRetriever re-orders the merged set by rank for orderBy 'rank'", async () => {
|
|
370
|
+
const index = buildIndex([
|
|
371
|
+
{ id: 'a', rank: 1, tags: ['x'], updated: 100, seq: 1 },
|
|
372
|
+
{ id: 'b', rank: 3, tags: ['x'], updated: 100, seq: 2 },
|
|
373
|
+
{ id: 'c', rank: 2, tags: ['x'], updated: 100, seq: 3 }
|
|
374
|
+
]);
|
|
375
|
+
const hybrid = HybridRetriever.create(
|
|
376
|
+
[RecencyRetriever.create(index).orThrow(), TagRetriever.create(index).orThrow()],
|
|
377
|
+
ScoreUnionMergeStrategy.create().orThrow()
|
|
378
|
+
).orThrow();
|
|
379
|
+
expect(await hybrid.retrieve({ tag: 'x' as Tag, orderBy: 'rank' })).toSucceedAndSatisfy(
|
|
380
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
381
|
+
// All three score 2 (surfaced by both children); orderBy rank re-sorts the merge.
|
|
382
|
+
expect(ids(records)).toEqual(['b', 'c', 'a']);
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("SemanticRetriever preserves vector-similarity order regardless of orderBy 'rank'", async () => {
|
|
388
|
+
const index = buildIndex([
|
|
389
|
+
{ id: 'a', rank: 9, updated: 1 },
|
|
390
|
+
{ id: 'b', rank: 1, updated: 1 }
|
|
391
|
+
]);
|
|
392
|
+
const vectorIndex: IVectorIndex = {
|
|
393
|
+
add: (t: IEdgeTarget) => Promise.resolve(succeed(`ref-${t.id}`)),
|
|
394
|
+
remove: (t: IEdgeTarget) => Promise.resolve(succeed(t)),
|
|
395
|
+
query: () =>
|
|
396
|
+
Promise.resolve(
|
|
397
|
+
succeed([
|
|
398
|
+
{ target: et('b'), score: 0.9 },
|
|
399
|
+
{ target: et('a'), score: 0.5 }
|
|
400
|
+
])
|
|
401
|
+
)
|
|
402
|
+
};
|
|
403
|
+
const r = SemanticRetriever.create({
|
|
404
|
+
index,
|
|
405
|
+
backend: {
|
|
406
|
+
vectorIndex,
|
|
407
|
+
embedQuery: () => Promise.resolve(succeed(Float32Array.from([0.1])))
|
|
408
|
+
}
|
|
409
|
+
}).orThrow();
|
|
410
|
+
// Even though `a` has the higher rank, semantic keeps the similarity order (b before a).
|
|
411
|
+
expect(await r.retrieve({ semantic: 'q', orderBy: 'rank' })).toSucceedAndSatisfy(
|
|
412
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
413
|
+
expect(ids(records)).toEqual(['b', 'a']);
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('query axes: kinds (kind-set) and offset', () => {
|
|
420
|
+
function multiKindRetriever(): RecencyRetriever {
|
|
421
|
+
return RecencyRetriever.create(
|
|
422
|
+
buildIndex([
|
|
423
|
+
{ id: 'k1', kind: 'knowledge', updated: 40 },
|
|
424
|
+
{ id: 'n1', kind: 'note', updated: 30 },
|
|
425
|
+
{ id: 'e1', kind: 'event', updated: 20 },
|
|
426
|
+
{ id: 'k2', kind: 'knowledge', updated: 10 }
|
|
427
|
+
])
|
|
428
|
+
).orThrow();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
describe('kinds', () => {
|
|
432
|
+
test("an absent kinds axis imposes no kind constraint (today's behavior)", async () => {
|
|
433
|
+
expect(await multiKindRetriever().retrieve({})).toSucceedAndSatisfy(
|
|
434
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
435
|
+
expect([...ids(records)].sort()).toEqual(['e1', 'k1', 'k2', 'n1']);
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('restricts to records in ANY of the listed kinds', async () => {
|
|
441
|
+
expect(
|
|
442
|
+
await multiKindRetriever().retrieve({
|
|
443
|
+
kinds: ['knowledge', 'event'] as unknown as ReadonlyArray<Kind>
|
|
444
|
+
})
|
|
445
|
+
).toSucceedAndSatisfy((records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
446
|
+
expect([...ids(records)].sort()).toEqual(['e1', 'k1', 'k2']);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('an explicit empty kinds array matches NOTHING (not match-all)', async () => {
|
|
451
|
+
expect(await multiKindRetriever().retrieve({ kinds: [] })).toSucceedWith([]);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('kind and kinds compose as AND (kind must be a member of kinds)', async () => {
|
|
455
|
+
// kind=knowledge AND kinds includes knowledge → knowledge records only.
|
|
456
|
+
expect(
|
|
457
|
+
await multiKindRetriever().retrieve({
|
|
458
|
+
kind: knowledge,
|
|
459
|
+
kinds: ['knowledge', 'note'] as unknown as ReadonlyArray<Kind>
|
|
460
|
+
})
|
|
461
|
+
).toSucceedAndSatisfy((records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
462
|
+
expect([...ids(records)].sort()).toEqual(['k1', 'k2']);
|
|
463
|
+
});
|
|
464
|
+
// kind=knowledge but kinds excludes knowledge → nothing can satisfy both.
|
|
465
|
+
expect(
|
|
466
|
+
await multiKindRetriever().retrieve({
|
|
467
|
+
kind: knowledge,
|
|
468
|
+
kinds: ['note', 'event'] as unknown as ReadonlyArray<Kind>
|
|
469
|
+
})
|
|
470
|
+
).toSucceedWith([]);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('offset', () => {
|
|
475
|
+
function ordered(): RecencyRetriever {
|
|
476
|
+
return RecencyRetriever.create(
|
|
477
|
+
buildIndex([
|
|
478
|
+
{ id: 'a', updated: 10 },
|
|
479
|
+
{ id: 'b', updated: 40 },
|
|
480
|
+
{ id: 'c', updated: 30 },
|
|
481
|
+
{ id: 'd', updated: 20 }
|
|
482
|
+
])
|
|
483
|
+
).orThrow();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
test('skips after ordering, forming a stable { offset, limit } page window', async () => {
|
|
487
|
+
// Recency order is [b, c, d, a].
|
|
488
|
+
expect(await ordered().retrieve({ offset: 1, limit: 2 })).toSucceedAndSatisfy(
|
|
489
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
490
|
+
expect(ids(records)).toEqual(['c', 'd']);
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('an absent offset is unchanged from today', async () => {
|
|
496
|
+
expect(await ordered().retrieve({ limit: 2 })).toSucceedAndSatisfy(
|
|
497
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
498
|
+
expect(ids(records)).toEqual(['b', 'c']);
|
|
499
|
+
}
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('an offset past the end yields an empty page', async () => {
|
|
504
|
+
expect(await ordered().retrieve({ offset: 99 })).toSucceedWith([]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test('a negative offset does not slip into slice (treated as no skip)', async () => {
|
|
508
|
+
expect(await ordered().retrieve({ offset: -3, limit: 2 })).toSucceedAndSatisfy(
|
|
509
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
510
|
+
expect(ids(records)).toEqual(['b', 'c']);
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
});
|
|
221
514
|
});
|
|
222
515
|
});
|
|
223
516
|
|
|
@@ -293,11 +586,11 @@ class FakeVectorIndex implements IVectorIndex {
|
|
|
293
586
|
this._hits = hits;
|
|
294
587
|
this._failQuery = failQuery;
|
|
295
588
|
}
|
|
296
|
-
public add(
|
|
297
|
-
return Promise.resolve(succeed(`ref-${id}`));
|
|
589
|
+
public add(t: IEdgeTarget): Promise<Result<string>> {
|
|
590
|
+
return Promise.resolve(succeed(`ref-${t.id}`));
|
|
298
591
|
}
|
|
299
|
-
public remove(
|
|
300
|
-
return Promise.resolve(succeed(
|
|
592
|
+
public remove(t: IEdgeTarget): Promise<Result<IEdgeTarget>> {
|
|
593
|
+
return Promise.resolve(succeed(t));
|
|
301
594
|
}
|
|
302
595
|
public query(__vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
|
|
303
596
|
this.lastTopK = topK;
|
|
@@ -349,8 +642,8 @@ describe('SemanticRetriever', () => {
|
|
|
349
642
|
index,
|
|
350
643
|
backend: {
|
|
351
644
|
vectorIndex: new FakeVectorIndex([
|
|
352
|
-
{
|
|
353
|
-
{
|
|
645
|
+
{ target: et('b'), score: 0.9 },
|
|
646
|
+
{ target: et('a'), score: 0.5 }
|
|
354
647
|
]),
|
|
355
648
|
embedQuery: okEmbed
|
|
356
649
|
}
|
|
@@ -372,9 +665,9 @@ describe('SemanticRetriever', () => {
|
|
|
372
665
|
index,
|
|
373
666
|
backend: {
|
|
374
667
|
vectorIndex: new FakeVectorIndex([
|
|
375
|
-
{
|
|
376
|
-
{
|
|
377
|
-
{
|
|
668
|
+
{ target: et('gone'), score: 0.99 },
|
|
669
|
+
{ target: et('b'), score: 0.8 },
|
|
670
|
+
{ target: et('a'), score: 0.7 }
|
|
378
671
|
]),
|
|
379
672
|
embedQuery: okEmbed
|
|
380
673
|
}
|
|
@@ -386,12 +679,40 @@ describe('SemanticRetriever', () => {
|
|
|
386
679
|
);
|
|
387
680
|
});
|
|
388
681
|
|
|
682
|
+
test('resolves a hit to the correctly-scoped record when two records share an id stem across scopes', async () => {
|
|
683
|
+
// Two records with the identical stem `turn-3` under different scopes — the
|
|
684
|
+
// exact same-stem-across-scopes collision the scope-qualified hit fixes. The
|
|
685
|
+
// retriever must re-resolve each hit through its full `(scope, id)` key, not a
|
|
686
|
+
// bare id (which would ambiguously match either record).
|
|
687
|
+
const index = buildIndex([
|
|
688
|
+
{ id: 'turn-3', scope: 'conv-a', tags: ['from-a'] },
|
|
689
|
+
{ id: 'turn-3', scope: 'conv-b', tags: ['from-b'] }
|
|
690
|
+
]);
|
|
691
|
+
const r = SemanticRetriever.create({
|
|
692
|
+
index,
|
|
693
|
+
backend: {
|
|
694
|
+
// The vector backend scored the conv-b record; the retriever must return
|
|
695
|
+
// exactly that record, never the same-stem conv-a record.
|
|
696
|
+
vectorIndex: new FakeVectorIndex([{ target: et('turn-3', 'conv-b'), score: 0.9 }]),
|
|
697
|
+
embedQuery: okEmbed
|
|
698
|
+
}
|
|
699
|
+
}).orThrow();
|
|
700
|
+
expect(await r.retrieve({ semantic: 'q' })).toSucceedAndSatisfy(
|
|
701
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
702
|
+
expect(records).toHaveLength(1);
|
|
703
|
+
expect(records[0].envelope.id).toBe('turn-3');
|
|
704
|
+
// The tag proves it is the conv-b record, not the same-stem conv-a one.
|
|
705
|
+
expect(records[0].envelope.tags).toEqual(['from-b']);
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
});
|
|
709
|
+
|
|
389
710
|
test('forwards topK to the vector index and applies the post-filter limit', async () => {
|
|
390
711
|
const index = buildIndex([{ id: 'a' }, { id: 'b' }, { id: 'c' }]);
|
|
391
712
|
const vectorIndex = new FakeVectorIndex([
|
|
392
|
-
{
|
|
393
|
-
{
|
|
394
|
-
{
|
|
713
|
+
{ target: et('a'), score: 0.9 },
|
|
714
|
+
{ target: et('b'), score: 0.8 },
|
|
715
|
+
{ target: et('c'), score: 0.7 }
|
|
395
716
|
]);
|
|
396
717
|
const r = SemanticRetriever.create({ index, backend: { vectorIndex, embedQuery: okEmbed } }).orThrow();
|
|
397
718
|
expect(await r.retrieve({ semantic: 'q', topK: 2, limit: 2 })).toSucceedAndSatisfy(
|
|
@@ -404,7 +725,7 @@ describe('SemanticRetriever', () => {
|
|
|
404
725
|
});
|
|
405
726
|
|
|
406
727
|
test('defaults topK to 10 when the query omits it', async () => {
|
|
407
|
-
const vectorIndex = new FakeVectorIndex([{
|
|
728
|
+
const vectorIndex = new FakeVectorIndex([{ target: et('a'), score: 0.9 }]);
|
|
408
729
|
const r = SemanticRetriever.create({
|
|
409
730
|
index: buildIndex([{ id: 'a' }]),
|
|
410
731
|
backend: { vectorIndex, embedQuery: okEmbed }
|
|
@@ -446,8 +767,8 @@ describe('SemanticRetriever', () => {
|
|
|
446
767
|
test('normalizes a rejecting vector backend into a Failure', async () => {
|
|
447
768
|
// A vector index whose `query` rejects (throws) rather than returning a fail.
|
|
448
769
|
const rejectingIndex: IVectorIndex = {
|
|
449
|
-
add: (
|
|
450
|
-
remove: (
|
|
770
|
+
add: (t: IEdgeTarget) => Promise.resolve(succeed(`ref-${t.id}`)),
|
|
771
|
+
remove: (t: IEdgeTarget) => Promise.resolve(succeed(t)),
|
|
451
772
|
query: () => Promise.reject(new Error('socket hangup'))
|
|
452
773
|
};
|
|
453
774
|
const r = SemanticRetriever.create({
|
|
@@ -551,7 +872,7 @@ describe('HybridRetriever', () => {
|
|
|
551
872
|
const semantic = SemanticRetriever.create({
|
|
552
873
|
index,
|
|
553
874
|
backend: {
|
|
554
|
-
vectorIndex: new FakeVectorIndex([{
|
|
875
|
+
vectorIndex: new FakeVectorIndex([{ target: et('b'), score: 0.9 }]),
|
|
555
876
|
embedQuery: () => Promise.resolve(succeed(Float32Array.from([1])))
|
|
556
877
|
}
|
|
557
878
|
}).orThrow();
|
|
@@ -586,9 +907,9 @@ describe('HybridRetriever', () => {
|
|
|
586
907
|
).orThrow();
|
|
587
908
|
expect(hybrid.capabilities.supportsLinkTraversal).toBe(true);
|
|
588
909
|
// Union supports link traversal, so the hybrid does NOT loud-fail.
|
|
589
|
-
expect(await hybrid.retrieve({ linkedTo: 'a'
|
|
910
|
+
expect(await hybrid.retrieve({ linkedTo: et('a'), hops: 2 })).toSucceed();
|
|
590
911
|
// The link-capable child keeps the axes; the plain child has them stripped.
|
|
591
|
-
expect(linkChild.lastQuery?.linkedTo).
|
|
912
|
+
expect(linkChild.lastQuery?.linkedTo).toEqual(et('a'));
|
|
592
913
|
expect(linkChild.lastQuery?.hops).toBe(2);
|
|
593
914
|
expect(plainChild.lastQuery?.linkedTo).toBeUndefined();
|
|
594
915
|
expect(plainChild.lastQuery?.hops).toBeUndefined();
|
|
@@ -631,8 +952,8 @@ describe('HybridRetriever', () => {
|
|
|
631
952
|
index,
|
|
632
953
|
backend: {
|
|
633
954
|
vectorIndex: new FakeVectorIndex([
|
|
634
|
-
{
|
|
635
|
-
{
|
|
955
|
+
{ target: et('d'), score: 0.9 },
|
|
956
|
+
{ target: et('a'), score: 0.8 }
|
|
636
957
|
]),
|
|
637
958
|
embedQuery: () => Promise.resolve(succeed(Float32Array.from([1])))
|
|
638
959
|
}
|
|
@@ -659,4 +980,39 @@ describe('HybridRetriever', () => {
|
|
|
659
980
|
}
|
|
660
981
|
);
|
|
661
982
|
});
|
|
983
|
+
|
|
984
|
+
test('applies the { offset, limit } window after merge', async () => {
|
|
985
|
+
const { index } = build();
|
|
986
|
+
const recency = RecencyRetriever.create(index).orThrow();
|
|
987
|
+
const hybrid = HybridRetriever.create([recency], ScoreUnionMergeStrategy.create().orThrow()).orThrow();
|
|
988
|
+
// Merged recency order is [b(30), c(20), a(10)]; offset 1 + limit 1 → [c].
|
|
989
|
+
expect(await hybrid.retrieve({ offset: 1, limit: 1 })).toSucceedAndSatisfy(
|
|
990
|
+
(records: ReadonlyArray<IMemoryRecord<unknown>>) => {
|
|
991
|
+
expect(ids(records)).toEqual(['c']);
|
|
992
|
+
}
|
|
993
|
+
);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test('strips offset from child queries (offset is a post-merge concern)', async () => {
|
|
997
|
+
const child = new RecordingRetriever({
|
|
998
|
+
supportsSemanticRecall: false,
|
|
999
|
+
supportsTemporalQuery: false,
|
|
1000
|
+
supportsLinkTraversal: false
|
|
1001
|
+
});
|
|
1002
|
+
const hybrid = HybridRetriever.create([child], ScoreUnionMergeStrategy.create().orThrow()).orThrow();
|
|
1003
|
+
expect(await hybrid.retrieve({ offset: 2, limit: 3 })).toSucceed();
|
|
1004
|
+
expect(child.lastQuery?.offset).toBeUndefined();
|
|
1005
|
+
expect(child.lastQuery?.limit).toBeUndefined();
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test('passes the kinds axis through to children (a shared pre-filter axis)', async () => {
|
|
1009
|
+
const child = new RecordingRetriever({
|
|
1010
|
+
supportsSemanticRecall: false,
|
|
1011
|
+
supportsTemporalQuery: false,
|
|
1012
|
+
supportsLinkTraversal: false
|
|
1013
|
+
});
|
|
1014
|
+
const hybrid = HybridRetriever.create([child], ScoreUnionMergeStrategy.create().orThrow()).orThrow();
|
|
1015
|
+
expect(await hybrid.retrieve({ kinds: ['knowledge'] as unknown as ReadonlyArray<Kind> })).toSucceed();
|
|
1016
|
+
expect(child.lastQuery?.kinds).toEqual(['knowledge']);
|
|
1017
|
+
});
|
|
662
1018
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
BodyConverterRegistry,
|
|
11
11
|
FileTreeMemoryStore,
|
|
12
12
|
IBodyConverterRegistry,
|
|
13
|
+
IEdgeTarget,
|
|
13
14
|
IIdentityCodec,
|
|
14
15
|
IIndexedMemoryRecord,
|
|
15
16
|
IMemoryRecord,
|
|
@@ -26,11 +27,14 @@ import {
|
|
|
26
27
|
MtmIdentityCodec,
|
|
27
28
|
IWritePolicy,
|
|
28
29
|
SemanticRetriever,
|
|
30
|
+
TemporalIdentityCodec,
|
|
31
|
+
TemporalVersionedPolicy,
|
|
29
32
|
envelopeConverter
|
|
30
33
|
} from '../../../index';
|
|
31
34
|
|
|
32
35
|
const knowledgeKind: Kind = 'knowledge' as Kind;
|
|
33
36
|
const mtmKind: Kind = 'mtm' as Kind;
|
|
37
|
+
const factKind: Kind = 'fact' as Kind;
|
|
34
38
|
|
|
35
39
|
function mutableRoot(): FileTree.IMutableFileTreeDirectoryItem {
|
|
36
40
|
const tree = FileTree.inMemory([], { mutable: true }).orThrow();
|
|
@@ -95,13 +99,13 @@ class SpyVectorIndex implements IVectorIndex {
|
|
|
95
99
|
public failRemove: boolean = false;
|
|
96
100
|
private readonly _inner: InMemoryCosineIndex = InMemoryCosineIndex.create().orThrow();
|
|
97
101
|
|
|
98
|
-
public async add(
|
|
99
|
-
this.calls.push(`add:${id}`);
|
|
100
|
-
return this.failAdd ? fail('add boom') : this._inner.add(
|
|
102
|
+
public async add(target: IEdgeTarget, vector: Float32Array): Promise<Result<string>> {
|
|
103
|
+
this.calls.push(`add:${target.id}`);
|
|
104
|
+
return this.failAdd ? fail('add boom') : this._inner.add(target, vector);
|
|
101
105
|
}
|
|
102
|
-
public async remove(
|
|
103
|
-
this.calls.push(`remove:${id}`);
|
|
104
|
-
return this.failRemove ? fail('remove boom') : this._inner.remove(
|
|
106
|
+
public async remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {
|
|
107
|
+
this.calls.push(`remove:${target.id}`);
|
|
108
|
+
return this.failRemove ? fail('remove boom') : this._inner.remove(target);
|
|
105
109
|
}
|
|
106
110
|
public query(vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {
|
|
107
111
|
return this._inner.query(vector, topK);
|
|
@@ -132,21 +136,22 @@ describe('FileTreeMemoryStore embed-on-write', () => {
|
|
|
132
136
|
const store = knowledgeStore(index, recordEmbed);
|
|
133
137
|
expect(await store.put(makeRecord('doc-a', 'cat cat'))).toSucceedAndSatisfy(
|
|
134
138
|
(record: IMemoryRecord<unknown>) => {
|
|
135
|
-
// InMemoryCosineIndex keys entries by
|
|
136
|
-
|
|
139
|
+
// InMemoryCosineIndex keys entries by the scope-qualified target, so the
|
|
140
|
+
// ref IS that canonical key (scope + NUL + id).
|
|
141
|
+
expect(record.envelope.embeddingRef).toBe('knowledge\0doc-a');
|
|
137
142
|
}
|
|
138
143
|
);
|
|
139
144
|
expect(index.size).toBe(1);
|
|
140
145
|
// The persisted file carries the embeddingRef (round-trips through disk).
|
|
141
146
|
expect(await store.getById('knowledge' as MemoryScopeKey, 'doc-a' as MemoryId)).toSucceedAndSatisfy(
|
|
142
147
|
(record: IMemoryRecord<unknown> | undefined) => {
|
|
143
|
-
expect(record?.envelope.embeddingRef).toBe('
|
|
148
|
+
expect(record?.envelope.embeddingRef).toBe('knowledge\0doc-a');
|
|
144
149
|
}
|
|
145
150
|
);
|
|
146
151
|
// The vector is queryable.
|
|
147
152
|
expect(await index.query(featureVector('cat'), 1)).toSucceedAndSatisfy(
|
|
148
153
|
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
149
|
-
expect(hits[0].id).toBe('doc-a');
|
|
154
|
+
expect(hits[0].target.id).toBe('doc-a');
|
|
150
155
|
}
|
|
151
156
|
);
|
|
152
157
|
});
|
|
@@ -213,7 +218,7 @@ describe('FileTreeMemoryStore embed-on-write', () => {
|
|
|
213
218
|
expect(index.size).toBe(2);
|
|
214
219
|
expect(await index.query(featureVector('cat'), 5)).toSucceedAndSatisfy(
|
|
215
220
|
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
216
|
-
expect(hits.map((h) => h.id)).not.toContain('turn-0');
|
|
221
|
+
expect(hits.map((h) => h.target.id)).not.toContain('turn-0');
|
|
217
222
|
}
|
|
218
223
|
);
|
|
219
224
|
});
|
|
@@ -343,4 +348,71 @@ describe('FileTreeMemoryStore embed-on-write', () => {
|
|
|
343
348
|
);
|
|
344
349
|
});
|
|
345
350
|
});
|
|
351
|
+
|
|
352
|
+
describe('versioned (temporal) embed-on-write', () => {
|
|
353
|
+
// Wires the vector lifecycle against a TemporalIdentityCodec-backed kind so the
|
|
354
|
+
// versioned put path (`_putVersioned -> _embedOnWrite(built, scope)`) is
|
|
355
|
+
// exercised with a real vector index. The flat-path tests already cover the
|
|
356
|
+
// SHARED `_embedOnWrite` line, so this asserts specifically that the VERSIONED
|
|
357
|
+
// call site threads the correct entity scope (not the version stem or some
|
|
358
|
+
// other variable) into the vector target — a scope swap at that call site would
|
|
359
|
+
// fail these assertions, whereas line coverage alone would not catch it.
|
|
360
|
+
function temporalStore(
|
|
361
|
+
vectorIndex: IVectorIndex,
|
|
362
|
+
embed: (r: IMemoryRecord<unknown>) => Promise<Result<Float32Array>>
|
|
363
|
+
): FileTreeMemoryStore {
|
|
364
|
+
const reg: IBodyConverterRegistry = BodyConverterRegistry.create().orThrow();
|
|
365
|
+
reg.register(factKind, Converters.string);
|
|
366
|
+
return FileTreeMemoryStore.create({
|
|
367
|
+
root: mutableRoot(),
|
|
368
|
+
registry: reg,
|
|
369
|
+
codecs: new Map<Kind, IIdentityCodec>([[factKind, TemporalIdentityCodec.create('facts').orThrow()]]),
|
|
370
|
+
writePolicies: new Map<Kind, IWritePolicy>([[factKind, TemporalVersionedPolicy.create().orThrow()]]),
|
|
371
|
+
vectorIndex,
|
|
372
|
+
embed
|
|
373
|
+
}).orThrow();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
test('stamps the entity-scope-qualified target as embeddingRef for each version', async () => {
|
|
377
|
+
const spy = new SpyVectorIndex();
|
|
378
|
+
const store = temporalStore(spy, recordEmbed);
|
|
379
|
+
// Two versions of the SAME temporal entity `greeting` (distinct bodies so the
|
|
380
|
+
// second mints a new version rather than deduping). The entity subtree scope
|
|
381
|
+
// is `facts/entities/greeting`; the version stem is `greeting-v<store-seq>`
|
|
382
|
+
// (the store's monotonic seq, which starts at 1 for the first write).
|
|
383
|
+
expect(await store.put(makeRecord('greeting', 'cat', 'fact'))).toSucceedAndSatisfy(
|
|
384
|
+
(record: IMemoryRecord<unknown>) => {
|
|
385
|
+
// embeddingRef IS edgeTargetKey(target) = `<entity-scope>\0<version-stem>`.
|
|
386
|
+
// A wrong scope at the versioned call site would corrupt the prefix here.
|
|
387
|
+
expect(record.envelope.embeddingRef).toBe('facts/entities/greeting\0greeting-v1');
|
|
388
|
+
}
|
|
389
|
+
);
|
|
390
|
+
expect(await store.put(makeRecord('greeting', 'dog', 'fact'))).toSucceedAndSatisfy(
|
|
391
|
+
(record: IMemoryRecord<unknown>) => {
|
|
392
|
+
expect(record.envelope.embeddingRef).toBe('facts/entities/greeting\0greeting-v2');
|
|
393
|
+
}
|
|
394
|
+
);
|
|
395
|
+
// Both versions were added under the entity scope, keyed by their version stems.
|
|
396
|
+
expect(spy.calls).toEqual(['add:greeting-v1', 'add:greeting-v2']);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('keeps two temporal entities under distinct scopes — no cross-entity vector collision', async () => {
|
|
400
|
+
const index = InMemoryCosineIndex.create().orThrow();
|
|
401
|
+
const store = temporalStore(index, recordEmbed);
|
|
402
|
+
(await store.put(makeRecord('greeting', 'cat', 'fact'))).orThrow();
|
|
403
|
+
(await store.put(makeRecord('farewell', 'fish', 'fact'))).orThrow();
|
|
404
|
+
// Two distinct entity subtrees → two distinct vector entries.
|
|
405
|
+
expect(index.size).toBe(2);
|
|
406
|
+
// A query surfaces both, each carrying its own entity-scope-qualified target.
|
|
407
|
+
expect(await index.query(featureVector('cat'), 5)).toSucceedAndSatisfy(
|
|
408
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
409
|
+
const targets = hits.map((h) => `${h.target.scope}\0${h.target.id}`).sort();
|
|
410
|
+
expect(targets).toEqual([
|
|
411
|
+
'facts/entities/farewell\0farewell-v2',
|
|
412
|
+
'facts/entities/greeting\0greeting-v1'
|
|
413
|
+
]);
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
346
418
|
});
|