@zakkster/lite-bake-stream 1.3.0 → 1.4.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 CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [1.4.0] -- 2026-09-01
6
+
7
+ M4 -- the sample window does what its name says, and null stops eating lanes. Two inference-window bugs fixed together (BS-07, BS-20). Suite: 461 tests / 461 pass / 0 fail / 0 todo. Torture: 44/44 fast, 47/47 full (incl. the 64 MB structural scenario), arrayBuffers growth 0. Mutation measurement: the 8-test manifest failed exactly and the 5 canaries passed on baseline src. Findings probes: 19 NOT-REPRODUCED / 0 REPRODUCED / 0 PARTIAL -- the ledger's first all-clear; the probe file is retired.
8
+
9
+ ### Changed
10
+
11
+ - BS-07: the sample-and-infer window is now byte-true. The Tokenizer exposes `absOffset` (a getter over the cumulative consumed-input-byte counter that already survived chunk boundaries), and the Writer reads it at record boundaries via a new optional `setInputSource(tokenizer)` hook the Tokenizer calls at construction. The window ends at the first record boundary at or after `sampleBytes` INPUT bytes -- independent of chunking and record count. Previously `setInputByteOffset` had zero callers, so the window measured record count ("first `sampleBytes` records") and `serialize()` on a large input buffered the whole thing columnar until finalize (SPEC invariant 1 violated on the default path). `setInputByteOffset` is now a documented no-op retained until 2.0. A hand-driven Writer with no wired source samples by record count (documented fallback; `null` is not a 0-byte estimate). No sink-protocol change: the hook is a call probed with `typeof`, never a field write, so a frozen sink is untouched (BS-19). See decisions/0007-sample-window-bytes.md.
12
+ - BS-20: JSON `null` is now lane-neutral. In the sample window `onNull` marks the field but sets no kind, so `null + string` infers U32 (was `W_MIXED_LANE_TYPES`) and `null + number` infers F64; an only-null field still infers F64. Post-freeze, `null` is legal on both lanes -- F64 -> `0` (unchanged), U32 -> `""` (index 0, the reserved empty-string entry; was `W_LANE_MISMATCH`). Booleans are UNCHANGED: `true`/`false` on a U32 lane still throw `W_LANE_MISMATCH` (pinned). A field that is null through the whole sample window then sees a string post-freeze still throws `W_LANE_MISMATCH` (the pinned sharp edge; no post-freeze schema evolution). The `test/_verify.js` oracle and the t5 scenarios adopt the same policy in the same change. See decisions/0008-null-policy.md.
13
+
14
+ ### Fixed
15
+
16
+ - BS-07: `sampleBytes` now bounds INPUT bytes as documented; sample memory is O(`sampleBytes`) (columnar staging plus the shared string table), independent of total input size. Proven structurally at full tier (64 MB default-opts stream: shards commit mid-ingest; the sample buffer is nulled after the window drains) and by a t7 SampleBuffer retention witness with a t9 BREAK control.
17
+ - BS-20: a single `null` in a string field no longer poisons inference or throws on an explicit U32 lane -- real-world API JSON that nulls string fields now compiles.
18
+
19
+ ## [1.3.1] -- 2026-09-01
20
+
21
+ M3 remainder (BS-05) -- the UTF-8 door ends the promise-A/behavior-B split with a measured decision. Option A (validate at the tokenizer, refuse invalid UTF-8 with `E_INVALID_UTF8`) was built, proven correctness-green (460/460, torture fast 42/42), and rejected on its pre-committed budget: unicode-heavy throughput regressed 229.7 -> 213.1 MB/s (-7.2%, over the 3% ceiling), because the baseline scan bulk-copies `>= 0x80` bytes with zero inspection so validation is pure added cost, while ASCII stayed within noise. SPEC 5.5 is instead rewritten to match the long-standing behavior (documented-permissive), and the dead code is removed. No bytes move at runtime. See decisions/0006-utf8-door.md for the before/after numbers and the rejected option. Suite: 453 tests / 453 pass / 0 fail / 0 todo. Torture: 42/42 fast, 44/44 full, arrayBuffers growth 0. Inverted mutation check: all 49 battery and pin tests pass on baseline src as well -- zero behavior change, proven.
22
+
23
+ ### Changed
24
+
25
+ - SPEC 5.5 and SPEC 7's U32 row rewritten to documented-permissive: string bytes between the quotes are stored byte-exact through U32 lanes regardless of UTF-8 validity; invalid sequences are not a parse error. `get()`/`getString()` decode with replacement (U+FFFD); `shardStringTable(s).bytesAt(i)` returns the exact stored bytes, the schema-mode twin of `PreserveReader.getBytes(i)`. This is a documentation-and-dead-code change only -- no runtime behavior changed, no new public surface (the accessor already existed).
26
+
27
+ ### Fixed
28
+
29
+ - BS-05: the SPEC no longer promises a UTF-8 parse error the tokenizer never enforced, and `Tokenizer.js` no longer declares an unreachable `E_INVALID_UTF8` code. The byte-exact string contract is now stated truthfully: invalid bytes round-trip byte-exact and decode with U+FFFD, in schema mode exactly as in preserve mode. Lone-surrogate escapes still refuse with `E_UNPAIRED_SURROGATE` (an escape-grammar rule, unrelated to input-UTF-8 validity).
30
+
5
31
  ## [1.3.0] -- 2026-09-01
