@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
|
@@ -41,14 +41,22 @@ const MERGE_PATCH_OPTIONS = {
|
|
|
41
41
|
* view, runs the merge over that view, then rebuilds a coherent record. The
|
|
42
42
|
* identity and transaction-time envelope fields (`id`, `entityId`, `kind`,
|
|
43
43
|
* `created`, `updated`, `seq`, `contentHash`) are NOT mutable and are
|
|
44
|
-
* preserved verbatim; the store stamps `updated` / `seq` on write.
|
|
44
|
+
* preserved verbatim; the store stamps `updated` / `seq` / `contentHash` on write.
|
|
45
|
+
*
|
|
46
|
+
* Because the surface is pinned rather than caller-supplied, the RFC-7386
|
|
47
|
+
* guarantees documented on {@link IWritePolicy.applyUpdate} apply to every field
|
|
48
|
+
* listed above — in particular to `provenance`, whose keys merge individually, a
|
|
49
|
+
* `null` on any one of which clears that key alone, and a `null` on the whole
|
|
50
|
+
* block of which is rejected loudly (it is a required field). Consumers may
|
|
51
|
+
* depend on this; it is covered by tests.
|
|
45
52
|
* @public
|
|
46
53
|
*/
|
|
47
54
|
export class KnowledgeLwwPolicy {
|
|
48
55
|
constructor(cloneEditor, mergeEditor) {
|
|
49
56
|
/**
|
|
50
57
|
* The knowledge mutable surface: the body plus the envelope metadata a
|
|
51
|
-
* consumer may revise without minting a new entity.
|
|
58
|
+
* consumer may revise without minting a new entity. Pinned, not
|
|
59
|
+
* caller-supplied — see the class remarks for what that guarantees.
|
|
52
60
|
*/
|
|
53
61
|
this.mutableFields = [
|
|
54
62
|
'body',
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"writePolicy.js","sourceRoot":"","sources":["../../../src/packlets/types/writePolicy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { JsonObject } from '@fgv/ts-json-base';\nimport { IJsonEditorOptions, JsonEditor } from '@fgv/ts-json';\nimport { IEdge, IMemoryEnvelope, IMemoryRecord, IProvenance } from './envelope';\nimport { MemoryId, Tag } from './ids';\n\n/**\n * The granularity at which the store deduplicates an incoming write against the\n * existing vault, declared per kind via {@link IWritePolicy.dedupScope}.\n *\n * - `'content'`: scope-wide cross-id content dedup. An identical\n * `{ kind, body, links }` triple ANYWHERE in the scope — even under a\n * different id — is a no-op that returns the existing record. The knowledge\n * kind family uses this.\n * - `'entity'`: same-id content dedup only. An identical re-put of the SAME\n * entity is a no-op, but two DISTINCT entities with identical content never\n * collapse. The experience (memory) kind families use this so that, e.g.,\n * two turns whose summaries happen to be byte-identical both persist.\n * @public\n */\nexport type DedupScope = 'content' | 'entity';\n\n/**\n * The default {@link DedupScope} applied when a policy does not declare one.\n * Entity-scoped dedup is the safe default — it never silently collapses two\n * distinct entities with coincidentally-identical content.\n * @public\n */\nexport const DEFAULT_DEDUP_SCOPE: DedupScope = 'entity';\n\n/**\n * The admission decision returned by {@link IWritePolicy.admit}.\n * @public\n */\nexport type AdmissionDecision =\n | { readonly decision: 'accept' }\n | { readonly decision: 'reject'; readonly reason: string }\n | {\n readonly decision: 'cull-oldest';\n /** {@link MemoryId}s to evict before writing the incoming record. */\n readonly evict: ReadonlyArray<MemoryId>;\n };\n\n/**\n * A per-kind write policy, injected at store construction. Invoked AFTER\n * content-hash dedup (dedup is always pre-policy). The policy decides\n * admission and, on update, applies a JSON Merge Patch (RFC-7386) restricted\n * to the declared mutable fields.\n * @public\n */\nexport interface IWritePolicy {\n /**\n * Names of the fields a merge-patch update may touch. Fields outside this\n * list are immutable; a change to one constitutes a new entity (its\n * `contentHash` differs). Used by {@link IWritePolicy.applyUpdate} to\n * restrict which fields the patch applies.\n */\n readonly mutableFields: ReadonlyArray<string>;\n\n /**\n * The granularity at which the store deduplicates an incoming write for this\n * kind. Optional; when absent the store applies {@link DEFAULT_DEDUP_SCOPE}\n * (`'entity'`). See {@link DedupScope}.\n */\n readonly dedupScope?: DedupScope;\n\n /**\n * Determine whether the incoming record is admitted.\n * @param incoming - The record about to be written.\n * @param existing - The admission cohort the kind's cap applies to: the\n * records in the same scope of the same kind, EXCLUDING the record at\n * `incoming`'s target id. Empty on a first write into an empty cohort.\n * Excluding the target id makes the post-write count uniform\n * (`existing.length + 1`) across first-writes and same-id updates. A\n * last-write-wins policy that has no cap ignores this argument.\n * @returns A {@link AdmissionDecision}.\n */\n admit(\n incoming: IMemoryRecord<unknown>,\n existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision>;\n\n /**\n * Apply a JSON Merge Patch (RFC-7386) to the mutable fields of an existing\n * record. Called when admission is `accept` AND a record with the same\n * `entityId` already exists (an update, not a first write).\n *\n * @param existing - The current persisted record.\n * @param patch - A partial JSON object in Merge Patch format. `null`\n * deletes the corresponding key; arrays replace wholesale; nested objects\n * deep-merge. Only keys in {@link IWritePolicy.mutableFields} are applied.\n * @returns The updated record (envelope + body).\n */\n applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>>;\n}\n\n/**\n * RFC-7386-compliant merge options for {@link IWritePolicy.applyUpdate}.\n *\n * @remarks\n * Phase-A verification (design-lock §5.1) confirmed `@fgv/ts-json`'s\n * `JsonEditor` diverges from RFC-7386 under its defaults on two axes —\n * `nullAsDelete` defaults `false` and `arrayMergeBehavior` defaults\n * `'append'`. Both are corrected here via the existing option surface, so the\n * primitive is composed (not extended, not hand-rolled).\n */\nconst MERGE_PATCH_OPTIONS: Partial<IJsonEditorOptions> = {\n merge: {\n nullAsDelete: true,\n arrayMergeBehavior: 'replace'\n }\n};\n\n/**\n * Last-write-wins write policy for the knowledge kind family. Admission\n * always accepts (no cap, no cull); updates apply an RFC-7386 merge patch\n * restricted to the knowledge mutable surface.\n *\n * @remarks\n * **Merge-surface pin (resolves design-lock §5.3's body-vs-envelope muddle).**\n * The declared `mutableFields` span both axes of a record: `body` is the\n * per-kind body, while `tags` / `links` / `provenance` / `embeddingRef` live\n * on the {@link IMemoryEnvelope}. `applyUpdate` projects exactly those fields\n * — each read from its canonical location — into a single record-level JSON\n * view, runs the merge over that view, then rebuilds a coherent record. The\n * identity and transaction-time envelope fields (`id`, `entityId`, `kind`,\n * `created`, `updated`, `seq`, `contentHash`) are NOT mutable and are\n * preserved verbatim; the store stamps `updated` / `seq` on write.\n * @public\n */\nexport class KnowledgeLwwPolicy implements IWritePolicy {\n /**\n * The knowledge mutable surface: the body plus the envelope metadata a\n * consumer may revise without minting a new entity.\n */\n public readonly mutableFields: ReadonlyArray<string> = [\n 'body',\n 'tags',\n 'links',\n 'provenance',\n 'embeddingRef'\n ];\n\n /**\n * Knowledge dedups scope-wide: an identical `{ kind, body, links }` triple\n * anywhere in the `knowledge` scope — even under a different `docId` — is a\n * no-op. Declared explicitly so the B1 content-dedup behavior (and its tests)\n * are unchanged by the {@link DedupScope} amendment.\n */\n public readonly dedupScope: DedupScope = 'content';\n\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances\n * (one for cloning, one for the RFC-7386 merge), both with the template /\n * conditional / multivalue / reference rules disabled (empty rules array).\n */\n public static create(): Result<KnowledgeLwwPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new KnowledgeLwwPolicy(cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n __existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n // Last-write-wins: always accept. No cap, no cull.\n return succeed({ decision: 'accept' });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the mutable fields into a single record-level view, each sourced\n // from its canonical location. `embeddingRef` is omitted when `undefined`\n // (the editor rejects undefined property values).\n const view: Record<string, unknown> = {\n body: existing.body,\n tags: existing.envelope.tags,\n links: existing.envelope.links,\n provenance: existing.envelope.provenance\n };\n if (existing.envelope.embeddingRef !== undefined) {\n view.embeddingRef = existing.envelope.embeddingRef;\n }\n\n // Restrict the incoming patch to the declared mutable fields; out-of-scope\n // keys are never applied (the store enforces the same constraint).\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch\n // onto the clone so the persisted record is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. `body` / `tags` /\n * `links` / `provenance` are required and may not be deleted by a patch.\n * `embeddingRef` is optional: when it is absent from the merged view —\n * because the existing record never carried it OR a `null` patch deleted it\n * (RFC-7386) — it is restored as `undefined` (absent), NOT `null`. This keeps\n * an originally-absent `embeddingRef` from silently flipping to `null` on an\n * unrelated update, so the field round-trips hash-stably through the store's\n * content-hash recomputation.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const required: ReadonlyArray<string> = ['body', 'tags', 'links', 'provenance'];\n const missing: ReadonlyArray<string> = required.filter((field) => !(field in merged));\n if (missing.length > 0) {\n return fail(`knowledge LWW: merge patch may not delete required field(s): ${missing.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types. (The types packlet cannot import the\n // converters packlet without a cycle, so these are structural restorations\n // of fields the merge preserved, not fresh untrusted input.)\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: merged.tags as unknown as ReadonlyArray<Tag>,\n links: merged.links as unknown as ReadonlyArray<IEdge>,\n provenance: merged.provenance as unknown as IProvenance,\n embeddingRef: 'embeddingRef' in merged ? (merged.embeddingRef as string | null) : undefined\n };\n return succeed({ envelope, body: merged.body });\n }\n}\n\n/**\n * Parameters for {@link MemoryCapCullPolicy.create}.\n * @public\n */\nexport interface IMemoryCapCullPolicyParams {\n /**\n * Maximum number of records the policy admits before culling the oldest.\n * Counted over the `existing` cohort passed to {@link IWritePolicy.admit}.\n * Absent = no cap (admission always accepts).\n */\n readonly maxRecords?: number;\n /**\n * The fields a merge-patch update may touch (drawn from the record-level\n * mutable vocabulary: `body` / `tags` / `links` / `provenance` /\n * `embeddingRef`). Fields outside this list are immutable.\n */\n readonly mutableFields: ReadonlyArray<string>;\n}\n\n/**\n * The record-level fields a {@link MemoryCapCullPolicy} merge-patch may project,\n * mapped to their canonical location on a record. Mirrors the store's\n * mutable-field accessor vocabulary; a declared mutable field outside this set\n * is inert (the store cannot project it either).\n */\nconst CAP_CULL_FIELD_READERS: ReadonlyMap<string, (record: IMemoryRecord<unknown>) => unknown> = new Map<\n string,\n (record: IMemoryRecord<unknown>) => unknown\n>([\n ['body', (r) => r.body],\n ['tags', (r) => r.envelope.tags],\n ['links', (r) => r.envelope.links],\n ['provenance', (r) => r.envelope.provenance],\n ['embeddingRef', (r) => r.envelope.embeddingRef]\n]);\n\n/** The record-level mutable fields that may never be deleted by a merge patch. */\nconst CAP_CULL_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>([\n 'body',\n 'tags',\n 'links',\n 'provenance'\n]);\n\n/**\n * Bounded-ring write policy for the experience (memory) kind families.\n * Admission accepts until `maxRecords` is reached, then evicts the oldest\n * record(s) by `created` ascending (design-lock §5.3); updates apply the same\n * RFC-7386 merge patch as {@link KnowledgeLwwPolicy}, restricted to the declared\n * {@link IMemoryCapCullPolicyParams.mutableFields | mutableFields}.\n *\n * @remarks\n * - **Dedup scope.** Declares `dedupScope: 'entity'` — two distinct memory\n * entities (e.g. `turn-5` / `turn-9`) with identical `{ kind, body, links }`\n * never collapse; only an identical re-put of the SAME entity is a no-op.\n * - **Eviction boundary.** `admit` only DECIDES (returns the `MemoryId`s to\n * evict); the store executes the file deletions and index patches. The\n * `existing` cohort the cap counts against is whatever the store supplies to\n * `admit`.\n * @public\n */\nexport class MemoryCapCullPolicy implements IWritePolicy {\n /** {@inheritDoc IWritePolicy.mutableFields} */\n public readonly mutableFields: ReadonlyArray<string>;\n\n /** Experience kinds dedup per-entity (see the class remarks). */\n public readonly dedupScope: DedupScope = 'entity';\n\n /** The admission cap; `undefined` = no cap. */\n private readonly _maxRecords: number | undefined;\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(params: IMemoryCapCullPolicyParams, cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this.mutableFields = params.mutableFields;\n this._maxRecords = params.maxRecords;\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances\n * (one for cloning, one for the RFC-7386 merge) with the same merge config as\n * {@link KnowledgeLwwPolicy} (`nullAsDelete` true, `arrayMergeBehavior`\n * `'replace'`, rules disabled).\n */\n public static create(params: IMemoryCapCullPolicyParams): Result<MemoryCapCullPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new MemoryCapCullPolicy(params, cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n if (this._maxRecords === undefined || existing.length < this._maxRecords) {\n return succeed({ decision: 'accept' });\n }\n // Cap reached: evict the oldest by `created` ascending so the post-write\n // count is exactly `maxRecords` (existing.length - maxRecords + 1 victims).\n const evict: ReadonlyArray<MemoryId> = [...existing]\n .sort((a, b) => a.envelope.created - b.envelope.created)\n .slice(0, existing.length - this._maxRecords + 1)\n .map((record) => record.envelope.id);\n return succeed({ decision: 'cull-oldest', evict });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the declared mutable fields (restricted to the known record-level\n // vocabulary) into a single record-level view from their canonical\n // locations. An `undefined` value is omitted (the editor rejects undefined\n // property values).\n const view: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n const reader: ((record: IMemoryRecord<unknown>) => unknown) | undefined =\n CAP_CULL_FIELD_READERS.get(field);\n if (reader !== undefined) {\n const value: unknown = reader(existing);\n if (value !== undefined) {\n view[field] = value;\n }\n }\n }\n\n // Restrict the incoming patch to the declared mutable fields.\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch\n // onto the clone so the persisted record is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. Only the declared mutable\n * fields are taken from the merge; undeclared fields are preserved verbatim\n * from `existing`. A `null` patch that deletes a *declared mutable* required\n * field (`body` / `tags` / `links` / `provenance`) is an error — a required\n * field that is NOT declared mutable simply falls through to its `existing.*`\n * value and is never at risk. `embeddingRef`, when mutable, is restored as\n * `undefined` (absent) if the merge dropped it — same hash-stable semantics as\n * {@link KnowledgeLwwPolicy}.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const deleted: ReadonlyArray<string> = this.mutableFields.filter(\n (field) => CAP_CULL_REQUIRED_FIELDS.has(field) && !(field in merged)\n );\n if (deleted.length > 0) {\n return fail(`memory cap-cull: merge patch may not delete required field(s): ${deleted.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types. (The types packlet cannot import the\n // converters packlet without a cycle, so these are structural restorations\n // of fields the merge preserved, not fresh untrusted input — mirrors\n // KnowledgeLwwPolicy._rebuild.)\n const embeddingRefMutable: boolean = this.mutableFields.includes('embeddingRef');\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: 'tags' in merged ? (merged.tags as unknown as ReadonlyArray<Tag>) : existing.envelope.tags,\n links: 'links' in merged ? (merged.links as unknown as ReadonlyArray<IEdge>) : existing.envelope.links,\n provenance:\n 'provenance' in merged ? (merged.provenance as unknown as IProvenance) : existing.envelope.provenance,\n embeddingRef: embeddingRefMutable\n ? 'embeddingRef' in merged\n ? (merged.embeddingRef as string | null)\n : undefined\n : existing.envelope.embeddingRef\n };\n return succeed({ envelope, body: 'body' in merged ? merged.body : existing.body });\n }\n}\n\n/**\n * Write policy for a versioned (temporal) kind family, implementing\n * invalidate-don't-delete. Admission always accepts — history is retained, never\n * culled — and updates apply the same RFC-7386 merge patch as\n * {@link KnowledgeLwwPolicy}, restricted to the temporal mutable surface.\n *\n * @remarks\n * The policy does NOT perform the version file writes or set `invalid_at` — that\n * is the store's versioned write branch, driven by the kind's\n * {@link ITemporalIdentityCodec}. The policy's role is limited to admission and\n * the merge that forms the **new version's** content from the **current**\n * version plus the incoming patch (the merge-patch-under-versioning contract).\n *\n * - **Dedup scope.** `'entity'` — an identical re-put of the current content is a\n * no-op (the store compares the incoming content hash against the current\n * version), so identical writes do not spawn redundant versions.\n * - **Mutable surface.** `body` + the envelope metadata a consumer may revise\n * (`tags` / `links` / `provenance` / `embeddingRef`). `temporal` is NOT mutable\n * here — `valid_at` / `invalid_at` are set by the store's versioned branch.\n * @public\n */\nexport class TemporalVersionedPolicy implements IWritePolicy {\n /** The temporal mutable surface (mirrors {@link KnowledgeLwwPolicy}). */\n public readonly mutableFields: ReadonlyArray<string> = [\n 'body',\n 'tags',\n 'links',\n 'provenance',\n 'embeddingRef'\n ];\n\n /** Versioned kinds dedup per-entity against the current version (see the class remarks). */\n public readonly dedupScope: DedupScope = 'entity';\n\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances (one\n * for cloning, one for the RFC-7386 merge), rules disabled — the same merge\n * config as the shipped policies.\n */\n public static create(): Result<TemporalVersionedPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new TemporalVersionedPolicy(cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n __existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n // Invalidate-don't-delete: always accept. Superseded versions are retained\n // (invalidated), never culled.\n return succeed({ decision: 'accept' });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the mutable fields into a single record-level view, each sourced\n // from its canonical location. `embeddingRef` is omitted when `undefined`.\n const view: Record<string, unknown> = {\n body: existing.body,\n tags: existing.envelope.tags,\n links: existing.envelope.links,\n provenance: existing.envelope.provenance\n };\n if (existing.envelope.embeddingRef !== undefined) {\n view.embeddingRef = existing.envelope.embeddingRef;\n }\n\n // Restrict the incoming patch to the declared mutable fields.\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch onto\n // the clone so the current version is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. `body` / `tags` / `links` /\n * `provenance` are required and may not be deleted by a patch; `embeddingRef`,\n * when dropped by the merge, is restored as `undefined` (absent) — the same\n * hash-stable semantics as {@link KnowledgeLwwPolicy}.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const required: ReadonlyArray<string> = ['body', 'tags', 'links', 'provenance'];\n const missing: ReadonlyArray<string> = required.filter((field) => !(field in merged));\n if (missing.length > 0) {\n return fail(`temporal versioned: merge patch may not delete required field(s): ${missing.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types (structural restorations, not fresh\n // untrusted input — mirrors KnowledgeLwwPolicy._rebuild).\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: merged.tags as unknown as ReadonlyArray<Tag>,\n links: merged.links as unknown as ReadonlyArray<IEdge>,\n provenance: merged.provenance as unknown as IProvenance,\n embeddingRef: 'embeddingRef' in merged ? (merged.embeddingRef as string | null) : undefined\n };\n return succeed({ envelope, body: merged.body });\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"writePolicy.js","sourceRoot":"","sources":["../../../src/packlets/types/writePolicy.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { JsonObject } from '@fgv/ts-json-base';\nimport { IJsonEditorOptions, JsonEditor } from '@fgv/ts-json';\nimport { IEdge, IMemoryEnvelope, IMemoryRecord, IProvenance } from './envelope';\nimport { MemoryId, Tag } from './ids';\n\n/**\n * The granularity at which the store deduplicates an incoming write against the\n * existing vault, declared per kind via {@link IWritePolicy.dedupScope}.\n *\n * - `'content'`: scope-wide cross-id content dedup. An identical\n * `{ kind, body, links }` triple ANYWHERE in the scope — even under a\n * different id — is a no-op that returns the existing record. The knowledge\n * kind family uses this.\n * - `'entity'`: same-id content dedup only. An identical re-put of the SAME\n * entity is a no-op, but two DISTINCT entities with identical content never\n * collapse. The experience (memory) kind families use this so that, e.g.,\n * two turns whose summaries happen to be byte-identical both persist.\n * @public\n */\nexport type DedupScope = 'content' | 'entity';\n\n/**\n * The default {@link DedupScope} applied when a policy does not declare one.\n * Entity-scoped dedup is the safe default — it never silently collapses two\n * distinct entities with coincidentally-identical content.\n * @public\n */\nexport const DEFAULT_DEDUP_SCOPE: DedupScope = 'entity';\n\n/**\n * The admission decision returned by {@link IWritePolicy.admit}.\n * @public\n */\nexport type AdmissionDecision =\n | { readonly decision: 'accept' }\n | { readonly decision: 'reject'; readonly reason: string }\n | {\n readonly decision: 'cull-oldest';\n /** {@link MemoryId}s to evict before writing the incoming record. */\n readonly evict: ReadonlyArray<MemoryId>;\n };\n\n/**\n * A per-kind write policy, injected at store construction. Invoked AFTER\n * content-hash dedup (dedup is always pre-policy). The policy decides\n * admission and, on update, applies a JSON Merge Patch (RFC-7386) restricted\n * to the declared mutable fields.\n * @public\n */\nexport interface IWritePolicy {\n /**\n * Names of the fields a merge-patch update may touch. Fields outside this\n * list are immutable; a change to one constitutes a new entity (its\n * `contentHash` differs). Used by {@link IWritePolicy.applyUpdate} to\n * restrict which fields the patch applies.\n */\n readonly mutableFields: ReadonlyArray<string>;\n\n /**\n * The granularity at which the store deduplicates an incoming write for this\n * kind. Optional; when absent the store applies {@link DEFAULT_DEDUP_SCOPE}\n * (`'entity'`). See {@link DedupScope}.\n */\n readonly dedupScope?: DedupScope;\n\n /**\n * Determine whether the incoming record is admitted.\n * @param incoming - The record about to be written.\n * @param existing - The admission cohort the kind's cap applies to: the\n * records in the same scope of the same kind, EXCLUDING the record at\n * `incoming`'s target id. Empty on a first write into an empty cohort.\n * Excluding the target id makes the post-write count uniform\n * (`existing.length + 1`) across first-writes and same-id updates. A\n * last-write-wins policy that has no cap ignores this argument.\n * @returns A {@link AdmissionDecision}.\n */\n admit(\n incoming: IMemoryRecord<unknown>,\n existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision>;\n\n /**\n * Apply a JSON Merge Patch (RFC-7386) to the mutable fields of an existing\n * record. Called when admission is `accept` AND a record with the same\n * `entityId` already exists (an update, not a first write).\n *\n * @remarks\n * **RFC-7386 semantics are the contract here, not an artifact of the shipped\n * policies' merge configuration.** Implementations are expected to honor them,\n * and consumers may rely on them:\n *\n * - **Objects merge per key.** A supplied key overwrites; an omitted key is\n * PRESERVED, not dropped. Patching one key of a nested object (e.g. one\n * field of `provenance`) leaves its siblings intact.\n * - **An explicit `null` on a sub-key clears exactly that sub-key.** This is\n * the sanctioned way to remove a single key from a nested object.\n * - **Arrays replace wholesale** — `tags` / `links` are never element-merged.\n * - **A whole-block `null` that would delete a REQUIRED field is rejected\n * loudly** (`Result.fail`), never silently accepted. `body` / `tags` /\n * `links` / `provenance` are required; `embeddingRef` is optional and a\n * `null` restores it to absent (NOT to `null`), so it stays hash-stable.\n *\n * Which fields these guarantees cover is **policy-dependent**: they apply to\n * the fields the policy declares in {@link IWritePolicy.mutableFields}, and a\n * field outside that list is inert — its patch key is dropped before the merge,\n * so a `null` on it neither clears the value nor raises an error.\n *\n * @param existing - The current persisted record.\n * @param patch - A partial JSON object in Merge Patch format. `null`\n * deletes the corresponding key; arrays replace wholesale; nested objects\n * deep-merge. Only keys in {@link IWritePolicy.mutableFields} are applied.\n * @returns The updated record (envelope + body).\n */\n applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>>;\n}\n\n/**\n * RFC-7386-compliant merge options for {@link IWritePolicy.applyUpdate}.\n *\n * @remarks\n * Phase-A verification (design-lock §5.1) confirmed `@fgv/ts-json`'s\n * `JsonEditor` diverges from RFC-7386 under its defaults on two axes —\n * `nullAsDelete` defaults `false` and `arrayMergeBehavior` defaults\n * `'append'`. Both are corrected here via the existing option surface, so the\n * primitive is composed (not extended, not hand-rolled).\n */\nconst MERGE_PATCH_OPTIONS: Partial<IJsonEditorOptions> = {\n merge: {\n nullAsDelete: true,\n arrayMergeBehavior: 'replace'\n }\n};\n\n/**\n * Last-write-wins write policy for the knowledge kind family. Admission\n * always accepts (no cap, no cull); updates apply an RFC-7386 merge patch\n * restricted to the knowledge mutable surface.\n *\n * @remarks\n * **Merge-surface pin (resolves design-lock §5.3's body-vs-envelope muddle).**\n * The declared `mutableFields` span both axes of a record: `body` is the\n * per-kind body, while `tags` / `links` / `provenance` / `embeddingRef` live\n * on the {@link IMemoryEnvelope}. `applyUpdate` projects exactly those fields\n * — each read from its canonical location — into a single record-level JSON\n * view, runs the merge over that view, then rebuilds a coherent record. The\n * identity and transaction-time envelope fields (`id`, `entityId`, `kind`,\n * `created`, `updated`, `seq`, `contentHash`) are NOT mutable and are\n * preserved verbatim; the store stamps `updated` / `seq` / `contentHash` on write.\n *\n * Because the surface is pinned rather than caller-supplied, the RFC-7386\n * guarantees documented on {@link IWritePolicy.applyUpdate} apply to every field\n * listed above — in particular to `provenance`, whose keys merge individually, a\n * `null` on any one of which clears that key alone, and a `null` on the whole\n * block of which is rejected loudly (it is a required field). Consumers may\n * depend on this; it is covered by tests.\n * @public\n */\nexport class KnowledgeLwwPolicy implements IWritePolicy {\n /**\n * The knowledge mutable surface: the body plus the envelope metadata a\n * consumer may revise without minting a new entity. Pinned, not\n * caller-supplied — see the class remarks for what that guarantees.\n */\n public readonly mutableFields: ReadonlyArray<string> = [\n 'body',\n 'tags',\n 'links',\n 'provenance',\n 'embeddingRef'\n ];\n\n /**\n * Knowledge dedups scope-wide: an identical `{ kind, body, links }` triple\n * anywhere in the `knowledge` scope — even under a different `docId` — is a\n * no-op. Declared explicitly so the B1 content-dedup behavior (and its tests)\n * are unchanged by the {@link DedupScope} amendment.\n */\n public readonly dedupScope: DedupScope = 'content';\n\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances\n * (one for cloning, one for the RFC-7386 merge), both with the template /\n * conditional / multivalue / reference rules disabled (empty rules array).\n */\n public static create(): Result<KnowledgeLwwPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new KnowledgeLwwPolicy(cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n __existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n // Last-write-wins: always accept. No cap, no cull.\n return succeed({ decision: 'accept' });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the mutable fields into a single record-level view, each sourced\n // from its canonical location. `embeddingRef` is omitted when `undefined`\n // (the editor rejects undefined property values).\n const view: Record<string, unknown> = {\n body: existing.body,\n tags: existing.envelope.tags,\n links: existing.envelope.links,\n provenance: existing.envelope.provenance\n };\n if (existing.envelope.embeddingRef !== undefined) {\n view.embeddingRef = existing.envelope.embeddingRef;\n }\n\n // Restrict the incoming patch to the declared mutable fields; out-of-scope\n // keys are never applied (the store enforces the same constraint).\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch\n // onto the clone so the persisted record is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. `body` / `tags` /\n * `links` / `provenance` are required and may not be deleted by a patch.\n * `embeddingRef` is optional: when it is absent from the merged view —\n * because the existing record never carried it OR a `null` patch deleted it\n * (RFC-7386) — it is restored as `undefined` (absent), NOT `null`. This keeps\n * an originally-absent `embeddingRef` from silently flipping to `null` on an\n * unrelated update, so the field round-trips hash-stably through the store's\n * content-hash recomputation.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const required: ReadonlyArray<string> = ['body', 'tags', 'links', 'provenance'];\n const missing: ReadonlyArray<string> = required.filter((field) => !(field in merged));\n if (missing.length > 0) {\n return fail(`knowledge LWW: merge patch may not delete required field(s): ${missing.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types. (The types packlet cannot import the\n // converters packlet without a cycle, so these are structural restorations\n // of fields the merge preserved, not fresh untrusted input.)\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: merged.tags as unknown as ReadonlyArray<Tag>,\n links: merged.links as unknown as ReadonlyArray<IEdge>,\n provenance: merged.provenance as unknown as IProvenance,\n embeddingRef: 'embeddingRef' in merged ? (merged.embeddingRef as string | null) : undefined\n };\n return succeed({ envelope, body: merged.body });\n }\n}\n\n/**\n * Parameters for {@link MemoryCapCullPolicy.create}.\n * @public\n */\nexport interface IMemoryCapCullPolicyParams {\n /**\n * Maximum number of records the policy admits before culling the oldest.\n * Counted over the `existing` cohort passed to {@link IWritePolicy.admit}.\n * Absent = no cap (admission always accepts).\n */\n readonly maxRecords?: number;\n /**\n * The fields a merge-patch update may touch (drawn from the record-level\n * mutable vocabulary: `body` / `tags` / `links` / `provenance` /\n * `embeddingRef`). Fields outside this list are immutable.\n *\n * @remarks\n * This list — unlike {@link KnowledgeLwwPolicy}'s pinned surface — is\n * caller-supplied, so it is what decides which fields get the RFC-7386\n * guarantees documented on {@link IWritePolicy.applyUpdate}. Declare\n * `provenance` here to get per-key provenance merging and `null` sub-key\n * clearing; omit it and every provenance patch key is inert (dropped before\n * the merge, so the existing value is preserved verbatim and even a\n * whole-block `null` is a silent no-op rather than an error).\n */\n readonly mutableFields: ReadonlyArray<string>;\n}\n\n/**\n * The record-level fields a {@link MemoryCapCullPolicy} merge-patch may project,\n * mapped to their canonical location on a record. Mirrors the store's\n * mutable-field accessor vocabulary; a declared mutable field outside this set\n * is inert (the store cannot project it either).\n */\nconst CAP_CULL_FIELD_READERS: ReadonlyMap<string, (record: IMemoryRecord<unknown>) => unknown> = new Map<\n string,\n (record: IMemoryRecord<unknown>) => unknown\n>([\n ['body', (r) => r.body],\n ['tags', (r) => r.envelope.tags],\n ['links', (r) => r.envelope.links],\n ['provenance', (r) => r.envelope.provenance],\n ['embeddingRef', (r) => r.envelope.embeddingRef]\n]);\n\n/** The record-level mutable fields that may never be deleted by a merge patch. */\nconst CAP_CULL_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>([\n 'body',\n 'tags',\n 'links',\n 'provenance'\n]);\n\n/**\n * Bounded-ring write policy for the experience (memory) kind families.\n * Admission accepts until `maxRecords` is reached, then evicts the oldest\n * record(s) by `created` ascending (design-lock §5.3); updates apply the same\n * RFC-7386 merge patch as {@link KnowledgeLwwPolicy}, restricted to the declared\n * {@link IMemoryCapCullPolicyParams.mutableFields | mutableFields}.\n *\n * @remarks\n * - **Dedup scope.** Declares `dedupScope: 'entity'` — two distinct memory\n * entities (e.g. `turn-5` / `turn-9`) with identical `{ kind, body, links }`\n * never collapse; only an identical re-put of the SAME entity is a no-op.\n * - **Eviction boundary.** `admit` only DECIDES (returns the `MemoryId`s to\n * evict); the store executes the file deletions and index patches. The\n * `existing` cohort the cap counts against is whatever the store supplies to\n * `admit`.\n * @public\n */\nexport class MemoryCapCullPolicy implements IWritePolicy {\n /** {@inheritDoc IWritePolicy.mutableFields} */\n public readonly mutableFields: ReadonlyArray<string>;\n\n /** Experience kinds dedup per-entity (see the class remarks). */\n public readonly dedupScope: DedupScope = 'entity';\n\n /** The admission cap; `undefined` = no cap. */\n private readonly _maxRecords: number | undefined;\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(params: IMemoryCapCullPolicyParams, cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this.mutableFields = params.mutableFields;\n this._maxRecords = params.maxRecords;\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances\n * (one for cloning, one for the RFC-7386 merge) with the same merge config as\n * {@link KnowledgeLwwPolicy} (`nullAsDelete` true, `arrayMergeBehavior`\n * `'replace'`, rules disabled).\n */\n public static create(params: IMemoryCapCullPolicyParams): Result<MemoryCapCullPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new MemoryCapCullPolicy(params, cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n if (this._maxRecords === undefined || existing.length < this._maxRecords) {\n return succeed({ decision: 'accept' });\n }\n // Cap reached: evict the oldest by `created` ascending so the post-write\n // count is exactly `maxRecords` (existing.length - maxRecords + 1 victims).\n const evict: ReadonlyArray<MemoryId> = [...existing]\n .sort((a, b) => a.envelope.created - b.envelope.created)\n .slice(0, existing.length - this._maxRecords + 1)\n .map((record) => record.envelope.id);\n return succeed({ decision: 'cull-oldest', evict });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the declared mutable fields (restricted to the known record-level\n // vocabulary) into a single record-level view from their canonical\n // locations. An `undefined` value is omitted (the editor rejects undefined\n // property values).\n const view: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n const reader: ((record: IMemoryRecord<unknown>) => unknown) | undefined =\n CAP_CULL_FIELD_READERS.get(field);\n if (reader !== undefined) {\n const value: unknown = reader(existing);\n if (value !== undefined) {\n view[field] = value;\n }\n }\n }\n\n // Restrict the incoming patch to the declared mutable fields.\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch\n // onto the clone so the persisted record is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. Only the declared mutable\n * fields are taken from the merge; undeclared fields are preserved verbatim\n * from `existing`. A `null` patch that deletes a *declared mutable* required\n * field (`body` / `tags` / `links` / `provenance`) is an error — a required\n * field that is NOT declared mutable simply falls through to its `existing.*`\n * value and is never at risk. `embeddingRef`, when mutable, is restored as\n * `undefined` (absent) if the merge dropped it — same hash-stable semantics as\n * {@link KnowledgeLwwPolicy}.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const deleted: ReadonlyArray<string> = this.mutableFields.filter(\n (field) => CAP_CULL_REQUIRED_FIELDS.has(field) && !(field in merged)\n );\n if (deleted.length > 0) {\n return fail(`memory cap-cull: merge patch may not delete required field(s): ${deleted.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types. (The types packlet cannot import the\n // converters packlet without a cycle, so these are structural restorations\n // of fields the merge preserved, not fresh untrusted input — mirrors\n // KnowledgeLwwPolicy._rebuild.)\n const embeddingRefMutable: boolean = this.mutableFields.includes('embeddingRef');\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: 'tags' in merged ? (merged.tags as unknown as ReadonlyArray<Tag>) : existing.envelope.tags,\n links: 'links' in merged ? (merged.links as unknown as ReadonlyArray<IEdge>) : existing.envelope.links,\n provenance:\n 'provenance' in merged ? (merged.provenance as unknown as IProvenance) : existing.envelope.provenance,\n embeddingRef: embeddingRefMutable\n ? 'embeddingRef' in merged\n ? (merged.embeddingRef as string | null)\n : undefined\n : existing.envelope.embeddingRef\n };\n return succeed({ envelope, body: 'body' in merged ? merged.body : existing.body });\n }\n}\n\n/**\n * Write policy for a versioned (temporal) kind family, implementing\n * invalidate-don't-delete. Admission always accepts — history is retained, never\n * culled — and updates apply the same RFC-7386 merge patch as\n * {@link KnowledgeLwwPolicy}, restricted to the temporal mutable surface.\n *\n * @remarks\n * The policy does NOT perform the version file writes or set `invalid_at` — that\n * is the store's versioned write branch, driven by the kind's\n * {@link ITemporalIdentityCodec}. The policy's role is limited to admission and\n * the merge that forms the **new version's** content from the **current**\n * version plus the incoming patch (the merge-patch-under-versioning contract).\n *\n * - **Dedup scope.** `'entity'` — an identical re-put of the current content is a\n * no-op (the store compares the incoming content hash against the current\n * version), so identical writes do not spawn redundant versions.\n * - **Mutable surface.** `body` + the envelope metadata a consumer may revise\n * (`tags` / `links` / `provenance` / `embeddingRef`). `temporal` is NOT mutable\n * here — `valid_at` / `invalid_at` are set by the store's versioned branch.\n * @public\n */\nexport class TemporalVersionedPolicy implements IWritePolicy {\n /** The temporal mutable surface (mirrors {@link KnowledgeLwwPolicy}). */\n public readonly mutableFields: ReadonlyArray<string> = [\n 'body',\n 'tags',\n 'links',\n 'provenance',\n 'embeddingRef'\n ];\n\n /** Versioned kinds dedup per-entity against the current version (see the class remarks). */\n public readonly dedupScope: DedupScope = 'entity';\n\n /** Deep-clones the mutable view without RFC-7386 null-deletion semantics. */\n private readonly _cloneEditor: JsonEditor;\n /** Applies the RFC-7386 merge patch. */\n private readonly _mergeEditor: JsonEditor;\n\n private constructor(cloneEditor: JsonEditor, mergeEditor: JsonEditor) {\n this._cloneEditor = cloneEditor;\n this._mergeEditor = mergeEditor;\n }\n\n /**\n * Family-convention factory. Constructs the shared `JsonEditor` instances (one\n * for cloning, one for the RFC-7386 merge), rules disabled — the same merge\n * config as the shipped policies.\n */\n public static create(): Result<TemporalVersionedPolicy> {\n return JsonEditor.create({}, []).onSuccess((cloneEditor) =>\n JsonEditor.create(MERGE_PATCH_OPTIONS, []).onSuccess((mergeEditor) =>\n succeed(new TemporalVersionedPolicy(cloneEditor, mergeEditor))\n )\n );\n }\n\n /** {@inheritDoc IWritePolicy.admit} */\n public admit(\n __incoming: IMemoryRecord<unknown>,\n __existing: ReadonlyArray<IMemoryRecord<unknown>>\n ): Result<AdmissionDecision> {\n // Invalidate-don't-delete: always accept. Superseded versions are retained\n // (invalidated), never culled.\n return succeed({ decision: 'accept' });\n }\n\n /** {@inheritDoc IWritePolicy.applyUpdate} */\n public applyUpdate(\n existing: IMemoryRecord<unknown>,\n patch: Record<string, unknown>\n ): Result<IMemoryRecord<unknown>> {\n // Project the mutable fields into a single record-level view, each sourced\n // from its canonical location. `embeddingRef` is omitted when `undefined`.\n const view: Record<string, unknown> = {\n body: existing.body,\n tags: existing.envelope.tags,\n links: existing.envelope.links,\n provenance: existing.envelope.provenance\n };\n if (existing.envelope.embeddingRef !== undefined) {\n view.embeddingRef = existing.envelope.embeddingRef;\n }\n\n // Restrict the incoming patch to the declared mutable fields.\n const scopedPatch: Record<string, unknown> = {};\n for (const field of this.mutableFields) {\n if (field in patch) {\n scopedPatch[field] = patch[field];\n }\n }\n\n // Clone the view (no null-deletion), then apply the RFC-7386 merge patch onto\n // the clone so the current version is never mutated in place.\n return this._cloneEditor\n .mergeObjectInPlace({}, view as JsonObject)\n .onSuccess((clone) => this._mergeEditor.mergeObjectInPlace(clone, scopedPatch as JsonObject))\n .onSuccess((merged) => this._rebuild(existing, merged));\n }\n\n /**\n * Reassemble a record from the merged mutable view. `body` / `tags` / `links` /\n * `provenance` are required and may not be deleted by a patch; `embeddingRef`,\n * when dropped by the merge, is restored as `undefined` (absent) — the same\n * hash-stable semantics as {@link KnowledgeLwwPolicy}.\n */\n private _rebuild(existing: IMemoryRecord<unknown>, merged: JsonObject): Result<IMemoryRecord<unknown>> {\n const required: ReadonlyArray<string> = ['body', 'tags', 'links', 'provenance'];\n const missing: ReadonlyArray<string> = required.filter((field) => !(field in merged));\n if (missing.length > 0) {\n return fail(`temporal versioned: merge patch may not delete required field(s): ${missing.join(', ')}`);\n }\n\n // The merged values are JSON projections of the already-validated typed\n // record; restore the domain types (structural restorations, not fresh\n // untrusted input — mirrors KnowledgeLwwPolicy._rebuild).\n const envelope: IMemoryEnvelope = {\n ...existing.envelope,\n tags: merged.tags as unknown as ReadonlyArray<Tag>,\n links: merged.links as unknown as ReadonlyArray<IEdge>,\n provenance: merged.provenance as unknown as IProvenance,\n embeddingRef: 'embeddingRef' in merged ? (merged.embeddingRef as string | null) : undefined\n };\n return succeed({ envelope, body: merged.body });\n }\n}\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
import { Converters, fail, succeed } from '@fgv/ts-utils';
|
|
6
|
+
/**
|
|
7
|
+
* Converter for an {@link IFragmentLocator}. Validates the two offsets only — the
|
|
8
|
+
* span's *meaning* (character / byte / token unit) is the consumer's, and the index
|
|
9
|
+
* never interprets it. Note the span is advisory: see {@link IFragmentLocator}.
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
export const fragmentLocatorConverter = Converters.object({
|
|
13
|
+
start: Converters.number,
|
|
14
|
+
end: Converters.number
|
|
15
|
+
});
|
|
16
|
+
/** Validates an already-constructed `Float32Array` embedding vector in place. */
|
|
17
|
+
const embeddingVectorConverter = Converters.generic((from) => from instanceof Float32Array ? succeed(from) : fail('embedded fragment: vector must be a Float32Array'));
|
|
18
|
+
/** The field-shape half of {@link embeddedFragmentConverter}; the invariant is layered on top. */
|
|
19
|
+
const embeddedFragmentFieldsConverter = Converters.object({
|
|
20
|
+
locator: fragmentLocatorConverter.optional(),
|
|
21
|
+
fragmentId: Converters.string.optional(),
|
|
22
|
+
vector: embeddingVectorConverter
|
|
23
|
+
}, { optionalFields: ['locator', 'fragmentId'] });
|
|
24
|
+
/**
|
|
25
|
+
* Converter for an {@link IEmbeddedFragment}, and the boundary that enforces the
|
|
26
|
+
* fragment-identifiability invariant: **at least one of `locator` / `fragmentId`
|
|
27
|
+
* must be present.**
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* The invariant lives here rather than in the type. A conditional-required union
|
|
31
|
+
* (`{ locator; fragmentId? } | { locator?; fragmentId }`) was considered and
|
|
32
|
+
* declined: it costs at every construction site and buys nothing at the read site,
|
|
33
|
+
* where each field reads as `… | undefined` either way. What it must not cost is
|
|
34
|
+
* identifiability — a fragment carrying neither identity cannot be resolved back to
|
|
35
|
+
* anything by a consumer holding the query hit, so it is rejected here.
|
|
36
|
+
* @public
|
|
37
|
+
*/
|
|
38
|
+
export const embeddedFragmentConverter = embeddedFragmentFieldsConverter.withConstraint((fragment) => fragment.locator === undefined && fragment.fragmentId === undefined
|
|
39
|
+
? fail('embedded fragment: at least one of `locator` or `fragmentId` is required')
|
|
40
|
+
: succeed(fragment));
|
|
41
|
+
//# sourceMappingURL=fragmentConverters.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fragmentConverters.js","sourceRoot":"","sources":["../../../src/packlets/vector/fragmentConverters.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAa,UAAU,EAAU,IAAI,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7E;;;;;GAKG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAgC,UAAU,CAAC,MAAM,CAAmB;IACvG,KAAK,EAAE,UAAU,CAAC,MAAM;IACxB,GAAG,EAAE,UAAU,CAAC,MAAM;CACvB,CAAC,CAAC;AAEH,iFAAiF;AACjF,MAAM,wBAAwB,GAA4B,UAAU,CAAC,OAAO,CAC1E,CAAC,IAAa,EAAwB,EAAE,CACtC,IAAI,YAAY,YAAY,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,kDAAkD,CAAC,CAC1G,CAAC;AAEF,kGAAkG;AAClG,MAAM,+BAA+B,GAAiC,UAAU,CAAC,MAAM,CACrF;IACE,OAAO,EAAE,wBAAwB,CAAC,QAAQ,EAAE;IAC5C,UAAU,EAAE,UAAU,CAAC,MAAM,CAAC,QAAQ,EAAE;IACxC,MAAM,EAAE,wBAAwB;CACjC,EACD,EAAE,cAAc,EAAE,CAAC,SAAS,EAAE,YAAY,CAAC,EAAE,CAC9C,CAAC;AAEF;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,yBAAyB,GACpC,+BAA+B,CAAC,cAAc,CAAC,CAAC,QAA2B,EAAE,EAAE,CAC7E,QAAQ,CAAC,OAAO,KAAK,SAAS,IAAI,QAAQ,CAAC,UAAU,KAAK,SAAS;IACjE,CAAC,CAAC,IAAI,CAAC,0EAA0E,CAAC;IAClF,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CACtB,CAAC","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Converter, Converters, Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEmbeddedFragment, IFragmentLocator } from './vectorIndex';\n\n/**\n * Converter for an {@link IFragmentLocator}. Validates the two offsets only — the\n * span's *meaning* (character / byte / token unit) is the consumer's, and the index\n * never interprets it. Note the span is advisory: see {@link IFragmentLocator}.\n * @public\n */\nexport const fragmentLocatorConverter: Converter<IFragmentLocator> = Converters.object<IFragmentLocator>({\n start: Converters.number,\n end: Converters.number\n});\n\n/** Validates an already-constructed `Float32Array` embedding vector in place. */\nconst embeddingVectorConverter: Converter<Float32Array> = Converters.generic<Float32Array>(\n (from: unknown): Result<Float32Array> =>\n from instanceof Float32Array ? succeed(from) : fail('embedded fragment: vector must be a Float32Array')\n);\n\n/** The field-shape half of {@link embeddedFragmentConverter}; the invariant is layered on top. */\nconst embeddedFragmentFieldsConverter: Converter<IEmbeddedFragment> = Converters.object<IEmbeddedFragment>(\n {\n locator: fragmentLocatorConverter.optional(),\n fragmentId: Converters.string.optional(),\n vector: embeddingVectorConverter\n },\n { optionalFields: ['locator', 'fragmentId'] }\n);\n\n/**\n * Converter for an {@link IEmbeddedFragment}, and the boundary that enforces the\n * fragment-identifiability invariant: **at least one of `locator` / `fragmentId`\n * must be present.**\n *\n * @remarks\n * The invariant lives here rather than in the type. A conditional-required union\n * (`{ locator; fragmentId? } | { locator?; fragmentId }`) was considered and\n * declined: it costs at every construction site and buys nothing at the read site,\n * where each field reads as `… | undefined` either way. What it must not cost is\n * identifiability — a fragment carrying neither identity cannot be resolved back to\n * anything by a consumer holding the query hit, so it is rejected here.\n * @public\n */\nexport const embeddedFragmentConverter: Converter<IEmbeddedFragment> =\n embeddedFragmentFieldsConverter.withConstraint((fragment: IEmbeddedFragment) =>\n fragment.locator === undefined && fragment.fragmentId === undefined\n ? fail('embedded fragment: at least one of `locator` or `fragmentId` is required')\n : succeed(fragment)\n );\n"]}
|
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { fail, succeed } from '@fgv/ts-utils';
|
|
6
6
|
import { edgeTargetKey } from '../types';
|
|
7
|
+
/** Project an incoming fragment's identity fields, dropping the ones it did not carry. */
|
|
8
|
+
function fragmentIdentity(fragment) {
|
|
9
|
+
return Object.assign(Object.assign({}, (fragment.locator !== undefined ? { locator: fragment.locator } : {})), (fragment.fragmentId !== undefined ? { fragmentId: fragment.fragmentId } : {}));
|
|
10
|
+
}
|
|
7
11
|
/**
|
|
8
12
|
* The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the
|
|
9
13
|
* fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many
|
|
10
|
-
* `Float32Array`s per record (one per
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* `Float32Array`s per record (one per {@link IEmbeddedFragment | fragment}) and
|
|
15
|
+
* answers a query by computing cosine similarity against every stored fragment,
|
|
16
|
+
* returning the top-k fragment hits by descending score. Each hit carries back
|
|
17
|
+
* whichever of {@link IFragmentLocator | locator} / `fragmentId` its fragment was
|
|
18
|
+
* added with; a fragment must carry at least one of the two.
|
|
13
19
|
*
|
|
14
20
|
* @remarks
|
|
15
21
|
* Same regime and same non-goals as {@link InMemoryCosineIndex}: no external
|
|
@@ -63,6 +69,12 @@ export class InMemoryFragmentCosineIndex {
|
|
|
63
69
|
if (fragment.vector.length === 0) {
|
|
64
70
|
return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
|
|
65
71
|
}
|
|
72
|
+
// A fragment carrying neither identity cannot be resolved back to anything by a
|
|
73
|
+
// consumer holding the hit — the same invariant `embeddedFragmentConverter`
|
|
74
|
+
// enforces at the untyped boundary, re-checked here at the index seam.
|
|
75
|
+
if (fragment.locator === undefined && fragment.fragmentId === undefined) {
|
|
76
|
+
return Promise.resolve(fail(`fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`));
|
|
77
|
+
}
|
|
66
78
|
if (dimension === undefined) {
|
|
67
79
|
dimension = fragment.vector.length;
|
|
68
80
|
}
|
|
@@ -70,7 +82,7 @@ export class InMemoryFragmentCosineIndex {
|
|
|
70
82
|
return Promise.resolve(fail(`fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`));
|
|
71
83
|
}
|
|
72
84
|
// Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.
|
|
73
|
-
stored.push({
|
|
85
|
+
stored.push({ identity: fragmentIdentity(fragment), vector: Float32Array.from(fragment.vector) });
|
|
74
86
|
}
|
|
75
87
|
// Whole-record replace: an empty `fragments` array drops the record entirely
|
|
76
88
|
// rather than leaving an empty shell behind. Commit the (possibly newly-derived)
|
|
@@ -104,11 +116,7 @@ export class InMemoryFragmentCosineIndex {
|
|
|
104
116
|
for (const fragment of record.fragments) {
|
|
105
117
|
scored.push({
|
|
106
118
|
key: edgeTargetKey(record.target),
|
|
107
|
-
hit: {
|
|
108
|
-
target: record.target,
|
|
109
|
-
score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),
|
|
110
|
-
locator: fragment.locator
|
|
111
|
-
}
|
|
119
|
+
hit: Object.assign({ target: record.target, score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector) }, fragment.identity)
|
|
112
120
|
});
|
|
113
121
|
}
|
|
114
122
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"inMemoryFragmentCosineIndex.js","sourceRoot":"","sources":["../../../src/packlets/vector/inMemoryFragmentCosineIndex.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEdgeTarget, edgeTargetKey } from '../types';\nimport {\n FragmentEmbedder,\n IEmbeddedFragment,\n IFragmentLocator,\n IFragmentVectorIndex,\n IMemoryRecordSource,\n IScopedMemoryRecord,\n IVectorQueryHit\n} from './vectorIndex';\n\n/** One stored fragment: its in-record locator plus the vector for that span. */\ninterface IStoredFragment {\n readonly locator: IFragmentLocator;\n readonly vector: Float32Array;\n}\n\n/** Every stored fragment for one record, tagged with the record's scoped address. */\ninterface IStoredRecordFragments {\n readonly target: IEdgeTarget;\n readonly fragments: IStoredFragment[];\n}\n\n/** A candidate hit carried through selection: the fragment's key, hit, and score. */\ninterface IScoredFragment {\n readonly key: string;\n readonly hit: IVectorQueryHit;\n}\n\n/**\n * The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the\n * fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many\n * `Float32Array`s per record (one per in-record {@link IFragmentLocator | span})\n * and answers a query by computing cosine similarity against every stored\n * fragment, returning the top-k fragment hits by descending score.\n *\n * @remarks\n * Same regime and same non-goals as {@link InMemoryCosineIndex}: no external\n * dependency, no ANN structure, a linear scan over the stored fragments — the seam\n * ({@link IFragmentVectorIndex}) stays open for a consumer to swap a persistent /\n * ANN backend once N grows. `addFragments` is whole-record-replace, so re-authoring\n * a document never leaves stale fragments behind. The index has a single dimension\n * established by the first fragment added; every subsequent fragment and every\n * `query` vector must match it or fail loudly — a mismatched dimension is an\n * embedder-wiring bug, never a silent zero-similarity result.\n * {@link InMemoryFragmentCosineIndex.rebuild | rebuild} clears the index (and the\n * established dimension), so a re-embed with a different model is supported.\n *\n * The optional `maxPerRecord` cap on `query` is applied **during selection**, before\n * the `topK` cut, so one long document with many strong fragments cannot crowd every\n * other record out of the result.\n * @public\n */\nexport class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {\n /**\n * Stored fragments keyed by the canonical {@link edgeTargetKey} of the record's\n * scope-qualified address, so two records that share a filename stem across\n * scopes occupy distinct entries and never overwrite each other's fragments.\n */\n private readonly _records: Map<string, IStoredRecordFragments>;\n /** The dimension of every stored fragment vector; `undefined` until the first `add`. */\n private _dimension: number | undefined;\n\n private constructor() {\n this._records = new Map<string, IStoredRecordFragments>();\n this._dimension = undefined;\n }\n\n /** The number of records that currently have at least one stored fragment. */\n public get recordCount(): number {\n return this._records.size;\n }\n\n /** The total number of fragments currently held across all records. */\n public get fragmentCount(): number {\n let total: number = 0;\n for (const record of this._records.values()) {\n total += record.fragments.length;\n }\n return total;\n }\n\n /** Family-convention factory. */\n public static create(): Result<InMemoryFragmentCosineIndex> {\n return succeed(new InMemoryFragmentCosineIndex());\n }\n\n /** {@inheritDoc IFragmentVectorIndex.addFragments} */\n public addFragments(\n target: IEdgeTarget,\n fragments: ReadonlyArray<IEmbeddedFragment>\n ): Promise<Result<number>> {\n const key: string = edgeTargetKey(target);\n // Validate every fragment before mutating any state, so a bad fragment never\n // leaves the record half-replaced OR the index dimension half-established\n // (whole-record-replace must be all-or-nothing). The effective dimension is the\n // established one, or — on a still-dimensionless index — the first fragment's\n // length; it is only committed to `this._dimension` once the whole batch passes.\n const stored: IStoredFragment[] = [];\n let dimension: number | undefined = this._dimension;\n for (const fragment of fragments) {\n if (fragment.vector.length === 0) {\n return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));\n }\n if (dimension === undefined) {\n dimension = fragment.vector.length;\n } else if (fragment.vector.length !== dimension) {\n return Promise.resolve(\n fail(\n `fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`\n )\n );\n }\n // Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.\n stored.push({ locator: fragment.locator, vector: Float32Array.from(fragment.vector) });\n }\n // Whole-record replace: an empty `fragments` array drops the record entirely\n // rather than leaving an empty shell behind. Commit the (possibly newly-derived)\n // dimension only alongside a successful, non-empty store.\n if (stored.length === 0) {\n this._records.delete(key);\n } else {\n this._dimension = dimension;\n this._records.set(key, { target, fragments: stored });\n }\n return Promise.resolve(succeed(stored.length));\n }\n\n /** {@inheritDoc IFragmentVectorIndex.remove} */\n public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {\n this._records.delete(edgeTargetKey(target));\n return Promise.resolve(succeed(target));\n }\n\n /** {@inheritDoc IFragmentVectorIndex.query} */\n public query(\n vector: Float32Array,\n topK: number,\n maxPerRecord?: number\n ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (topK <= 0 || this._records.size === 0) {\n return Promise.resolve(succeed([]));\n }\n if (vector.length !== this._dimension) {\n return Promise.resolve(\n fail(\n `fragment index: query dimension ${vector.length} does not match index dimension ${this._dimension}`\n )\n );\n }\n const queryMagnitude: number = InMemoryFragmentCosineIndex._magnitude(vector);\n const scored: IScoredFragment[] = [];\n for (const record of this._records.values()) {\n for (const fragment of record.fragments) {\n scored.push({\n key: edgeTargetKey(record.target),\n hit: {\n target: record.target,\n score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),\n locator: fragment.locator\n }\n });\n }\n }\n // Descending by score; the caller re-resolves each `(target, locator)` hit.\n scored.sort((a, b) => b.hit.score - a.hit.score);\n\n const hits: IVectorQueryHit[] = [];\n // Apply the per-record cap during selection (before the topK cut) so a single\n // long document cannot monopolize the result. `undefined` maxPerRecord means\n // uncapped; the counter map is always allocated (tiny) so the guard narrows\n // `maxPerRecord` directly without a non-null assertion.\n const perRecord: Map<string, number> = new Map<string, number>();\n for (const candidate of scored) {\n if (hits.length >= topK) {\n break;\n }\n if (maxPerRecord !== undefined) {\n const used: number = perRecord.get(candidate.key) ?? 0;\n if (used >= maxPerRecord) {\n continue;\n }\n perRecord.set(candidate.key, used + 1);\n }\n hits.push(candidate.hit);\n }\n return Promise.resolve(succeed(hits));\n }\n\n /**\n * Re-embed every record from `source` and rebuild the fragment index from\n * scratch. Clears the current contents (and the established dimension) first, so\n * a re-embed with a different model is supported. Returns the total number of\n * fragments indexed.\n *\n * On any failure (list, embed, or add) the index is rolled back to empty rather\n * than left in a partially-rebuilt state.\n *\n * @param source - The scope-qualified record source to re-embed.\n * @param embed - The fragment embedder applied to each record.\n */\n public async rebuild(source: IMemoryRecordSource, embed: FragmentEmbedder): Promise<Result<number>> {\n this._reset();\n const listed: Result<ReadonlyArray<IScopedMemoryRecord>> = await source.list();\n if (listed.isFailure()) {\n return fail(`fragment index rebuild: failed to list records: ${listed.message}`);\n }\n for (const scoped of listed.value) {\n const embedded: Result<ReadonlyArray<IEmbeddedFragment>> = await embed(scoped.record);\n if (embedded.isFailure()) {\n this._reset();\n return fail(\n `fragment index rebuild: embedding '${edgeTargetKey(scoped.target)}' failed: ${embedded.message}`\n );\n }\n const added: Result<number> = await this.addFragments(scoped.target, embedded.value);\n if (added.isFailure()) {\n this._reset();\n return fail(`fragment index rebuild: ${added.message}`);\n }\n }\n return succeed(this.fragmentCount);\n }\n\n /** Empty the index and forget the established dimension. */\n private _reset(): void {\n this._records.clear();\n this._dimension = undefined;\n }\n\n /** The Euclidean magnitude (L2 norm) of a vector. */\n private static _magnitude(vector: Float32Array): number {\n let sum: number = 0;\n for (let i: number = 0; i < vector.length; i++) {\n sum += vector[i] * vector[i];\n }\n return Math.sqrt(sum);\n }\n\n /**\n * Cosine similarity between the query (whose magnitude is precomputed once and\n * reused across the scan) and a stored fragment vector. A zero-magnitude vector\n * on either side yields `0` rather than `NaN`.\n */\n private static _cosine(query: Float32Array, queryMagnitude: number, stored: Float32Array): number {\n const storedMagnitude: number = InMemoryFragmentCosineIndex._magnitude(stored);\n if (queryMagnitude === 0 || storedMagnitude === 0) {\n return 0;\n }\n let dot: number = 0;\n for (let i: number = 0; i < query.length; i++) {\n dot += query[i] * stored[i];\n }\n return dot / (queryMagnitude * storedMagnitude);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"inMemoryFragmentCosineIndex.js","sourceRoot":"","sources":["../../../src/packlets/vector/inMemoryFragmentCosineIndex.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, fail, succeed } from '@fgv/ts-utils';\nimport { IEdgeTarget, edgeTargetKey } from '../types';\nimport {\n FragmentEmbedder,\n IEmbeddedFragment,\n IFragmentVectorIndex,\n IMemoryRecordSource,\n IScopedMemoryRecord,\n IVectorQueryHit\n} from './vectorIndex';\n\n/**\n * The identity fields a fragment was added with, already in query-hit shape: a field\n * the fragment did not carry is *absent*, never present-but-`undefined`, so a hit\n * for a fragment added without a `fragmentId` is structurally identical to one\n * produced before `fragmentId` existed.\n */\ntype FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;\n\n/** Project an incoming fragment's identity fields, dropping the ones it did not carry. */\nfunction fragmentIdentity(fragment: IEmbeddedFragment): FragmentIdentity {\n return {\n ...(fragment.locator !== undefined ? { locator: fragment.locator } : {}),\n ...(fragment.fragmentId !== undefined ? { fragmentId: fragment.fragmentId } : {})\n };\n}\n\n/** One stored fragment: the identity it was added with plus its vector. */\ninterface IStoredFragment {\n readonly identity: FragmentIdentity;\n readonly vector: Float32Array;\n}\n\n/** Every stored fragment for one record, tagged with the record's scoped address. */\ninterface IStoredRecordFragments {\n readonly target: IEdgeTarget;\n readonly fragments: IStoredFragment[];\n}\n\n/** A candidate hit carried through selection: the fragment's key, hit, and score. */\ninterface IScoredFragment {\n readonly key: string;\n readonly hit: IVectorQueryHit;\n}\n\n/**\n * The brute-force, in-memory cosine {@link IFragmentVectorIndex} — the\n * fragment-granular sibling of {@link InMemoryCosineIndex}. Stores many\n * `Float32Array`s per record (one per {@link IEmbeddedFragment | fragment}) and\n * answers a query by computing cosine similarity against every stored fragment,\n * returning the top-k fragment hits by descending score. Each hit carries back\n * whichever of {@link IFragmentLocator | locator} / `fragmentId` its fragment was\n * added with; a fragment must carry at least one of the two.\n *\n * @remarks\n * Same regime and same non-goals as {@link InMemoryCosineIndex}: no external\n * dependency, no ANN structure, a linear scan over the stored fragments — the seam\n * ({@link IFragmentVectorIndex}) stays open for a consumer to swap a persistent /\n * ANN backend once N grows. `addFragments` is whole-record-replace, so re-authoring\n * a document never leaves stale fragments behind. The index has a single dimension\n * established by the first fragment added; every subsequent fragment and every\n * `query` vector must match it or fail loudly — a mismatched dimension is an\n * embedder-wiring bug, never a silent zero-similarity result.\n * {@link InMemoryFragmentCosineIndex.rebuild | rebuild} clears the index (and the\n * established dimension), so a re-embed with a different model is supported.\n *\n * The optional `maxPerRecord` cap on `query` is applied **during selection**, before\n * the `topK` cut, so one long document with many strong fragments cannot crowd every\n * other record out of the result.\n * @public\n */\nexport class InMemoryFragmentCosineIndex implements IFragmentVectorIndex {\n /**\n * Stored fragments keyed by the canonical {@link edgeTargetKey} of the record's\n * scope-qualified address, so two records that share a filename stem across\n * scopes occupy distinct entries and never overwrite each other's fragments.\n */\n private readonly _records: Map<string, IStoredRecordFragments>;\n /** The dimension of every stored fragment vector; `undefined` until the first `add`. */\n private _dimension: number | undefined;\n\n private constructor() {\n this._records = new Map<string, IStoredRecordFragments>();\n this._dimension = undefined;\n }\n\n /** The number of records that currently have at least one stored fragment. */\n public get recordCount(): number {\n return this._records.size;\n }\n\n /** The total number of fragments currently held across all records. */\n public get fragmentCount(): number {\n let total: number = 0;\n for (const record of this._records.values()) {\n total += record.fragments.length;\n }\n return total;\n }\n\n /** Family-convention factory. */\n public static create(): Result<InMemoryFragmentCosineIndex> {\n return succeed(new InMemoryFragmentCosineIndex());\n }\n\n /** {@inheritDoc IFragmentVectorIndex.addFragments} */\n public addFragments(\n target: IEdgeTarget,\n fragments: ReadonlyArray<IEmbeddedFragment>\n ): Promise<Result<number>> {\n const key: string = edgeTargetKey(target);\n // Validate every fragment before mutating any state, so a bad fragment never\n // leaves the record half-replaced OR the index dimension half-established\n // (whole-record-replace must be all-or-nothing). The effective dimension is the\n // established one, or — on a still-dimensionless index — the first fragment's\n // length; it is only committed to `this._dimension` once the whole batch passes.\n const stored: IStoredFragment[] = [];\n let dimension: number | undefined = this._dimension;\n for (const fragment of fragments) {\n if (fragment.vector.length === 0) {\n return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));\n }\n // A fragment carrying neither identity cannot be resolved back to anything by a\n // consumer holding the hit — the same invariant `embeddedFragmentConverter`\n // enforces at the untyped boundary, re-checked here at the index seam.\n if (fragment.locator === undefined && fragment.fragmentId === undefined) {\n return Promise.resolve(\n fail(\n `fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`\n )\n );\n }\n if (dimension === undefined) {\n dimension = fragment.vector.length;\n } else if (fragment.vector.length !== dimension) {\n return Promise.resolve(\n fail(\n `fragment index: cannot add '${key}': fragment dimension ${fragment.vector.length} does not match index dimension ${dimension}`\n )\n );\n }\n // Defensive copy: the caller may reuse or mutate the buffer after `addFragments`.\n stored.push({ identity: fragmentIdentity(fragment), vector: Float32Array.from(fragment.vector) });\n }\n // Whole-record replace: an empty `fragments` array drops the record entirely\n // rather than leaving an empty shell behind. Commit the (possibly newly-derived)\n // dimension only alongside a successful, non-empty store.\n if (stored.length === 0) {\n this._records.delete(key);\n } else {\n this._dimension = dimension;\n this._records.set(key, { target, fragments: stored });\n }\n return Promise.resolve(succeed(stored.length));\n }\n\n /** {@inheritDoc IFragmentVectorIndex.remove} */\n public remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>> {\n this._records.delete(edgeTargetKey(target));\n return Promise.resolve(succeed(target));\n }\n\n /** {@inheritDoc IFragmentVectorIndex.query} */\n public query(\n vector: Float32Array,\n topK: number,\n maxPerRecord?: number\n ): Promise<Result<ReadonlyArray<IVectorQueryHit>>> {\n if (topK <= 0 || this._records.size === 0) {\n return Promise.resolve(succeed([]));\n }\n if (vector.length !== this._dimension) {\n return Promise.resolve(\n fail(\n `fragment index: query dimension ${vector.length} does not match index dimension ${this._dimension}`\n )\n );\n }\n const queryMagnitude: number = InMemoryFragmentCosineIndex._magnitude(vector);\n const scored: IScoredFragment[] = [];\n for (const record of this._records.values()) {\n for (const fragment of record.fragments) {\n scored.push({\n key: edgeTargetKey(record.target),\n hit: {\n target: record.target,\n score: InMemoryFragmentCosineIndex._cosine(vector, queryMagnitude, fragment.vector),\n ...fragment.identity\n }\n });\n }\n }\n // Descending by score; the caller re-resolves each `(target, locator)` hit.\n scored.sort((a, b) => b.hit.score - a.hit.score);\n\n const hits: IVectorQueryHit[] = [];\n // Apply the per-record cap during selection (before the topK cut) so a single\n // long document cannot monopolize the result. `undefined` maxPerRecord means\n // uncapped; the counter map is always allocated (tiny) so the guard narrows\n // `maxPerRecord` directly without a non-null assertion.\n const perRecord: Map<string, number> = new Map<string, number>();\n for (const candidate of scored) {\n if (hits.length >= topK) {\n break;\n }\n if (maxPerRecord !== undefined) {\n const used: number = perRecord.get(candidate.key) ?? 0;\n if (used >= maxPerRecord) {\n continue;\n }\n perRecord.set(candidate.key, used + 1);\n }\n hits.push(candidate.hit);\n }\n return Promise.resolve(succeed(hits));\n }\n\n /**\n * Re-embed every record from `source` and rebuild the fragment index from\n * scratch. Clears the current contents (and the established dimension) first, so\n * a re-embed with a different model is supported. Returns the total number of\n * fragments indexed.\n *\n * On any failure (list, embed, or add) the index is rolled back to empty rather\n * than left in a partially-rebuilt state.\n *\n * @param source - The scope-qualified record source to re-embed.\n * @param embed - The fragment embedder applied to each record.\n */\n public async rebuild(source: IMemoryRecordSource, embed: FragmentEmbedder): Promise<Result<number>> {\n this._reset();\n const listed: Result<ReadonlyArray<IScopedMemoryRecord>> = await source.list();\n if (listed.isFailure()) {\n return fail(`fragment index rebuild: failed to list records: ${listed.message}`);\n }\n for (const scoped of listed.value) {\n const embedded: Result<ReadonlyArray<IEmbeddedFragment>> = await embed(scoped.record);\n if (embedded.isFailure()) {\n this._reset();\n return fail(\n `fragment index rebuild: embedding '${edgeTargetKey(scoped.target)}' failed: ${embedded.message}`\n );\n }\n const added: Result<number> = await this.addFragments(scoped.target, embedded.value);\n if (added.isFailure()) {\n this._reset();\n return fail(`fragment index rebuild: ${added.message}`);\n }\n }\n return succeed(this.fragmentCount);\n }\n\n /** Empty the index and forget the established dimension. */\n private _reset(): void {\n this._records.clear();\n this._dimension = undefined;\n }\n\n /** The Euclidean magnitude (L2 norm) of a vector. */\n private static _magnitude(vector: Float32Array): number {\n let sum: number = 0;\n for (let i: number = 0; i < vector.length; i++) {\n sum += vector[i] * vector[i];\n }\n return Math.sqrt(sum);\n }\n\n /**\n * Cosine similarity between the query (whose magnitude is precomputed once and\n * reused across the scan) and a stored fragment vector. A zero-magnitude vector\n * on either side yields `0` rather than `NaN`.\n */\n private static _cosine(query: Float32Array, queryMagnitude: number, stored: Float32Array): number {\n const storedMagnitude: number = InMemoryFragmentCosineIndex._magnitude(stored);\n if (queryMagnitude === 0 || storedMagnitude === 0) {\n return 0;\n }\n let dot: number = 0;\n for (let i: number = 0; i < query.length; i++) {\n dot += query[i] * stored[i];\n }\n return dot / (queryMagnitude * storedMagnitude);\n }\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/packlets/vector/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './vectorIndex';\nexport * from './inMemoryCosineIndex';\nexport * from './inMemoryFragmentCosineIndex';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/packlets/vector/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,eAAe,CAAC;AAC9B,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nexport * from './vectorIndex';\nexport * from './fragmentConverters';\nexport * from './inMemoryCosineIndex';\nexport * from './inMemoryFragmentCosineIndex';\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vectorIndex.js","sourceRoot":"","sources":["../../../src/packlets/vector/vectorIndex.ts"],"names":[],"mappings":"AAAA;;;GAGG","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result } from '@fgv/ts-utils';\nimport { IEdgeTarget, IMemoryRecord } from '../types';\n\n/**\n * A half-open `[start, end)` span into a record's body — the in-record locator a\n * {@link IFragmentVectorIndex} carries on each fragment hit. `start` is inclusive,\n * `end` exclusive. The unit (character / byte / token offsets) is the consumer's\n * choice: the index stores the two integers opaquely and never interprets them,\n * so they line up with whatever locator the consumer's own read side uses.\n * @public\n */\nexport interface IFragmentLocator {\n /** Inclusive start offset into the record body. */\n readonly start: number;\n /** Exclusive end offset into the record body. */\n readonly end: number;\n}\n\n/**\n * A single hit returned by {@link IVectorIndex.query} (or\n * {@link IFragmentVectorIndex.query}): the matched record's scope-qualified\n * {@link IEdgeTarget | address} and the backend's similarity score (higher = more\n * similar; the exact scale is backend-defined). Hits are returned in descending\n * score order.\n *\n * @remarks\n * The address is a `(scope, id)` pair, NOT a bare {@link MemoryId} — per-scope\n * codecs (e.g. the medium-term codec's `turn-<n>` stems) legally mint the same\n * stem under different scopes, so a bare id could not disambiguate two records\n * that share a stem. The caller re-resolves the hit against the record index by\n * the same scoped address.\n *\n * `locator` is present only on hits from a {@link IFragmentVectorIndex} — it\n * identifies WHICH fragment of the record matched. Record-granular\n * {@link IVectorIndex} hits omit it.\n * @public\n */\nexport interface IVectorQueryHit {\n /** The scope-qualified address of the matched record. */\n readonly target: IEdgeTarget;\n /** Backend similarity score; higher is more similar. */\n readonly score: number;\n /** The matched fragment's in-record span; present only for fragment-index hits. */\n readonly locator?: IFragmentLocator;\n}\n\n/**\n * The vector-index seam an embedding backend implements to make\n * {@link SemanticRetriever | semantic recall} operational.\n *\n * @remarks\n * Vectors cross this seam as `Float32Array` (the in-memory representation an\n * embedding model produces); `number[]` is reserved for the JSON-wire edges\n * (e.g. a provider's embedding response). The in-package brute-force cosine\n * implementation is {@link InMemoryCosineIndex}; a consumer can swap an external\n * ANN backend behind the same seam once N grows beyond the in-memory regime.\n *\n * Every operation returns a `Result` (async, since a real backend does I/O) so\n * failure is explicit and never throws across the seam.\n * @public\n */\nexport interface IVectorIndex {\n /**\n * Add (or replace) the embedding for the scope-qualified `target`. Returns the\n * opaque {@link IMemoryEnvelope.embeddingRef | embeddingRef} the store stamps\n * onto the envelope so a later read knows the record is embedded. Keying on the\n * `(scope, id)` address (not a bare id) is load-bearing: two records that share\n * a filename stem across scopes must not clobber each other's embedding.\n */\n add(target: IEdgeTarget, vector: Float32Array): Promise<Result<string>>;\n\n /**\n * Remove the embedding for the scope-qualified `target`. Returns the removed\n * target. Idempotent — removing a target with no embedding still succeeds\n * (returns the target).\n */\n remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>>;\n\n /**\n * Return the `topK` nearest records to `vector`, in descending score order.\n */\n query(vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;\n}\n\n/**\n * One embedded fragment of a record: its in-record {@link IFragmentLocator | span}\n * and the vector for that span. Produced by a {@link FragmentEmbedder} and stored\n * via {@link IFragmentVectorIndex.addFragments}.\n * @public\n */\nexport interface IEmbeddedFragment {\n /** The fragment's in-record span. */\n readonly locator: IFragmentLocator;\n /** The embedding vector for that span. */\n readonly vector: Float32Array;\n}\n\n/**\n * The fragment-granular sibling of {@link IVectorIndex}: instead of one vector per\n * record it holds many vectors per record, each tagged with an in-record\n * {@link IFragmentLocator}, and its `query` returns per-fragment hits carrying that\n * locator. This is the seam behind sub-document semantic search — the \"discovery\"\n * half of a search-then-read contract, where a hit's `(target, locator)` tells the\n * consumer which record AND which span to read.\n *\n * @remarks\n * Deliberately NOT `extends IVectorIndex`: an index keyed by `(target, locator)`\n * has no well-defined single-vector `add(target, vector)`. It is a parallel\n * contract with three operations — `addFragments`, `remove`, `query` — reusing\n * {@link IVectorQueryHit} (whose `locator` is always populated here). Kept distinct\n * from the record-granular index per the consumer contract: memory recall stays\n * record-granular; sub-document knowledge uses a separate fragment index.\n * @public\n */\nexport interface IFragmentVectorIndex {\n /**\n * Add (or replace) all fragments for the scope-qualified `target`. Whole-record\n * semantics: every fragment previously held for `target` is dropped and replaced\n * by `fragments`, so a re-authored document never leaves stale fragments behind.\n * Returns the number of fragments now held for the record.\n */\n addFragments(target: IEdgeTarget, fragments: ReadonlyArray<IEmbeddedFragment>): Promise<Result<number>>;\n\n /**\n * Remove every fragment for the scope-qualified `target`. Returns the removed\n * target. Idempotent — removing a target with no fragments still succeeds.\n */\n remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>>;\n\n /**\n * Return the `topK` nearest fragments to `vector`, in descending score order,\n * each hit carrying its record `target` and fragment `locator`. When\n * `maxPerRecord` is supplied, no more than that many fragments of any single\n * record appear in the result — the cap is applied during selection (before the\n * `topK` cut) so one long document cannot crowd out others.\n */\n query(\n vector: Float32Array,\n topK: number,\n maxPerRecord?: number\n ): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;\n}\n\n/**\n * Embeds a complete record into a vector for the store's embed-on-write hook.\n * Async and `Result`-returning, since a real embedder does a network call (cloud\n * provider) or in-process model inference. The consumer wires this — the core\n * package never calls an embedding provider directly, staying embedder-agnostic.\n * @public\n */\nexport type MemoryEmbedder = (record: IMemoryRecord<unknown>) => Promise<Result<Float32Array>>;\n\n/**\n * The fragment-granular sibling of {@link MemoryEmbedder}: chunks a record's body\n * and embeds each chunk, returning one {@link IEmbeddedFragment} per chunk. The\n * chunking policy (window size, overlap) lives entirely in the consumer's embedder\n * — the core stays chunking-agnostic, exactly as it stays embedder-agnostic for\n * the record-granular path. Used by the store's fragment-embed-on-write hook.\n * @public\n */\nexport type FragmentEmbedder = (\n record: IMemoryRecord<unknown>\n) => Promise<Result<ReadonlyArray<IEmbeddedFragment>>>;\n\n/**\n * A record paired with its scope-qualified {@link IEdgeTarget | address}, as\n * yielded by {@link IMemoryRecordSource.list}. The address is required because\n * {@link InMemoryCosineIndex.rebuild} keys each re-embedded entry on the\n * scope-qualified target, not a bare {@link MemoryId} — two records that share a\n * filename stem across scopes must not collide when the whole vault is re-indexed.\n * @public\n */\nexport interface IScopedMemoryRecord {\n /** The record's scope-qualified `(scope, id)` address. */\n readonly target: IEdgeTarget;\n /** The record itself, passed to the embedder. */\n readonly record: IMemoryRecord<unknown>;\n}\n\n/**\n * The minimal record-source surface {@link InMemoryCosineIndex.rebuild} reads to\n * re-embed an entire vault. Each entry carries the record's scope-qualified\n * address (see {@link IScopedMemoryRecord}) so the rebuild keys the vector index\n * exactly as the incremental embed-on-write path does. A consumer backs this with\n * the store's scoped index — the vector packlet does not import the store packlet\n * (which depends on the vector packlet for {@link IVectorIndex}, so the reverse\n * import would be a cycle).\n * @public\n */\nexport interface IMemoryRecordSource {\n /** List every record in the vault, each paired with its scoped address. */\n list(): Promise<Result<ReadonlyArray<IScopedMemoryRecord>>>;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"vectorIndex.js","sourceRoot":"","sources":["../../../src/packlets/vector/vectorIndex.ts"],"names":[],"mappings":"AAAA;;;GAGG","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result } from '@fgv/ts-utils';\nimport { IEdgeTarget, IMemoryRecord } from '../types';\n\n/**\n * A half-open `[start, end)` span into a record's body — the in-record locator a\n * {@link IFragmentVectorIndex} may carry on a fragment. `start` is inclusive,\n * `end` exclusive. The unit (character / byte / token offsets) is the consumer's\n * choice: the index stores the two integers opaquely and never interprets them,\n * so they line up with whatever locator the consumer's own read side uses.\n *\n * @remarks\n * **The span is advisory.** It names the region of the body a fragment was\n * *derived from*; it is NOT a slice guaranteed to reproduce the fragment's text.\n * `body.slice(start, end)` round-trips only under a segmenter that merely chooses\n * boundaries. Under a **rewriting** segmenter — one that turns a span into a\n * curated block, an increasingly common ingestion shape when a model both selects\n * and rewrites — the fragment text is not a substring of the body at all, and the\n * fragmentation is not re-derivable from the body. Treat the span as a pointer for\n * locating context, never as an extraction recipe.\n *\n * A fragment whose provenance cannot honestly be expressed as a body span should\n * omit the locator entirely and carry an {@link IEmbeddedFragment.fragmentId}\n * instead.\n * @public\n */\nexport interface IFragmentLocator {\n /** Inclusive start offset into the record body. */\n readonly start: number;\n /** Exclusive end offset into the record body. */\n readonly end: number;\n}\n\n/**\n * A single hit returned by {@link IVectorIndex.query} (or\n * {@link IFragmentVectorIndex.query}): the matched record's scope-qualified\n * {@link IEdgeTarget | address} and the backend's similarity score (higher = more\n * similar; the exact scale is backend-defined). Hits are returned in descending\n * score order.\n *\n * @remarks\n * The address is a `(scope, id)` pair, NOT a bare {@link MemoryId} — per-scope\n * codecs (e.g. the medium-term codec's `turn-<n>` stems) legally mint the same\n * stem under different scopes, so a bare id could not disambiguate two records\n * that share a stem. The caller re-resolves the hit against the record index by\n * the same scoped address.\n *\n * **No single field discriminates a fragment hit from a record-granular hit.** A\n * record hit carries neither `locator` nor `fragmentId`; a fragment hit carries at\n * least one of the two, but not necessarily any particular one — a fragment with a\n * body span but no consumer-minted id, and a fragment with an id but no honest span,\n * are both legal. Testing one field for presence therefore cannot tell you which\n * kind of hit you hold.\n *\n * That \"at least one\" requirement is enforced on the upsert side by\n * {@link embeddedFragmentConverter} — a different boundary from this type — and is\n * deliberately NOT offered here as a discriminator either. A caller keyed off it\n * would be coupled to an invariant this type does not own, and would fail silently\n * if the invariant were ever relaxed.\n *\n * **The robust rule is that fragment-ness is determined by which index produced the\n * hit**: {@link IFragmentVectorIndex.query} returns fragment hits and\n * {@link IVectorIndex.query} returns record hits. The caller chose the index it\n * queried, so it already knows which kind it is holding.\n *\n * Note in particular that an absent `locator` now carries **two** distinct meanings\n * — a record-granular hit, or a fragment with no honest body span (see\n * {@link IFragmentLocator}) — which is precisely why presence-branching is unsafe.\n * @public\n */\nexport interface IVectorQueryHit {\n /** The scope-qualified address of the matched record. */\n readonly target: IEdgeTarget;\n /** Backend similarity score; higher is more similar. */\n readonly score: number;\n /**\n * The advisory in-record span the matched fragment was derived from, when the\n * producing fragment carried one. Absent on record-granular hits AND on fragment\n * hits with no honest span — see the remarks above; do not branch on its presence.\n */\n readonly locator?: IFragmentLocator;\n /**\n * The opaque identity the producing fragment was stored with, carried back\n * verbatim. Absent on record-granular hits AND on fragment hits stored without\n * one — see the remarks above; do not branch on its presence.\n */\n readonly fragmentId?: string;\n}\n\n/**\n * The vector-index seam an embedding backend implements to make\n * {@link SemanticRetriever | semantic recall} operational.\n *\n * @remarks\n * Vectors cross this seam as `Float32Array` (the in-memory representation an\n * embedding model produces); `number[]` is reserved for the JSON-wire edges\n * (e.g. a provider's embedding response). The in-package brute-force cosine\n * implementation is {@link InMemoryCosineIndex}; a consumer can swap an external\n * ANN backend behind the same seam once N grows beyond the in-memory regime.\n *\n * Every operation returns a `Result` (async, since a real backend does I/O) so\n * failure is explicit and never throws across the seam.\n * @public\n */\nexport interface IVectorIndex {\n /**\n * Add (or replace) the embedding for the scope-qualified `target`. Returns the\n * opaque {@link IMemoryEnvelope.embeddingRef | embeddingRef} the store stamps\n * onto the envelope so a later read knows the record is embedded. Keying on the\n * `(scope, id)` address (not a bare id) is load-bearing: two records that share\n * a filename stem across scopes must not clobber each other's embedding.\n */\n add(target: IEdgeTarget, vector: Float32Array): Promise<Result<string>>;\n\n /**\n * Remove the embedding for the scope-qualified `target`. Returns the removed\n * target. Idempotent — removing a target with no embedding still succeeds\n * (returns the target).\n */\n remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>>;\n\n /**\n * Return the `topK` nearest records to `vector`, in descending score order.\n */\n query(vector: Float32Array, topK: number): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;\n}\n\n/**\n * One embedded fragment of a record: the fragment's vector, plus at least one of the\n * two ways to identify it — its advisory in-record {@link IFragmentLocator | span}\n * and/or an opaque consumer-minted {@link IEmbeddedFragment.fragmentId | fragmentId}.\n * Produced by a {@link FragmentEmbedder} and stored via\n * {@link IFragmentVectorIndex.addFragments}.\n *\n * @remarks\n * Both identity fields are optional **in the type**, but the \"at least one\"\n * requirement is real — a fragment carrying neither is unidentifiable at the read\n * side. It is enforced by {@link embeddedFragmentConverter} (and re-checked by the\n * in-package index implementations) rather than by a conditional-required union\n * (`{ locator; fragmentId? } | { locator?; fragmentId }`), which was considered and\n * declined: the union costs at every construction site and buys nothing at the read\n * site, where each field reads as `… | undefined` either way.\n * @public\n */\nexport interface IEmbeddedFragment {\n /**\n * The region of the record body this fragment was derived from, when one can be\n * stated honestly. Advisory — see {@link IFragmentLocator}; it is NOT a slice that\n * reproduces the fragment text. Omit it for a fragment with no honest body span (a\n * rewriting segmenter), in which case `fragmentId` must be supplied.\n */\n readonly locator?: IFragmentLocator;\n /**\n * An opaque, consumer-minted identity for this fragment, carried verbatim through\n * the index and returned on the corresponding {@link IVectorQueryHit}. The index\n * **never parses it, never filters on it, and never assigns meaning to it** — it is\n * a bytestring, not part of the query path. It exists so a fragment stays\n * identifiable when its text is not re-derivable from the record body.\n *\n * The guarantee is \"we never parse it\", NOT \"we keep it stable\". Because\n * `addFragments` is whole-record-replace, an updated record re-emits its entire\n * fragment set, so **any stability of a fragment id across re-embeds is the\n * consumer's responsibility**, not the index's.\n */\n readonly fragmentId?: string;\n /** The embedding vector for this fragment. */\n readonly vector: Float32Array;\n}\n\n/**\n * The fragment-granular sibling of {@link IVectorIndex}: instead of one vector per\n * record it holds many vectors per record, each tagged with the identity its\n * {@link IEmbeddedFragment} carried, and its `query` returns per-fragment hits\n * carrying that identity back. This is the seam behind sub-document semantic search\n * — the \"discovery\" half of a search-then-read contract, where a hit tells the\n * consumer which record AND which fragment of it to read.\n *\n * @remarks\n * Deliberately NOT `extends IVectorIndex`: an index holding many vectors per record\n * has no well-defined single-vector `add(target, vector)`. It is a parallel contract\n * with three operations — `addFragments`, `remove`, `query` — reusing\n * {@link IVectorQueryHit}, on which both `locator` and `fragmentId` are optional. A\n * fragment hit populates whichever of the two its stored fragment carried; see\n * {@link IVectorQueryHit} for why that is not a discriminator and why fragment-ness\n * is determined by the index queried, not by field presence. Kept distinct from the\n * record-granular index per the consumer contract: memory recall stays\n * record-granular; sub-document knowledge uses a separate fragment index.\n * @public\n */\nexport interface IFragmentVectorIndex {\n /**\n * Add (or replace) all fragments for the scope-qualified `target`. Whole-record\n * semantics: every fragment previously held for `target` is dropped and replaced\n * by `fragments`, so a re-authored document never leaves stale fragments behind.\n * Returns the number of fragments now held for the record.\n */\n addFragments(target: IEdgeTarget, fragments: ReadonlyArray<IEmbeddedFragment>): Promise<Result<number>>;\n\n /**\n * Remove every fragment for the scope-qualified `target`. Returns the removed\n * target. Idempotent — removing a target with no fragments still succeeds.\n */\n remove(target: IEdgeTarget): Promise<Result<IEdgeTarget>>;\n\n /**\n * Return the `topK` nearest fragments to `vector`, in descending score order,\n * each hit carrying its record `target` plus whichever of `locator` /\n * `fragmentId` the stored fragment was added with. When\n * `maxPerRecord` is supplied, no more than that many fragments of any single\n * record appear in the result — the cap is applied during selection (before the\n * `topK` cut) so one long document cannot crowd out others.\n */\n query(\n vector: Float32Array,\n topK: number,\n maxPerRecord?: number\n ): Promise<Result<ReadonlyArray<IVectorQueryHit>>>;\n}\n\n/**\n * Embeds a complete record into a vector for the store's embed-on-write hook.\n * Async and `Result`-returning, since a real embedder does a network call (cloud\n * provider) or in-process model inference. The consumer wires this — the core\n * package never calls an embedding provider directly, staying embedder-agnostic.\n * @public\n */\nexport type MemoryEmbedder = (record: IMemoryRecord<unknown>) => Promise<Result<Float32Array>>;\n\n/**\n * The fragment-granular sibling of {@link MemoryEmbedder}: chunks a record's body\n * and embeds each chunk, returning one {@link IEmbeddedFragment} per chunk. The\n * chunking policy (window size, overlap) lives entirely in the consumer's embedder\n * — the core stays chunking-agnostic, exactly as it stays embedder-agnostic for\n * the record-granular path. Used by the store's fragment-embed-on-write hook.\n * @public\n */\nexport type FragmentEmbedder = (\n record: IMemoryRecord<unknown>\n) => Promise<Result<ReadonlyArray<IEmbeddedFragment>>>;\n\n/**\n * A record paired with its scope-qualified {@link IEdgeTarget | address}, as\n * yielded by {@link IMemoryRecordSource.list}. The address is required because\n * {@link InMemoryCosineIndex.rebuild} keys each re-embedded entry on the\n * scope-qualified target, not a bare {@link MemoryId} — two records that share a\n * filename stem across scopes must not collide when the whole vault is re-indexed.\n * @public\n */\nexport interface IScopedMemoryRecord {\n /** The record's scope-qualified `(scope, id)` address. */\n readonly target: IEdgeTarget;\n /** The record itself, passed to the embedder. */\n readonly record: IMemoryRecord<unknown>;\n}\n\n/**\n * The minimal record-source surface {@link InMemoryCosineIndex.rebuild} reads to\n * re-embed an entire vault. Each entry carries the record's scope-qualified\n * address (see {@link IScopedMemoryRecord}) so the rebuild keys the vector index\n * exactly as the incremental embed-on-write path does. A consumer backs this with\n * the store's scoped index — the vector packlet does not import the store packlet\n * (which depends on the vector packlet for {@link IVectorIndex}, so the reverse\n * import would be a cycle).\n * @public\n */\nexport interface IMemoryRecordSource {\n /** List every record in the vault, each paired with its scoped address. */\n list(): Promise<Result<ReadonlyArray<IScopedMemoryRecord>>>;\n}\n"]}
|