diff --git a/.changeset/historical-snapshot-compatibility.md b/.changeset/historical-snapshot-compatibility.md new file mode 100644 index 0000000..81de8e4 --- /dev/null +++ b/.changeset/historical-snapshot-compatibility.md @@ -0,0 +1,5 @@ +--- +"webgpu-search": patch +--- + +Add a frozen snapshot from the published 1.0.1 package to verify backward-compatible restoration, document contents, and ordered search scores in CI. diff --git a/docs/IMPROVEMENT-PLAN.md b/docs/IMPROVEMENT-PLAN.md index 4ccbe2c..201483c 100644 --- a/docs/IMPROVEMENT-PLAN.md +++ b/docs/IMPROVEMENT-PLAN.md @@ -31,6 +31,7 @@ There are about 300 tests, but several core modules have none of their own. This - **Why:** AGENTS.md §2.4 requires the two paths to score the same, and nothing enforces it today. ### 2.2 Snapshot codec tests +- **Historical compatibility covered:** `scripts/test-snapshot.ts` loads the frozen `test/fixtures/snapshot-1.0.1.bin` written by the published npm package, with provenance and fixed search expectations in its JSON manifest. Existing round-trip and typed corruption/profile error checks remain in the same suite, which CI runs through `test:snapshot`. - Add `test/snapshot-codec.test.ts`: - Saving an index and loading it back gives identical search results. - Bad input is rejected with the right typed error: wrong magic bytes, wrong version, truncated data, a corrupted checksum, a mismatched text profile. diff --git a/packages/webgpu-search/test/fixtures/README.md b/packages/webgpu-search/test/fixtures/README.md new file mode 100644 index 0000000..5e83efc --- /dev/null +++ b/packages/webgpu-search/test/fixtures/README.md @@ -0,0 +1,34 @@ +# Historical snapshots + +`snapshot-1.0.1.bin` was written by the **published npm package +`webgpu-search@1.0.1`**, not by the working tree. Its JSON manifest records +the npm tarball URL and integrity, input documents, index options, snapshot +format and SHA-256, and ordered search results from that released package. + +The fixture protects compatibility with an older writer independently of +current serialization code. The snapshot suite loads the bytes directly and +checks result IDs, scores, matched fields, and restored documents. CI already +runs it through `bun run test:snapshot`. + +## Preserve the fixture + +Do not regenerate or update this pair to make a failing test pass. Investigate +the compatibility regression instead. Add a separately versioned fixture when +covering another release; retain the existing ones. + +To reproduce the original generation in a temporary directory: + +1. Install `webgpu-search@1.0.1` with + `npm install --ignore-scripts --no-audit --no-fund --save-exact webgpu-search@1.0.1`. +2. Verify its `package-lock.json` integrity matches `producer.integrity` in + the manifest. Import `DocumentIndex` from that installation, outside this + monorepo so workspace aliases cannot select current code. +3. Pass the manifest's `documents` and `options` to `DocumentIndex.create()`. + Write `new Uint8Array(index.serialize())` to a temporary file. +4. Run each manifest query using its `mode` and `cpuScorer: 'exact'`. Compare + `totalMatches` and ordered `{ id, score, matchedField }` against `expected`. + Destroy the index when finished. + +The resulting file must match `snapshotSha256`; reproduction must not overwrite +the checked-in artifact. Queries cover weighted multi-field ranking, ASCII, +German and Greek case folding, decomposed accents, CJK, emoji/ZWJ, and no hits. diff --git a/packages/webgpu-search/test/fixtures/snapshot-1.0.1.bin b/packages/webgpu-search/test/fixtures/snapshot-1.0.1.bin new file mode 100644 index 0000000..4598e89 Binary files /dev/null and b/packages/webgpu-search/test/fixtures/snapshot-1.0.1.bin differ diff --git a/packages/webgpu-search/test/fixtures/snapshot-1.0.1.json b/packages/webgpu-search/test/fixtures/snapshot-1.0.1.json new file mode 100644 index 0000000..e7ba4ac --- /dev/null +++ b/packages/webgpu-search/test/fixtures/snapshot-1.0.1.json @@ -0,0 +1,187 @@ +{ + "producer": { + "package": "webgpu-search", + "version": "1.0.1", + "tarball": "https://registry.npmjs.org/webgpu-search/-/webgpu-search-1.0.1.tgz", + "integrity": "sha512-psu6FVagazLpKH1miZez3PqIYhZ2rqBLDupogh7D5nZIr6teC6RpvgYEtm0v3m3MEuV2ll/JTdzz0JVwAPmApQ==" + }, + "options": { + "fields": [ + { + "name": "title", + "weight": 2 + }, + { + "name": "content", + "weight": 1 + } + ], + "preferGpu": false + }, + "documents": [ + { + "id": "ascii-1", + "title": "WebGPU search", + "content": "Fast parallel search for documents." + }, + { + "id": "ascii-2", + "title": "Search guide", + "content": "WebGPU search with a CPU fallback." + }, + { + "id": "unicode", + "title": "Straße café", + "content": "İstanbul and Greek Σ σ ς." + }, + { + "id": "cjk", + "title": "東京 検索", + "content": "日本語 search guide." + }, + { + "id": "emoji", + "title": "Developer 👩‍💻", + "content": "Launch 🚀 with emoji." + } + ], + "queries": [ + { + "query": "webgpu", + "mode": "substring", + "expected": { + "totalMatches": 2, + "results": [ + { + "id": "ascii-1", + "score": 1986, + "matchedField": "title" + }, + { + "id": "ascii-2", + "score": 972, + "matchedField": "content" + } + ] + } + }, + { + "query": "search", + "mode": "fuzzy", + "expected": { + "totalMatches": 3, + "results": [ + { + "id": "ascii-2", + "score": 748, + "matchedField": "title" + }, + { + "id": "ascii-1", + "score": 726, + "matchedField": "title" + }, + { + "id": "cjk", + "score": 359, + "matchedField": "content" + } + ] + } + }, + { + "query": "strasse", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "unicode", + "score": 1990, + "matchedField": "title" + } + ] + } + }, + { + "query": "café", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "unicode", + "score": 1824, + "matchedField": "title" + } + ] + } + }, + { + "query": "σ", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "unicode", + "score": 775, + "matchedField": "content" + } + ] + } + }, + { + "query": "東京", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "cjk", + "score": 1994, + "matchedField": "title" + } + ] + } + }, + { + "query": "👩‍💻", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "emoji", + "score": 1780, + "matchedField": "title" + } + ] + } + }, + { + "query": "🚀", + "mode": "substring", + "expected": { + "totalMatches": 1, + "results": [ + { + "id": "emoji", + "score": 911, + "matchedField": "content" + } + ] + } + }, + { + "query": "absent-query", + "mode": "substring", + "expected": { + "totalMatches": 0, + "results": [] + } + } + ], + "snapshotSha256": "b34c39e7698e5b1d0054b57b6f4d07b5ca1a41661787cf8267ea1f9241297ff2", + "snapshotFormatVersion": 4 +} diff --git a/scripts/test-snapshot.ts b/scripts/test-snapshot.ts index 993a905..baa17ba 100644 --- a/scripts/test-snapshot.ts +++ b/scripts/test-snapshot.ts @@ -1,4 +1,7 @@ import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import snapshotFixture from '../packages/webgpu-search/test/fixtures/snapshot-1.0.1.json'; import { DocumentIndex, SearchWorkerClient, @@ -1571,6 +1574,38 @@ async function runSnapshotTests() { console.log(' ✅ Profile guards + live-format semantics confirmed'); } + // Unlike live round-trips, these bytes and expectations come from the + // published 1.0.1 writer and must survive changes to the current encoder. + console.log('18. Testing the frozen webgpu-search@1.0.1 snapshot...'); + { + const bytes = await readFile(new URL( + '../packages/webgpu-search/test/fixtures/snapshot-1.0.1.bin', import.meta.url + )); + assert.strictEqual(createHash('sha256').update(bytes).digest('hex'), snapshotFixture.snapshotSha256); + const snapshot = Uint8Array.from(bytes).buffer; + assert.strictEqual(decodeSnapshotHeader(snapshot).formatVersion, snapshotFixture.snapshotFormatVersion); + const restored = await DocumentIndex.fromSnapshot(snapshot, { options: { preferGpu: false } }); + try { + assert.strictEqual(restored.getStats().docCount, snapshotFixture.documents.length); + for (const entry of snapshotFixture.queries) { + const response = await restored.search(entry.query, { + mode: entry.mode as 'fuzzy' | 'substring', + cpuScorer: 'exact', + }); + assert.deepStrictEqual({ + totalMatches: response.totalMatches, + results: response.results.map(({ id, score, matchedField }) => ({ id, score, matchedField })), + }, entry.expected, `1.0.1 snapshot search changed for ${JSON.stringify(entry.query)}`); + for (const hit of response.results) { + assert.deepStrictEqual(hit.doc, snapshotFixture.documents.find(doc => doc.id === hit.id)); + } + } + } finally { + restored.destroy(); + } + console.log(' ✅ Published 1.0.1 snapshot compatibility confirmed'); + } + console.log('\n--- All Persistence Tests Passed Successfully! ✅ ---'); }