6
32
 
7
33
  M2 -- a container is verified at the door or refused at the door. The four readers (`Reader`, `PreserveReader`, `RangeReader`, `MultiReader`) now agree with each other and with the `Writer` about the bytes between them; every malformed container names its refusal with a code, and a standalone SPEC byte-checker (reads SPEC.md offsets only, imports nothing from `src/`) is the contract's executable form. All new validation runs at open/parse/construction time -- the `get`/`syncRange`/`findShards` row loops and both tokenizer byte loops are byte-identical. Suite: 432 tests / 431 pass / 0 fail / 1 todo. Torture: 37/37 fast, 39/39 full, arrayBuffers growth 0. Mutation check: all 52 door tests fail on source revert; all 23 behavior-unchanged pins hold.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  > Streaming byte-level JSON compiler for [`@zakkster/lite-bake`](https://github.com/PeshoVurtoleta/lite-bake). Zero-GC, tree-shakeable, gigabyte-scale.
15
15
 
16
- **Status:** v1.3.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.
16
+ **Status:** v1.4.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
17
 
18
18
  ## Two modes, one API
19
19
 
package/SPEC.md CHANGED
@@ -274,7 +274,7 @@ The boundary is exercised explicitly by `test/NumericBoundary.test.js`, which pi
274
274
 
275
275
  ### 5.5 String semantics (v1)
276
276
 
277
- - Input MUST be valid UTF-8. Invalid sequences are a parse error.
277
+ - String bytes between the quotes are stored byte-exact through U32 lanes, regardless of UTF-8 validity. Invalid sequences are **not** a parse error; the prior rule declaring them one (and its `E_INVALID_UTF8` code) is withdrawn. `get()` / `getString()` decode with replacement (U+FFFD); `shardStringTable(s).bytesAt(i)` returns the exact stored bytes, as `PreserveReader.getBytes(i)` does in preserve mode.
278
278
  - All standard JSON escapes are supported: `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`.
279
279
  - Surrogate pairs (`\uD800`..`\uDBFF` followed by `\uDC00`..`\uDFFF`) are combined and emitted as UTF-8.
280
280
  - Unpaired surrogates are a parse error.
@@ -334,12 +334,14 @@ Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK
334
334
  | :-- | :-- | :-- |
335
335
  | JSON number in fast-path domain (≤15 sig digits, `|exp| ≤ 22`) | F64 | **bit-exact IEEE 754 double** |
336
336
  | JSON number in slow-path domain | F64 | within 1 ULP of the correctly-rounded value |
