@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/CHANGELOG.md +165 -128
- package/README.md +293 -109
- package/SPEC.md +30 -30
- package/llms.txt +12 -14
- package/package.json +2 -2
- package/src/FileIngest.js +6 -2
- package/src/MultiReader.js +1 -1
- package/src/Opts.js +23 -0
- package/src/PreserveReader.js +3 -1
- package/src/PreserveTokenizer.js +12 -3
- package/src/PreserveWriter.js +8 -2
- package/src/RangeReader.js +111 -16
- package/src/Reader.js +3 -1
- package/src/Split.js +8 -4
- package/src/StringTable.js +7 -4
- package/src/Tokenizer.js +3 -2
- package/src/Views.js +3 -0
- package/src/Writer.js +6 -4
- package/src/index.js +5 -1
- package/types/MultiReader.d.ts +1 -1
- package/types/RangeReader.d.ts +21 -5
- package/types/Reader.d.ts +1 -1
- package/types/Split.d.ts +1 -1
- package/types/Tokenizer.d.ts +1 -1
- package/types/Writer.d.ts +2 -2
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
|
[](https://www.npmjs.com/package/@zakkster/lite-bake-stream)
|
|
4
6
|
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
5
7
|

|
|
@@ -11,46 +13,20 @@
|
|
|
11
13
|

|
|
12
14
|
[](./LICENSE)
|
|
13
15
|
|
|
14
|
-
|
|
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
|
-
|
|
18
|
+
## The gigabyte-scale JSON front door the ecosystem was missing
|
|
28
19
|
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
40
|
+
## Table of contents
|
|
65
41
|
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
<details>
|
|
256
|
+
<summary>Allocation table and the 8 GB soak</summary>
|
|
105
257
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
+
**Qualified at 8 GB.** Overnight soak, M1 MacBook Pro, both release gates armed:
|
|
127
269
|
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
+
</details>
|
|
138
297
|
|
|
139
|
-
|
|
298
|
+
## Design decisions worth knowing
|
|
140
299
|
|
|
141
|
-
|
|
300
|
+
Each call is an on-disk record under `decisions/`:
|
|
142
301
|
|
|
143
|
-
|
|
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
|
-
|
|
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` |
|
|
150
|
-
| `npm run torture` |
|
|
151
|
-
| `npm run torture:full` |
|
|
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
|
|
326
|
+
| `npm run soak:1gb` | 1 GB | Real-hardware qualification |
|
|
155
327
|
| `npm run soak:overnight` | 8 GB | Overnight `caffeinate` run |
|
|
156
328
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
351
|
+
MIT (c) 2026 Zahary Shinikchiev
|
package/SPEC.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# LBK1 Format Specification
|
|
2
2
|
|
|
3
|
-
> Version 1
|
|
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
|
|
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.**
|
|
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`
|
|
30
|
-
- `bytes(n)`
|
|
31
|
-
- `varlen_utf8`
|
|
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
|
|
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]`
|
|
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)
|
|
154
|
-
reader.findShards(fieldName, {min, max})
|
|
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.
|
|
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
|
|
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`
|
|
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)
|
|
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.
|
|
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
|
|
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
|
-
- `[`
|
|
232
|
-
- Any other JSON value byte (`{`, `"`, `-`, `0`..`9`, `t`, `f`, `n`)
|
|
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 decimal
|
|
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
|
|
273
|
-
- **Slow path (may drift
|
|
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()`
|
|
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
|
|
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
|
|
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 (
|
|
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 (
|
|
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**
|
|
351
|
-
2. **Property-based fuzz**
|
|
352
|
-
3. **Torture-scale verification**
|
|
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
|
|