@zakkster/lite-bake-stream 1.5.0 → 1.6.1

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # @zakkster/lite-bake-stream
2
2
 
3
+ > Streaming byte-level JSON to LBK1 binary containers. Zero-GC, tree-shakeable, gigabyte-scale.
4
+
3
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-bake-stream.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-bake-stream)
4
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
5
7
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
@@ -11,26 +13,257 @@
11
13
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
12
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
13
15
 
14
- > Streaming byte-level JSON compiler for [`@zakkster/lite-bake`](https://github.com/PeshoVurtoleta/lite-bake). Zero-GC, tree-shakeable, gigabyte-scale.
16
+ **Status:** v1.6.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
17
+
18
+ ## The gigabyte-scale JSON front door the ecosystem was missing
19
+
20
+ You have a gigabyte of JSON and a program that has to read it. `JSON.parse`
21
+ cannot get you there: V8 caps a single string near 512 MB, and well under that
22
+ ceiling the intermediate object graph shreds your heap. This package is the
23
+ front door -- a chunk-safe UTF-8 JSON SAX scanner that reads bytes off a stream
24
+ and emits a flat, seekable LBK1 binary container, without ever materializing the
25
+ object graph.
15
26
 
16
- **Status:** v1.5.0. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
27
+ ```bash
28
+ npm install @zakkster/lite-bake-stream
29
+ ```
17
30
 
18
- ## Two modes, one API
31
+ ```js
32
+ import { serialize, deserialize } from '@zakkster/lite-bake-stream';
33
+
34
+ const container = serialize(ndjsonBytes);
35
+ const reader = deserialize(container);
36
+ reader.get(0, 'id'); // 1
37
+ reader.findShards('id', { min: 500 }); // shard pruning via zone maps
38
+ ```
19
39
 
20
- - **Schema mode** (default): F64/U32 lane packing, zone-maps query pruning, byte-exact preservation for flat records. Millions of rows/sec random access, zero-GC hot path.
21
- - **Preserve mode**: `serialize(input, { preserve: true })`. Opaque byte blobs — the module doesn't crack open records, it just moves them intact. Deeply nested API JSON, arrays, mixed types — any valid JSON round-trips byte-for-byte identical. Reader exposes `getBytes(i)` (zero-alloc), `getString(i)`, `getJSON(i)`.
40
+ ## Table of contents
22
41
 
23
- `deserialize()` auto-dispatches on the container's flag bit. If you're compiling gigabytes of API responses with 2-3 levels of nesting, preserve mode is what you want. If the data is flat and you want columnar-style range queries, schema mode is what you want.
42
+ - [Why this exists](#why-this-exists)
43
+ - [What you get](#what-you-get)
44
+ - [The core surface](#the-core-surface)
45
+ - [API reference](#api-reference)
46
+ - [Composability](#composability)
47
+ - [Zero-GC design notes](#zero-gc-design-notes)
48
+ - [Design decisions worth knowing](#design-decisions-worth-knowing)
49
+ - [Testing](#testing)
50
+ - [What this is not](#what-this-is-not)
51
+ - [Ecosystem](#ecosystem)
52
+ - [License](#license)
24
53
 
25
54
  ## Why this exists
26
55
 
27
- `lite-bake` compiles a JS array into a flat binary buffer. That's the destination format you want. But if your input is a gigabyte of JSON, you can't call `JSON.parse` on it V8's string limit is ~512 MB, and even under that, the intermediate object graph will trash the heap and destroy your process before `bake()` ever runs.
56
+ The enemy is `JSON.parse`, not any one downstream format. Two hard walls stop it
57
+ on large input:
58
+
59
+ - **The string ceiling.** V8 refuses to hold a single string beyond roughly
60
+ 512 MB. A gigabyte file cannot even be read into memory as text, let alone
61
+ parsed.
62
+ - **Object-graph heap death.** Even under the ceiling, `JSON.parse` allocates a
63
+ fully-realized object graph -- every object, array, string, and boxed number.
64
+ For a gigabyte of records that graph is many gigabytes of live heap, and the
65
+ garbage collector spends the run trying (and failing) to keep up.
66
+
67
+ `lite-bake-stream` never builds the graph. It scans bytes as they arrive, packs
68
+ each record into typed-lane shard buffers (or preserves it as an opaque blob),
69
+ and writes an LBK1 container whose Reader gives you random access without
70
+ re-parsing. The input-size ceiling becomes your disk, not your heap.
71
+
72
+ ## What you get
73
+
74
+ Two ingest modes share one top-level API. `deserialize()` auto-dispatches on the
75
+ container's flag bit, so a consumer never has to know which mode wrote the bytes.
76
+
77
+ - **Schema mode** (default): F64/U32 lane packing, per-shard zone-maps for query
78
+ pruning, byte-exact preservation for flat records. Random access to millions
79
+ of rows per second on a zero-GC hot path.
80
+ - **Preserve mode** (`serialize(input, { preserve: true })`): opaque byte blobs.
81
+ The module does not crack open records, it moves them intact. Deeply nested
82
+ API JSON, arrays, mixed types -- any valid JSON round-trips byte-for-byte
83
+ identical. The reader exposes `getBytes(i)` (zero-alloc view), `getString(i)`,
84
+ and `getJSON(i)`.
85
+
86
+ Compiling gigabytes of API responses with two-to-three levels of nesting?
87
+ Preserve mode. Flat records that want columnar-style range queries? Schema mode.
88
+
89
+ ## The core surface
90
+
91
+ <details>
92
+ <summary>Streaming emission, bounded memory, and integrity</summary>
93
+
94
+ `finalize()` returns the whole container as one `ArrayBuffer` -- peak memory is
95
+ `O(container)`. For bounded-memory output, `writer.finalizeToSink(sink, { layout })`
96
+ emits to a caller sink and returns `{ totalRows, shardCount, schema | mode,
97
+ bytesWritten, layout }` (no buffer). A sink is any object with a synchronous
98
+ `write(bytes)`; `layout: 'stream'` also needs `writeAt(bytes, position)` for one
99
+ header backpatch. The contract is satisfiable by `fs.write(fd, buf, 0, len, pos)`
100
+ and the File System Access API's `createWritable().write({ type: 'write',
101
+ position })`; the test suite uses an in-memory sink of the same shape. No sink
102
+ class is exported -- the contract is public, the convenience implementation is
103
+ internal.
104
+
105
+ Two ways to drive a stream, with different peaks:
106
+
107
+ - **Two-step, bounded RAM** -- call `writer.beginStream(sink, { layout: 'stream' })`
108
+ BEFORE feeding, feed the tokenizer, then `writer.finalizeToSink(sink, { layout:
109
+ 'stream' })`. Each shard is written to the sink as it finalizes and its bytes
110
+ are dropped, so peak memory is `2*targetShardBytes + shardCount*(40 + 16*T) +
111
+ schemaBlockBytes` -- `O(targetShardBytes + directory)`, never `O(container)`.
112
+ This is the mode the 500 MB gate proves.
113
+ - **One-shot, buffered** -- call `writer.finalizeToSink(sink, opts)` alone (no
114
+ `beginStream`). Correct and simplest, but the shards are buffered first, so
115
+ peak is `containerBytes + sum(shard bytes)` -- `O(container)`, the same as
116
+ `finalize()`.
117
+
118
+ `layout: 'prefix'` is the classic layout, byte-identical to `finalize()`.
119
+ `layout: 'stream'` places the schema/directory/zone-map trailer and footer after
120
+ the payloads with one header backpatch; a default-emitted stream container is a
121
+ legal v1 container (`format_version` stays 1) that every shipped reader,
122
+ `checkContainer`, and `mergeContainers` accept. A malformed sink (non-object,
123
+ missing `write`, missing `writeAt` for stream, or an async/thenable return)
124
+ throws `W_BAD_SINK`; a sink that throws mid-emission fails the writer closed and
125
+ rethrows the source error verbatim (a retry then hits `W_FINALIZED`).
126
+
127
+ **Optional CRC-32C** (Castagnoli, table-driven, zero deps). Opt in with
128
+ `{ crc: true }` on the writer (or serialize `writer` opts); coverage is
129
+ `[0, footer_off)`, folded per emitted chunk. Readers expose `verifyCrc()` ->
130
+ `'ok' | 'absent'` (a mismatch throws `R_BAD_CRC`) on `Reader`/`PreserveReader`
131
+ (sync) and `RangeReader` (async). The open option `{ verifyCrc: true }` (also
132
+ `deserialize(bytes, opts)`) fails closed on both mismatch (`R_BAD_CRC`) and
133
+ absence (`R_CRC_ABSENT`) -- `null` is not zero. `0xFFFFFFFF` means absent and
134
+ stays legal. `mergeContainers` recomputes the CRC iff every input carried one,
135
+ else emits absent. See `decisions/0009-streaming-emission.md`.
136
+
137
+ Future lane kinds and payload modes (an I64 exact-integer lane, a columnar
138
+ payload mode, a container-level string table) land via the format's
139
+ forward-compat seams -- `min_reader_version` on ShardEntry, reserved
140
+ FieldDescriptor flags, the `metadata_off` block wrapper -- without a
141
+ `format_version` bump.
142
+
143
+ </details>
144
+
145
+ ## API reference
146
+
147
+ Every subpath is a standalone import; the browser reader never pulls the writer.
148
+ Every subpath also exports a `VERSION` const.
149
+
150
+ **Root** (`@zakkster/lite-bake-stream`)
151
+
152
+ - `serialize(input, opts?) -> Uint8Array` -- ingest bytes into an LBK1 container.
153
+ `opts.preserve` selects preserve mode; `opts.writer` forwards writer options.
154
+ - `deserialize(bytes, opts?) -> Reader | PreserveReader` -- auto-dispatch on the
155
+ container flag bit. Plus class re-exports and `StringTableError`.
156
+
157
+ **Schema mode**
158
+
159
+ - `Tokenizer` (`/tokenizer`) -- chunk-safe UTF-8 JSON SAX scanner. `feed(chunk)`,
160
+ `end()`, sink callbacks fire synchronously.
161
+ - `Writer` (`/writer`) -- LBK1 shard emitter; plugs into the Tokenizer. F64/U32
162
+ lanes, per-shard string tables and zone maps, `beginStream`/`finalizeToSink`.
163
+ - `Reader` (`/reader`) -- container parser, sync, zero-alloc `get(row, field)`
164
+ plus zone-map query APIs `shardBounds(shardIdx, field)` and
165
+ `findShards(field, range)`.
166
+ - `RangeReader` (`/range-reader`) -- HTTP Range lazy shard loading; synchronous
167
+ query pruning after open.
168
+ - `MultiReader` (`/multi-reader`) -- logical union over N Readers sharing a
169
+ schema.
170
+ - `splitNDJSON`, `compilePart`, `compileInParts`, `mergeContainers` (`/split`) --
171
+ worker-agnostic split, compile, and merge.
172
+
173
+ **Preserve mode**
174
+
175
+ - `PreserveTokenizer` (`/preserve-tokenizer`) -- NDJSON record-boundary scanner
176
+ with JSON-aware depth tracking, chunk-safe.
177
+ - `PreserveWriter` (`/preserve-writer`) -- opaque byte-blob sink, pre-allocated
178
+ shard buffer, zero-GC record path.
179
+ - `PreserveReader` (`/preserve-reader`) -- `getBytes(i)` (zero-alloc view),
180
+ `getString(i)`, `getJSON(i)`.
181
+
182
+ **Shared**
183
+
184
+ - `StringTable` (`/string-table`) -- byte-level UTF-8 interning primitive.
185
+ - `ingestStream(readableStream, opts?)`, `ingestFile(file, opts?)`
186
+ (`/file-ingest`) -- browser helpers piping a `ReadableStream<Uint8Array>` (e.g.
187
+ `File.stream()`) through the Tokenizer + Writer, returning a Reader. The
188
+ per-chunk `onProgress` callback's state object is reused across calls (mutated
189
+ in place, zero per-chunk allocation) -- copy it if retained past the callback.
190
+
191
+ **Zero-copy view contract.** A byte view returned by `getBytes(i)` (and any
192
+ shard raw-access getter) is a plain `subarray` over the container's
193
+ `ArrayBuffer`: it pins that buffer for as long as the view is reachable. Copy the
194
+ bytes out (`getBytes(i).slice()`) if you need the container itself to be
195
+ collectable.
196
+
197
+ ### Constants
198
+
199
+ Lane kinds (SPEC 4.2):
200
+
201
+ | Kind | Name | Bytes |
202
+ | ---: | :---- | ----: |
203
+ | `1` | `F64` | 8 |
204
+ | `2` | `F32` | 4 |
205
+ | `3` | `U32` | 4 |
206
+ | `4` | `U8` | 1 |
207
+
208
+ Ceilings:
209
+
210
+ | Limit | Value |
211
+ | :----------------------------------- | -----------: |
212
+ | String-table blob bytes (u32) | `4294967295` |
213
+ | String-table entry count | `4294967294` |
214
+ | u64 offset safe `Number()` cast | `2^53 - 1` |
215
+
216
+ Error-code families (thrown with a stable `code`; the full inventory is pinned by
217
+ the torture gate):
218
+
219
+ | Prefix | Class |
220
+ | :----- | :----------------------------------------------------------------- |
221
+ | `E_` | `TokenizerError` (e.g. `E_NUMBER_OVERFLOW`) |
222
+ | `W_` | `WriterError` (`W_MIXED_LANE_TYPES`, `W_LANE_MISMATCH`, `W_BAD_SINK`, `W_FINALIZED`) |
223
+ | `R_` | `ReaderError` / `RangeReaderError` (`R_ROW_OUT_OF_RANGE`, `R_OFFSET_TOO_LARGE`, `R_BAD_CRC`, `R_CRC_ABSENT`, ...) |
224
+ | `M_` | `MultiReaderError` (`M_ROW_OUT_OF_RANGE`) |
225
+ | `S_` | `SplitError` |
226
+ | `ST_` | `StringTableError` (`ST_BLOB_OVERFLOW`) |
227
+ | `P*_` | `PreserveTokenizerError` / `PreserveWriterError` / `PreserveReaderError` |
228
+
229
+ ## Composability
230
+
231
+ The `/split` subpath turns one logical dataset into worker-parallel or
232
+ checkpointed parts, and the readers merge them back into a single view:
233
+
234
+ ```js
235
+ import { splitNDJSON, compileInParts, mergeContainers } from '@zakkster/lite-bake-stream/split';
236
+ import { RangeReader } from '@zakkster/lite-bake-stream/range-reader';
237
+
238
+ // 1. split NDJSON into byte-aligned parts (record boundaries preserved)
239
+ const parts = splitNDJSON(ndjsonBytes, { partBytes: 64 * 1024 * 1024 });
240
+
241
+ // 2. compile each part (fan out to workers if you like), then merge
242
+ const containers = compileInParts(parts);
243
+ const merged = mergeContainers(containers);
28
244
 
29
- `lite-bake-stream` is the front door: a chunk-safe UTF-8 JSON SAX scanner that reads bytes off a stream and emits `lite-bake`-shaped output shards, without ever materializing the object graph.
245
+ // 3. open the merged container and prune shards before fetching them
246
+ const reader = await RangeReader.open(adapterOver(merged));
247
+ const shards = reader.findShards('id', { min: 500, max: 999 });
248
+ for (const s of shards) reader.get(s.firstRow, 'id');
249
+ ```
30
250
 
31
- ## Qualified at 8 GB
251
+ ## Zero-GC design notes
32
252
 
33
- Overnight soak, M1 MacBook Pro, both release gates armed:
253
+ <details>
254
+ <summary>Allocation table and the 8 GB soak</summary>
255
+
256
+ The hot path allocates nothing steady-state. Buffers are pre-allocated and
257
+ reused across the churn:
258
+
259
+ | Operation | Steady-state allocation |
260
+ | :-------------------- | :---------------------- |
261
+ | `feed(chunk)` | 0 (views into the tokenizer's internal buffer) |
262
+ | per row | 0 (typed-lane staging is pre-allocated) |
263
+ | per shard roll | 0 (working buffers reused across rolls) |
264
+ | `Reader.get(row, f)` | 0 for F64; `U32` string fields resolve through a cached decoder |
265
+
266
+ **Qualified at 8 GB.** Overnight soak, M1 MacBook Pro, both release gates armed:
34
267
 
35
268
  | | |
36
269
  | :-- | --: |
@@ -44,107 +277,73 @@ Overnight soak, M1 MacBook Pro, both release gates armed:
44
277
  | Cells verified | 590,206,212 |
45
278
  | **Preservation mismatches** | **0** |
46
279
 
47
- **499 KB of heap allocation to compile 8 GB of JSON.** Total, for the whole run — not per shard, not per second. Zero garbage collections of any kind. Every one of 590 million declared cells round-tripped byte-exact.
280
+ 499 KB of heap allocation to compile 8 GB of JSON -- total, for the whole run,
281
+ not per shard, not per second. Zero garbage collections of any kind. Every one of
282
+ 590 million declared cells round-tripped byte-exact.
48
283
 
49
- Tokenizer throughput is ~55% of `JSON.parse` (237 MB/s vs 416 MB/s on a 50 MB fixture). That's the trade: you give up some raw speed and you get an input size ceiling that's bounded by your disk instead of your heap.
284
+ **Bounded streaming, measured.** The full-tier gate drives the two-step bounded
285
+ path, streaming 500 MB in 8 MiB chunks into a counting sink, and asserts
286
+ `peakRss - baselineRss <= 4*targetShardBytes + 64*shardCount + 64 MiB` -- measured
287
+ 32.1 MiB against the 96.0 MiB bound at 41 shards (the buffered prefix path is
288
+ ~704 MiB at the same 500 MB).
50
289
 
51
- ## Quickstart
290
+ Tokenizer throughput is ~55% of `JSON.parse` (237 MB/s vs 416 MB/s on a 50 MB
291
+ fixture). That is the trade: you give up some raw speed and you get an input-size
292
+ ceiling bounded by your disk instead of your heap.
52
293
 
53
- **Schema mode** (flat records, columnar queries):
294
+ </details>
54
295
 
55
- ```js
56
- import { serialize, deserialize } from '@zakkster/lite-bake-stream';
296
+ ## Design decisions worth knowing
57
297
 
58
- const container = serialize(ndjsonBytes);
59
- const reader = deserialize(container);
60
- reader.get(0, 'id'); // 1
61
- reader.findShards('id', { min: 500 }); // shard pruning via zone maps
62
- ```
63
-
64
- **Preserve mode** (any JSON shape, byte-exact passthrough):
298
+ Each call is an on-disk record under `decisions/`:
65
299
 
66
- ```js
67
- import { serialize, deserialize } from '@zakkster/lite-bake-stream';
68
-
69
- // Handles arbitrarily nested API JSON
70
- const container = serialize(apiResponseBytes, { preserve: true });
71
- const reader = deserialize(container);
72
- reader.getBytes(0); // Uint8Array view, zero allocation
73
- reader.getString(0); // TextDecoder allocation
74
- reader.getJSON(0); // full JSON.parse, allocates the parsed value graph
75
- ```
300
+ - [0001](decisions/0001-reserved-empty-string.md) -- reserve string-table index 0 as the empty string.
301
+ - [0002](decisions/0002-record-shape-policy.md) -- depth-0 non-object records are refused, not dropped.
302
+ - [0003](decisions/0003-duplicate-field-names.md) -- duplicate schema field names are refused at freeze.
303
+ - [0004](decisions/0004-container-doors-and-lying-metadata.md) -- a container is verified at the door; a lying pointer is corruption.
304
+ - [0005](decisions/0005-one-unknown-field-policy.md) -- absent names are refused, untracked names fall back.
305
+ - [0006](decisions/0006-utf8-door.md) -- the UTF-8 door is documented-permissive, not a tokenizer gate.
306
+ - [0007](decisions/0007-sample-window-bytes.md) -- the sample window is byte-true, not record-count.
307
+ - [0008](decisions/0008-null-policy.md) -- JSON null is lane-neutral.
308
+ - [0009](decisions/0009-streaming-emission.md) -- streaming emission, sinks, and optional CRC-32C.
309
+ - [0010](decisions/0010-preserve-mode.md) -- why a second (preserve) mode exists at all.
310
+ - [0011](decisions/0011-sample-drain-reintern.md) -- per-shard re-intern on drain and the byte-true sample window.
311
+ - [0012](decisions/0012-clinger-fast-path.md) -- the Clinger fast path and the two-tier F64 guarantee.
76
312
 
77
- For streaming ingest with progress, use `/file-ingest`. For HTTP Range-fetched containers (schema mode), `/range-reader`. For joining multiple containers into one logical view (schema mode), `/multi-reader`. For parallel or checkpointed compilation (schema mode), `/split`.
313
+ ## Testing
78
314
 
79
- ## What shipped in 1.0
80
-
81
- | Milestone | Deliverable |
82
- | :-------- | :-------------------------------------------------------------------------- |
83
- | ✅ **M0** | LBK1 container format spec, forward-compat seams for the I64 lane. |
84
- | ✅ **M1** | Chunk-safe tokenizer; zero-GC on the hot path after warmup. |
85
- | ✅ **M2** | Schema-driven writer; sample-and-infer path; typed-lane shard buffers. |
86
- | ✅ **M3** | Per-shard UTF-8 string table with pre-alloc typed-array hash-map interning. |
87
- | ✅ **M4** | `serialize()`/`deserialize()`, MultiReader, full type declarations. |
88
- | ✅ **M5** | Browser reader, HTTP Range fetch, `File.stream()` client-side ingest. |
89
- | ✅ **M6** | NDJSON splitter + per-part compilation + container merge. |
90
- | ✅ **M7** | Per-shard zone maps (min/max per F64 column) + query-pruning APIs. |
91
- | ✅ **M8** | RFC 8259 conformance corpus + robustness fuzz. |
92
-
93
- ## Roadmap beyond 1.0
94
-
95
- All of these fit the format's existing forward-compat seams — `format_version` stays at `1`.
96
-
97
- | | |
98
- | :-- | :-- |
99
- | **I64 lane** | Exact 64-bit integer preservation. FieldDescriptor flags are already reserved for it; shards using it set `min_reader_version: 2`. |
100
- | **Columnar payload mode** | Column-contiguous shard payloads instead of interleaved rows. Better compression, SIMD-friendly scans. Opt-in via a shard flag. |
101
- | **Container-level string table** | Cross-shard interning for high-repetition corpora. Lands in the `metadata_off` block alongside zone maps. |
102
- | **Oscilloscope-rack demo** | Reel-to-reel tape transport at actual MB/s, waterfall spectrum per shard, VU needles, seven-segment counters. Locked 60fps on an iPhone 7 with 100 MB NDJSON. |
103
-
104
- ## What's here today
105
-
106
- - `SPEC.md` — LBK1 container spec draft 0.1, per-shard string table (§3.3), preservation contract (§7).
107
- - `src/index.js` — top-level convenience API: `serialize(input)` and `deserialize(bytes)` plus class re-exports.
108
- - `src/Tokenizer.js` — chunk-safe SAX tokenizer with Clinger's fast-path decimal→F64 conversion.
109
- - `src/Writer.js` — LBK1 shard emitter with F64 and U32 lanes, sample-and-infer schema, per-shard string tables, per-shard zone maps.
110
- - `src/Reader.js` — LBK1 container parser with typed-array views, transparent string-field resolution, and zone-map query APIs.
111
- - `src/StringTable.js` — open-addressed byte-level interning with zero-alloc hit path.
112
- - `src/RangeReader.js` — LBK1 reader with lazy shard loading via an IO adapter (HTTP Range or in-memory mock); zone maps loaded once at open enable synchronous query pruning.
113
- - `src/MultiReader.js` — union view over N Reader instances with matching schemas; merged shard indices for query pruning across containers.
114
- - `src/Split.js` — NDJSON splitter, per-part compilation, and container merge for worker-parallel or checkpointed ingest.
115
- - `src/FileIngest.js` — browser helper: `File.stream()` → Reader. Any `ReadableStream<Uint8Array>` works.
116
- - `types/*.d.ts` — 8 hand-written TypeScript declaration files, one per subpath.
117
- - `demo/` — single-file HTML + module JS. Ingest a JSON file → LBK1 → browse in `@zakkster/lite-table`. Zone-maps query-pruning panel visualizes the fetch-set reduction. `npm run demo`.
118
- - `test/*.test.js` — 233 tests across 12 files, including a 66-fixture RFC 8259 conformance corpus and a 7-scenario robustness fuzz (~2,300 tokenizer invocations per run).
119
- - `test/_verify.js` — universal round-trip verifier used by tests, torture, and soak.
120
- - `bench/bench-tokenizer.js` — interleaved-rep bench vs `JSON.parse`.
121
- - `bench/torture.js` — 37-scenario CI harness. Two release gates: `maxMajor: 0` (zero-GC) AND `verify: true` (value-exact round-trip on 77,500+ rows per run).
122
- - `bench/soak.js` — CLI-configurable soak with the preservation gate wired in.
123
-
124
- Two release gates. Zero major GC AND every declared cell round-trips exactly. If either fails, no publish.
125
-
126
- ## Release testing tiers
127
-
128
- The gates run at four scales, chosen to fit different hardware and time budgets:
315
+ The gates run at scales chosen to fit different hardware and time budgets:
129
316
 
130
317
  | Command | Scale | Use |
131
318
  | :-- | :-- | :-- |
132
- | `npm test` | 96 tests | Dev loop, every save |
133
- | `npm run torture` | 37 scenarios (~1 MB each) | Before every commit |
134
- | `npm run torture:full` | + 500 MB full-tier soak scenarios | Before every publish |
319
+ | `npm test` | 519 tests across 36 files | Dev loop, every save |
320
+ | `npm run torture` | 44 fast scenarios (~1 MB each) | Before every commit |
321
+ | `npm run torture:full` | 47 scenarios incl. the 500 MB soak | Before every publish |
135
322
  | `npm run soak` | 100 MB with preservation gate | Sanity check |
136
323
  | `npm run soak:500` | 500 MB | Pre-publish scale check |
137
- | `npm run soak:1gb` | 1 GB | Real-hardware qualification (M4 Pro etc.) |
324
+ | `npm run soak:1gb` | 1 GB | Real-hardware qualification |
138
325
  | `npm run soak:overnight` | 8 GB | Overnight `caffeinate` run |
139
326
 
140
- Custom sizes:
141
- ```
142
- node --expose-gc bench/soak.js --gb=4 --verify
143
- caffeinate node --expose-gc bench/soak.js --gb=16 --verify # macOS overnight
144
- ```
327
+ The test tree includes a 66-fixture RFC 8259 conformance corpus, a property-based
328
+ robustness fuzz, and two structural guards: an ASCII-law scanner
329
+ (`test/AsciiLaw.test.js`) and an API-surface drift guard
330
+ (`test/ApiSurface.test.js`), each also wired into the torture tier with an armed
331
+ control that proves it can fail. Two release gates decide a publish: zero major
332
+ GC AND every declared cell round-trips exactly. If either fails, no publish.
333
+
334
+ ## What this is not
335
+
336
+ No JSON5, JSONC, comments, or trailing commas. No streaming field updates, no
337
+ compression, no native (non-JS) producers. LBK1 is this package's own container
338
+ format; non-JSON producers are welcome as long as they emit a well-formed
339
+ container.
340
+
341
+ ## Ecosystem
145
342
 
146
- Every soak run reports ingest throughput (MB/s), verification throughput (rows/s), the zero-GC gate result, container compression ratio, and progress lines so long runs are observable. The preservation gate proves — cell by cell — that every declared field of every row round-trips exactly.
343
+ The `demo/` directory is a single-file HTML + module-JS app: ingest a JSON file,
344
+ compile it to LBK1, and browse the result, with a zone-maps panel that visualizes
345
+ the query-pruning fetch-set reduction. Run it with `npm run demo`.
147
346
 
148
347
  ## License
149
348
 
150
- MIT © 2026 Zahary Shinikchiev
349
+ MIT (c) 2026 Zahary Shinikchiev
package/SPEC.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # LBK1 Format Specification
2
2
 
3
- > Version 1 frozen as of `@zakkster/lite-bake-stream` v1.0.0.
3
+ > Version 1 -- frozen as of `@zakkster/lite-bake-stream` v1.0.0.
4
4
  > Future lane kinds and payload modes land via the forward-compat seams
5
5
  > (`min_reader_version`, reserved FieldDescriptor flags, the `metadata_off`
6
6
  > block wrapper) without a `format_version` bump.
@@ -10,7 +10,7 @@
10
10
 
11
11
  LBK1 is the on-disk / on-wire container for streaming-compiled `lite-bake` datasets. It carries one or more shards of interleaved binary rows plus the metadata needed to reconstruct a Reader over the whole dataset without re-parsing JSON.
12
12
 
13
- The tokenizer specified in section 5 is the reference producer path from UTF-8 JSON (top-level array or NDJSON) into an LBK1 container. Non-JSON producers are allowed as long as they emit a well-formed container.
13
+ The tokenizer specified in section 5 is the canonical producer path from UTF-8 JSON (top-level array or NDJSON) into an LBK1 container. Non-JSON producers are allowed as long as they emit a well-formed container.
14
14
 
15
15
  Numbers on the hot path are F64. Section 4.3 reserves the schema-level bit that will admit a future `I64_BYTES_PRESERVED` lane without a container format-version bump.
16
16
 
@@ -22,13 +22,13 @@ These are load-bearing; every other decision follows from them.
22
22
  2. **Forward-compatible via strict decoders.** Unknown flags or lane kinds are rejected, not ignored. Silent corruption is the failure mode we refuse.
23
23
  3. **Shard-scoped versioning.** A shard header carries the minimum reader version required to decode that shard. Old readers refuse new shards cleanly; new readers read old shards forever.
24
24
  4. **Zero-copy where the CPU allows it.** Row payloads are aligned so that typed-array views over the shard buffer read fields with native alignment.
25
- 5. **Zero runtime dependencies.** Reference producer and reader share the `@zakkster` no-dep constraint.
25
+ 5. **Zero runtime dependencies.** Producer and reader share the `@zakkster` no-dep constraint.
26
26
 
27
27
  ## 2. Notation
28
28
 
29
- - `u8`, `u16`, `u32`, `u64`, `f32`, `f64` little-endian unless stated.
30
- - `bytes(n)` a raw byte run of length n.
31
- - `varlen_utf8` u32 length prefix followed by that many UTF-8 bytes.
29
+ - `u8`, `u16`, `u32`, `u64`, `f32`, `f64` -- little-endian unless stated.
30
+ - `bytes(n)` -- a raw byte run of length n.
31
+ - `varlen_utf8` -- u32 length prefix followed by that many UTF-8 bytes.
32
32
  - Offsets are relative to the start of the container unless stated.
33
33
  - All multi-byte integers in headers are little-endian. Row payloads use native endianness with a byte-order marker in the container header (section 3.1); non-native containers require a byteswap on load or must be rejected.
34
34
 
@@ -84,7 +84,7 @@ Padded with zero bytes so the block ends on an 8-byte boundary.
84
84
  There are two string-table locations in an LBK1 container:
85
85
 
86
86
  - **Container-level** at `string_table_off` (v1 reserved; not written by the current Writer). Reserved for future container-wide interning across shards.
87
- - **Per-shard** at each ShardEntry's `local_string_off` (M3, implemented). Every shard with U32-lane fields has its own local string table so shards remain independently decodable critical for HTTP Range fetches (M5+) and worker-parallel readers (M6+).
87
+ - **Per-shard** at each ShardEntry's `local_string_off` (M3, implemented). Every shard with U32-lane fields has its own local string table so shards remain independently decodable -- critical for HTTP Range fetches (M5+) and worker-parallel readers (M6+).
88
88
 
89
89
  Both use the same on-disk layout:
90
90
 
@@ -144,14 +144,14 @@ f64 maxes[shard_count * tracked_field_count] Same layout.
144
144
  ```
145
145
 
146
146
  - `tracked_field_count` MAY be less than the schema's F64 field count (the writer chooses which fields to track). For fields not present in `field_indices`, `shardBounds` returns `null`.
147
- - `mins[s*T + t]` and `maxes[s*T + t]` are the exact min/max of the tracked field's values across the rows in shard `s`. For shards where the field is missing (defaults to `0`), the min/max collapse to `[0, 0]` still valid input to range predicates.
147
+ - `mins[s*T + t]` and `maxes[s*T + t]` are the exact min/max of the tracked field's values across the rows in shard `s`. For shards where the field is missing (defaults to `0`), the min/max collapse to `[0, 0]` -- still valid input to range predicates.
148
148
  - Zone maps segment is placed between the shard directory and the first shard payload. This keeps it inside a single Range fetch alongside the header/schema/directory during Reader initialization.
149
149
 
150
150
  Reader query APIs (see `Reader.js`):
151
151
 
152
152
  ```
153
- reader.shardBounds(shardIdx, fieldName) {min, max} | null
154
- reader.findShards(fieldName, {min, max}) shardIdx[] // shards that MAY contain the range
153
+ reader.shardBounds(shardIdx, fieldName) -> {min, max} | null
154
+ reader.findShards(fieldName, {min, max}) -> shardIdx[] // shards that MAY contain the range
155
155
  ```
156
156
 
157
157
  `findShards` returns exactly the shards whose bounds overlap `[min, max]`; a shard whose bounds are disjoint is skipped, saving one range fetch per skipped shard in the RangeReader path.
@@ -165,7 +165,9 @@ reader.findShards(fieldName, {min, max}) → shardIdx[] // shards that MA
165
165
  | 8 | 4 | `magic_end` | ASCII `1 K B L` = `0x31 0x4B 0x42 0x4C`. |
166
166
  | 12 | 4 | `footer_len`| u32. Currently `16`. Lets future versions grow the footer without breaking magic detection. |
167
167
 
168
- CRC is optional in M4; producers that omit it write `0xFFFFFFFF`.
168
+ CRC is optional; producers that omit it write `0xFFFFFFFF` (absent). Producers opt in with the writer option `{ crc: true }`. The CRC-32C uses the Castagnoli polynomial (bit-reflected `0x82F63B78`); check value `CRC32C("123456789") = 0xE3069283`. Readers verify with `verifyCrc()` (returns `'ok' | 'absent'`, throws `R_BAD_CRC` on mismatch) or the open option `{ verifyCrc: true }`, which additionally throws `R_CRC_ABSENT` when the caller demanded verification but the CRC is absent.
169
+
170
+ Note (SPEC 3 / 3.6): container offsets are ABSOLUTE, so two conformant orderings exist for a v1 container. The classic PREFIX ordering places the schema block, shard directory, and zone maps before the shard payloads. The STREAMING ordering (`layout: 'stream'`) places the shard payloads first, then the schema block, shard directory, and zone maps as a trailer, then the footer; the schema block still immediately precedes the shard directory (adjacency preserved). Both are valid v1 containers; format_version is unchanged.
169
171
 
170
172
  ## 4. Schema and field descriptors
171
173
 
@@ -189,12 +191,12 @@ Fixed-width descriptors let a Reader load the schema with a single `Uint32Array`
189
191
 
190
192
  | Value | Name | Bytes | Notes |
191
193
  | ----: | ------- | ----: | ------------------------------------------------------------------------ |
192
- | `1` | `F64` | 8 | The only numeric lane in M4. Matches lite-bake's `Types.F64`. |
194
+ | `1` | `F64` | 8 | The only numeric lane in M4. |
193
195
  | `2` | `F32` | 4 | Reserved for future producer opt-in via schema override. |
194
196
  | `3` | `U32` | 4 | String-table index (M3+). Row cell holds a u32 index into the shard's `local_string_off` table. |
195
197
  | `4` | `U8` | 1 | Reserved. |
196
198
 
197
- Values 5..127 reserved for future lite-bake type lanes (matching `Types.*`).
199
+ Values 5..127 reserved for future lane kinds defined by this spec.
198
200
 
199
201
  Values 128..255 reserved for stream-specific lanes; see 4.4.
200
202
 
@@ -202,10 +204,10 @@ Values 128..255 reserved for stream-specific lanes; see 4.4.
202
204
 
203
205
  Currently all bits are reserved and must be zero. Reserved bit assignments (v2+) will use:
204
206
 
205
- - Bit 0: `LANE_KIND_EXT` if set, the field uses an extended lane; consult 4.4.
207
+ - Bit 0: `LANE_KIND_EXT` -- if set, the field uses an extended lane; consult 4.4.
206
208
  - Bits 1..7: unassigned.
207
209
 
208
- This is the seam Option (c) `I64_BYTES_PRESERVED` slides into without a container format-version bump. A shard using it sets `min_reader_version = 2` at the directory level; v1 readers refuse the shard, not the whole container.
210
+ This is the seam Option (c) -- `I64_BYTES_PRESERVED` -- slides into without a container format-version bump. A shard using it sets `min_reader_version = 2` at the directory level; v1 readers refuse the shard, not the whole container.
209
211
 
210
212
  ### 4.4 Extended lane kinds (reserved for v2)
211
213
 
@@ -218,16 +220,16 @@ When `flags` bit 0 is set on a FieldDescriptor, `lane_kind` is interpreted from
218
220
 
219
221
  All other values in the extended range are reserved and must be rejected.
220
222
 
221
- ## 5. Reference producer: tokenizer contract
223
+ ## 5. Producer: tokenizer contract
222
224
 
223
- The M1 tokenizer is a chunk-safe UTF-8 JSON SAX scanner. It does not itself write LBK1 shards that is the M2 writer's job. This section specifies the contract between them so M1 can be built and benched in isolation.
225
+ The M1 tokenizer is a chunk-safe UTF-8 JSON SAX scanner. It does not itself write LBK1 shards -- that is the M2 writer's job. This section specifies the contract between them so M1 can be built and benched in isolation.
224
226
 
225
227
  ### 5.1 Input framing
226
228
 
227
229
  Two top-level modes, auto-detected by the first non-whitespace byte:
228
230
 
229
- - `[` Top-level JSON array. Records are the array's elements.
230
- - Any other JSON value byte (`{`, `"`, `-`, `0`..`9`, `t`, `f`, `n`) NDJSON. Records are separated by JSON whitespace (`0x20`, `0x09`, `0x0A`, `0x0D`).
231
+ - `[` -- Top-level JSON array. Records are the array's elements.
232
+ - Any other JSON value byte (`{`, `"`, `-`, `0`..`9`, `t`, `f`, `n`) -- NDJSON. Records are separated by JSON whitespace (`0x20`, `0x09`, `0x0A`, `0x0D`).
231
233
 
232
234
  Records themselves must be JSON objects for the reference writer path. Non-object records are accepted by the tokenizer (it is generic) but rejected by the writer.
233
235
 
@@ -265,10 +267,10 @@ For a string that spans multiple chunks, the tokenizer accumulates into an inter
265
267
 
266
268
  #### 5.4.1 F64 preservation contract (M3)
267
269
 
268
- The tokenizer implements Clinger's fast-path decimalF64 conversion with an exact `POW10[0..22]` table. This gives a two-tier preservation guarantee that consumers can rely on:
270
+ The tokenizer implements Clinger's fast-path decimal->F64 conversion with an exact `POW10[0..22]` table. This gives a two-tier preservation guarantee that consumers can rely on:
269
271
 
270
- - **Fast path (guaranteed correctly-rounded):** any numeric literal whose decimal representation has 15 significant digits AND absolute net exponent 22 produces the SAME IEEE 754 double as JavaScript's `Number(str)`. This covers all integer IDs within `Number.MAX_SAFE_INTEGER`, all "typical" decimals (prices, ratios, timestamps, coordinates), and scientific notation within `1e±22`. This is the domain the round-trip verifier (`test/_verify.js`) asserts bit-exact across every torture scenario.
271
- - **Slow path (may drift 1 ULP):** literals outside the fast path (16+ significant digits, or `|exponent| > 22`) fall through to a naive `intPart + fracPart/fracDiv * Math.pow(10, exp)` computation. The result is within 1 ULP of the correctly-rounded value. Correctly-rounded parsing across the full F64 domain (David Gay's `strtod` or equivalent) is planned for M4+.
272
+ - **Fast path (guaranteed correctly-rounded):** any numeric literal whose decimal representation has <= 15 significant digits AND absolute net exponent <= 22 produces the SAME IEEE 754 double as JavaScript's `Number(str)`. This covers all integer IDs within `Number.MAX_SAFE_INTEGER`, all "typical" decimals (prices, ratios, timestamps, coordinates), and scientific notation within `1e+/-22`. This is the domain the round-trip verifier (`test/_verify.js`) asserts bit-exact across every torture scenario.
273
+ - **Slow path (may drift <= 1 ULP):** literals outside the fast path (16+ significant digits, or `|exponent| > 22`) fall through to a naive `intPart + fracPart/fracDiv * Math.pow(10, exp)` computation. The result is within 1 ULP of the correctly-rounded value. Correctly-rounded parsing across the full F64 domain (David Gay's `strtod` or equivalent) is planned for M4+.
272
274
 
273
275
  The boundary is exercised explicitly by `test/NumericBoundary.test.js`, which pins expected drift so any regression is caught.
274
276
 
@@ -294,10 +296,10 @@ Subpath entries carved so consumers pay only for what they import. Each entry is
294
296
  | `@zakkster/lite-bake-stream/writer` | LBK1 shard emitter. Explicit or sample-and-infer schema. Emits per-shard zone maps. |
295
297
  | `@zakkster/lite-bake-stream/reader` | LBK1 parser over an in-memory `ArrayBuffer`. Synchronous. Zone-maps query APIs. |
296
298
  | `@zakkster/lite-bake-stream/string-table` | Byte-level UTF-8 interning primitive. |
297
- | `@zakkster/lite-bake-stream/file-ingest` | Browser helper: `File.stream()` Reader. Any `ReadableStream<Uint8Array>` works. |
299
+ | `@zakkster/lite-bake-stream/file-ingest` | Browser helper: `File.stream()` -> Reader. Any `ReadableStream<Uint8Array>` works. |
298
300
  | `@zakkster/lite-bake-stream/range-reader` | LBK1 parser with lazy shard loading via an IO adapter. HTTP Range fetch or in-memory mock. Synchronous zone-maps pruning. |
299
301
  | `@zakkster/lite-bake-stream/multi-reader` | Logical union over N Reader instances. Rows and shards cumulatively addressed; findShards merges across sub-readers. |
300
- | `@zakkster/lite-bake-stream/split` | NDJSON splitter + per-part compile + container merge. Executor-agnostic wire to any worker pool or run serially. |
302
+ | `@zakkster/lite-bake-stream/split` | NDJSON splitter + per-part compile + container merge. Executor-agnostic -- wire to any worker pool or run serially. |
301
303
 
302
304
  Every entry works in browsers (evergreen Chromium / Firefox / Safari) and Node 18+; no Node-specific APIs are used in the source. Every subpath has a matching `.d.ts` file in `types/` for TypeScript consumers.
303
305
 
@@ -328,11 +330,11 @@ Not currently supported in preserve mode: `RangeReader` (HTTP-Range lazy loading
328
330
 
329
331
  ## 7. Round-trip preservation contract
330
332
 
331
- Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK1 container `C` produced by the reference writer satisfies the following for every row `i [0, rowCount(S))` and every field `f Σ`:
333
+ Given source NDJSON `S` and a schema `Sigma` (either explicit or inferred), the LBK1 container `C` produced by the reference writer satisfies the following for every row `i in [0, rowCount(S))` and every field `f in Sigma`:
332
334
 
333
335
  | Source value at (i, f) | Lane | Round-trip result |
334
336
  | :-- | :-- | :-- |
335
- | JSON number in fast-path domain (15 sig digits, `|exp| 22`) | F64 | **bit-exact IEEE 754 double** |
337
+ | JSON number in fast-path domain (<=15 sig digits, `|exp| <= 22`) | F64 | **bit-exact IEEE 754 double** |
336
338
  | JSON number in slow-path domain | F64 | within 1 ULP of the correctly-rounded value |
337
339
  | JSON string (any bytes, valid UTF-8 or not) | U32 | **byte-exact bytes; validity not asserted** |
338
340
  | JSON `true` / `false` | F64 | `1` / `0` (documented coercion) |
@@ -340,14 +342,14 @@ Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK
340
342
  | JSON `null` | U32 | `""` (index 0, the reserved empty-string entry; BS-20: null is lane-neutral) |
341
343
  | absent field (missing in source) | F64 | `0` (default) |
342
344
  | absent field | U32 | `""` (index 0 is the reserved empty-string entry) |
343
- | field in source but NOT in the schema | -- | silently dropped (matches lite-bake core) |
345
+ | field in source but NOT in the schema | -- | silently dropped (see decisions/0005-one-unknown-field-policy.md) |
344
346
  | field in the schema with wrong value type (post-freeze), except null | -- | `W_LANE_MISMATCH` error, container not produced; null is lane-legal on both lanes (F64 -> 0, U32 -> "") |
345
347
 
346
348
  The preservation contract is asserted mechanically by three complementary layers:
347
349
 
348
- 1. **Unit tests** `test/RoundTrip.test.js`, `test/StringRoundTrip.test.js` verify individual known-value scenarios.
349
- 2. **Property-based fuzz** `test/DataPreservation.test.js` generates arbitrary random shapes across 12 seeds and asserts exact preservation on every cell of every row.
350
- 3. **Torture-scale verification** `bench/torture.js` runs the `verifyRoundTrip` helper (`test/_verify.js`) OUTSIDE the GC-measured band, asserting that every scenario opting into `verify: true` produces a container whose Reader output matches its source NDJSON byte-for-byte. The verification budget currently covers ~73,500 rows across 12 scenarios per fast-tier run; the `verified` column in the torture report shows the row count actually checked.
350
+ 1. **Unit tests** -- `test/RoundTrip.test.js`, `test/StringRoundTrip.test.js` verify individual known-value scenarios.
351
+ 2. **Property-based fuzz** -- `test/DataPreservation.test.js` generates arbitrary random shapes across 12 seeds and asserts exact preservation on every cell of every row.
352
+ 3. **Torture-scale verification** -- `node --expose-gc test/torture.mjs` (44 fast / 47 full scenarios) runs the `verifyRoundTrip` helper (`test/_verify.js`) OUTSIDE the GC-measured band, asserting that every scenario opting into `verify: true` produces a container whose Reader output matches its source NDJSON byte-for-byte. The verification budget currently covers ~103,000 rows across 20 scenarios per fast-tier run; the `verified` column in the torture report shows the row count actually checked.
351
353
 
352
354
  The boundary between "bit-exact" and "1 ULP" for F64 is pinned by `test/NumericBoundary.test.js`. Any drift in the tokenizer's number parser trips these tests before it can reach production.
353
355