337
- | JSON string (any valid UTF-8) | U32 | **byte-exact UTF-8 sequence** |
338
- | JSON `true` / `false` / `null` | F64 | `1` / `0` / `0` (documented coercion) |
337
+ | JSON string (any bytes, valid UTF-8 or not) | U32 | **byte-exact bytes; validity not asserted** |
338
+ | JSON `true` / `false` | F64 | `1` / `0` (documented coercion) |
339
+ | JSON `null` | F64 | `0` (documented coercion) |
340
+ | JSON `null` | U32 | `""` (index 0, the reserved empty-string entry; BS-20: null is lane-neutral) |
339
341
  | absent field (missing in source) | F64 | `0` (default) |
340
342
  | absent field | U32 | `""` (index 0 is the reserved empty-string entry) |
341
- | field in source but NOT in `Σ` | | silently dropped (matches lite-bake core) |
342
- | field in `Σ` with wrong value type (post-freeze) | | `W_LANE_MISMATCH` error, container not produced |
343
+ | field in source but NOT in the schema | -- | silently dropped (matches lite-bake core) |
344
+ | 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 -> "") |
343
345
 
344
346
  The preservation contract is asserted mechanically by three complementary layers:
345
347
 
@@ -351,6 +353,14 @@ The boundary between "bit-exact" and "1 ULP" for F64 is pinned by `test/NumericB
351
353
 
352
354
  Correctness of the preservation contract is a release gate on par with the zero-GC gate. If either fails, no publish.
353
355
 
356
+ ### 7.1 Sample window (byte-true)
357
+
358
+ In sample-and-infer mode (no explicit schema) the Writer observes the first `sampleBytes` of INPUT before it freezes an inferred schema. The window is byte-true: it ends at the first record boundary AT OR AFTER `sampleBytes` input bytes have been consumed (BS-07). "Input bytes" is the Tokenizer's `absOffset` -- the cumulative count of bytes consumed across every `feed()` chunk, correct across chunk boundaries -- read by the Writer at record boundaries, never in the byte scan. The freeze point is therefore independent of how the input is chunked and of record count: 1000 tiny records or one 5 KB record both freeze at ~`sampleBytes` input bytes, not at a record count.
359
+
360
+ The Writer receives the live Tokenizer through an OPTIONAL capability hook: at construction the Tokenizer calls `sink.setInputSource(tokenizer)` if the method exists. This is a call, never a field write onto the sink, so a frozen sink is untouched (BS-19) and a sink without the method is skipped. The required sink event set is unchanged. A hand-driven Writer with no wired source samples by record count (documented fallback; never a 0-byte estimate). `setInputByteOffset` is a deprecated no-op.
361
+
362
+ Sample memory is O(`sampleBytes`): the columnar staging area plus the shared string table (bounded by string cardinality within the window), independent of total input size. The staging buffer drains to shards at freeze and is released.
363
+
354
364
  ## 8. Release gates
355
365
 
356
366
  - **M4 GA gate.** JSON conformance corpus (RFC 8259 + JSONTestSuite), fuzzed chunk-boundary splits at every byte position, F64 round-trip parity, container round-trip parity, zero-alloc-per-row proven by lite-gc-profiler after warmup.
package/llms.txt CHANGED
@@ -8,13 +8,13 @@ Ingest gigabyte-scale JSON (top-level array or NDJSON) into the `lite-bake` LBK1
8
8
 
9
9
  ## Status
10
10
 
11
- v1.3.0 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
11
+ v1.4.0 — stable. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak (M1 MacBook Pro): 98.37M rows, 4.89 GB container, zero major GC, zero minor GC, 499 KB total heap allocation, 590.21M cells verified byte-exact, zero mismatches. Tokenizer benches at 222-237 MB/s (~55% of JSON.parse, with no object graph allocated).
12
12
 
13
13
  Public API follows semver from 1.0.0. Future additions (I64 lane, columnar payload mode, container-level string table) land via the format's forward-compat seams -- `min_reader_version` on ShardEntry, reserved FieldDescriptor flags, the `metadata_off` block wrapper -- without a format_version bump.
14
14
 
15
15
  See SPEC.md for the LBK1 container format, section 3.6 for zone maps, section 4.3 for the reserved field flags.
16
16
 
17
- ## Public API (v1.3.0)
17
+ ## Public API (v1.4.0)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
@@ -49,7 +49,7 @@ Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`
49
49
 
