albex 0.1.0 → 0.6.0

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +416 -0
  2. package/README.md +244 -112
  3. package/dist/albex-worker.d.ts +70 -0
  4. package/dist/albex-worker.d.ts.map +1 -0
  5. package/dist/albex-worker.js +153 -0
  6. package/dist/albex-worker.js.map +1 -0
  7. package/dist/albex.d.ts +508 -6
  8. package/dist/albex.d.ts.map +1 -1
  9. package/dist/albex.js +1911 -141
  10. package/dist/albex.js.map +1 -1
  11. package/dist/errors.d.ts +52 -0
  12. package/dist/errors.d.ts.map +1 -0
  13. package/dist/errors.js +66 -0
  14. package/dist/errors.js.map +1 -0
  15. package/dist/gpu/bloom-runtime.d.ts +60 -0
  16. package/dist/gpu/bloom-runtime.d.ts.map +1 -0
  17. package/dist/gpu/bloom-runtime.js +176 -0
  18. package/dist/gpu/bloom-runtime.js.map +1 -0
  19. package/dist/gpu/bloom-shader.wgsl.d.ts +19 -0
  20. package/dist/gpu/bloom-shader.wgsl.d.ts.map +1 -0
  21. package/dist/gpu/bloom-shader.wgsl.js +49 -0
  22. package/dist/gpu/bloom-shader.wgsl.js.map +1 -0
  23. package/dist/persistence.d.ts +21 -0
  24. package/dist/persistence.d.ts.map +1 -0
  25. package/dist/persistence.js +174 -0
  26. package/dist/persistence.js.map +1 -0
  27. package/dist/pool/coordinator.d.ts +98 -0
  28. package/dist/pool/coordinator.d.ts.map +1 -0
  29. package/dist/pool/coordinator.js +247 -0
  30. package/dist/pool/coordinator.js.map +1 -0
  31. package/dist/profile.d.ts +100 -0
  32. package/dist/profile.d.ts.map +1 -0
  33. package/dist/profile.js +200 -0
  34. package/dist/profile.js.map +1 -0
  35. package/dist/resource-manager.d.ts +56 -0
  36. package/dist/resource-manager.d.ts.map +1 -0
  37. package/dist/resource-manager.js +138 -0
  38. package/dist/resource-manager.js.map +1 -0
  39. package/dist/tiered-store.d.ts +98 -0
  40. package/dist/tiered-store.d.ts.map +1 -0
  41. package/dist/tiered-store.js +238 -0
  42. package/dist/tiered-store.js.map +1 -0
  43. package/dist/wasm-bindings.d.ts +180 -0
  44. package/dist/wasm-bindings.d.ts.map +1 -0
  45. package/dist/wasm-bindings.js +128 -0
  46. package/dist/wasm-bindings.js.map +1 -0
  47. package/dist/worker-protocol.d.ts +86 -0
  48. package/dist/worker-protocol.d.ts.map +1 -0
  49. package/dist/worker-protocol.js +20 -0
  50. package/dist/worker-protocol.js.map +1 -0
  51. package/dist/worker-runtime.d.ts +14 -0
  52. package/dist/worker-runtime.d.ts.map +1 -0
  53. package/dist/worker-runtime.js +109 -0
  54. package/dist/worker-runtime.js.map +1 -0
  55. package/package.json +60 -13
  56. package/src/albex-worker.ts +187 -0
  57. package/src/albex.ts +2136 -189
  58. package/src/errors.ts +76 -0
  59. package/src/gpu/bloom-runtime.ts +229 -0
  60. package/src/gpu/bloom-shader.wgsl.ts +48 -0
  61. package/src/persistence.ts +175 -0
  62. package/src/pool/coordinator.ts +324 -0
  63. package/src/profile.ts +280 -0
  64. package/src/resource-manager.ts +167 -0
  65. package/src/tiered-store.ts +259 -0
  66. package/src/wasm-bindings.ts +349 -0
  67. package/src/worker-protocol.ts +48 -0
  68. package/src/worker-runtime.ts +106 -0
  69. package/wasm/pkg/albex_pdf.wasm +0 -0
  70. package/wasm/pkg/albex_wasm.wasm +0 -0
  71. package/wasm/pkg/albex_wasm_bg.wasm +0 -0
  72. package/wasm/pkg/albex_wasm_simd.wasm +0 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,416 @@
