@hraness/sponge 0.2.4 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { readConfined, hash } from '../acquisition/files.js';
|
|
4
|
+
import { object } from '../acquisition/normalize.js';
|
|
5
|
+
import { localizeMarkdown } from '../app/markdown.js';
|
|
6
|
+
import { checkDigest } from '../digest/schema.js';
|
|
7
|
+
import { accessClass, defaultVisibility } from '../identity/uri.js';
|
|
8
|
+
import { convertJungleDigest, parseJungleEditorial, jungleTags, JungleDigestError } from './jungle-digest.js';
|
|
9
|
+
import { jungleNamespace, jungleRealm, visibilityAction } from './jungle-store.js';
|
|
10
|
+
import { importBundleEntry, importBundleHash } from './apply.js';
|
|
11
|
+
const noteBytes = 4 * 1024 ** 2, captureBytes = 16 * 1024 ** 2;
|
|
12
|
+
const errorCode = (error) => String(error?.code ?? (error instanceof Error ? error.message : 'unclassified'));
|
|
13
|
+
/** A capture link is `<dir>/<name>` under `kb/`, never escaping it; the bundle is its directory. */
|
|
14
|
+
function captureLink(link) { if (isAbsolute(link) || link.includes('\\') || link.split('/').length < 2 || link.split('/').some(p => !p || p === '.' || p === '..'))
|
|
15
|
+
throw new JungleDigestError('no-capture'); return link; }
|
|
16
|
+
/** Digest v1 only when the note converts with nothing dropped or trimmed and passes every rule against this revision's Markdown. */
|
|
17
|
+
export function jungleV1(editorial, markdown) {
|
|
18
|
+
if (editorial.form !== 'digest')
|
|
19
|
+
return { issues: [] };
|
|
20
|
+
const c = convertJungleDigest(editorial.markdown, markdown), check = checkDigest(c.gold, markdown, { tags: jungleTags }), issues = [...c.lossy, ...c.issues.map(i => `${i.path}:${i.code}`), ...check.issues.map(i => `${i.path}:${i.code}`)];
|
|
21
|
+
return c.clean && check.ok ? { v1: c.gold, issues: [] } : { issues: [...new Set(issues)] };
|
|
22
|
+
}
|
|
23
|
+
/** What a verified import would store, read from the bundle without the verifier: the same localization, from the manifest's asset list. */
|
|
24
|
+
async function previewCapture(kb, bundle, link) {
|
|
25
|
+
const manifest = object(JSON.parse((await readConfined(kb, join(bundle, 'capture.json'), captureBytes)).toString('utf8'))), pdf = manifest.kind === 'pdf';
|
|
26
|
+
const listed = (pdf ? (Array.isArray(manifest.images) ? manifest.images.map(i => object(i).asset) : []) : Array.isArray(manifest.assets) ? manifest.assets : []).flatMap(raw => { const a = raw && typeof raw === 'object' ? raw : {}; return typeof a.path === 'string' && typeof a.sha256 === 'string' ? [{ relativePath: a.path, sha256: a.sha256, bytes: Number(a.bytes), mediaType: String(a.mimeType) }] : []; });
|
|
27
|
+
const text = (await readConfined(kb, link + '.md', captureBytes)).toString('utf8'), markdown = localizeMarkdown(pdf ? text.replace(/^<!-- pdf-page: ([1-9][0-9]*) -->$/gm, '## Page $1') : text, listed).markdown;
|
|
28
|
+
const locator = pdf ? String(object(manifest.source ?? {}).requestedUrl ?? '') : String(manifest.sourceUrl ?? '');
|
|
29
|
+
return { markdown, locator };
|
|
30
|
+
}
|
|
31
|
+
export async function importJungle(store, jungle, options = {}) {
|
|
32
|
+
if (!isAbsolute(jungle))
|
|
33
|
+
throw Object.assign(Error('invalid-input'), { code: 'invalid-input' });
|
|
34
|
+
const kb = await realpath(join(jungle, 'kb')), notesDir = join(kb, 'notes', 'reading'), files = (await readdir(notesDir)).filter(f => /^[a-z0-9][a-z0-9-]*\.md$/.test(f)).sort();
|
|
35
|
+
const limit = options.limit ?? 1000, offset = options.offset ?? 0, dryRun = options.dryRun !== false;
|
|
36
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || !Number.isSafeInteger(offset) || offset < 0 || offset > files.length)
|
|
37
|
+
throw Object.assign(Error('invalid-input'), { code: 'invalid-input' });
|
|
38
|
+
if (!dryRun && !options.verify)
|
|
39
|
+
throw Object.assign(Error('runtime-missing'), { code: 'runtime-missing' });
|
|
40
|
+
const report = { schemaVersion: 1, dryRun, captureVerification: dryRun ? 'deferred' : 'verified', notes: files.length, counts: { newItems: 0, existingItems: 0, editorial: {}, v1: {}, visibility: {}, skipped: {} }, entries: [], nextOffset: offset + limit < files.length ? offset + limit : null };
|
|
41
|
+
const bump = (m, k) => { m[k] = (m[k] ?? 0) + 1; }, seen = new Set();
|
|
42
|
+
for (const file of files.slice(offset, offset + limit)) {
|
|
43
|
+
if (options.signal?.aborted)
|
|
44
|
+
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
45
|
+
const slug = file.slice(0, -3);
|
|
46
|
+
let entry = { slug };
|
|
47
|
+
try {
|
|
48
|
+
let editorial;
|
|
49
|
+
try {
|
|
50
|
+
editorial = parseJungleEditorial((await readConfined(notesDir, file, noteBytes)).toString('utf8'));
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
const code = error instanceof JungleDigestError ? error.code : 'malformed';
|
|
54
|
+
entry.skipped = code === 'not-digest' ? 'not-reading-note' : code === 'no-capture' ? 'no-capture' : 'malformed-note';
|
|
55
|
+
throw entry;
|
|
56
|
+
}
|
|
57
|
+
entry = { slug, form: editorial.form, status: editorial.status };
|
|
58
|
+
let link;
|
|
59
|
+
try {
|
|
60
|
+
link = captureLink(editorial.capture);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
entry.skipped = 'no-capture';
|
|
64
|
+
throw entry;
|
|
65
|
+
}
|
|
66
|
+
const bundle = dirname(link);
|
|
67
|
+
try {
|
|
68
|
+
await readConfined(kb, join(bundle, 'capture.json'), captureBytes);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
entry.skipped = 'capture-missing';
|
|
72
|
+
throw entry;
|
|
73
|
+
}
|
|
74
|
+
const prior = store.importedItem(jungleRealm, hash(JSON.stringify([jungleNamespace, link])));
|
|
75
|
+
if (prior?.deleted) {
|
|
76
|
+
entry.skipped = 'item-deleted';
|
|
77
|
+
entry.itemId = prior.itemId;
|
|
78
|
+
throw entry;
|
|
79
|
+
}
|
|
80
|
+
// Two notes on one capture share its item, in a dry run too.
|
|
81
|
+
entry.item = prior || seen.has(link) ? 'existing' : 'new';
|
|
82
|
+
seen.add(link);
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
if (prior?.revisionId) {
|
|
85
|
+
const markdown = store.revisionMarkdown(prior.revisionId), { v1, issues } = jungleV1(editorial, markdown), plan = store.planJungleNote({ itemId: prior.itemId, revisionId: prior.revisionId, editorial, ...(v1 ? { v1 } : {}) });
|
|
86
|
+
Object.assign(entry, { itemId: prior.itemId, editorial: plan.editorial, v1: plan.v1, ...(issues.length ? { v1Issues: issues } : {}), visibility: plan.visibility });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const preview = await previewCapture(kb, bundle, link), { v1, issues } = jungleV1(editorial, preview.markdown), access = accessClass(jungleRealm, preview.locator);
|
|
90
|
+
Object.assign(entry, { editorial: 'new', v1: editorial.form !== 'digest' ? 'not-digest' : v1 ? 'new' : 'not-convertible', ...(issues.length ? { v1Issues: issues } : {}), visibility: visibilityAction({ access, default: defaultVisibility(access, 'private'), override: null }, editorial.status, false).action });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
let imported;
|
|
95
|
+
try {
|
|
96
|
+
imported = await importBundleEntry(store, kb, jungleNamespace, { id: link, bundle, privacy: 'private', notes: [] }, options.verify, false, options.signal);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
const code = errorCode(error);
|
|
100
|
+
if (['cleanup-required', 'owner-busy', 'cancelled'].includes(code))
|
|
101
|
+
throw error;
|
|
102
|
+
entry.skipped = code === 'unsupported' ? 'capture-unsupported' : 'capture-failed';
|
|
103
|
+
entry.code = code;
|
|
104
|
+
throw entry;
|
|
105
|
+
}
|
|
106
|
+
const itemId = imported.itemId, revisionId = store.importedRevision(itemId, importBundleHash(imported.sourceHash)), markdown = store.revisionMarkdown(revisionId), { v1, issues } = jungleV1(editorial, markdown);
|
|
107
|
+
const plan = store.applyJungleNote({ itemId, revisionId, editorial, ...(v1 ? { v1 } : {}) });
|
|
108
|
+
Object.assign(entry, { itemId, editorial: plan.editorial, v1: plan.v1, ...(issues.length ? { v1Issues: issues } : {}), visibility: plan.visibility });
|
|
109
|
+
}
|
|
110
|
+
bump(report.counts.editorial, entry.editorial);
|
|
111
|
+
bump(report.counts.v1, entry.v1);
|
|
112
|
+
bump(report.counts.visibility, entry.visibility);
|
|
113
|
+
if (entry.item === 'new')
|
|
114
|
+
report.counts.newItems++;
|
|
115
|
+
else
|
|
116
|
+
report.counts.existingItems++;
|
|
117
|
+
report.entries.push(entry);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error !== entry) {
|
|
121
|
+
const code = errorCode(error);
|
|
122
|
+
if (['cleanup-required', 'owner-busy', 'cancelled'].includes(code)) {
|
|
123
|
+
report.nextOffset = offset + report.entries.length;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
entry.skipped = 'capture-failed';
|
|
127
|
+
entry.code = code;
|
|
128
|
+
}
|
|
129
|
+
bump(report.counts.skipped, entry.skipped);
|
|
130
|
+
report.entries.push(entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return report;
|
|
134
|
+
}
|
|
@@ -23,6 +23,13 @@ export declare class IndexError extends Error {
|
|
|
23
23
|
code: 'invalid-index' | 'stale-index' | 'incomplete-index' | 'cancelled-index';
|
|
24
24
|
constructor(code: 'invalid-index' | 'stale-index' | 'incomplete-index' | 'cancelled-index');
|
|
25
25
|
}
|
|
26
|
+
export type VectorEncoding = 'f32' | 'int8';
|
|
27
|
+
/** Storage format follows the space identity: no `quantization` (legacy) or `f32` stores float32 LE; `int8-symmetric-maxabs-v1` stores one int8 code per dimension. */
|
|
28
|
+
export declare function spaceEncoding(identity: Record<string, unknown>): VectorEncoding;
|
|
29
|
+
/** int8 codes are round(x*127/max|x|) of a unit vector. The per-vector scale is not stored: cosine is scale-invariant, so codes rank directly and dequantize by renormalizing. */
|
|
30
|
+
export declare function encodeVector(vector: readonly number[], encoding: VectorEncoding): Buffer;
|
|
31
|
+
/** Exact byte length, finite floats, int8 codes within [-127,127] and nonzero; int8 returns the unit dequantized vector. */
|
|
32
|
+
export declare function decodeVector(bytes: Uint8Array, dimensions: number, encoding: VectorEncoding): number[];
|
|
26
33
|
/** Unicode-safe chunks fit the fixed document prompt within the embedding text budget, preferring whitespace breaks. */
|
|
27
34
|
export declare function chunkDocument(text: string): string[];
|
|
28
35
|
/** Identity must already be the verified embedder fingerprint. Existing identities are immutable. */
|
|
@@ -21,6 +21,30 @@ catch (e) {
|
|
|
21
21
|
} }
|
|
22
22
|
function epoch(db) { return Number(db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch); }
|
|
23
23
|
function revisions(db) { return db.prepare('SELECT id AS itemId,current_revision AS revisionId FROM items WHERE deleted_at IS NULL AND current_revision IS NOT NULL ORDER BY id').all(); }
|
|
24
|
+
/** Storage format follows the space identity: no `quantization` (legacy) or `f32` stores float32 LE; `int8-symmetric-maxabs-v1` stores one int8 code per dimension. */
|
|
25
|
+
export function spaceEncoding(identity) { const q = identity.quantization; if (q === undefined || q === 'f32')
|
|
26
|
+
return 'f32'; if (q === 'int8-symmetric-maxabs-v1')
|
|
27
|
+
return 'int8'; return fail(); }
|
|
28
|
+
/** int8 codes are round(x*127/max|x|) of a unit vector. The per-vector scale is not stored: cosine is scale-invariant, so codes rank directly and dequantize by renormalizing. */
|
|
29
|
+
export function encodeVector(vector, encoding) { if (!vector.length || vector.some(n => !Number.isFinite(n)))
|
|
30
|
+
fail(); if (encoding === 'f32') {
|
|
31
|
+
const bytes = Buffer.alloc(vector.length * 4);
|
|
32
|
+
vector.forEach((n, i) => bytes.writeFloatLE(n, i * 4));
|
|
33
|
+
return bytes;
|
|
34
|
+
} const max = Math.max(...vector.map(Math.abs)); if (!(max > 0))
|
|
35
|
+
fail(); return Buffer.from(Int8Array.from(vector, n => Math.round(n * 127 / max)).buffer); }
|
|
36
|
+
/** Exact byte length, finite floats, int8 codes within [-127,127] and nonzero; int8 returns the unit dequantized vector. */
|
|
37
|
+
export function decodeVector(bytes, dimensions, encoding) { const b = Buffer.from(bytes); if (encoding === 'f32') {
|
|
38
|
+
if (b.length !== dimensions * 4)
|
|
39
|
+
fail();
|
|
40
|
+
const vector = Array.from({ length: dimensions }, (_, i) => b.readFloatLE(i * 4));
|
|
41
|
+
if (vector.some(n => !Number.isFinite(n)))
|
|
42
|
+
fail();
|
|
43
|
+
return vector;
|
|
44
|
+
} if (b.length !== dimensions)
|
|
45
|
+
fail(); const codes = Array.from({ length: dimensions }, (_, i) => b.readInt8(i)); if (codes.some(n => n < -127))
|
|
46
|
+
fail(); const norm = Math.hypot(...codes); if (!(norm > 0))
|
|
47
|
+
fail(); return codes.map(n => n / norm); }
|
|
24
48
|
const chunkBytes = embeddingTextBytes - Buffer.byteLength('title: none | text: ');
|
|
25
49
|
/** Unicode-safe chunks fit the fixed document prompt within the embedding text budget, preferring whitespace breaks. */
|
|
26
50
|
export function chunkDocument(text) { const chunks = []; let part = '', size = 0, cut = -1, cutSize = 0; for (const char of text) {
|
|
@@ -61,15 +85,16 @@ function snapshot(db) {
|
|
|
61
85
|
}
|
|
62
86
|
return { version: 1, revisions: current, inputs };
|
|
63
87
|
}
|
|
64
|
-
function generation(db, id) { const row = db.prepare('SELECT g.*,s.dimensions FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?').get(id); if (!row)
|
|
88
|
+
function generation(db, id) { const row = db.prepare('SELECT g.*,s.dimensions,s.identity_json FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint WHERE g.id=?').get(id); if (!row)
|
|
65
89
|
return fail(); const coverage = JSON.parse(String(row.coverage_json)); const profile = JSON.parse(String(row.profile_json)); if (coverage.version !== 1 || !Array.isArray(coverage.inputs) || !Array.isArray(coverage.revisions) || typeof profile.profileId !== 'string')
|
|
66
|
-
fail(); return { row, coverage, profile }; }
|
|
90
|
+
fail(); return { row, coverage, profile, encoding: spaceEncoding(JSON.parse(String(row.identity_json))) }; }
|
|
67
91
|
/** Identity must already be the verified embedder fingerprint. Existing identities are immutable. */
|
|
68
92
|
export function createGeneration(db, options) {
|
|
69
93
|
if (!options.profileId || options.profileId.length > 128 || !/^[a-f0-9]{64}$/.test(options.fingerprint) || !Number.isSafeInteger(options.dimensions) || options.dimensions < 1 || options.dimensions > 4096)
|
|
70
94
|
fail();
|
|
71
95
|
if (spaceFingerprint(options.identity) !== options.fingerprint || options.identity.dimensions !== options.dimensions)
|
|
72
96
|
fail();
|
|
97
|
+
spaceEncoding(options.identity);
|
|
73
98
|
return atomic(db, () => { const identity = JSON.stringify(Object.fromEntries(Object.keys(options.identity).sort().map(k => [k, options.identity[k]]))); if (!identity || Buffer.byteLength(identity) > 16384)
|
|
74
99
|
fail(); const existing = db.prepare('SELECT * FROM embedding_spaces WHERE fingerprint=?').get(options.fingerprint); if (existing && (existing.identity_json !== identity || Number(existing.dimensions) !== options.dimensions || existing.normalization !== 'unit-l2' || existing.metric !== 'cosine'))
|
|
75
100
|
fail(); if (!existing)
|
|
@@ -86,7 +111,7 @@ export function pendingInputs(db, id, limit = 16) { if (!Number.isSafeInteger(li
|
|
|
86
111
|
export function writeBatch(db, id, fingerprint, batch) {
|
|
87
112
|
if (!batch.length || batch.length > 256)
|
|
88
113
|
fail();
|
|
89
|
-
atomic(db, () => { const { row, coverage } = generation(db, id); if (row.state === 'cancelled')
|
|
114
|
+
atomic(db, () => { const { row, coverage, encoding } = generation(db, id); if (row.state === 'cancelled')
|
|
90
115
|
fail('cancelled-index'); if (row.state !== 'building' || row.fingerprint !== fingerprint)
|
|
91
116
|
fail(); const expected = new Map(coverage.inputs.map(i => [i.sourceId, i])); const seen = new Set(); for (const result of batch) {
|
|
92
117
|
const input = expected.get(result.sourceId);
|
|
@@ -103,8 +128,7 @@ export function writeBatch(db, id, fingerprint, batch) {
|
|
|
103
128
|
fail();
|
|
104
129
|
}
|
|
105
130
|
seen.add(result.sourceId);
|
|
106
|
-
const bytes =
|
|
107
|
-
result.vector.forEach((n, i) => bytes.writeFloatLE(n, i * 4));
|
|
131
|
+
const bytes = encodeVector(result.vector, encoding);
|
|
108
132
|
const prior = db.prepare('SELECT vector,input_hash FROM vectors WHERE generation_id=? AND source_id=?').get(id, input.sourceId);
|
|
109
133
|
if (prior && prior.input_hash !== null) {
|
|
110
134
|
if (prior.input_hash !== result.inputHash || !bytes.equals(Buffer.from(prior.vector)))
|
|
@@ -125,7 +149,6 @@ export function cancelGeneration(db, id) { atomic(db, () => { const { row } = ge
|
|
|
125
149
|
fail(); db.prepare("UPDATE index_generations SET state='cancelled' WHERE id=?").run(id); }); }
|
|
126
150
|
/** Read-side join removes deleted/refreshed revisions even when an old generation stays active. */
|
|
127
151
|
export function activeVectors(db, profileId) { const active = db.prepare('SELECT generation_id FROM active_indexes WHERE profile_id=?').get(profileId); if (!active)
|
|
128
|
-
return []; const id = String(active.generation_id), { row, coverage } = generation(db, id), inputs = new Map(coverage.inputs.map(i => [i.sourceId, i])); return db.prepare('SELECT v.* FROM vectors v JOIN revisions r ON r.id=v.revision_id JOIN items i ON i.id=r.item_id WHERE v.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=v.revision_id ORDER BY v.source_id').all(id).map(v => { const
|
|
129
|
-
fail(); const vector =
|
|
130
|
-
fail(); return { fingerprint: String(row.fingerprint), generationId: id, source: source, vector }; }); }
|
|
152
|
+
return []; const id = String(active.generation_id), { row, coverage, encoding } = generation(db, id), inputs = new Map(coverage.inputs.map(i => [i.sourceId, i])); return db.prepare('SELECT v.* FROM vectors v JOIN revisions r ON r.id=v.revision_id JOIN items i ON i.id=r.item_id WHERE v.generation_id=? AND i.deleted_at IS NULL AND i.current_revision=v.revision_id ORDER BY v.source_id').all(id).map(v => { const source = inputs.get(String(v.source_id)); if (!source)
|
|
153
|
+
fail(); const vector = decodeVector(v.vector, Number(row.dimensions), encoding); return { fingerprint: String(row.fingerprint), generationId: id, source: source, vector }; }); }
|
|
131
154
|
export const rollbackGeneration = activateGeneration;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type LibraryStore } from '../store/index.js';
|
|
2
|
+
import { type PageStats, type PruneEntry } from './prune.js';
|
|
3
|
+
export interface PruneReport {
|
|
4
|
+
dryRun: boolean;
|
|
5
|
+
keep: number;
|
|
6
|
+
generations: PruneEntry[];
|
|
7
|
+
prunedVectors: number;
|
|
8
|
+
estimatedBytes: number;
|
|
9
|
+
before: PageStats;
|
|
10
|
+
after: PageStats;
|
|
11
|
+
reclaimedBytes: number;
|
|
12
|
+
vacuum: 'skipped' | 'done' | 'would-run';
|
|
13
|
+
note: string;
|
|
14
|
+
}
|
|
15
|
+
/** Holds worker custody and the store owner fence for the whole run, so no worker, index run or query starts;
|
|
16
|
+
* each bounded batch rechecks owner, cleanup and backup/GC fences in its own transaction. Refuses while index work runs. */
|
|
17
|
+
export declare function pruneIndex(store: LibraryStore, options?: {
|
|
18
|
+
keep?: number;
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
vacuum?: boolean;
|
|
21
|
+
}): Promise<PruneReport>;
|
|
22
|
+
export declare function pruneText(report: PruneReport): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { StoreError } from '../store/index.js';
|
|
4
|
+
import { acquireWorkerCustody } from '../worker/custody.js';
|
|
5
|
+
import { syncDir } from '../worker/journal.js';
|
|
6
|
+
import { maximumKeep } from './prune.js';
|
|
7
|
+
const note = 'Rollback to a pruned generation needs a rebuild (`sponge index create`).';
|
|
8
|
+
const blocked = (store) => { if (store.ownerSnapshot())
|
|
9
|
+
throw new StoreError('owner-busy'); if (store.pendingCleanupCount())
|
|
10
|
+
throw Object.assign(Error('cleanup-required'), { code: 'cleanup-required' }); if (store.hasGcFence())
|
|
11
|
+
throw new StoreError('owner-busy'); };
|
|
12
|
+
const total = (entries, key) => entries.filter(g => g.action === 'prune').reduce((n, g) => n + g[key], 0);
|
|
13
|
+
/** Holds worker custody and the store owner fence for the whole run, so no worker, index run or query starts;
|
|
14
|
+
* each bounded batch rechecks owner, cleanup and backup/GC fences in its own transaction. Refuses while index work runs. */
|
|
15
|
+
export async function pruneIndex(store, options = {}) {
|
|
16
|
+
const keep = options.keep ?? 1, dryRun = options.dryRun === true;
|
|
17
|
+
if (!Number.isSafeInteger(keep) || keep < 0 || keep > maximumKeep)
|
|
18
|
+
throw new StoreError('invalid-input');
|
|
19
|
+
if (dryRun) {
|
|
20
|
+
const { generations, stats } = store.pruneIndexPlan(keep);
|
|
21
|
+
return { dryRun, keep, generations, prunedVectors: total(generations, 'vectors'), estimatedBytes: total(generations, 'bytes'), before: stats, after: stats, reclaimedBytes: 0, vacuum: options.vacuum ? 'would-run' : 'skipped', note };
|
|
22
|
+
}
|
|
23
|
+
blocked(store);
|
|
24
|
+
const custody = await acquireWorkerCustody(store.root);
|
|
25
|
+
let owner;
|
|
26
|
+
try {
|
|
27
|
+
owner = store.acquireOwner(custody.incarnation);
|
|
28
|
+
const { generations, stats: before } = store.pruneIndexPlan(keep);
|
|
29
|
+
let prunedVectors = 0;
|
|
30
|
+
for (const generation of generations.filter(g => g.action === 'prune'))
|
|
31
|
+
for (;;) {
|
|
32
|
+
const n = store.pruneIndexStep(owner, generation.id, keep);
|
|
33
|
+
prunedVectors += n;
|
|
34
|
+
if (!n)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
// A concurrent rollback can make a planned generation a kept one again; report what actually happened.
|
|
38
|
+
const now = new Map(store.pruneIndexPlan(keep).generations.map(g => [g.id, g])), final = generations.map(g => { const current = now.get(g.id); return g.action === 'prune' && current?.action === 'keep' ? current : g; });
|
|
39
|
+
let after = store.pruneIndexPlan(keep).stats, vacuum = 'skipped';
|
|
40
|
+
if (options.vacuum) {
|
|
41
|
+
after = store.vacuumLibrary(owner);
|
|
42
|
+
vacuum = 'done';
|
|
43
|
+
}
|
|
44
|
+
return { dryRun, keep, generations: final, prunedVectors, estimatedBytes: total(final, 'bytes'), before, after, reclaimedBytes: vacuum === 'done' ? before.bytes - after.bytes : (after.freePages - before.freePages) * after.pageSize, vacuum, note };
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
try {
|
|
48
|
+
if (owner)
|
|
49
|
+
store.releaseOwner(owner);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await custody.release();
|
|
53
|
+
try {
|
|
54
|
+
await rm(custody.directory, { recursive: true });
|
|
55
|
+
await syncDir(join(store.root, 'runtime', 'workers'));
|
|
56
|
+
}
|
|
57
|
+
catch { /* Runtime GC removes this settled journal. */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function pruneText(report) {
|
|
62
|
+
const lines = report.generations.map(g => `${g.action === 'prune' ? (report.dryRun ? 'would prune' : 'pruned') : 'kept'}\t${g.id}\t${g.profileId}\t${g.space}\t${g.state}\t${g.reason}\t${g.vectors} vectors`);
|
|
63
|
+
lines.push(report.dryRun ? `Would free about ${report.estimatedBytes} bytes of rows${report.vacuum === 'would-run' ? '; --vacuum would then shrink the file' : ''}` : `Reclaimed ${report.reclaimedBytes} bytes (${report.vacuum === 'done' ? 'file shrank after VACUUM' : 'freed pages are reused; --vacuum shrinks the file'})`);
|
|
64
|
+
if (report.generations.some(g => g.action === 'prune'))
|
|
65
|
+
lines.push(report.note);
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export type PruneReason = 'active' | 'building' | 'queued' | 'running' | 'rollback' | 'unknown' | 'retired' | 'stale' | 'cancelled' | 'failed' | 'pruned';
|
|
3
|
+
export interface PruneEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
profileId: string;
|
|
6
|
+
fingerprint: string;
|
|
7
|
+
space: string;
|
|
8
|
+
state: string;
|
|
9
|
+
action: 'keep' | 'prune';
|
|
10
|
+
reason: PruneReason;
|
|
11
|
+
vectors: number;
|
|
12
|
+
bytes: number;
|
|
13
|
+
}
|
|
14
|
+
export interface PageStats {
|
|
15
|
+
pageSize: number;
|
|
16
|
+
pages: number;
|
|
17
|
+
freePages: number;
|
|
18
|
+
bytes: number;
|
|
19
|
+
}
|
|
20
|
+
export declare const maximumKeep = 16, pruneBatchRows = 1000;
|
|
21
|
+
/** Caller holds a write transaction. Keeps active, building/resumable and job-referenced work, plus the `keep` most
|
|
22
|
+
* recent rollback-ready retired generations per profile. Stale retired generations can never activate again (the
|
|
23
|
+
* revision epoch only grows), so they are pruned. Recency is index job creation; VACUUM may renumber rowids. */
|
|
24
|
+
export declare function planPrune(db: DatabaseSync, keep: number): PruneEntry[];
|
|
25
|
+
/** One bounded step for one planned generation, inside the caller's write transaction. The first step marks it pruned
|
|
26
|
+
* and drops its coverage, so it is never rollback-ready while partially deleted. Returns rows deleted; 0 means done. */
|
|
27
|
+
export declare function pruneStep(db: DatabaseSync, id: string, keep: number, limit?: number): number;
|
|
28
|
+
export declare function pageStats(db: DatabaseSync): PageStats;
|
|
29
|
+
/** VACUUM rewrites the database through a temporary copy and the WAL: about the file size again on each volume. */
|
|
30
|
+
export declare function vacuumSpace(root: string, bytes: number, margin?: number): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { statSync, statfsSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { StoreError } from '../store/types.js';
|
|
4
|
+
import { activateGeneration, IndexError, spaceEncoding } from './index.js';
|
|
5
|
+
export const maximumKeep = 16, pruneBatchRows = 1000;
|
|
6
|
+
/** Pruned rows keep their id (index_jobs history references it) with no coverage and no vectors. */
|
|
7
|
+
const prunedCoverage = JSON.stringify({ version: 1, revisions: [], inputs: [] });
|
|
8
|
+
/** Exactly the rollback rules: a probe activation inside a savepoint that is always rolled back. */
|
|
9
|
+
function rollbackReady(db, id) { db.exec('SAVEPOINT prune_probe'); try {
|
|
10
|
+
activateGeneration(db, id);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error instanceof IndexError)
|
|
15
|
+
return false;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
db.exec('ROLLBACK TO prune_probe; RELEASE prune_probe');
|
|
20
|
+
} }
|
|
21
|
+
/** Caller holds a write transaction. Keeps active, building/resumable and job-referenced work, plus the `keep` most
|
|
22
|
+
* recent rollback-ready retired generations per profile. Stale retired generations can never activate again (the
|
|
23
|
+
* revision epoch only grows), so they are pruned. Recency is index job creation; VACUUM may renumber rowids. */
|
|
24
|
+
export function planPrune(db, keep) {
|
|
25
|
+
if (!Number.isSafeInteger(keep) || keep < 0 || keep > maximumKeep)
|
|
26
|
+
throw new StoreError('invalid-input');
|
|
27
|
+
if (!db.isTransaction)
|
|
28
|
+
throw new StoreError('invalid-input');
|
|
29
|
+
const epoch = Number(db.prepare('SELECT revision_epoch FROM library_info WHERE id=1').get()?.revision_epoch), active = new Set(db.prepare('SELECT generation_id FROM active_indexes').all().map(r => String(r.generation_id)));
|
|
30
|
+
const rows = db.prepare("SELECT g.id,g.fingerprint,g.state,g.revision_epoch,g.profile_json,length(g.coverage_json) AS coverage,s.dimensions,s.identity_json,j.state AS job,(SELECT count(*) FROM vectors v WHERE v.generation_id=g.id) AS vectors,(SELECT COALESCE(sum(length(v.vector)+length(v.source_id)+length(v.revision_id)+length(v.generation_id)+length(v.derivation_hash)+COALESCE(length(v.input_hash),0)+COALESCE(length(v.asset_sha256),0)),0) FROM vectors v WHERE v.generation_id=g.id) AS bytes FROM index_generations g JOIN embedding_spaces s ON s.fingerprint=g.fingerprint LEFT JOIN index_jobs j ON j.generation_id=g.id ORDER BY j.created_at IS NULL,j.created_at DESC,g.rowid DESC").all();
|
|
31
|
+
const kept = new Map();
|
|
32
|
+
return rows.flatMap(row => {
|
|
33
|
+
const id = String(row.id), state = String(row.state), job = row.job === null ? null : String(row.job), vectors = Number(row.vectors);
|
|
34
|
+
let profileId, space;
|
|
35
|
+
try {
|
|
36
|
+
profileId = String(JSON.parse(String(row.profile_json)).profileId);
|
|
37
|
+
const identity = JSON.parse(String(row.identity_json));
|
|
38
|
+
space = `${spaceEncoding(identity)}-${Number(row.dimensions)}`;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new StoreError('integrity');
|
|
42
|
+
}
|
|
43
|
+
if (state === 'pruned' && !vectors && Number(row.coverage) <= prunedCoverage.length)
|
|
44
|
+
return [];
|
|
45
|
+
const entry = (action, reason) => [{ id, profileId, fingerprint: String(row.fingerprint), space, state, action, reason, vectors, bytes: Number(row.bytes) + (action === 'prune' ? Math.max(0, Number(row.coverage) - prunedCoverage.length) : 0) }];
|
|
46
|
+
if (active.has(id))
|
|
47
|
+
return entry('keep', 'active');
|
|
48
|
+
if (job === 'running' || job === 'cleanup-pending')
|
|
49
|
+
return entry('keep', 'running');
|
|
50
|
+
if (state === 'pruned')
|
|
51
|
+
return entry('prune', 'pruned');
|
|
52
|
+
if (state === 'cancelled')
|
|
53
|
+
return entry('prune', 'cancelled');
|
|
54
|
+
// A building generation stays resumable until the revision epoch moves; after that it can never activate.
|
|
55
|
+
if (state === 'building')
|
|
56
|
+
return job === 'queued' ? entry('keep', 'queued') : Number(row.revision_epoch) === epoch ? entry('keep', 'building') : entry('prune', 'failed');
|
|
57
|
+
if (state !== 'retired')
|
|
58
|
+
return entry('keep', 'unknown');
|
|
59
|
+
if (!rollbackReady(db, id))
|
|
60
|
+
return entry('prune', 'stale');
|
|
61
|
+
const n = kept.get(profileId) ?? 0;
|
|
62
|
+
if (n < keep) {
|
|
63
|
+
kept.set(profileId, n + 1);
|
|
64
|
+
return entry('keep', 'rollback');
|
|
65
|
+
}
|
|
66
|
+
return entry('prune', 'retired');
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** One bounded step for one planned generation, inside the caller's write transaction. The first step marks it pruned
|
|
70
|
+
* and drops its coverage, so it is never rollback-ready while partially deleted. Returns rows deleted; 0 means done. */
|
|
71
|
+
export function pruneStep(db, id, keep, limit = pruneBatchRows) {
|
|
72
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000)
|
|
73
|
+
throw new StoreError('invalid-input');
|
|
74
|
+
const row = db.prepare('SELECT state FROM index_generations WHERE id=?').get(id);
|
|
75
|
+
if (!row)
|
|
76
|
+
throw new StoreError('unavailable');
|
|
77
|
+
// Once marked, a generation can never become active, building or retired again: no replanning per batch.
|
|
78
|
+
if (row.state !== 'pruned') {
|
|
79
|
+
const planned = planPrune(db, keep).find(e => e.id === id);
|
|
80
|
+
if (!planned || planned.action !== 'prune')
|
|
81
|
+
return 0;
|
|
82
|
+
db.prepare("UPDATE index_generations SET state='pruned',coverage_json=? WHERE id=?").run(prunedCoverage, id);
|
|
83
|
+
db.prepare("UPDATE index_jobs SET state='cancelled',cancel_requested=1 WHERE generation_id=? AND state='queued'").run(id);
|
|
84
|
+
}
|
|
85
|
+
else if (db.prepare('SELECT 1 FROM active_indexes WHERE generation_id=?').get(id))
|
|
86
|
+
throw new StoreError('integrity');
|
|
87
|
+
return Number(db.prepare('DELETE FROM vectors WHERE rowid IN (SELECT rowid FROM vectors WHERE generation_id=? LIMIT ?)').run(id, limit).changes);
|
|
88
|
+
}
|
|
89
|
+
export function pageStats(db) { const pageSize = Number(db.prepare('PRAGMA page_size').get()?.page_size), pages = Number(db.prepare('PRAGMA page_count').get()?.page_count), freePages = Number(db.prepare('PRAGMA freelist_count').get()?.freelist_count); return { pageSize, pages, freePages, bytes: pages * pageSize }; }
|
|
90
|
+
/** VACUUM rewrites the database through a temporary copy and the WAL: about the file size again on each volume. */
|
|
91
|
+
export function vacuumSpace(root, bytes, margin = 64 * 1024 ** 2) {
|
|
92
|
+
const free = (path) => { const s = statfsSync(path); return s.bavail * s.bsize; }, temp = tmpdir(), same = statSync(root).dev === statSync(temp).dev;
|
|
93
|
+
if (same ? free(root) < 2 * bytes + margin : free(root) < bytes + margin || free(temp) < bytes + margin)
|
|
94
|
+
throw new StoreError('limit-exceeded');
|
|
95
|
+
}
|
|
@@ -3,7 +3,7 @@ import { StoreError } from '../store/index.js';
|
|
|
3
3
|
import { acquireWorkerCustody, proveAttemptCleanup } from '../worker/custody.js';
|
|
4
4
|
import { supervisedRunner } from '../worker/supervisor.js';
|
|
5
5
|
import { directory, writeJournal } from '../worker/journal.js';
|
|
6
|
-
import { embedBatch, embeddingFingerprint, encodeEmbeddingRequest } from '../inference/embedding/index.js';
|
|
6
|
+
import { embedBatch, embeddingFingerprint, encodeEmbeddingRequest, resolveIndexSpace, projectVector } from '../inference/embedding/index.js';
|
|
7
7
|
import { deriveEmbeddingImage } from './image.js';
|
|
8
8
|
import { failure } from '../acquisition/errors.js';
|
|
9
9
|
/** Same durable worker lock/owner/supervisor as acquisition, with sequential bounded batches. */
|
|
@@ -39,8 +39,8 @@ export async function drainIndexQueue(store, options) {
|
|
|
39
39
|
const job = store.indexJob(attempt.jobId);
|
|
40
40
|
if (!job)
|
|
41
41
|
throw new StoreError('unavailable');
|
|
42
|
-
const generationId = String(job.generation_id), status = store.indexStatus(generationId);
|
|
43
|
-
if (
|
|
42
|
+
const generationId = String(job.generation_id), status = store.indexStatus(generationId), space = resolveIndexSpace(options.runtime.helperSha256, status.fingerprint);
|
|
43
|
+
if (!space)
|
|
44
44
|
throw new StoreError('integrity');
|
|
45
45
|
await checkpoint('indexing');
|
|
46
46
|
for (;;) {
|
|
@@ -64,9 +64,9 @@ export async function drainIndexQueue(store, options) {
|
|
|
64
64
|
batch.push({ kind: 'image', png: derived.png });
|
|
65
65
|
}
|
|
66
66
|
const expected = encodeEmbeddingRequest(batch).inputHashes, embedded = await (options.embed ?? embedBatch)(batch, { runtime: options.runtime, scratchRoot, runProcess, signal: controller.signal });
|
|
67
|
-
if (embedded.fingerprint !==
|
|
67
|
+
if (embedded.fingerprint !== embeddingFingerprint(options.runtime.helperSha256) || embedded.inputHashes.length !== expected.length || embedded.inputHashes.some((hash, i) => hash !== expected[i]))
|
|
68
68
|
throw new StoreError('integrity');
|
|
69
|
-
store.writeIndexAttemptBatch(attempt,
|
|
69
|
+
store.writeIndexAttemptBatch(attempt, status.fingerprint, inputs.map((input, i) => ({ ...input, inputHash: expected[i], vector: projectVector(embedded.vectors[i], space.dimensions) })));
|
|
70
70
|
await options.onBatch?.(attempt, store.indexStatus(generationId).completed);
|
|
71
71
|
}
|
|
72
72
|
await checkpoint('promoting');
|
|
@@ -32,6 +32,31 @@ export declare class EmbeddingError extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
export declare function spaceFingerprint(identity: Record<string, string | number>): string;
|
|
34
34
|
export declare function embeddingFingerprint(helperSha256: string): string;
|
|
35
|
+
/** Stored index spaces over the fixed 768-dim helper output. `f32-768` is the legacy identity and keeps its original fingerprint so existing generations stay readable; `int8-768` (default) stores symmetric per-vector max-abs int8 codes of the full vector; `int8-512` also keeps only the renormalized Matryoshka prefix. Quantization alone was near-lossless on real queries while the 512 prefix moved chunk top-1 on a quarter of them (docs/decisions/storage-diet.md), so the default keeps all 768 dimensions for 256 more bytes per row. */
|
|
36
|
+
export declare const indexEncodings: {
|
|
37
|
+
readonly 'f32-768': {};
|
|
38
|
+
readonly 'int8-768': {
|
|
39
|
+
readonly quantization: "int8-symmetric-maxabs-v1";
|
|
40
|
+
};
|
|
41
|
+
readonly 'int8-512': {
|
|
42
|
+
readonly dimensions: 512;
|
|
43
|
+
readonly truncation: "matryoshka-prefix-renormalized-v1";
|
|
44
|
+
readonly quantization: "int8-symmetric-maxabs-v1";
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
export type IndexEncoding = keyof typeof indexEncodings;
|
|
48
|
+
export declare const defaultIndexEncoding: IndexEncoding;
|
|
49
|
+
export interface IndexSpace {
|
|
50
|
+
encoding: IndexEncoding;
|
|
51
|
+
identity: Record<string, string | number>;
|
|
52
|
+
fingerprint: string;
|
|
53
|
+
dimensions: number;
|
|
54
|
+
}
|
|
55
|
+
export declare function indexSpace(helperSha256: string, encoding?: IndexEncoding): IndexSpace;
|
|
56
|
+
/** The stored space for a generation fingerprint built from this helper, if any. */
|
|
57
|
+
export declare function resolveIndexSpace(helperSha256: string, fingerprint: string): IndexSpace | undefined;
|
|
58
|
+
/** Matryoshka prefix truncation of a unit helper vector, renormalized to unit L2. */
|
|
59
|
+
export declare function projectVector(vector: readonly number[], dimensions: number): number[];
|
|
35
60
|
/** Versioned length-delimited bytes. No user content or image pathname enters argv. */
|
|
36
61
|
export declare function encodeEmbeddingRequest(inputs: readonly EmbeddingInput[]): {
|
|
37
62
|
bytes: Buffer;
|
|
@@ -19,6 +19,17 @@ export function spaceFingerprint(identity) { const keys = Object.keys(identity).
|
|
|
19
19
|
fail(); return hash(JSON.stringify(Object.fromEntries(keys.map(k => [k, identity[k]])))); }
|
|
20
20
|
export function embeddingFingerprint(helperSha256) { if (!/^[a-f0-9]{64}$/.test(helperSha256))
|
|
21
21
|
fail(); return spaceFingerprint({ ...embeddingIdentity, helperSha256 }); }
|
|
22
|
+
/** Stored index spaces over the fixed 768-dim helper output. `f32-768` is the legacy identity and keeps its original fingerprint so existing generations stay readable; `int8-768` (default) stores symmetric per-vector max-abs int8 codes of the full vector; `int8-512` also keeps only the renormalized Matryoshka prefix. Quantization alone was near-lossless on real queries while the 512 prefix moved chunk top-1 on a quarter of them (docs/decisions/storage-diet.md), so the default keeps all 768 dimensions for 256 more bytes per row. */
|
|
23
|
+
export const indexEncodings = { 'f32-768': {}, 'int8-768': { quantization: 'int8-symmetric-maxabs-v1' }, 'int8-512': { dimensions: 512, truncation: 'matryoshka-prefix-renormalized-v1', quantization: 'int8-symmetric-maxabs-v1' } };
|
|
24
|
+
export const defaultIndexEncoding = 'int8-768';
|
|
25
|
+
export function indexSpace(helperSha256, encoding = defaultIndexEncoding) { if (!/^[a-f0-9]{64}$/.test(helperSha256) || !Object.hasOwn(indexEncodings, encoding))
|
|
26
|
+
fail(); const identity = { ...embeddingIdentity, helperSha256, ...indexEncodings[encoding] }; return { encoding, identity, fingerprint: spaceFingerprint(identity), dimensions: Number(identity.dimensions) }; }
|
|
27
|
+
/** The stored space for a generation fingerprint built from this helper, if any. */
|
|
28
|
+
export function resolveIndexSpace(helperSha256, fingerprint) { return Object.keys(indexEncodings).map(e => indexSpace(helperSha256, e)).find(s => s.fingerprint === fingerprint); }
|
|
29
|
+
/** Matryoshka prefix truncation of a unit helper vector, renormalized to unit L2. */
|
|
30
|
+
export function projectVector(vector, dimensions) { if (!Number.isSafeInteger(dimensions) || dimensions < 1 || dimensions > vector.length || vector.some(n => !Number.isFinite(n)))
|
|
31
|
+
fail(); const prefix = vector.slice(0, dimensions), norm = Math.hypot(...prefix); if (!(norm > 0))
|
|
32
|
+
fail(); return dimensions === vector.length ? [...vector] : prefix.map(n => n / norm); }
|
|
22
33
|
/** Versioned length-delimited bytes. No user content or image pathname enters argv. */
|
|
23
34
|
export function encodeEmbeddingRequest(inputs) {
|
|
24
35
|
if (!inputs.length || inputs.length > 16)
|