50
50
  - `{ fields: ['id', 'x', 'y'] }` — all-F64 shorthand, back-compat with M2.
51
51
  - `{ fields: [{name:'id', laneKind:'f64'}, {name:'tag', laneKind:'u32'}] }` — mixed lanes (M3).
52
- - Sample-and-infer (default when no schema is passed): first `sampleBytes` of input observed; fields with only numbers become F64, only strings become U32, mixed types raise `W_MIXED_LANE_TYPES` at freeze.
52
+ - Sample-and-infer (default when no schema is passed): the first `sampleBytes` of INPUT are observed, byte-true (BS-07) -- the window ends at the first record boundary at or after `sampleBytes` input bytes, read from the Tokenizer's `absOffset`, independent of chunking and record count (a hand-driven Writer with no wired source samples by record count). Fields with only numbers become F64, only strings become U32. null is lane-neutral (BS-20): it sets no kind, so null+string infers U32 (no longer "mixed") and null+number infers F64; post-freeze null reads 0 on an F64 lane and "" on a U32 lane. A field that saw both a real number and a real string raises `W_MIXED_LANE_TYPES` at freeze. Sample memory is O(`sampleBytes`): columnar staging plus a shared string table, independent of total input size.
53
53
 
54
54
  ## Contract
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zakkster/lite-bake-stream",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Streaming byte-level JSON to lite-bake binary compiler. Zero-GC, tree-shakeable, gigabyte-scale.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
package/src/FileIngest.js CHANGED
@@ -27,7 +27,7 @@ import { PreserveWriter } from './PreserveWriter.js';
27
27
  import { PreserveReader } from './PreserveReader.js';
28
28
  import { checkOpts } from './Opts.js';
29
29
 
30
- export const VERSION = '1.3.0';
30
+ export const VERSION = '1.4.0';
31
31
 
32
32
  const U32_MAX = 4294967295;