1
+ # Changelog
2
+
3
+ All notable changes to Albex are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and Albex follows [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.6.0] — 2026-05-31
9
+
10
+ Release de auditoría algorítmica + robustez. Cierra los hallazgos #1–#8 de
11
+ la revisión externa en profundidad: sube la selectividad del pre-filtro de
12
+ forma drástica, elimina dos clases de corrupción/pérdida silenciosa de
13
+ datos, y de paso corrige una falta de _soundness_ del filtro Bloom bajo
14
+ matching difuso que existía desde el principio. El binario crece ~2 KB
15
+ (33 KB baseline · 37 KB SIMD) por el código del pre-filtro de trigramas.
16
+
17
+ ### Breaking changes
18
+
19
+ - **ABI WASM v2 → v3** (hallazgo #7). El binario principal exporta ahora
20
+ `getLastIndexOverflow` y el host fija el rango ABI aceptado a `[3, 3]`
21
+ (antes `[1, 2]`, con un mínimo inalcanzable que la lista de exports
22
+ requeridos ya hacía imposible). Un `.wasm` cacheado de 0.5.x falla con
23
+ `AlbexAbiMismatchError` claro en `init()` en vez de petar más tarde.
24
+
25
+ ### Added
26
+
27
+ - **Pre-filtro de trigramas q-gram** (hallazgo #1). El Bloom de 64 bits
28
+ (un bit por `c & 0x3F`) está saturado en chunks de prosa de 512 bytes —
29
+ cada chunk contiene casi todo el alfabeto, así que no poda y el Bitap
30
+ acababa ejecutándose sobre todo el corpus. Se añade una firma de 256
31
+ bits de los **trigramas** de cada chunk (`CHUNK_SIG`, BSS, no infla el
32
+ `.wasm`) como segundo pre-filtro mucho más selectivo. Es **sound bajo
33
+ matching difuso**: una ocurrencia con `e` errores conserva ≥ `N − 3e`
34
+ de los trigramas exactos del token (lema q-gram), y la firma no tiene
35
+ falsos negativos, así que nunca descarta un match real — solo poda
36
+ chunks que demostrablemente no pueden contenerlo. El Bitap confirma
37
+ cada superviviente: la corrección no cambia, solo se encoge el conjunto
38
+ candidato.
39
+ - **`AlbexCapacityError` con campo `limit`** (`'chunks' | 'text' | 'docs'
40
+ | 'names'`). El nuevo export `getLastIndexOverflow()` señaliza qué pool
41
+ se llenó durante el último `begin..endDocument`.
42
+
43
+ ### Fixed
44
+
45
+ - **Pérdida silenciosa de datos al agotar capacidad** (hallazgo #3). Los
46
+ pools del WASM se llenaban en silencio y `indexFile` devolvía un
47
+ documento a medio indexar indistinguible de uno completo (`getStats()`
48
+ mentía). Ahora `indexFile` lee el overflow y lanza `AlbexCapacityError`.
49
+ El documento que desborda se **revierte atómicamente** en el WASM
50
+ (`endDocument` restaura `chunk_count`/`text_used`/`name_used` al inicio
51
+ del doc): indexación all-or-nothing por fichero, sin chunks huérfanos
52
+ sin nombre.
53
+ - **Re-entrancy en el path async** (hallazgo #2). Una única instancia WASM
54
+ con estado global y ops async que ceden al scheduler entre slices: dos
55
+ operaciones solapadas se corrompían (un `searchBegin` nuevo reseteaba el
56
+ cursor de una búsqueda cooperativa en vuelo). Las ops async se serializan
57
+ ahora con una cola interna; los mutadores/búsquedas síncronos rechazan
58
+ ejecutarse a media operación (`AlbexError` kind `'busy'`) en vez de
59
+ corromper. El worker-runtime procesa mensajes estrictamente en orden.
60
+ - **Bloom no-sound bajo fuzzy** (corolario del #1). El filtro Bloom de
61
+ caracteres rechazaba un match aproximado cuando el carácter sustituido
62
+ no estaba en el chunk (bug latente pre-0.6.0). Ahora el Bloom solo se
63
+ aplica a tokens exactos (`eff_errors == 0`); los tokens difusos se
64
+ filtran solo con el conteo de trigramas, que sí es sound.
65
+ - **Frase + `windowed`** (hallazgo #7). El post-filtro de frase corría
66
+ contra el snippet recortado, así que `{ windowed: true }` podía descartar
67
+ un match válido cuyo segundo término caía fuera de la ventana. Ahora la
68
+ comprobación de adyacencia corre contra el **texto completo** del chunk;
69
+ el windowing solo afecta a la visualización.
70
+ - **GPU + OR**: el hash del pre-filtro GPU usaba siempre el patrón de la
71
+ rama 0, generando una máscara de candidatos errónea para las ramas i≠0 de
72
+ una query OR y descartando sus hits en silencio (hallazgo #6).
73
+ - **Corte de chunk a mitad de codepoint UTF-8** (hallazgo #7): el corte duro
74
+ a 512 bytes podía partir una secuencia multibyte; ahora retrocede a la
75
+ frontera de codepoint y el snippet del borde no renderiza `�`.
76
+ - **`replaceDocument` sin reclamar espacio** (hallazgo #7): repetidos
77
+ replaces dejaban tombstones en el text pool; ahora se compacta de forma
78
+ oportunista bajo presión.
79
+
80
+ ### Performance
81
+
82
+ - `prepareQuery` ya no hace un `memset` de 64 KB en pila por query (hallazgo
83
+ #4). La query de trabajo se acota a `MAX_QUERY_BYTES` (1 KB).
84
+
85
+ ### Tooling
86
+
87
+ - `npm run relaunch`: limpia artefactos, recompila WASM (baseline + SIMD) +
88
+ PDF + TS + OCR, corre los tests, empaqueta la librería (`npm pack`) y
89
+ levanta el demo en `http://localhost:5173/demo/`. Scripts auxiliares
90
+ `clean` / `clean:all` / `build:ocr`.
91
+ - Cobertura: +16 tests (110 en total) — selectividad del trigram, soundness
92
+ fuzzy, supervivencia de firmas tras compact/restore, error de capacidad,
93
+ guard de concurrencia, frase+windowed.
94
+
95
+ ## [0.5.0] — 2026-05-30
96
+
97
+ Release de endurecimiento — cierra las cinco clases de bugs accionables
98
+ de la auditoría externa de código. Sin nuevas features que pidan datos
99
+ reales para ser correctas. El binario crece ~2 KB respecto a 0.4.0 por
100
+ la lógica de query parsing en Rust + ABI version.
101
+
102
+ ### Breaking changes
103
+
104
+ - **`@albex/ocr` ahora requiere `engine.attachOcr()`** (audit 3.8). El
105
+ patrón anterior de mutar `engine.ocrImage = ...` y
106
+ `engine.ocrConfig = ...` directamente queda eliminado. Para
107
+ integradores que usaban `@albex/ocr`, **el cambio es transparente** —
108
+ `enableOcr(engine)` sigue siendo la misma llamada. Para quien tuviera
109
+ un adaptador manual: usar `engine.attachOcr({ recognize, options })`.
110
+ - **Tiers eliminados** (audit 4.1). Mini/std/pro × baseline/SIMD (6
111
+ binarios) consolidados a **baseline + SIMD** (2 binarios). El
112
+ parámetro `tier` de `AlbexOptions` queda como noop deprecado. El alias
113
+ `albex_wasm_bg.wasm` se mantiene para compatibilidad con `0.4.x`.
114
+ - `pickTier(profile)` siempre devuelve `'std'` ahora. La función queda
115
+ exportada para compatibilidad de código fuente, no de comportamiento.
116
+
117
+ ### Fixed
118
+
119
+ - **Validación runtime de la ABI WASM** (audit 3.2). `asAlbexExports` y
120
+ `asAlbexPdfExports` ya no son `as unknown as` ceremoniales — verifican
121
+ que `memory` sea una `WebAssembly.Memory`, que cada export requerido
122
+ exista, y que `abiVersion()` esté en el rango soportado. Lanzan
123
+ `AlbexAbiMismatchError` con la lista de exports faltantes cuando algo
124
+ no encaja. Antes, un binario incompatible instanciaba en silencio y
125
+ petaba en el primer call site que tocaba la función ausente.
126
+ - **`makePdfWasmImports` falla rápido** ante imports desconocidos
127
+ (compatibilidad con el cambio anterior de 0.3.1; ya estaba pero ahora
128
+ alineado con la ABI version del módulo).
129
+
130
+ ### Added
131
+
132
+ - **Canal de diagnósticos estructurado** (audit 3.6). Los `console.warn`
133
+ diseminados por el path de indexación desaparecen — los reemplaza un
134
+ buffer interno de `AlbexDiagnostic[]` consultable con
135
+ `engine.takeDiagnostics()`. Cada entrada es `{kind, stage, message, file?,
136
+ page?}` con `kind` en `'recovered' | 'skipped' | 'fallback' | 'info'`.
137
+ Cubre: fallback PDF→OCR, OCR fail por imagen, GPU caída a CPU, descarga
138
+ PDF WASM en red restringida. Capped a 256 entradas para no explotar en
139
+ corpus muy corruptos. La API de "best-effort" se mantiene, pero el
140
+ caller ahora puede inspeccionar qué se perdió.
141
+ - **`engine.attachOcr(adapter)`** — extension point formal. Devuelve un
142
+ `OcrHandle` con `dispose()`. El motor valida el contrato y rechaza
143
+ un segundo `attachOcr` mientras haya uno activo. La propiedad pública
144
+ `engine.ocrImage` se mantiene como getter de feature-detect pero no es
145
+ asignable — para evitar el patrón anti-encapsulación del audit.
146
+ - **`abiVersion()` exportada por ambos módulos WASM**. Main = v2 (incluye
147
+ query parser nuevo); PDF = v3 (incluye image extraction). El validador
148
+ TS rechaza binarios fuera de rango.
149
+
150
+ ### Architecture — query parsing moves to WASM (audit "two truths")
151
+
152
+ Pre-0.5.0 el TypeScript dueño de `parseQuery`, `tokenize`,
153
+ `tokensToWasmQuery`, mientras Rust tokenizaba al indexar. Dos verdades
154
+ sobre qué era un "token".
155
+
156
+ - Nuevos exports WASM: `prepareQuery`, `getQueryKind`,
157
+ `getQueryBranchCount`, `getQueryBranchPattern`, `selectQueryBranch`.
158
+ - Hasta **8 branches OR** soportadas, **4 tokens por branch**, **256
159
+ bytes por pattern compilado** — todo en static BSS, sin alocación.
160
+ - `containsPhrase` queda en TS porque opera sobre snippets (output del
161
+ WASM), no sobre la query — no es divergencia de tokenizer.
162
+ - `parseQuery`, `tokenize`, `tokensToWasmQuery` eliminados del TS.
163
+ - Un único algoritmo de "qué es un token" entre indexación y querying.
164
+
165
+ ### Build & maintenance
166
+
167
+ - **`prepublishOnly` rebuildea WASM + corre tests** desde 0.3.1, ya
168
+ garantizado en 0.5.0.
169
+ - Build pipeline simplificada: `scripts/build-wasm.mjs` produce solo
170
+ dos binarios. `npm pack --dry-run` muestra 4 archivos `.wasm` en lugar
171
+ de 8.
172
+ - `wasm/Cargo.toml` añade `wee_alloc` (~1 KB) para el staging Vec del
173
+ restore atómico de 0.4.0.
174
+
175
+ ### Tests
176
+
177
+ - 94 vitest cases verdes (era 88 en 0.4.0). Cinco tests del tier matrix
178
+ eliminados (mini/std/pro ya no existen). Tests nuevos:
179
+ - `tests/abi-validation.test.ts` (5): valida que `AlbexAbiMismatchError`
180
+ se lanza ante exports faltantes, abiVersion fuera de rango, memory
181
+ inválida.
182
+ - `tests/diagnostics.test.ts` (4): valida `takeDiagnostics()` drena,
183
+ cap a 256, reset limpia.
184
+
185
+ ### Postponed con razón
186
+
187
+ Cosas del audit que NO se cierran en 0.5.0 porque cerrarlas sin datos
188
+ reales sería adivinar:
189
+
190
+ - **3.5 OCR paralelización**: optimización sin profiling no es ingeniería.
191
+ - **3.9 Adaptive runtime con métricas reales**: requiere corpus y uso
192
+ reales para validar decisiones.
193
+ - **4.3 GPU equivalence test**: requiere corpus >20k chunks que aún no
194
+ está checked in.
195
+ - **7 parsers lite a WASM**: ~3 semanas serias. Separable. No es bug
196
+ fix, es mejora arquitectural más limpia con tiempo dedicado.
197
+
198
+ ## [0.4.0] — 2026-05-30
199
+
200
+ Cierre de dos clases enteras de bugs identificadas por la auditoría externa
201
+ de código. Sin cambios cosméticos — el binario crece ~4 KB por la lógica
202
+ de atomicidad y el allocator necesario para el staging buffer.
203
+
204
+ ### Fixed — atomic snapshot restore (audit 3.4)
205
+
206
+ - **Snapshot v3 con formato por campos**. Reemplaza la copia byte a byte
207
+ de los structs internos `Chunk`/`DocEntry` (`from_raw_parts`) por un
208
+ encoding explícito little-endian. El formato deja de depender del
209
+ layout en memoria de Rust, del target, del padding o de cambios en
210
+ los tipos. Lo que va al disco es un contrato.
211
+ - **`restoreCommit()` — protocolo de 3 fases atómico**. El antiguo
212
+ `restoreBegin` reseteaba el estado y escribía los counters antes de
213
+ recibir un solo byte del payload. Si `restoreFeed` fallaba a mitad,
214
+ el corpus previo quedaba destruido. v3 acumula todo el payload en un
215
+ staging buffer y solo aplica al estado vivo cuando `restoreCommit`
216
+ valida que el tamaño completo coincide con el header. Un commit
217
+ fallido deja el motor con el corpus previo intacto.
218
+ - **Compatibilidad backwards**. v1 y v2 siguen cargando — para ellos
219
+ `restoreBegin` mantiene la semántica vieja (no-atómica) y
220
+ `restoreCommit` es no-op que devuelve 1. El primer `save()` tras
221
+ cargar un snapshot viejo lo reescribe como v3.
222
+ - Binarios crecen ~4 KB por la lógica nueva y por `wee_alloc` (única
223
+ fuente de alocación en el módulo, usada por el staging Vec).
224
+
225
+ ### Fixed — single source of truth for content hash (audit "two truths")
226
+
227
+ - **FNV-1a 64-bit ahora vive en Rust**. La implementación TypeScript que
228
+ duplicaba el algoritmo desaparece. Tres nuevos exports
229
+ (`hashBegin`/`hashFeed`/`hashFinish`) implementan el hash en streaming
230
+ para archivos mayores que el scratchpad. El método privado del engine
231
+ `_contentHash` produce exactamente el mismo string hex de 16
232
+ caracteres que devolvía la versión TS — ningún caller cambia.
233
+
234
+ ### Added — tests
235
+
236
+ - `tests/load-restores-docs.test.ts`: nuevo test "a v3 restore that
237
+ never commits leaves the previous index intact". Verifica
238
+ explícitamente la atomicidad: trunca el payload de un snapshot al
239
+ 75 %, intenta cargarlo, verifica que `load()` devuelve `false` y que
240
+ el corpus previo sigue indexado y consultable.
241
+ - `tests/hash.test.ts`: reescrito para validar el hash WASM contra el
242
+ engine real (la versión vieja era una re-implementación TS standalone
243
+ comparándose consigo misma). Cubre shape, determinismo, sensibilidad
244
+ a un byte, FNV offset basis, streaming sobre 96 KB (> scratchpad).
245
+ - 88 tests verdes (era 85 en 0.3.1).
246
+
247
+ ### Postponed
248
+
249
+ - Mover el tokenizador y query parser a WASM (audit "wrapper TS hace
250
+ demasiado") se traslada a 0.5.0. Es mejora arquitectural, no cierre
251
+ de bug — y tiene suficientes trade-offs de diseño (semánticas de OR,
252
+ post-filter de phrase) como para no publicar una API a medio cocer.
253
+
254
+ ## [0.3.1] — 2026-05-30
255
+
256
+ Hardening pass after an external code audit. No new features; three
257
+ specific issues addressed.
258
+
259
+ ### Fixed
260
+
261
+ - **Debug logs removed from the indexing hot path.** Three `console.log`
262
+ statements added during the OCR-worker-abort diagnostic session were
263
+ firing on every PDF (hybrid OCR decision) and every embedded image
264
+ (kind / len / magic-byte trace). They are gone; the legitimate
265
+ `console.warn` messages for actual failures stay.
266
+
267
+ - **`makePdfWasmImports` now fails fast on unknown imports.** Previously
268
+ any unrecognised import was satisfied with a `console.warn` stub,
269
+ which let the module instantiate and defer the real failure to an
270
+ arbitrary call inside `extractPdf`. The loader now throws
271
+ `AlbexInitError` at boot with a clear "rebuild your binary" message.
272
+ An unknown import means the wasm-bindgen / lopdf / getrandom graph
273
+ drifted from what this loader was written for; better to surface that
274
+ immediately than to hang or crash mid-extraction.
275
+
276
+ - **`prepublishOnly` now rebuilds every WASM artifact and runs the
277
+ entire test suite.** It was running only `tsc + banner.mjs`, which
278
+ meant the WASM binaries published to npm could be out of sync with
279
+ the current Rust source. The script is now `npm run build:all && npm
280
+ test`. Publishing takes longer, but the package is guaranteed to
281
+ contain binaries reproducible from the source it ships.
282
+
283
+ ## [0.3.0] — 2026-05-30
284
+
285
+ ### Hybrid PDF OCR (opt-in)
286
+
287
+ - New `@albex/ocr` option `alwaysExtractEmbeddedImages: boolean` (default
288
+ `false`). When enabled, the engine OCRs the embedded images of EVERY
289
+ PDF on top of the normal text extraction — catching text that lives
290
+ only inside scanned annexes, stamps, signatures, or screenshots inside
291
+ otherwise-native PDFs.
292
+ - Demo exposes the flag as a checkbox in the OCR panel; status shows
293
+ `ready (spa, hybrid)` when active.
294
+
295
+ ### PDF parse-crash → OCR fallback
296
+
297
+ - When `extractPdf` traps (pdf-extract crashes on a PDF that other tools
298
+ read fine), the engine now re-instantiates the WASM and tries the
299
+ lopdf-only image-extraction path before throwing. With OCR wired, many
300
+ formerly "unsupported encoding" PDFs become searchable.
301
+ - Error message updated: instead of misleading "the file may be
302
+ malformed", users see clear guidance pointing at OCR as the recovery
303
+ path.
304
+
305
+ ### Demo sandbox
306
+
307
+ - Importmap to jsDelivr for `tesseract.js` (only loaded when the user
308
+ enables OCR).
309
+ - Full OCR panel: language select, hybrid-mode checkbox, lifecycle
310
+ status.
311
+ - Two fixture PDFs in `demo/fixtures/` for end-to-end testing:
312
+ `hybrid-test.pdf` (vector text + embedded image with text) and
313
+ `scanned-only-test.pdf` (100% image, no vector text).
314
+ - Global `window.onerror` + `unhandledrejection` handlers so Tesseract
315
+ worker aborts surface as Log entries instead of crashing the page.
316
+ - New `npm run serve` script wraps `npx serve -p 5173` for reproducible
317
+ local testing.
318
+
319
+ ### Breaking changes
320
+
321
+ - **`searchStream` renamed to `searchCooperative`.** The original name
322
+ implied incremental streaming, which the method never provided — it
323
+ yields to the scheduler between slices and then returns a batch.
324
+ The new name is honest. `searchStream` is kept as a deprecated alias
325
+ on `AlbexEngine`, `AlbexEngineWorker` and `AlbexPool`; it logs a
326
+ one-time `console.warn` on first call and will be removed in 0.4.0.
327
+
328
+ - **Snapshot format bumped to v2.** Existing v1 snapshots still load —
329
+ their documents come back with empty `contentHash` strings, same as
330
+ before. On the next `save()` they are rewritten as v2. No data loss;
331
+ no migration step required.
332
+
333
+ ### Added
334
+
335
+ - **Scanned-PDF OCR fallback.** When `extractPdf` returns `-2` (image-
336
+ only PDF) AND `@albex/ocr` has been wired via `enableOcr(engine)`,
337
+ the engine now extracts embedded JPEG / JPEG2000 image XObjects from
338
+ the PDF and runs them through Tesseract.js to recover text. Covers the
339
+ great majority of real-world scanned PDFs. Other compression filters
340
+ (FlateDecode, CCITTFaxDecode, JBIG2Decode) are not yet supported; pages
341
+ using them register with zero chunks (same behaviour as before).
342
+
343
+ - **`getPageCount`, `extractPageImages`, `getPageImage{Len,Ptr,Kind}`**
344
+ added to `albex_pdf.wasm` to support the scanned-PDF path. The PDF
345
+ binary grew from ~1.04 MB to ~1.19 MB.
346
+
347
+ - **Snapshot v2 persists per-document content hashes.** `load()` now
348
+ repopulates the in-memory `_docs` list correctly: `getStats().documents`
349
+ is right after a restore, and content-hash de-duplication survives the
350
+ round-trip (re-indexing the same file does not create a fresh slot).
351
+
352
+ - **New WASM exports**: `setDocumentContentHash`, `getDocContentHashPtr`,
353
+ `getDocContentHashLen`. Used by the host to round-trip the FNV-1a 64-bit
354
+ hash through the snapshot format.
355
+
356
+ - **OCR sandbox in the demo.** `demo/index.html` now ships an "Enable
357
+ OCR" panel that lazy-loads Tesseract.js through an importmap and
358
+ exposes per-document OCR status. Drop a scanned PDF and the demo OCR's
359
+ it automatically.
360
+
361
+ ### Fixed
362
+
363
+ - **`load()` repopulates `_docs` from the WASM tables.** Previously it
364
+ left `_docs = []` after a successful restore, which made
365
+ `engine.getStats().documents` return `0` even though searches against
366
+ the restored corpus worked. The README advertised "snapshot the index
367
+ and restore it" without that caveat.
368
+
369
+ - **CSV parser strips the UTF-8 BOM.** Files exported as "CSV UTF-8"
370
+ by Excel kept the BOM glued to the first field of the first row,
371
+ breaking column alignment and search hits on the first header
372
+ ("Subject", "Asunto", etc.).
373
+
374
+ - **EML parser decodes `base64` and `quoted-printable` bodies.** Real
375
+ emails almost always use one of these transfer encodings; before the
376
+ fix the body surfaced as opaque encoded blobs that searches could
377
+ never hit. Nested multipart (`multipart/alternative` inside
378
+ `multipart/mixed`) is now also unwrapped recursively.
379
+
380
+ - **RTF parser decodes `\'XX` hex bytes (via Windows-1252) and `\uN ?`
381
+ Unicode escapes.** Spanish/French/German content stored as cp1252
382
+ used to lose every accent; Word's modern `\u` escapes used to eat
383
+ the fallback ASCII character. Also added `\emdash`, `\endash`,
384
+ `\bullet`, `\lquote`, `\rquote`, `\ldblquote`, `\rdblquote`, `\tab`,
385
+ and soft-hyphen/non-breaking-space handling.
386
+
387
+ ### Documentation
388
+
389
+ - **README claims grounded.** Removed "every modern bundler",
390
+ "60 fps even on huge corpora", "5–10× speedup", "works for 99 % of
391
+ users", "11 formats" (without the `lite` qualifier). The matrix of
392
+ what is tested vs what is expected to work is now explicit. Bench
393
+ results are flagged as synthetic.
394
+
395
+ - **Persistence caveats documented.** The `Persistence` feature bullet
396
+ now describes the v2 / v1 difference and what survives the round trip.
397
+
398
+ ### Tests
399
+
400
+ - 71 → 83 vitest tests, all green. New suites:
401
+ - `tests/scanned-pdf.test.ts` (4 tests) — scanned-PDF OCR fallback
402
+ with a hand-rolled `FakePdfWasm`.
403
+ - `tests/load-restores-docs.test.ts` (4 tests) — verifies `load()`
404
+ repopulates `_docs` and that content-hash dedup survives v2.
405
+ - `tests/lite-parsers.test.ts` (11 tests) — adversarial fixtures for
406
+ CSV BOM, EML base64 / QP / nested multipart, RTF cp1252 / Unicode.
407
+
408
+ ## [0.2.0] — earlier
409
+
410
+ Initial public release. See git history for details: the surface was
411
+ the `AlbexEngine` class, the `albex_wasm_bg.wasm` and `albex_pdf.wasm`
412
+ binaries, lite parsers for the 11 formats, OPFS/IndexedDB persistence,
413
+ worker pool, tiered storage and optional WebGPU pre-filter.
414
+
415
+ [0.3.0]: https://github.com/RafaCalRob/Albex/releases/tag/v0.3.0
416
+ [0.2.0]: https://github.com/RafaCalRob/Albex/releases/tag/v0.2.0