@zakkster/lite-bake-stream 1.6.0 → 1.7.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.
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,46 +13,20 @@
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.
15
-
16
- **Status:** v1.6.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.
17
-
18
- ## Two modes, one API
19
-
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)`.
22
-
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.
24
-
25
- ## Why this exists
16
+ **Status:** v1.7.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.
26
17
 
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.
18
+ ## The gigabyte-scale JSON front door the ecosystem was missing
28
19
 
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.
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.
30
26
 
31
- ## Qualified at 8 GB
32
-
33
- Overnight soak, M1 MacBook Pro, both release gates armed:
34
-
35
- | | |
36
- | :-- | --: |
37
- | Source | 8.00 GB NDJSON |
38
- | Container | 4.89 GB (61.1% of source) |
39
- | Rows | 98,367,702 |
40
- | Ingest throughput | 110.0 MB/s |
41
- | **Major GC** | **0** |
42
- | **Minor GC** | **0** |
43
- | **Total heap allocation** | **499.2 KB** |
44
- | Cells verified | 590,206,212 |
45
- | **Preservation mismatches** | **0** |
46
-
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.
48
-
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.
50
-
51
- ## Quickstart
52
-
53
- **Schema mode** (flat records, columnar queries):
27
+ ```bash
28
+ npm install @zakkster/lite-bake-stream
29
+ ```
54
30
 
55
31
  ```js
56
32
  import { serialize, deserialize } from '@zakkster/lite-bake-stream';
@@ -61,107 +37,315 @@ reader.get(0, 'id'); // 1
61
37
  reader.findShards('id', { min: 500 }); // shard pruning via zone maps
62
38
  ```
63
39
 
64
- **Preserve mode** (any JSON shape, byte-exact passthrough):
40
+ ## Table of contents
65
41
 
66
- ```js
67
- import { serialize, deserialize } from '@zakkster/lite-bake-stream';
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)
68
53
 
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
- ```
54
+ ## Why this exists
76
55
 
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`.
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. Optional `{ signal }` cancels in-flight range I/O
168
+ (`R_ABORTED`); once it fires the reader is dead for new I/O.
169
+ - `MultiReader` (`/multi-reader`) -- logical union over N Readers sharing a
170
+ schema.
171
+ - `splitNDJSON`, `compilePart`, `compileInParts`, `mergeContainers` (`/split`) --
172
+ worker-agnostic split, compile, and merge.
173
+
174
+ **Preserve mode**
175
+
176
+ - `PreserveTokenizer` (`/preserve-tokenizer`) -- NDJSON record-boundary scanner
177
+ with JSON-aware depth tracking, chunk-safe.
178
+ - `PreserveWriter` (`/preserve-writer`) -- opaque byte-blob sink, pre-allocated
179
+ shard buffer, zero-GC record path.
180
+ - `PreserveReader` (`/preserve-reader`) -- `getBytes(i)` (zero-alloc view),
181
+ `getString(i)`, `getJSON(i)`.
182
+
183
+ **Shared**
184
+
185
+ - `StringTable` (`/string-table`) -- byte-level UTF-8 interning primitive.
186
+ - `ingestStream(readableStream, opts?)`, `ingestFile(file, opts?)`
187
+ (`/file-ingest`) -- browser helpers piping a `ReadableStream<Uint8Array>` (e.g.
188
+ `File.stream()`) through the Tokenizer + Writer, returning a Reader. The
189
+ per-chunk `onProgress` callback's state object is reused across calls (mutated
190
+ in place, zero per-chunk allocation) -- copy it if retained past the callback.
191
+
192
+ **Zero-copy view contract.** A byte view returned by `getBytes(i)` (and any
193
+ shard raw-access getter) is a plain `subarray` over the container's
194
+ `ArrayBuffer`: it pins that buffer for as long as the view is reachable. Copy the
195
+ bytes out (`getBytes(i).slice()`) if you need the container itself to be
196
+ collectable.
197
+
198
+ ### Constants
199
+
200
+ Lane kinds (SPEC 4.2):
201
+
202
+ | Kind | Name | Bytes |
203
+ | ---: | :---- | ----: |
204
+ | `1` | `F64` | 8 |
205
+ | `2` | `F32` | 4 |
206
+ | `3` | `U32` | 4 |
207
+ | `4` | `U8` | 1 |
208
+
209
+ Ceilings:
210
+
211
+ | Limit | Value |
212
+ | :----------------------------------- | -----------: |
213
+ | String-table blob bytes (u32) | `4294967295` |
214
+ | String-table entry count | `4294967294` |
215
+ | u64 offset safe `Number()` cast | `2^53 - 1` |
216
+
217
+ Error-code families (thrown with a stable `code`; the full inventory is pinned by
218
+ the torture gate):
219
+
220
+ | Prefix | Class |
221
+ | :----- | :----------------------------------------------------------------- |
222
+ | `E_` | `TokenizerError` (e.g. `E_NUMBER_OVERFLOW`) |
223
+ | `W_` | `WriterError` (`W_MIXED_LANE_TYPES`, `W_LANE_MISMATCH`, `W_BAD_SINK`, `W_FINALIZED`) |
224
+ | `R_` | `ReaderError` / `RangeReaderError` (`R_ROW_OUT_OF_RANGE`, `R_OFFSET_TOO_LARGE`, `R_BAD_CRC`, `R_CRC_ABSENT`, `R_ABORTED`, ...) |
225
+ | `M_` | `MultiReaderError` (`M_ROW_OUT_OF_RANGE`) |
226
+ | `S_` | `SplitError` |
227
+ | `ST_` | `StringTableError` (`ST_BLOB_OVERFLOW`) |
228
+ | `P*_` | `PreserveTokenizerError` / `PreserveWriterError` / `PreserveReaderError` |
229
+
230
+ ## Composability
231
+
232
+ The `/split` subpath turns one logical dataset into worker-parallel or
233
+ checkpointed parts, and the readers merge them back into a single view:
78
234
 