33
33
  const INGEST_OPTS = {
@@ -25,7 +25,7 @@
25
25
  // M_ROW_OUT_OF_RANGE - rowIdx >= totalRows
26
26
  // M_TOO_MANY_ROWS - cumulative row count exceeds Number.MAX_SAFE_INTEGER
27
27
 
28
- export const VERSION = '1.3.0';
28
+ export const VERSION = '1.4.0';
29
29
 
30
30
  export class MultiReaderError extends Error {
31
31
  constructor(code, msg) { super(msg); this.code = code; this.name = 'MultiReaderError'; }
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { toContainerBuffer } from './Views.js';
34
34
 
35
- export const VERSION = '1.3.0';
35
+ export const VERSION = '1.4.0';
36
36
 
37
37
  const CONTAINER_HEADER_BYTES = 48;
38
38
  const SHARD_ENTRY_BYTES = 40;
@@ -32,7 +32,7 @@
32
32
 
33
33
  import { checkOpts } from './Opts.js';
34
34
 
35
- export const VERSION = '1.3.0';
35
+ export const VERSION = '1.4.0';
36
36
 
37
37
  const U32_MAX = 4294967295;
38
38
  const PRESERVE_TOKENIZER_OPTS = {
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { checkOpts } from './Opts.js';
20
20
 
21
- export const VERSION = '1.3.0';
21
+ export const VERSION = '1.4.0';
22
22
 
23
23
  export class PreserveWriterError extends Error {
24
24
  constructor(code, msg) { super(msg); this.code = code; this.name = 'PreserveWriterError'; }
@@ -42,7 +42,7 @@
42
42
  import { StringTable } from './StringTable.js';
43
43
  import { checkOpts } from './Opts.js';
44
44
 
45
- export const VERSION = '1.3.0';
45
+ export const VERSION = '1.4.0';
46
46
 
47
47
  const CONTAINER_HEADER_BYTES = 48;
48
48
  const SHARD_ENTRY_BYTES = 40;
package/src/Reader.js CHANGED
@@ -25,7 +25,7 @@
25
25
  import { StringTable } from './StringTable.js';
26
26
  import { toContainerBuffer } from './Views.js';
27
27
 
28
- export const VERSION = '1.3.0';
28
+ export const VERSION = '1.4.0';
29
29
 
30
30
  const CONTAINER_HEADER_BYTES = 48;
31
31
  const SHARD_ENTRY_BYTES = 40;
package/src/Split.js CHANGED
@@ -37,7 +37,7 @@ import { Reader, ReaderError } from './Reader.js';
37
37
  import { StringTable } from './StringTable.js';
38
38
  import { checkOpts } from './Opts.js';
39
39
 
40
- export const VERSION = '1.3.0';
40
+ export const VERSION = '1.4.0';
41
41
 
42
42
  const LF = 0x0A;
43
43
  const CONTAINER_HEADER_BYTES = 48;
@@ -20,7 +20,7 @@
20
20
  // and at every reset(), so an absent U32 row cell (which is 0) decodes as ""
21
21
  // rather than aliasing the shard's first-interned string (SPEC 3.3, SPEC 7).
22
22
 
23
- export const VERSION = '1.3.0';
23
+ export const VERSION = '1.4.0';
24
24
 
25
25
  const EMPTY_SLOT = 0xFFFFFFFF; // MUST be unsigned; typed-array reads are unsigned
26
26
  const INITIAL_BLOB_BYTES = 64 * 1024;
package/src/Tokenizer.js CHANGED
@@ -4,13 +4,19 @@
4
4
  //
5
5
  // Contract: see /SPEC.md section 5.
6
6
  //
7
+ // Byte offset (BS-07): `absOffset` (getter) is the cumulative count of input
8
+ // bytes consumed across every feed() chunk; it survives chunk boundaries. A
9
+ // sink may expose setInputSource(tokenizer) to receive the live Tokenizer at
10
+ // construction and read absOffset at record boundaries (the Writer uses this
11
+ // for byte-true sampleBytes). Purely optional; a sink without the method is
12
+ // never called and never mutated (BS-19).
13
+ //
7
14
  // Error codes (stable):
8
15
  // E_UNEXPECTED_BYTE - byte does not belong in current state
9
16
  // E_UNEXPECTED_EOF - end() called mid-token
10
17
  // E_INVALID_ESCAPE - unknown \ sequence
11
18
  // E_INVALID_HEX - non-hex digit in \uXXXX
12
19
  // E_UNPAIRED_SURROGATE - lone high/low surrogate in \u escape
13
- // E_INVALID_UTF8 - malformed UTF-8 byte sequence
14
20
  // E_NUMBER_OVERFLOW - number magnitude exceeds F64 range
15
21
  // E_NUMBER_INVALID - malformed number literal
16
22
  // E_KEYWORD_MISMATCH - true/false/null spelled wrong
@@ -24,7 +30,7 @@
24
30
 
25
31
  import { checkOpts } from './Opts.js';
26
32
 
27
- export const VERSION = '1.3.0';
33
+ export const VERSION = '1.4.0';
28
34
 
29
35
  const U32_MAX = 4294967295;
30
36
  const TOKENIZER_OPTS = {
@@ -142,6 +148,12 @@ export class Tokenizer {
142
148
  this._onNull = sink.onNull ? sink.onNull.bind(sink) : NOOP;
143
149
  this._onEnd = sink.onEnd ? sink.onEnd.bind(sink) : NOOP;
144
150
 
151
+ // Optional capability hook (BS-07): a sink may expose setInputSource to
152
+ // receive this Tokenizer and read `absOffset` at record boundaries. This is
153
+ // a CALL, never a field write onto the sink, so a frozen sink is untouched
154
+ // (BS-19); a sink without the method is skipped. Cold path (construction).
155
+ if (typeof sink.setInputSource === 'function') sink.setInputSource(this);
156
+
145
157
  // Terminal state (BS-14): once ended or poisoned, the instance is dead.
146
158
  this._ended = false;
147
159
  this._poisoned = false;
@@ -369,6 +381,11 @@ export class Tokenizer {
369
381
  this._err('E_UNEXPECTED_EOF', 'input ended mid-token (state=' + this._state + ')');
370
382
  }
371
383
 
384
+ // Cumulative input bytes consumed across every feed() chunk (survives chunk
385
+ // boundaries). Read by a Writer sink at record boundaries for byte-true
386
+ // sampleBytes (BS-07); never read on the byte scan.
387
+ get absOffset() { return this._absOffset; }
388
+
372
389
  // -------- top-level handling --------
373
390
 
374
391
  _handleTopByte(b) {
package/src/Writer.js CHANGED
@@ -5,10 +5,15 @@
5
5
  // Two modes:
6
6
  // - Explicit schema: consumer passes { fields: [names...] } or
7
7
  // { fields: [{name, laneKind}...] }; no sampling.
8
- // - Sample-and-infer (default): first `sampleBytes` of input buffered in a
9
- // columnar staging area, per-field lane kind inferred from observed
10
- // value types (number->F64, string->U32-into-string-table). Fields that
11
- // saw both raise W_MIXED_LANE_TYPES at freeze.
8
+ // - Sample-and-infer (default): the first `sampleBytes` of INPUT (byte-true,
9
+ // BS-07: the window ends at the first record boundary at or after
10
+ // sampleBytes input bytes, read from the Tokenizer's absOffset via
11
+ // setInputSource; hand-driven Writers with no source sample by record
12
+ // count) buffered in a columnar staging area, per-field lane kind inferred
13
+ // from observed value types (number->F64, string->U32-into-string-table).
14
+ // null is lane-neutral (BS-20): it marks the field but sets no kind, so
15
+ // null+string infers U32 and null+number infers F64. A field that saw both
16
+ // a real number and a real string raises W_MIXED_LANE_TYPES at freeze.
12
17
  //
13
18
  // v1 lane kinds: F64 (numeric) and U32 (string-table index). Per-shard string
14
19
  // tables ride immediately after each shard's payload; the shard directory
@@ -23,8 +28,10 @@
23
28
  // W_NESTED_UNSUPPORTED - nested object/array inside a record
24
29
  // W_EMPTY_INPUT - finalize() with zero records seen
25
30
  // W_ROW_STRIDE_ZERO - schema has zero fields (nothing to store)
26
- // W_MIXED_LANE_TYPES - a field saw both number and string values
31
+ // W_MIXED_LANE_TYPES - a field saw both real number and string values
32
+ // (null excluded -- it is lane-neutral, BS-20)
27
33
  // W_LANE_MISMATCH - post-freeze: value type doesn't match schema lane
34
+ // (null is lane-legal on both lanes: F64->0, U32->"")
28
35
  // W_UNKNOWN_LANE_KIND - explicit schema declares an unknown laneKind
29
36
  // W_DUPLICATE_FIELD - schema declares the same field name twice
30
37
  // W_SCHEMA_TOO_WIDE - a field offset exceeds the u16 offset_in_row ceiling
@@ -36,7 +43,7 @@
36
43
  import { StringTable } from './StringTable.js';
37
44
  import { checkOpts } from './Opts.js';
38
45
 
39
- export const VERSION = '1.3.0';
46
+ export const VERSION = '1.4.0';
40
47
 
41
48
  const U32_MAX = 4294967295;
42
49
  // Post-finalize sentinel for _recordDepth. Chosen = 2 so every post-finalize
@@ -145,6 +152,15 @@ class SampleBuffer {
145
152
  meta.columnU32[this._rowCount] = idx;
146
153
  }
147
154
 
155
+ // BS-20: null is lane-neutral. setNull ensures the field exists but leaves its
156
+ // kind untouched, so an only-null field stays K_UNKNOWN (-> F64, reads 0),
157
+ // null+string infers U32, and null+number infers F64. The row's column slot is
158
+ // left at its default 0, which reads back as 0 on an F64 lane and the reserved
159
+ // "" on a U32 lane. No per-field flag: K_UNKNOWN already carries the only-null case.
160
+ setNull(name) {
161
+ this._ensureField(name);
162
+ }
163
+
148
164
  _ensureField(name) {
149
165
  let meta = this._fieldMeta.get(name);
150
166
  if (!meta) {
@@ -218,6 +234,11 @@ export class Writer {
218
234
  this._currentFieldIdx = -1;
219
235
  this._recordStartByteOffset = 0;
220
236
  this._absOffset = 0;
237
+ // BS-07: the live Tokenizer, handed over via setInputSource at construction.
238
+ // When present, the sample window measures INPUT bytes (byte-true) by reading
239
+ // _source.absOffset at record boundaries. null => hand-driven Writer: fall
240
+ // back to record-count sampling (Math.max(1, ...) below). null is not zero.
241
+ this._source = null;
221
242
 
222
243
  this._finalized = false;
223
244
 
@@ -237,7 +258,9 @@ export class Writer {
237
258
  const f = this._rowValueSlotsF64, u = this._rowValueSlotsU32;
238
259
  for (let i = 0; i < f.length; i++) { f[i] = 0; u[i] = 0; }
239
260
  }
240
- this._recordStartByteOffset = this._absOffset;
261
+ // Record-boundary read (BS-07): byte-true when a source is wired, else the
262
+ // dead hand-driven offset (0) that yields record-count sampling. Not the byte scan.
263
+ this._recordStartByteOffset = this._source !== null ? this._source.absOffset : this._absOffset;
241
264
  }
242
265
 
243
266
  onEndObject() {
@@ -286,7 +309,28 @@ export class Writer {
286
309
 
287
310
  onTrue() { this.onNumber(1); }
288
311
  onFalse() { this.onNumber(0); }
289
- onNull() { this.onNumber(0); }
312
+
313
+ // BS-20: null is lane-neutral. Mirrors onString's guard structure (depth /
314
+ // finalized doors, unknown-field idx<0 return) but dispatches per lane instead
315
+ // of throwing: F64 slot -> 0, U32 slot -> 0 = the reserved "" (index 0, real in
316
+ // every shard since BS-01). onTrue/onFalse stay delegating to onNumber, so a
317
+ // bool on a U32 lane still throws W_LANE_MISMATCH. Non-null values never reach
318
+ // here -- zero added cost on the numeric/string hot path.
319
+ onNull() {
320
+ if (this._recordDepth !== 1) {
321
+ if (this._finalized) throw new WriterError('W_FINALIZED', 'writer already finalized');
322
+ throw new WriterError('W_TOP_LEVEL_NOT_OBJECT', 'top-level null is not an object; LBK1 schema mode stores objects only (SPEC 5.1)');
323
+ }
324
+ if (this._schema) {
325
+ const idx = this._currentFieldIdx;
326
+ if (idx < 0) return;
327
+ if (this._fieldLaneKinds[idx] === LANE_F64) this._rowValueSlotsF64[idx] = 0;
328
+ else this._rowValueSlotsU32[idx] = 0;
329
+ } else {
330
+ if (!this._currentKeyName) return;
331
+ this._sample.setNull(this._currentKeyName);
332
+ }
333
+ }
290
334
 
291
335
  onString(bytes, from, to) {
292
336
  if (this._recordDepth !== 1) {
@@ -469,7 +513,8 @@ export class Writer {
469
513
  this._totalRows++;
470
514
  if (this._currentShardRowCount >= this._currentShardMaxRows) this._finalizeCurrentShard();
471
515
  } else {
472
- const consumed = Math.max(1, this._absOffset - this._recordStartByteOffset);
516
+ const endOff = this._source !== null ? this._source.absOffset : this._absOffset;
517
+ const consumed = Math.max(1, endOff - this._recordStartByteOffset);
473
518
  this._sample.commitRow(consumed);
474
519
  if (this._sample.byteEstimate >= this._sampleBytes) {
475
520
  this._freezeSchemaFromSample();
@@ -611,7 +656,16 @@ export class Writer {
611
656
  return this._container;
612
657
  }
613
658
 
614
- setInputByteOffset(n) { this._absOffset = n; }
659
+ // Optional capability hook (BS-07): the Tokenizer calls this at construction
660
+ // to hand over the live tokenizer. The Writer then reads src.absOffset at
661
+ // record boundaries so the sample window is byte-true. A CALL only; the
662
+ // Tokenizer is never mutated.
663
+ setInputSource(src) { this._source = src; }
664
+
665
+ // Deprecated (BS-07): superseded by setInputSource. The Writer now reads the
666
+ // live Tokenizer's absOffset at record boundaries, so this hand-driven offset
667
+ // dance is a documented no-op retained for source compatibility until 2.0.
668
+ setInputByteOffset() {}
615
669
 
616
670
  // -------- container assembly --------
617
671
 
package/src/index.js CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  PreserveWriter, PreserveWriterError,
29
29
  PreserveReader, PreserveReaderError,
30
30
  };
31
- export const VERSION = '1.3.0';
31
+ export const VERSION = '1.4.0';
32
32
 
33
33
  const encoder = new TextEncoder();
34
34
 
@@ -33,6 +33,12 @@ export class Tokenizer {
33
33
  feed(chunk: Uint8Array): void;
34
34
  /** Signal end of input. Any pending record is flushed to the sink. */
35
35
  end(): void;
36
+ /**
37
+ * Cumulative count of input bytes consumed across every `feed()` chunk;
38
+ * survives chunk boundaries. A sink reads this at record boundaries for
39
+ * byte-true `sampleBytes` (BS-07); it is never read on the byte scan.
40
+ */
41
+ readonly absOffset: number;
36
42
  }
37
43
 
38
44
  export class TokenizerError extends Error {
package/types/Writer.d.ts CHANGED
@@ -22,8 +22,12 @@ export interface WriterOptions {
22
22
  /** Target output bytes per shard. Default 32 MB. */
23
23
  targetShardBytes?: number;
24
24
  /**
25
- * Bytes of input to observe before freezing an inferred schema. Only used
26
- * in sample-and-infer mode (no explicit schema). Default = targetShardBytes.
25
+ * Bytes of INPUT to observe before freezing an inferred schema. Only used in
26
+ * sample-and-infer mode (no explicit schema). Byte-true (BS-07): the window
27
+ * ends at the first record boundary at or after `sampleBytes` input bytes,
28
+ * read from the Tokenizer's `absOffset` -- independent of chunking and record
29
+ * count. A hand-driven Writer with no wired source samples by record count.
30
+ * Default = targetShardBytes.
27
31
  */
28
32
  sampleBytes?: number;
29
33
  }
@@ -63,7 +67,18 @@ export class Writer implements TokenizerSinkForWriter {
63
67
  onFalse(): void;
64
68
  onNull(): void;
65
69
  onEnd(): void;
66
- /** Optional: set the current input byte offset for byte-estimate tracking. */
70
+ /**
71
+ * Optional capability hook (BS-07): the Tokenizer calls this at construction
72
+ * to hand over the live tokenizer. The Writer then reads `src.absOffset` at
73
+ * record boundaries so the sample window is byte-true. A call only; the
74
+ * Tokenizer is never mutated.
75
+ */
76
+ setInputSource(src: { readonly absOffset: number }): void;
77
+ /**
78
+ * @deprecated Superseded by `setInputSource` (BS-07). The Writer now reads the
79
+ * live Tokenizer's `absOffset` at record boundaries, so this is a no-op
80
+ * retained for source compatibility until 2.0.
81
+ */
67
82
  setInputByteOffset(n: number): void;
68
83
  /**
69
84
  * Seal the container and return the LBK1 binary. Call once, after the