@fgv/ts-agent-memory 5.1.0-45 → 5.1.0-47
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/{bc5b0f2098f7df2a5c498ea77b0de13316349b96.tar.log → 5667e36f1c0cc503a5c20a3ebff76e7b9a702d41.tar.log} +26 -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/README.md +134 -4
- package/dist/packlets/ingest/orchestrator.js +104 -19
- package/dist/packlets/ingest/orchestrator.js.map +1 -1
- package/dist/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
- package/dist/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
- package/dist/packlets/store/fileTreeMemoryStore.js +31 -8
- package/dist/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/dist/packlets/types/writePolicy.js +10 -2
- package/dist/packlets/types/writePolicy.js.map +1 -1
- package/dist/packlets/vector/fragmentConverters.js +41 -0
- package/dist/packlets/vector/fragmentConverters.js.map +1 -0
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
- package/dist/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
- package/dist/packlets/vector/index.js +1 -0
- package/dist/packlets/vector/index.js.map +1 -1
- package/dist/packlets/vector/vectorIndex.js.map +1 -1
- package/dist/test/unit/ingest/dedupScope.test.js +434 -0
- package/dist/test/unit/ingest/dedupScope.test.js.map +1 -0
- package/dist/test/unit/ingest/orchestrator.test.js +7 -4
- package/dist/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/dist/test/unit/store/fileTreeMemoryStore.test.js +64 -0
- package/dist/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/dist/test/unit/store/indexInjection.test.js +432 -0
- package/dist/test/unit/store/indexInjection.test.js.map +1 -0
- package/dist/test/unit/tools/memoryTools.test.js +1 -0
- package/dist/test/unit/tools/memoryTools.test.js.map +1 -1
- package/dist/test/unit/types/temporalPolicy.test.js +21 -0
- package/dist/test/unit/types/temporalPolicy.test.js.map +1 -1
- package/dist/test/unit/types/writePolicy.test.js +83 -0
- package/dist/test/unit/types/writePolicy.test.js.map +1 -1
- package/dist/test/unit/vector/fragmentConverters.test.js +58 -0
- package/dist/test/unit/vector/fragmentConverters.test.js.map +1 -0
- package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
- package/dist/test/unit/vector/inMemoryFragmentCosineIndex.test.js.map +1 -1
- package/dist/ts-agent-memory.d.ts +332 -42
- package/etc/ts-agent-memory.api.md +12 -1
- package/lib/packlets/ingest/orchestrator.d.ts +62 -5
- package/lib/packlets/ingest/orchestrator.d.ts.map +1 -1
- package/lib/packlets/ingest/orchestrator.js +104 -19
- package/lib/packlets/ingest/orchestrator.js.map +1 -1
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts +10 -7
- package/lib/packlets/retrieve/fragmentSemanticRetriever.d.ts.map +1 -1
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js +10 -7
- package/lib/packlets/retrieve/fragmentSemanticRetriever.js.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.d.ts +96 -4
- package/lib/packlets/store/fileTreeMemoryStore.d.ts.map +1 -1
- package/lib/packlets/store/fileTreeMemoryStore.js +31 -8
- package/lib/packlets/store/fileTreeMemoryStore.js.map +1 -1
- package/lib/packlets/types/writePolicy.d.ts +40 -2
- package/lib/packlets/types/writePolicy.d.ts.map +1 -1
- package/lib/packlets/types/writePolicy.js +10 -2
- package/lib/packlets/types/writePolicy.js.map +1 -1
- package/lib/packlets/vector/fragmentConverters.d.ts +25 -0
- package/lib/packlets/vector/fragmentConverters.d.ts.map +1 -0
- package/lib/packlets/vector/fragmentConverters.js +44 -0
- package/lib/packlets/vector/fragmentConverters.js.map +1 -0
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts +5 -3
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.d.ts.map +1 -1
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js +17 -9
- package/lib/packlets/vector/inMemoryFragmentCosineIndex.js.map +1 -1
- package/lib/packlets/vector/index.d.ts +1 -0
- package/lib/packlets/vector/index.d.ts.map +1 -1
- package/lib/packlets/vector/index.js +1 -0
- package/lib/packlets/vector/index.js.map +1 -1
- package/lib/packlets/vector/vectorIndex.d.ts +97 -22
- package/lib/packlets/vector/vectorIndex.d.ts.map +1 -1
- package/lib/packlets/vector/vectorIndex.js.map +1 -1
- package/lib/test/unit/ingest/dedupScope.test.d.ts +2 -0
- package/lib/test/unit/ingest/dedupScope.test.d.ts.map +1 -0
- package/lib/test/unit/ingest/dedupScope.test.js +436 -0
- package/lib/test/unit/ingest/dedupScope.test.js.map +1 -0
- package/lib/test/unit/ingest/orchestrator.test.js +7 -4
- package/lib/test/unit/ingest/orchestrator.test.js.map +1 -1
- package/lib/test/unit/store/fileTreeMemoryStore.test.js +64 -0
- package/lib/test/unit/store/fileTreeMemoryStore.test.js.map +1 -1
- package/lib/test/unit/store/indexInjection.test.d.ts +2 -0
- package/lib/test/unit/store/indexInjection.test.d.ts.map +1 -0
- package/lib/test/unit/store/indexInjection.test.js +434 -0
- package/lib/test/unit/store/indexInjection.test.js.map +1 -0
- package/lib/test/unit/tools/memoryTools.test.js +1 -0
- package/lib/test/unit/tools/memoryTools.test.js.map +1 -1
- package/lib/test/unit/types/temporalPolicy.test.js +21 -0
- package/lib/test/unit/types/temporalPolicy.test.js.map +1 -1
- package/lib/test/unit/types/writePolicy.test.js +83 -0
- package/lib/test/unit/types/writePolicy.test.js.map +1 -1
- package/lib/test/unit/vector/fragmentConverters.test.d.ts +2 -0
- package/lib/test/unit/vector/fragmentConverters.test.d.ts.map +1 -0
- package/lib/test/unit/vector/fragmentConverters.test.js +60 -0
- package/lib/test/unit/vector/fragmentConverters.test.js.map +1 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.test.js +65 -0
- package/lib/test/unit/vector/inMemoryFragmentCosineIndex.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/ingest/orchestrator.ts +116 -21
- package/src/packlets/retrieve/fragmentSemanticRetriever.ts +10 -7
- package/src/packlets/store/fileTreeMemoryStore.ts +114 -6
- package/src/packlets/types/writePolicy.ts +40 -2
- package/src/packlets/vector/fragmentConverters.ts +55 -0
- package/src/packlets/vector/inMemoryFragmentCosineIndex.ts +35 -8
- package/src/packlets/vector/index.ts +1 -0
- package/src/packlets/vector/vectorIndex.ts +97 -22
- package/src/test/unit/ingest/dedupScope.test.ts +542 -0
- package/src/test/unit/ingest/orchestrator.test.ts +4 -0
- package/src/test/unit/store/fileTreeMemoryStore.test.ts +75 -0
- package/src/test/unit/store/indexInjection.test.ts +550 -0
- package/src/test/unit/tools/memoryTools.test.ts +1 -0
- package/src/test/unit/types/temporalPolicy.test.ts +28 -0
- package/src/test/unit/types/writePolicy.test.ts +112 -0
- package/src/test/unit/vector/fragmentConverters.test.ts +80 -0
- package/src/test/unit/vector/inMemoryFragmentCosineIndex.test.ts +88 -0
- package/temp/build/lint/_eslint-5eVG3S6w.json +30 -14
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory.api.json +274 -21
- package/temp/ts-agent-memory.api.md +12 -1
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Hash, Logging, Result, fail, mapResults, succeed } from '@fgv/ts-utils';
|
|
7
7
|
import {
|
|
8
8
|
Convert,
|
|
9
|
+
DedupScope,
|
|
9
10
|
EntityId,
|
|
10
11
|
IEdge,
|
|
11
12
|
IEdgeTarget,
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
Kind,
|
|
18
19
|
LinkType,
|
|
19
20
|
MemoryId,
|
|
20
|
-
MemoryScopeKey,
|
|
21
21
|
Tag,
|
|
22
22
|
edgeTargetKey,
|
|
23
23
|
isTemporalRecord,
|
|
@@ -310,6 +310,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
310
310
|
const edgesResult: Result<ReadonlyArray<ICandidateEdge>> = await this._relate(
|
|
311
311
|
item,
|
|
312
312
|
writablePlans,
|
|
313
|
+
MemoryIngestOrchestrator._collapseRedirects(plans),
|
|
313
314
|
scoped,
|
|
314
315
|
byKey
|
|
315
316
|
);
|
|
@@ -443,7 +444,9 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
443
444
|
|
|
444
445
|
/**
|
|
445
446
|
* Stage 4 — resolve a dedup verdict. Layer 1: an exact `{ kind, body }` match in
|
|
446
|
-
* the candidate's scope is a `duplicate-of
|
|
447
|
+
* the candidate's scope is a `duplicate-of`, at the granularity the kind's
|
|
448
|
+
* {@link DedupScope} declares — scope-wide for `'content'`, restricted to the
|
|
449
|
+
* candidate's own entity for `'entity'` (design note §1). Layer 2 (only when
|
|
447
450
|
* a resolver + vector index + embedder are all wired): embed the candidate,
|
|
448
451
|
* surface over-threshold neighbors, and dispatch to the {@link IEntityResolver}.
|
|
449
452
|
* Otherwise the verdict is `new` (the exact-only fall-back path).
|
|
@@ -456,7 +459,7 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
456
459
|
snapshot: ReadonlyArray<IMemoryRecord<unknown>>,
|
|
457
460
|
byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
|
|
458
461
|
): Promise<Result<ResolutionVerdict>> {
|
|
459
|
-
return this._findExactMatch(kind, body, addr
|
|
462
|
+
return this._findExactMatch(kind, body, addr, snapshot).thenOnSuccess(async (matchId) => {
|
|
460
463
|
if (matchId !== undefined) {
|
|
461
464
|
// The exact-match cohort is filtered to `addr.scope`, so the match lives
|
|
462
465
|
// under that scope — its scope-qualified target is `(addr.scope, matchId)`.
|
|
@@ -534,27 +537,52 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
534
537
|
}
|
|
535
538
|
|
|
536
539
|
/**
|
|
537
|
-
* Find an existing record
|
|
538
|
-
*
|
|
539
|
-
* — only a live (non-temporal or current) record deduplicates a
|
|
540
|
+
* Find an existing record whose `{ kind, body }` hash matches the candidate's
|
|
541
|
+
* (layer-1 exact dedup, design note §1). Invalidated temporal versions are
|
|
542
|
+
* excluded — only a live (non-temporal or current) record deduplicates a
|
|
543
|
+
* candidate.
|
|
544
|
+
*
|
|
545
|
+
* @remarks
|
|
546
|
+
* The cohort's granularity is the kind's DECLARED {@link DedupScope}, read
|
|
547
|
+
* through {@link IMemoryStore.dedupScopeFor} so this layer and the store's own
|
|
548
|
+
* write path can never disagree:
|
|
549
|
+
*
|
|
550
|
+
* - `'content'` — every live same-kind record in the candidate's scope,
|
|
551
|
+
* regardless of entity. A cross-id body collision IS a duplicate (the
|
|
552
|
+
* knowledge family; unchanged behavior).
|
|
553
|
+
* - `'entity'` — only records at the candidate's OWN entity address. Two
|
|
554
|
+
* distinct entities with byte-identical bodies never collapse into one
|
|
555
|
+
* another, which is what the declaration has always meant on the direct-put
|
|
556
|
+
* path and now means here too.
|
|
557
|
+
*
|
|
558
|
+
* The same-id collapse itself remains the store's job — this layer only decides
|
|
559
|
+
* whether a cross-id body collision is eligible to be a `duplicate-of` at all.
|
|
540
560
|
*/
|
|
541
561
|
private _findExactMatch(
|
|
542
562
|
kind: Kind,
|
|
543
563
|
body: string,
|
|
544
|
-
|
|
564
|
+
addr: IIdentityCodecResult,
|
|
545
565
|
snapshot: ReadonlyArray<IMemoryRecord<unknown>>
|
|
546
566
|
): Result<MemoryId | undefined> {
|
|
567
|
+
const dedupScope: DedupScope = this._store.dedupScopeFor(kind);
|
|
547
568
|
// Same-kind, same-scope, LIVE (non-temporal or current) records are the exact
|
|
548
|
-
// cohort
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
569
|
+
// cohort, narrowed to the candidate's own entity address under `'entity'`
|
|
570
|
+
// granularity. `_resolveAddress(...).orDefault()` collapses an unresolved
|
|
571
|
+
// codec to `undefined` — a non-matching address — with no explicit failure
|
|
572
|
+
// branch, exactly as the scope-only filter did before.
|
|
573
|
+
const cohort: ReadonlyArray<IMemoryRecord<unknown>> = snapshot.filter((record) => {
|
|
574
|
+
if (record.envelope.kind !== kind || (isTemporalRecord(record) && !isVersionCurrent(record))) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
const recordAddr: IIdentityCodecResult | undefined = this._resolveAddress(
|
|
578
|
+
record.envelope.entityId,
|
|
579
|
+
record.envelope.kind
|
|
580
|
+
).orDefault();
|
|
581
|
+
if (recordAddr === undefined || recordAddr.scope !== addr.scope) {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
return dedupScope === 'content' || recordAddr.idStem === addr.idStem;
|
|
585
|
+
});
|
|
558
586
|
return this._exactKey(kind, body).onSuccess((key) =>
|
|
559
587
|
mapResults(
|
|
560
588
|
cohort.map((record) =>
|
|
@@ -588,10 +616,11 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
588
616
|
return this._hasher.computeHash({ kind, body });
|
|
589
617
|
}
|
|
590
618
|
|
|
591
|
-
/** Stage 5 — relate (host), validate edges, and run the write-time cycle guard. */
|
|
619
|
+
/** Stage 5 — relate (host), redirect collapsed targets, validate edges, and run the write-time cycle guard. */
|
|
592
620
|
private async _relate(
|
|
593
621
|
item: IIngestItem,
|
|
594
622
|
writablePlans: ReadonlyArray<ICandidatePlan>,
|
|
623
|
+
redirects: ReadonlyMap<string, IEdgeTarget>,
|
|
595
624
|
scoped: ReadonlyArray<IScopedRecord>,
|
|
596
625
|
byKey: ReadonlyMap<string, IMemoryRecord<unknown>>
|
|
597
626
|
): Promise<Result<ReadonlyArray<ICandidateEdge>>> {
|
|
@@ -606,26 +635,92 @@ export class MemoryIngestOrchestrator implements IMemoryIngestOrchestrator {
|
|
|
606
635
|
if (proposed.isFailure()) {
|
|
607
636
|
return proposed;
|
|
608
637
|
}
|
|
638
|
+
// Redirect BEFORE validation and BEFORE the cycle guard: an edge naming a
|
|
639
|
+
// collapsed candidate must be judged — and persisted — against the record it
|
|
640
|
+
// collapsed into, not against the address that no longer gets written.
|
|
641
|
+
const edges: ReadonlyArray<ICandidateEdge> = MemoryIngestOrchestrator._redirectEdges(
|
|
642
|
+
proposed.value,
|
|
643
|
+
redirects
|
|
644
|
+
);
|
|
609
645
|
// refIds and the existing-record view (`byKey`, shared with the verdict path)
|
|
610
646
|
// both key on the canonical scoped address, so a stem reused across scopes
|
|
611
647
|
// never aliases.
|
|
612
648
|
const refIds: ReadonlySet<string> = new Set<string>(
|
|
613
649
|
writablePlans.map((plan) => edgeTargetKey(plan.refTarget))
|
|
614
650
|
);
|
|
615
|
-
const validation: Result<true> = this._validateEdges(item,
|
|
651
|
+
const validation: Result<true> = this._validateEdges(item, edges, refIds, byKey);
|
|
616
652
|
if (validation.isFailure()) {
|
|
617
653
|
return fail(validation.message);
|
|
618
654
|
}
|
|
619
655
|
if (this._cycleGuard === 'reject') {
|
|
620
656
|
const guard: Result<true> = assertNoCycles(
|
|
621
657
|
MemoryIngestOrchestrator._existingEdges(scoped),
|
|
622
|
-
|
|
658
|
+
edges.map((e) => ({ source: e.source, target: e.edge.target, type: e.edge.type }))
|
|
623
659
|
);
|
|
624
660
|
if (guard.isFailure()) {
|
|
625
661
|
return fail(`ingest '${item.id}': ${guard.message}`);
|
|
626
662
|
}
|
|
627
663
|
}
|
|
628
|
-
return succeed(
|
|
664
|
+
return succeed(edges);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The stage-5 edge-target redirect map for this pass: every `duplicate-of`
|
|
669
|
+
* candidate's OWN scoped address, mapped to the address of the record its
|
|
670
|
+
* verdict collapsed it into (design note §3).
|
|
671
|
+
*
|
|
672
|
+
* @remarks
|
|
673
|
+
* A `duplicate-of` verdict means "this candidate IS that record". The candidate
|
|
674
|
+
* is therefore not written, and its address never becomes a live reference — so
|
|
675
|
+
* a sibling edge built against it in the same pass would resolve to neither a
|
|
676
|
+
* written candidate nor an existing record and would fail the WHOLE ingest item.
|
|
677
|
+
* That is a second-order hazard of collapsing, not a defect in the edge: the
|
|
678
|
+
* host related two candidates it was correctly told about, and one of them
|
|
679
|
+
* turned out to already exist.
|
|
680
|
+
*
|
|
681
|
+
* Redirecting is the honest repair, and it is independent of {@link DedupScope}
|
|
682
|
+
* — it applies equally to a `'content'` kind, where the collapse is exactly
|
|
683
|
+
* right and the ingest still must not fail.
|
|
684
|
+
*
|
|
685
|
+
* The map cannot chain: {@link MemoryIngestOrchestrator._planFromVerdict}
|
|
686
|
+
* requires every target-bearing verdict's target to already exist in the store
|
|
687
|
+
* snapshot, so a redirect destination is always a persisted record and never
|
|
688
|
+
* another collapsed candidate. One pass is sufficient by construction.
|
|
689
|
+
*/
|
|
690
|
+
private static _collapseRedirects(plans: ReadonlyArray<ICandidatePlan>): ReadonlyMap<string, IEdgeTarget> {
|
|
691
|
+
const redirects: Map<string, IEdgeTarget> = new Map<string, IEdgeTarget>();
|
|
692
|
+
for (const plan of plans) {
|
|
693
|
+
if (plan.verdict.verdict === 'duplicate-of') {
|
|
694
|
+
redirects.set(edgeTargetKey(plan.refTarget), plan.verdict.target);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return redirects;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Rewrite each edge whose TARGET names a collapsed candidate to point at the
|
|
702
|
+
* record that candidate collapsed into.
|
|
703
|
+
*
|
|
704
|
+
* @remarks
|
|
705
|
+
* Only the target is redirected. An edge whose SOURCE is a collapsed candidate
|
|
706
|
+
* stays untouched and is still rejected by {@link
|
|
707
|
+
* MemoryIngestOrchestrator._validateEdges}: sources are the records an edge is
|
|
708
|
+
* written ONTO, a collapsed candidate is never written, and the relation
|
|
709
|
+
* extractor is only ever offered writable candidates as edge sources in the
|
|
710
|
+
* first place. Silently relocating such an edge onto an existing record would
|
|
711
|
+
* attribute a link the host never asked for — the loud failure is correct there.
|
|
712
|
+
*/
|
|
713
|
+
private static _redirectEdges(
|
|
714
|
+
edges: ReadonlyArray<ICandidateEdge>,
|
|
715
|
+
redirects: ReadonlyMap<string, IEdgeTarget>
|
|
716
|
+
): ReadonlyArray<ICandidateEdge> {
|
|
717
|
+
if (redirects.size === 0) {
|
|
718
|
+
return edges;
|
|
719
|
+
}
|
|
720
|
+
return edges.map((e) => {
|
|
721
|
+
const to: IEdgeTarget | undefined = redirects.get(edgeTargetKey(e.edge.target));
|
|
722
|
+
return to === undefined ? e : { ...e, edge: { ...e.edge, target: to } };
|
|
723
|
+
});
|
|
629
724
|
}
|
|
630
725
|
|
|
631
726
|
/**
|
|
@@ -60,16 +60,19 @@ export interface IFragmentRetrieverCapabilities {
|
|
|
60
60
|
* The sub-document semantic-search retriever — the "discovery" half of a
|
|
61
61
|
* search-then-read contract. It embeds a fragment query, queries the
|
|
62
62
|
* {@link IFragmentVectorIndex}, and returns the raw per-fragment
|
|
63
|
-
* {@link IVectorQueryHit | hits} (each carrying a record `target` AND
|
|
64
|
-
* `locator`
|
|
65
|
-
*
|
|
63
|
+
* {@link IVectorQueryHit | hits} (each carrying a record `target` AND whichever of
|
|
64
|
+
* `locator` / `fragmentId` the stored fragment was indexed with), NOT resolved
|
|
65
|
+
* records: the consumer re-reads each record and resolves the fragment on its own
|
|
66
|
+
* read side. Note the `locator` span is advisory — see {@link IFragmentLocator}; it
|
|
67
|
+
* is not a slice guaranteed to reproduce the fragment's text.
|
|
66
68
|
*
|
|
67
69
|
* @remarks
|
|
68
70
|
* Deliberately NOT an {@link IMemoryRetriever}: memory recall is record-granular and
|
|
69
|
-
* returns records; fragment discovery is
|
|
70
|
-
* it a distinct surface matches the consumer contract (memory
|
|
71
|
-
* sub-document knowledge uses a separate fragment index) and
|
|
72
|
-
* record retriever's return type with
|
|
71
|
+
* returns records; fragment discovery is fragment-granular and returns fragment
|
|
72
|
+
* identities. Keeping it a distinct surface matches the consumer contract (memory
|
|
73
|
+
* stays record-granular; sub-document knowledge uses a separate fragment index) and
|
|
74
|
+
* avoids overloading the record retriever's return type with identity fields that
|
|
75
|
+
* only make sense here.
|
|
73
76
|
*
|
|
74
77
|
* When no backend is wired, `supportsFragmentRecall` is `false` and any fragment
|
|
75
78
|
* query degrades loudly ({@link FRAGMENT_SEMANTIC_UNWIRED_MESSAGE}) — it NEVER
|
|
@@ -147,6 +147,35 @@ export interface IMemoryStore {
|
|
|
147
147
|
*/
|
|
148
148
|
asRecordSource(): IMemoryRecordSource;
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The EFFECTIVE {@link DedupScope} for `kind` — the granularity at which a
|
|
152
|
+
* write for this kind deduplicates against the existing vault.
|
|
153
|
+
*
|
|
154
|
+
* @remarks
|
|
155
|
+
* This is a **read accessor over the store's already-injected write policies**,
|
|
156
|
+
* and it is the single place any caller — the store's own write path included —
|
|
157
|
+
* asks what a kind's dedup granularity is. It resolves the full chain the store
|
|
158
|
+
* applies on write: the kind's registered {@link IWritePolicy}, falling back to
|
|
159
|
+
* the store's default policy, then that policy's
|
|
160
|
+
* {@link IWritePolicy.dedupScope | dedupScope}, falling back to
|
|
161
|
+
* {@link DEFAULT_DEDUP_SCOPE}. Note the store's default policy is a
|
|
162
|
+
* {@link KnowledgeLwwPolicy}, which declares `'content'` — so a kind with NO
|
|
163
|
+
* registered policy resolves to `'content'`, not to `DEFAULT_DEDUP_SCOPE`.
|
|
164
|
+
*
|
|
165
|
+
* It exists so a caller that must agree with the store about dedup granularity
|
|
166
|
+
* — notably the ingest orchestrator's stage-4 layer-1 exact match — can read the
|
|
167
|
+
* declaration through this seam instead of being handed a second copy of the
|
|
168
|
+
* policy map. A second declaration site is precisely the defect this accessor
|
|
169
|
+
* was added to remove.
|
|
170
|
+
*
|
|
171
|
+
* Deliberately synchronous, total, and NOT `Result`-returning: it reads
|
|
172
|
+
* constructor-injected configuration, touches no I/O, and cannot fail (every
|
|
173
|
+
* link in the fallback chain has a total default). It exposes only the scope,
|
|
174
|
+
* never the {@link IWritePolicy} itself, so it can never become a back door for
|
|
175
|
+
* invoking admission or merge logic out of band.
|
|
176
|
+
*/
|
|
177
|
+
dedupScopeFor(kind: Kind): DedupScope;
|
|
178
|
+
|
|
150
179
|
/**
|
|
151
180
|
* Write a record. Validates the body, computes a content hash, deduplicates
|
|
152
181
|
* (scope-wide, before policy), applies the kind's {@link IWritePolicy}, stamps
|
|
@@ -194,6 +223,59 @@ export interface IFileTreeMemoryStoreCreateParams {
|
|
|
194
223
|
readonly rankProjectors?: ReadonlyMap<Kind, RankProjector>;
|
|
195
224
|
/** Default codec for kinds without an explicit entry. */
|
|
196
225
|
readonly defaultCodec?: IIdentityCodec;
|
|
226
|
+
/**
|
|
227
|
+
* Optional derived-index implementation. Defaults to a fresh {@link MemoryIndex} —
|
|
228
|
+
* omitting this parameter is byte-identical to the store's behavior before the
|
|
229
|
+
* parameter existed. When supplied, the store uses it for EVERY index operation
|
|
230
|
+
* it performs and never holds a second index, so an injected index is the store's
|
|
231
|
+
* only view of its own records:
|
|
232
|
+
*
|
|
233
|
+
* - `rebuild` — once, from the initial vault walk in `create()`.
|
|
234
|
+
* - `patch` — on every persisted write, delete, version invalidation, and
|
|
235
|
+
* cap-cull eviction.
|
|
236
|
+
* - `entries` — behind {@link IMemoryStore.list | list} /
|
|
237
|
+
* {@link IMemoryStore.listScoped | listScoped}, the keyed temporal reads, the
|
|
238
|
+
* write path's content-hash dedup and write-policy admission cohort, AND the
|
|
239
|
+
* temporal (versioned) write and delete paths, which resolve an entity's
|
|
240
|
+
* version history entirely from the index.
|
|
241
|
+
*
|
|
242
|
+
* That last group is the one to weigh before injecting anything other than a
|
|
243
|
+
* pass-through decorator: an index that filters, reorders, or otherwise reshapes
|
|
244
|
+
* `entries()` changes WRITE semantics, not just what reads return. Concretely,
|
|
245
|
+
* on a versioned kind the store derives an entity's whole version history from
|
|
246
|
+
* `entries()` filtered by scope, and that derivation decides which version a
|
|
247
|
+
* `put` treats as current (so what it dedups against and what it merges its
|
|
248
|
+
* patch over), which prior versions it stamps `invalid_at` on, what the
|
|
249
|
+
* admission cohort is, and which versions a `delete` tombstones. An index that
|
|
250
|
+
* hides a version makes it invisible to all of those — the FileTree still holds
|
|
251
|
+
* it, but the store will not supersede, invalidate, or tombstone it. On flat
|
|
252
|
+
* kinds the same reshaping changes what dedups and what a cap-cull policy
|
|
253
|
+
* evicts. A faithful delegating decorator — the intended use below — has no such
|
|
254
|
+
* effect. Note the store's keyed reads ({@link IMemoryStore.get | get} on a flat
|
|
255
|
+
* kind, and {@link IMemoryStore.getById | getById}) go to the FileTree, not the
|
|
256
|
+
* index; the FileTree remains the source of truth and the index stays a derived
|
|
257
|
+
* view.
|
|
258
|
+
*
|
|
259
|
+
* @remarks
|
|
260
|
+
* **This is an instrumentation seam, NOT a resident-memory fix.** The intended
|
|
261
|
+
* use is wrapping the shipped {@link MemoryIndex} in a decorator that counts and
|
|
262
|
+
* times the calls the store makes — resident bytes by kind, open cost against
|
|
263
|
+
* vault size, where the curve actually bends — so a decision about a partial-read
|
|
264
|
+
* redesign can be driven by measurements instead of estimates.
|
|
265
|
+
*
|
|
266
|
+
* It does NOT lower the store's resident-memory ceiling, and injecting a
|
|
267
|
+
* "persisted" or "lazy" index will not change that. {@link IMemoryIndex}'s read
|
|
268
|
+
* surface returns whole records by construction: `entries()` yields
|
|
269
|
+
* {@link IIndexedMemoryRecord}s and `byKind` / `byTag` / `byRecency` / `byRank`
|
|
270
|
+
* yield `IMemoryRecord<unknown>` — `{ envelope, body }` pairs with the body
|
|
271
|
+
* materialized. Any implementation satisfying the current contract must therefore
|
|
272
|
+
* be able to produce every body on demand. An injected index changes WHERE records
|
|
273
|
+
* come from; it does not change WHETHER bodies are held. Lowering the ceiling
|
|
274
|
+
* requires a partial-read (id-or-envelope-only) redesign of `IMemoryIndex` itself,
|
|
275
|
+
* which is separate, breaking, design-first work and is deliberately not part of
|
|
276
|
+
* this seam.
|
|
277
|
+
*/
|
|
278
|
+
readonly index?: IMemoryIndex;
|
|
197
279
|
/** Scope encoding. Defaults to {@link defaultMemoryScopeEncoding}. */
|
|
198
280
|
readonly scopeEncoding?: (scope: MemoryScopeKey) => Result<string>;
|
|
199
281
|
/**
|
|
@@ -412,13 +494,15 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
412
494
|
}
|
|
413
495
|
|
|
414
496
|
/**
|
|
415
|
-
* Family-convention factory.
|
|
416
|
-
*
|
|
417
|
-
*
|
|
497
|
+
* Family-convention factory. Resolves the derived index (the caller's
|
|
498
|
+
* {@link IFileTreeMemoryStoreCreateParams.index | index} when supplied, a fresh
|
|
499
|
+
* {@link MemoryIndex} otherwise) and a default LWW policy, then performs an
|
|
500
|
+
* initial FileTree walk so an existing vault is indexed (and the `seq` counter
|
|
501
|
+
* resumes past the highest persisted `seq`).
|
|
418
502
|
*/
|
|
419
503
|
public static create(params: IFileTreeMemoryStoreCreateParams): Result<FileTreeMemoryStore> {
|
|
420
504
|
return KnowledgeLwwPolicy.create().onSuccess((defaultPolicy) =>
|
|
421
|
-
|
|
505
|
+
FileTreeMemoryStore._resolveIndex(params.index).onSuccess((index) => {
|
|
422
506
|
const store: FileTreeMemoryStore = new FileTreeMemoryStore({
|
|
423
507
|
root: params.root,
|
|
424
508
|
registry: params.registry,
|
|
@@ -442,6 +526,21 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
442
526
|
);
|
|
443
527
|
}
|
|
444
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Resolve the derived index for a `create()`: the caller's injected
|
|
531
|
+
* {@link IMemoryIndex} verbatim, or a fresh {@link MemoryIndex} when none was
|
|
532
|
+
* supplied (the default that keeps an omitting caller byte-identical).
|
|
533
|
+
*/
|
|
534
|
+
private static _resolveIndex(index: IMemoryIndex | undefined): Result<IMemoryIndex> {
|
|
535
|
+
// Nullish rather than strictly-undefined, matching how every sibling optional
|
|
536
|
+
// param in `create()` handles absence (`params.codecs ?? new Map()`, and so on).
|
|
537
|
+
// A JS caller — or a TS caller arriving through an `unknown` escape hatch —
|
|
538
|
+
// passing `null` otherwise gets `null` installed as the store's index and fails
|
|
539
|
+
// later inside `entries()` with a message that names neither the param nor the
|
|
540
|
+
// cause.
|
|
541
|
+
return index ? succeed(index) : MemoryIndex.create();
|
|
542
|
+
}
|
|
543
|
+
|
|
445
544
|
/** {@inheritDoc IMemoryStore.get} */
|
|
446
545
|
public async get(kind: Kind, entityId: EntityId): Promise<Result<IMemoryRecord<unknown> | undefined>> {
|
|
447
546
|
const result: Result<IMemoryRecord<unknown> | undefined> = this._codecFor(kind).onSuccess((codec) =>
|
|
@@ -741,7 +840,10 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
741
840
|
hash: string
|
|
742
841
|
): Promise<Result<IPutOutcome>> {
|
|
743
842
|
const policy: IWritePolicy = this._policyFor(record.envelope.kind);
|
|
744
|
-
|
|
843
|
+
// Read through the public accessor so the write path and every external
|
|
844
|
+
// caller (the ingest orchestrator's layer-1) resolve dedup granularity from
|
|
845
|
+
// ONE place — there is no second declaration site.
|
|
846
|
+
const dedupScope: DedupScope = this.dedupScopeFor(record.envelope.kind);
|
|
745
847
|
// Content-hash dedup runs BEFORE policy. Its granularity is the kind's
|
|
746
848
|
// `dedupScope`:
|
|
747
849
|
// - 'content': an identical { kind, body, links } triple ANYWHERE in the
|
|
@@ -1171,7 +1273,8 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1171
1273
|
const priorCurrents: ReadonlyArray<IMemoryRecord<unknown>> = versions.filter(isVersionCurrent);
|
|
1172
1274
|
const current: IMemoryRecord<unknown> | undefined = selectCurrentVersion(versions);
|
|
1173
1275
|
const policy: IWritePolicy = this._policyFor(kind);
|
|
1174
|
-
|
|
1276
|
+
// Same single-owner read as the flat path (see `_writeResolved`).
|
|
1277
|
+
const dedupScope: DedupScope = this.dedupScopeFor(kind);
|
|
1175
1278
|
return this._contentHash(kind, body, envelope.links).thenOnSuccess((hash) => {
|
|
1176
1279
|
// Entity-scoped dedup: a re-put is a no-op only when the CURRENT content AND
|
|
1177
1280
|
// its mutable metadata are unchanged (does not spawn a redundant version).
|
|
@@ -1514,6 +1617,11 @@ export class FileTreeMemoryStore implements IMemoryStore {
|
|
|
1514
1617
|
return this._writePolicies.get(kind) ?? this._defaultPolicy;
|
|
1515
1618
|
}
|
|
1516
1619
|
|
|
1620
|
+
/** {@inheritDoc IMemoryStore.dedupScopeFor} */
|
|
1621
|
+
public dedupScopeFor(kind: Kind): DedupScope {
|
|
1622
|
+
return this._policyFor(kind).dedupScope ?? DEFAULT_DEDUP_SCOPE;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1517
1625
|
/**
|
|
1518
1626
|
* Read and validate the record at `<scope>/<idStem>.md`, returning `undefined`
|
|
1519
1627
|
* when the scope directory or file is absent. Verifies the on-disk id ↔
|
|
@@ -90,6 +90,27 @@ export interface IWritePolicy {
|
|
|
90
90
|
* record. Called when admission is `accept` AND a record with the same
|
|
91
91
|
* `entityId` already exists (an update, not a first write).
|
|
92
92
|
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* **RFC-7386 semantics are the contract here, not an artifact of the shipped
|
|
95
|
+
* policies' merge configuration.** Implementations are expected to honor them,
|
|
96
|
+
* and consumers may rely on them:
|
|
97
|
+
*
|
|
98
|
+
* - **Objects merge per key.** A supplied key overwrites; an omitted key is
|
|
99
|
+
* PRESERVED, not dropped. Patching one key of a nested object (e.g. one
|
|
100
|
+
* field of `provenance`) leaves its siblings intact.
|
|
101
|
+
* - **An explicit `null` on a sub-key clears exactly that sub-key.** This is
|
|
102
|
+
* the sanctioned way to remove a single key from a nested object.
|
|
103
|
+
* - **Arrays replace wholesale** — `tags` / `links` are never element-merged.
|
|
104
|
+
* - **A whole-block `null` that would delete a REQUIRED field is rejected
|
|
105
|
+
* loudly** (`Result.fail`), never silently accepted. `body` / `tags` /
|
|
106
|
+
* `links` / `provenance` are required; `embeddingRef` is optional and a
|
|
107
|
+
* `null` restores it to absent (NOT to `null`), so it stays hash-stable.
|
|
108
|
+
*
|
|
109
|
+
* Which fields these guarantees cover is **policy-dependent**: they apply to
|
|
110
|
+
* the fields the policy declares in {@link IWritePolicy.mutableFields}, and a
|
|
111
|
+
* field outside that list is inert — its patch key is dropped before the merge,
|
|
112
|
+
* so a `null` on it neither clears the value nor raises an error.
|
|
113
|
+
*
|
|
93
114
|
* @param existing - The current persisted record.
|
|
94
115
|
* @param patch - A partial JSON object in Merge Patch format. `null`
|
|
95
116
|
* deletes the corresponding key; arrays replace wholesale; nested objects
|
|
@@ -133,13 +154,21 @@ const MERGE_PATCH_OPTIONS: Partial<IJsonEditorOptions> = {
|
|
|
133
154
|
* view, runs the merge over that view, then rebuilds a coherent record. The
|
|
134
155
|
* identity and transaction-time envelope fields (`id`, `entityId`, `kind`,
|
|
135
156
|
* `created`, `updated`, `seq`, `contentHash`) are NOT mutable and are
|
|
136
|
-
* preserved verbatim; the store stamps `updated` / `seq` on write.
|
|
157
|
+
* preserved verbatim; the store stamps `updated` / `seq` / `contentHash` on write.
|
|
158
|
+
*
|
|
159
|
+
* Because the surface is pinned rather than caller-supplied, the RFC-7386
|
|
160
|
+
* guarantees documented on {@link IWritePolicy.applyUpdate} apply to every field
|
|
161
|
+
* listed above — in particular to `provenance`, whose keys merge individually, a
|
|
162
|
+
* `null` on any one of which clears that key alone, and a `null` on the whole
|
|
163
|
+
* block of which is rejected loudly (it is a required field). Consumers may
|
|
164
|
+
* depend on this; it is covered by tests.
|
|
137
165
|
* @public
|
|
138
166
|
*/
|
|
139
167
|
export class KnowledgeLwwPolicy implements IWritePolicy {
|
|
140
168
|
/**
|
|
141
169
|
* The knowledge mutable surface: the body plus the envelope metadata a
|
|
142
|
-
* consumer may revise without minting a new entity.
|
|
170
|
+
* consumer may revise without minting a new entity. Pinned, not
|
|
171
|
+
* caller-supplied — see the class remarks for what that guarantees.
|
|
143
172
|
*/
|
|
144
173
|
public readonly mutableFields: ReadonlyArray<string> = [
|
|
145
174
|
'body',
|
|
@@ -271,6 +300,15 @@ export interface IMemoryCapCullPolicyParams {
|
|
|
271
300
|
* The fields a merge-patch update may touch (drawn from the record-level
|
|
272
301
|
* mutable vocabulary: `body` / `tags` / `links` / `provenance` /
|
|
273
302
|
* `embeddingRef`). Fields outside this list are immutable.
|
|
303
|
+
*
|
|
304
|
+
* @remarks
|
|
305
|
+
* This list — unlike {@link KnowledgeLwwPolicy}'s pinned surface — is
|
|
306
|
+
* caller-supplied, so it is what decides which fields get the RFC-7386
|
|
307
|
+
* guarantees documented on {@link IWritePolicy.applyUpdate}. Declare
|
|
308
|
+
* `provenance` here to get per-key provenance merging and `null` sub-key
|
|
309
|
+
* clearing; omit it and every provenance patch key is inert (dropped before
|
|
310
|
+
* the merge, so the existing value is preserved verbatim and even a
|
|
311
|
+
* whole-block `null` is a silent no-op rather than an error).
|
|
274
312
|
*/
|
|
275
313
|
readonly mutableFields: ReadonlyArray<string>;
|
|
276
314
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Converter, Converters, Result, fail, succeed } from '@fgv/ts-utils';
|
|
7
|
+
import { IEmbeddedFragment, IFragmentLocator } from './vectorIndex';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converter for an {@link IFragmentLocator}. Validates the two offsets only — the
|
|
11
|
+
* span's *meaning* (character / byte / token unit) is the consumer's, and the index
|
|
12
|
+
* never interprets it. Note the span is advisory: see {@link IFragmentLocator}.
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
export const fragmentLocatorConverter: Converter<IFragmentLocator> = Converters.object<IFragmentLocator>({
|
|
16
|
+
start: Converters.number,
|
|
17
|
+
end: Converters.number
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Validates an already-constructed `Float32Array` embedding vector in place. */
|
|
21
|
+
const embeddingVectorConverter: Converter<Float32Array> = Converters.generic<Float32Array>(
|
|
22
|
+
(from: unknown): Result<Float32Array> =>
|
|
23
|
+
from instanceof Float32Array ? succeed(from) : fail('embedded fragment: vector must be a Float32Array')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** The field-shape half of {@link embeddedFragmentConverter}; the invariant is layered on top. */
|
|
27
|
+
const embeddedFragmentFieldsConverter: Converter<IEmbeddedFragment> = Converters.object<IEmbeddedFragment>(
|
|
28
|
+
{
|
|
29
|
+
locator: fragmentLocatorConverter.optional(),
|
|
30
|
+
fragmentId: Converters.string.optional(),
|
|
31
|
+
vector: embeddingVectorConverter
|
|
32
|
+
},
|
|
33
|
+
{ optionalFields: ['locator', 'fragmentId'] }
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Converter for an {@link IEmbeddedFragment}, and the boundary that enforces the
|
|
38
|
+
* fragment-identifiability invariant: **at least one of `locator` / `fragmentId`
|
|
39
|
+
* must be present.**
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* The invariant lives here rather than in the type. A conditional-required union
|
|
43
|
+
* (`{ locator; fragmentId? } | { locator?; fragmentId }`) was considered and
|
|
44
|
+
* declined: it costs at every construction site and buys nothing at the read site,
|
|
45
|
+
* where each field reads as `… | undefined` either way. What it must not cost is
|
|
46
|
+
* identifiability — a fragment carrying neither identity cannot be resolved back to
|
|
47
|
+
* anything by a consumer holding the query hit, so it is rejected here.
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
export const embeddedFragmentConverter: Converter<IEmbeddedFragment> =
|
|
51
|
+
embeddedFragmentFieldsConverter.withConstraint((fragment: IEmbeddedFragment) =>
|
|
52
|
+
fragment.locator === undefined && fragment.fragmentId === undefined
|
|
53
|
+
? fail('embedded fragment: at least one of `locator` or `fragmentId` is required')
|
|
54
|
+
: succeed(fragment)
|
|
55
|
+
);
|
|
@@ -8,16 +8,31 @@ import { IEdgeTarget, edgeTargetKey } from '../types';
|
|
|
8
8
|
import {
|
|
9
9
|
FragmentEmbedder,
|
|
10
10
|
IEmbeddedFragment,
|
|
11
|
-
IFragmentLocator,
|
|
12
11
|
IFragmentVectorIndex,
|
|
13
12
|
IMemoryRecordSource,
|
|
14
13
|
IScopedMemoryRecord,
|
|
15
14
|
IVectorQueryHit
|
|
16
15
|
} from './vectorIndex';
|
|
17
16
|
|
|
18
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* The identity fields a fragment was added with, already in query-hit shape: a field
|
|
19
|
+
* the fragment did not carry is *absent*, never present-but-`undefined`, so a hit
|
|
20
|
+
* for a fragment added without a `fragmentId` is structurally identical to one
|
|
21
|
+
* produced before `fragmentId` existed.
|
|
22
|
+
*/
|
|
23
|
+
type FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;
|
|
24
|
+
|
|
25
|
+
/** Project an incoming fragment's identity fields, dropping the ones it did not carry. */
|
|
26
|
+
function fragmentIdentity(fragment: IEmbeddedFragment): FragmentIdentity {
|
|
27
|
+
return {
|
|
28
|
+
...(fragment.locator !== undefined ? { locator: fragment.locator } : {}),
|
|
29
|
+
...(fragment.fragmentId !== undefined ? { fragmentId: fragment.fragmentId } : {})
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One stored fragment: the identity it was added with plus its vector. */
|
|
19
34
|
interface IStoredFragment {
|
|
20
|
-
readonly
|
|
35
|
+
readonly identity: FragmentIdentity;
|
|
21
36
|
readonly vector: Float32Array;
|
|
22
37
|
}
|
|
23
38
|
|
|
@@ -36,9 +51,11 @@ interface IScoredFragment {
|
|
|
36
51
|
/**
|
|
37
52
|
* The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the
|
|
38
53
|
* fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many
|
|
39
|
-
* `Float32Array`s per record (one per
|
|
40
|
-
*
|
|
41
|
-
*
|
|
54
|
+
* `Float32Array`s per record (one per {@link IEmbeddedFragment | fragment}) and
|
|
55
|
+
* answers a query by computing cosine similarity against every stored fragment,
|
|
56
|
+
* returning the top-k fragment hits by descending score. Each hit carries back
|
|
57
|
+
* whichever of {@link IFragmentLocator | locator} / `fragmentId` its fragment was
|
|
58
|
+
* added with; a fragment must carry at least one of the two.
|
|
42
59
|
*
|
|
43
60
|
* @remarks
|
|
44
61
|
* Same regime and same non-goals as {@link InMemoryCosineIndex}: no external
|
|
@@ -108,6 +125,16 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
|
|
|
108
125
|
if (fragment.vector.length === 0) {
|
|
109
126
|
return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
|
|
110
127
|
}
|
|
128
|
+
// A fragment carrying neither identity cannot be resolved back to anything by a
|
|
129
|
+
// consumer holding the hit — the same invariant `embeddedFragmentConverter`
|
|
130
|
+
// enforces at the untyped boundary, re-checked here at the index seam.
|
|
131
|
+
if (fragment.locator === undefined && fragment.fragmentId === undefined) {
|
|
132
|
+
return Promise.resolve(
|
|
133
|
+
fail(
|
|
134
|
+
`fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
111
138
|
if (dimension === undefined) {
|
|
112
139
|
dimension = fragment.vector.length;
|
|
113
140
|
} else if (fragment.vector.length !== dimension) {
|
|
@@ -118,7 +145,7 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
|
|
|
118
145
|
);
|
|
119
146
|
}
|
|
120
147
|
// Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.
|
|
121
|
-
stored.push({
|
|
148
|
+
stored.push({ identity: fragmentIdentity(fragment), vector: Float32Array.from(fragment.vector) });
|
|
122
149
|
}
|
|
123
150
|
// Whole-record replace: an empty `fragments` array drops the record entirely
|
|
124
151
|
// rather than leaving an empty shell behind. Commit the (possibly newly-derived)
|
|
@@ -163,7 +190,7 @@ export class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {
|
|
|
163
190
|
hit: {
|
|
164
191
|
target: record.target,
|
|
165
192
|
score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),
|
|
166
|
-
|
|
193
|
+
...fragment.identity
|
|
167
194
|
}
|
|
168
195
|
});
|
|
169
196
|
}
|