79
- ## What shipped in 1.0
235
+ ```js
236
+ import { splitNDJSON, compileInParts, mergeContainers } from '@zakkster/lite-bake-stream/split';
237
+ import { RangeReader } from '@zakkster/lite-bake-stream/range-reader';
80
238
 
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. |
239
+ // 1. split NDJSON into byte-aligned parts (record boundaries preserved)
240
+ const parts = splitNDJSON(ndjsonBytes, { partBytes: 64 * 1024 * 1024 });
92
241
 
93
- ## Roadmap beyond 1.0
242
+ // 2. compile each part (fan out to workers if you like), then merge
243
+ const containers = compileInParts(parts);
244
+ const merged = mergeContainers(containers);
94
245
 
95
- All of these fit the format's existing forward-compat seams `format_version` stays at `1`.
246
+ // 3. open the merged container and prune shards before fetching them
247
+ // (pass { signal } to cancel in-flight range I/O when the consumer detaches)
248
+ const reader = await RangeReader.open(adapterOver(merged), { signal: controller.signal });
249
+ const shards = reader.findShards('id', { min: 500, max: 999 });
250
+ for (const s of shards) reader.get(s.firstRow, 'id');
251
+ ```
96
252
 
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. |
253
+ ## Zero-GC design notes
103
254
 
104
- ## What's here today
255
+ <details>
256
+ <summary>Allocation table and the 8 GB soak</summary>
105
257
 
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.
258
+ The hot path allocates nothing steady-state. Buffers are pre-allocated and
259
+ reused across the churn:
123
260
 
124
- Two release gates. Zero major GC AND every declared cell round-trips exactly. If either fails, no publish.
261
+ | Operation | Steady-state allocation |
262
+ | :-------------------- | :---------------------- |
263
+ | `feed(chunk)` | 0 (views into the tokenizer's internal buffer) |
264
+ | per row | 0 (typed-lane staging is pre-allocated) |
265
+ | per shard roll | 0 (working buffers reused across rolls) |
266
+ | `Reader.get(row, f)` | 0 for F64; `U32` string fields resolve through a cached decoder |
125
267
 
126
- ## Streaming emission and integrity
268
+ **Qualified at 8 GB.** Overnight soak, M1 MacBook Pro, both release gates armed:
127
269
 
128
- `finalize()` returns the whole container as one `ArrayBuffer` -- peak memory is `O(container)`. For bounded-memory output, `writer.finalizeToSink(sink, { layout })` emits to a caller sink and returns `{ totalRows, shardCount, schema | mode, bytesWritten, layout }` (no buffer). A sink is any object with a synchronous `write(bytes)`; `layout: 'stream'` also needs `writeAt(bytes, position)` for one header backpatch -- satisfiable by `fs.write(fd, buf, 0, len, pos)`, the FS Access API's `createWritable().write({ type: 'write', position })`, and the in-memory `MemorySink`.
270
+ | | |
271
+ | :-- | --: |
272
+ | Source | 8.00 GB NDJSON |
273
+ | Container | 4.89 GB (61.1% of source) |
274
+ | Rows | 98,367,702 |
275
+ | Ingest throughput | 110.0 MB/s |
276
+ | **Major GC** | **0** |
277
+ | **Minor GC** | **0** |
278
+ | **Total heap allocation** | **499.2 KB** |
279
+ | Cells verified | 590,206,212 |
280
+ | **Preservation mismatches** | **0** |
129
281
 
130
- There are two ways to drive a stream, and they deliver different peaks:
282
+ 499 KB of heap allocation to compile 8 GB of JSON -- total, for the whole run,
283
+ not per shard, not per second. Zero garbage collections of any kind. Every one of
284
+ 590 million declared cells round-tripped byte-exact.
131
285
 
132
- - **Two-step, bounded RAM** -- call `writer.beginStream(sink, { layout: 'stream' })` BEFORE feeding, feed the tokenizer, then `writer.finalizeToSink(sink, { layout: 'stream' })`. Each shard is written to the sink as it finalizes and its bytes are dropped, so peak memory is `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes` -- `O(targetShardBytes + directory)`, never `O(container)`. This is the mode the 500 MB gate proves.
133
- - **One-shot, buffered** -- call `writer.finalizeToSink(sink, opts)` alone (no `beginStream`). Correct and simplest, but the shards are buffered first, so peak is `peak(prefix) = containerBytes + sum(shard bytes)` -- `O(container)`, the same as `finalize()`.
286
+ **Bounded streaming, measured.** The full-tier gate drives the two-step bounded
287
+ path, streaming 500 MB in 8 MiB chunks into a counting sink, and asserts
288
+ `peakRss - baselineRss <= 4*targetShardBytes + 64*shardCount + 64 MiB` -- measured
289
+ 32.1 MiB against the 96.0 MiB bound at 41 shards (the buffered prefix path is
290
+ ~704 MiB at the same 500 MB).
134
291
 
135
- `layout: 'prefix'` is the classic layout, **byte-identical** to `finalize()`. `layout: 'stream'` places the schema/directory/zone-map trailer and footer after the payloads with one header backpatch; a default-emitted stream container is a legal v1 container (format_version stays 1) that every shipped reader, `checkContainer`, and `mergeContainers` accept.
292
+ Tokenizer throughput is ~55% of `JSON.parse` (237 MB/s vs 416 MB/s on a 50 MB
293
+ fixture). That is the trade: you give up some raw speed and you get an input-size
294
+ ceiling bounded by your disk instead of your heap.
136
295
 
137
- A malformed sink (non-object, missing `write`, missing `writeAt` for stream, or an async/thenable return) throws `W_BAD_SINK`; a sink that throws mid-emission fails the writer closed and rethrows the source error verbatim (a retry then hits `W_FINALIZED`).
296
+ </details>
138
297
 
139
- **Memory model.** `peak(prefix) = containerBytes + sum(shard bytes)`; `peak(stream) = 2*targetShardBytes + shardCount*(40 + 16*T) + schemaBlockBytes`. The full-tier gate drives the two-step bounded path, streaming 500 MB in 8 MiB chunks into a counting sink, and asserts `peakRss - baselineRss <= 4*targetShardBytes + 64*shardCount + 64 MiB` (measured 32.1 MiB against the 96.0 MiB bound at 41 shards; the buffered path is ~704 MiB at 500 MB).
298
+ ## Design decisions worth knowing
140
299
 
141
- **Optional CRC-32C** (Castagnoli, table-driven, zero deps). Opt in with `{ crc: true }` on the writer (or serialize `writer` opts); coverage is `[0, footer_off)`, folded per emitted chunk. Readers expose `verifyCrc()` -> `'ok' | 'absent'` (a mismatch throws `R_BAD_CRC`) on `Reader`/`PreserveReader` (sync) and `RangeReader` (async). The open option `{ verifyCrc: true }` (also `deserialize(bytes, opts)`) fails closed on both mismatch (`R_BAD_CRC`) and absence (`R_CRC_ABSENT`) -- `null` is not zero. `0xFFFFFFFF` means absent and stays legal. `mergeContainers` recomputes the CRC iff every input carried one, else emits absent. See `decisions/0009-streaming-emission.md`.
300
+ Each call is an on-disk record under `decisions/`:
142
301
 
143
- ## Release testing tiers
302
+ - [0001](decisions/0001-reserved-empty-string.md) -- reserve string-table index 0 as the empty string.
303
+ - [0002](decisions/0002-record-shape-policy.md) -- depth-0 non-object records are refused, not dropped.
304
+ - [0003](decisions/0003-duplicate-field-names.md) -- duplicate schema field names are refused at freeze.
305
+ - [0004](decisions/0004-container-doors-and-lying-metadata.md) -- a container is verified at the door; a lying pointer is corruption.
306
+ - [0005](decisions/0005-one-unknown-field-policy.md) -- absent names are refused, untracked names fall back.
307
+ - [0006](decisions/0006-utf8-door.md) -- the UTF-8 door is documented-permissive, not a tokenizer gate.
308
+ - [0007](decisions/0007-sample-window-bytes.md) -- the sample window is byte-true, not record-count.
309
+ - [0008](decisions/0008-null-policy.md) -- JSON null is lane-neutral.
310
+ - [0009](decisions/0009-streaming-emission.md) -- streaming emission, sinks, and optional CRC-32C.
311
+ - [0010](decisions/0010-preserve-mode.md) -- why a second (preserve) mode exists at all.
312
+ - [0011](decisions/0011-sample-drain-reintern.md) -- per-shard re-intern on drain and the byte-true sample window.
313
+ - [0012](decisions/0012-clinger-fast-path.md) -- the Clinger fast path and the two-tier F64 guarantee.
144
314
 
145
- The gates run at four scales, chosen to fit different hardware and time budgets:
315
+ ## Testing
316
+
317
+ The gates run at scales chosen to fit different hardware and time budgets:
146
318
 
147
319
  | Command | Scale | Use |
148
320
  | :-- | :-- | :-- |
149
- | `npm test` | 96 tests | Dev loop, every save |
150
- | `npm run torture` | 37 scenarios (~1 MB each) | Before every commit |
151
- | `npm run torture:full` | + 500 MB full-tier soak scenarios | Before every publish |
321
+ | `npm test` | 530 tests across 37 files | Dev loop, every save |
322
+ | `npm run torture` | 44 fast scenarios (~1 MB each) | Before every commit |
323
+ | `npm run torture:full` | 47 scenarios incl. the 500 MB soak | Before every publish |
152
324
  | `npm run soak` | 100 MB with preservation gate | Sanity check |
153
325
  | `npm run soak:500` | 500 MB | Pre-publish scale check |
154
- | `npm run soak:1gb` | 1 GB | Real-hardware qualification (M4 Pro etc.) |
326
+ | `npm run soak:1gb` | 1 GB | Real-hardware qualification |
155
327
  | `npm run soak:overnight` | 8 GB | Overnight `caffeinate` run |
156
328
 
157
- Custom sizes:
158
- ```
159
- node --expose-gc bench/soak.js --gb=4 --verify
160
- caffeinate node --expose-gc bench/soak.js --gb=16 --verify # macOS overnight
161
- ```
329
+ The test tree includes a 66-fixture RFC 8259 conformance corpus, a property-based
330
+ robustness fuzz, and two structural guards: an ASCII-law scanner
331
+ (`test/AsciiLaw.test.js`) and an API-surface drift guard
332
+ (`test/ApiSurface.test.js`), each also wired into the torture tier with an armed
333
+ control that proves it can fail. Two release gates decide a publish: zero major
334
+ GC AND every declared cell round-trips exactly. If either fails, no publish.
335
+
336
+ ## What this is not
337
+
338
+ No JSON5, JSONC, comments, or trailing commas. No streaming field updates, no
339
+ compression, no native (non-JS) producers. LBK1 is this package's own container
340
+ format; non-JSON producers are welcome as long as they emit a well-formed
341
+ container.
342
+
343
+ ## Ecosystem
162
344
 
163
- 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.
345
+ The `demo/` directory is a single-file HTML + module-JS app: ingest a JSON file,
346
+ compile it to LBK1, and browse the result, with a zone-maps panel that visualizes
347
+ the query-pruning fetch-set reduction. Run it with `npm run demo`.
164
348
 
165
349
  ## License
166
350
 
167
- MIT © 2026 Zahary Shinikchiev
351
+ 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.
@@ -191,12 +191,12 @@ Fixed-width descriptors let a Reader load the schema with a single `Uint32Array`
191
191
 
192
192
  | Value | Name | Bytes | Notes |
193
193
  | ----: | ------- | ----: | ------------------------------------------------------------------------ |
194
- | `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. |
195
195
  | `2` | `F32` | 4 | Reserved for future producer opt-in via schema override. |
196
196
  | `3` | `U32` | 4 | String-table index (M3+). Row cell holds a u32 index into the shard's `local_string_off` table. |
197
197
  | `4` | `U8` | 1 | Reserved. |
198
198
 
199
- 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.
200
200
 
201
201
  Values 128..255 reserved for stream-specific lanes; see 4.4.
202
202
 
@@ -204,10 +204,10 @@ Values 128..255 reserved for stream-specific lanes; see 4.4.
204
204
 
205
205
  Currently all bits are reserved and must be zero. Reserved bit assignments (v2+) will use:
206
206
 
207
- - 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.
208
208
  - Bits 1..7: unassigned.
209
209
 
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.
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.
211
211
 
212
212
  ### 4.4 Extended lane kinds (reserved for v2)
213
213
 
@@ -220,16 +220,16 @@ When `flags` bit 0 is set on a FieldDescriptor, `lane_kind` is interpreted from
220
220
 
221
221
  All other values in the extended range are reserved and must be rejected.
222
222
 
223
- ## 5. Reference producer: tokenizer contract
223
+ ## 5. Producer: tokenizer contract
224
224
 
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.
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.
226
226
 
227
227
  ### 5.1 Input framing
228
228
 
229
229
  Two top-level modes, auto-detected by the first non-whitespace byte:
230
230
 
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
+ - `[` -- 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`).
233
233
 
234
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.
235
235
 
@@ -267,10 +267,10 @@ For a string that spans multiple chunks, the tokenizer accumulates into an inter
267
267
 
268
268
  #### 5.4.1 F64 preservation contract (M3)
269
269
 
270
- 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:
271
271
 
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
+ - **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+.
274
274
 
275
275
  The boundary is exercised explicitly by `test/NumericBoundary.test.js`, which pins expected drift so any regression is caught.
276
276
 
@@ -296,10 +296,10 @@ Subpath entries carved so consumers pay only for what they import. Each entry is
296
296
  | `@zakkster/lite-bake-stream/writer` | LBK1 shard emitter. Explicit or sample-and-infer schema. Emits per-shard zone maps. |
297
297
  | `@zakkster/lite-bake-stream/reader` | LBK1 parser over an in-memory `ArrayBuffer`. Synchronous. Zone-maps query APIs. |
298
298
  | `@zakkster/lite-bake-stream/string-table` | Byte-level UTF-8 interning primitive. |
299
- | `@zakkster/lite-bake-stream/file-ingest` | Browser helper: `File.stream()` Reader. Any `ReadableStream<Uint8Array>` works. |
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
+ | `@zakkster/lite-bake-stream/file-ingest` | Browser helper: `File.stream()` -> Reader. Any `ReadableStream<Uint8Array>` works. |
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. Optional reader-level `{ signal }` cancels in-flight range I/O (`R_ABORTED`); the adapter `fetch(byteOffset, byteLength, signal?)` contract is additive (a 2-arg adapter degrades cancellation only). |
301
301
  | `@zakkster/lite-bake-stream/multi-reader` | Logical union over N Reader instances. Rows and shards cumulatively addressed; findShards merges across sub-readers. |
302
- | `@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. |
303
303
 
304
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.
305
305
 
@@ -330,11 +330,11 @@ Not currently supported in preserve mode: `RangeReader` (HTTP-Range lazy loading
330
330
 
331
331
  ## 7. Round-trip preservation contract
332
332
 
333
- 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`:
334
334
 
335
335
  | Source value at (i, f) | Lane | Round-trip result |
336
336
  | :-- | :-- | :-- |
337
- | 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** |
338
338
  | JSON number in slow-path domain | F64 | within 1 ULP of the correctly-rounded value |
339
339
  | JSON string (any bytes, valid UTF-8 or not) | U32 | **byte-exact bytes; validity not asserted** |
340
340
  | JSON `true` / `false` | F64 | `1` / `0` (documented coercion) |
@@ -342,14 +342,14 @@ Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK
342
342
  | JSON `null` | U32 | `""` (index 0, the reserved empty-string entry; BS-20: null is lane-neutral) |
343
343
  | absent field (missing in source) | F64 | `0` (default) |
344
344
  | absent field | U32 | `""` (index 0 is the reserved empty-string entry) |
345
- | 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) |
346
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 -> "") |
347
347
 
348
348
  The preservation contract is asserted mechanically by three complementary layers:
349
349
 
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** `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.
353
353
 
354
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.
355
355