@zakkster/lite-bake-stream 1.3.1 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
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.1] -- 2026-09-02
6
+
7
+ M8 -- the four reserved torture cells become real; BS-30 and BS-31 close. No tier prints "reserved" any more. Suite: 461 tests / 461 pass / 0 fail / 0 todo (unchanged -- no named-suite edits). Torture: 44/44 fast (~1.3 s wall), 47/47 full, arrayBuffers growth 0. BREAK matrix: ten runs (`=1`, eight registry ids, one unknown id) all exit non-zero; the unknown id is refused at import. Inventory gate: 54 thrown codes / 0 unpinned. Falsifiability: with baseline src stashed, exactly one new assertion fails (the onProgress-identity pin), both tiers. Test-only except one hot-path reuse in `src/FileIngest.js` (below).
8
+
9
+ ### Changed
10
+
11
+ - BS-31 (hot path): `ingestStream`'s per-chunk `onProgress` state object is now allocated ONCE and mutated in place, removing a per-chunk object literal from the ingest read loop. The state object is a reused ephemeral view -- valid only during the callback; copy it if retained. Contract documented in `src/FileIngest.js`, `types/FileIngest.d.ts`, and `llms.txt`. Falsifiability: with baseline src stashed, exactly the new onProgress-identity pin fails (baseline allocates a fresh object per chunk); every other new cell passes on both trees.
12
+ - Torture cells t0/t1/t2/t4 filled in. t0: the re-chunk determinism law (byte-identical container across whole/string/1/7/64/4096-byte chunkings x both modes on a shared degenerate corpus) plus mergeContainers/compileInParts/MultiReader query-equivalence, findShards superset+point-exact, and splitNDJSON reassembly. t1: degenerate-value and refusal pins over the shared corpus (`test/torture/fixtures/degenerate.mjs`, reused by t0/t2 and MQ2). t2: every-byte-position split-invariance fuzz over both tokenizers. t4: API-abuse decided-policy pins plus the R5 adversarial doors.
13
+ - BREAK selector: `BAKE_TORTURE_BREAK` now accepts a registry id (t6, g, i, j, k, l, m, n) to arm exactly one break site -- each control proves its own exit-non-zero path -- alongside legacy `=1` (arm all, halt at first). An unknown value fails closed at import. Registry documented in `test/torture/harness.mjs`.
14
+ - Demo (repo-only, never shipped in the tarball): refreshed from its pre-1.0 state to the current release. The synthetic fixture now exercises the v1.4 inference-window semantics (~8% null string cells read back `""` with the lane still U32; ~8% absent numeric cells read `0`); schema-mode ingest uses `framing: 'auto'` so a dropped top-level `[...]` array works; error handling matches on stable codes (`W_NESTED_UNSUPPORTED`) instead of message text; the onProgress reuse contract is stated where the demo destructures the state object; both demo files are ASCII-clean. Every library call the demo makes was verified headless against `src/` (24 checks green, incl. both handled error paths).
15
+
16
+ ### Added
17
+
18
+ - BS-30: the code-inventory gate (`test/torture/inventory.mjs`, run from t8). It scans `src/*.js` for every thrown stable code (constructed-error and `_err`/`raise*` families), asserts each is pinned by at least one test, and prints its collected-code count (54). The six previously-open doors are pinned in t3/t4 (`R_UNSUPPORTED_VERSION`, `R_UNSUPPORTED_ENDIAN`, `R_ADAPTER_SHORT_READ`, `W_ROW_STRIDE_ZERO` x2 sites, `E_RECORD_TOO_LARGE`, `E_UNSUPPORTED_FRAMING`); t1 pins the two remaining unpinned degenerate codes (`E_INVALID_HEX`, `E_TRAILING_INPUT`). A t9 control feeds the scanner a synthetic unpinned code to prove the gate can flag one.
19
+ - BS-31: a t7 mid-stream-failure witness -- a ReadableStream whose pull throws makes `ingestStream` reject with the source error (no half-built Reader), and the lite-leak tracker returns to size 0. t6 gates the ingest sync core (feed + reused-progress mutation + `onProgress`) at zero allocation; the async await-read loop is covered by the t7 witness (runOpsGate rejects async fns).
20
+ - t9 controls (i)-(n): one in-run rehearsal per newly-real cell plus the inventory gate, so a plain `npm run torture` proves every new gate can fail.
21
+
22
+ ### Findings (ledgered, no ride-along fix per session non-goals)
23
+
24
+ - `Reader.get(1.5)` returns garbage (a fractional-byte-offset read) instead of a decided policy (throw / no-op / undefined) -- t4 records it, does not pin it as correct; the t9 (l) control proves the gate would catch a garbage return.
25
+ - `RangeReader.syncRange` without a prior `prefetchRange` throws the shared `R_TRUNCATED`; the ROADMAP calls for a distinct code. t4 records it, does not pin the shared code.
26
+ - `RangeReader` never length-validates an adapter's `fetch()` return: an adapter that returns fewer bytes than requested surfaces a raw downstream `RangeError`, not `R_ADAPTER_SHORT_READ` (whose two real sites are the Mock adapter's own past-size check and `HTTPRangeAdapter`'s 200-not-206 check). Ledgered as a reader-guard gap.
27
+
28
+ ## [1.4.0] -- 2026-09-01
29
+
30
+ 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.
31
+
32
+ ### Changed
33
+
34
+ - 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.
35
+ - 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.
36
+
37
+ ### Fixed
38
+
39
+ - 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.
40
+ - 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.
41
+
5
42
  ## [1.3.1] -- 2026-09-01
6
43
 
7
44
  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.
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.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
16
+ **Status:** v1.4.1. LBK1 format frozen at `format_version: 1`. Qualified on an 8 GB soak with zero GC and byte-exact preservation across 590 million cells.
17
17
 
18
18
  ## Two modes, one API
19
19
 
package/SPEC.md CHANGED
@@ -335,11 +335,13 @@ Given source NDJSON `S` and a schema `Σ` (either explicit or inferred), the LBK
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
337
  | JSON string (any bytes, valid UTF-8 or not) | U32 | **byte-exact bytes; validity not asserted** |
338
- | JSON `true` / `false` / `null` | F64 | `1` / `0` / `0` (documented coercion) |
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.1 — 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.1 — 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.1)
17
+ ## Public API (v1.4.1)
18
18
 
19
19
  Two ingest modes share one top-level API:
20
20
 
@@ -41,7 +41,7 @@ Shared:
41
41
  - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` through the pipeline. `preserve` option dispatches to the right writer.
42
42
 
43
43
  Error classes with stable `code`: `TokenizerError`, `WriterError`, `ReaderError`, `RangeReaderError`, `MultiReaderError`, `SplitError`, `PreserveTokenizerError`, `PreserveWriterError`, `PreserveReaderError`.
44
- - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` (e.g. `File.stream()`) through the Tokenizer + Writer, returning a Reader. Per-chunk `onProgress` callback.
44
+ - `ingestStream`, `ingestFile` (`/file-ingest`): browser helpers piping a `ReadableStream<Uint8Array>` (e.g. `File.stream()`) through the Tokenizer + Writer, returning a Reader. Per-chunk `onProgress` callback; its state object is reused across calls (mutated in place, zero per-chunk allocation) -- copy it if retained past the callback.
45
45
  - `TokenizerError`, `WriterError`, `ReaderError`, `RangeReaderError`: thrown on parse/write/read errors with stable `code`.
46
46
  - `VERSION` const per subpath.
47
47
 
@@ -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.1",
3
+ "version": "1.4.1",
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.1';
30
+ export const VERSION = '1.4.1';
31
31
 
32
32
  const U32_MAX = 4294967295;
33
33
  const INGEST_OPTS = {
@@ -49,7 +49,12 @@ function raiseIngest(code, msg) { throw new WriterError(code, msg); }
49
49
  // sampleBytes }. Preserve: { targetShardBytes, maxRecordBytes }.
50
50
  // onProgress(state): called after each chunk with { bytesIngested,
51
51
  // totalBytes, rowsWritten, shardsCommitted, chunkBytes,
52
- // chunkCount, elapsedMs }
52
+ // chunkCount, elapsedMs }. The state object is REUSED
53
+ // across calls -- one object is mutated in place per chunk
54
+ // (zero per-chunk allocation), so it is valid only during
55
+ // the callback; copy it if you retain it past the call.
56
+ // Same ephemeral-view contract as the tokenizer's byte
57
+ // ranges.
53
58
  // totalBytes: optional total for progress percentage. Pass file.size
54
59
  // when ingesting a File; omit for indeterminate streams.
55
60
  export async function ingestStream(readableStream, opts) {
@@ -73,10 +78,20 @@ export async function ingestStream(readableStream, opts) {
73
78
  : new Tokenizer(writer, { framing });
74
79
 
75
80
  const reader = readableStream.getReader();
76
- const t0 = (globalThis.performance && performance.now) ? performance.now() : Date.now();
81
+ const nowFn = (globalThis.performance && performance.now)
82
+ ? () => performance.now() : () => Date.now();
83
+ const t0 = nowFn();
77
84
  let bytesIngested = 0;
78
85
  let chunkCount = 0;
79
86
 
87
+ // One progress object, hoisted and mutated per chunk -- the ingest loop
88
+ // allocates nothing per chunk. Documented as a reused ephemeral view above.
89
+ const progress = onProgress ? {
90
+ bytesIngested: 0, totalBytes,
91
+ rowsWritten: 0, shardsCommitted: 0,
92
+ chunkBytes: 0, chunkCount: 0, elapsedMs: 0,
93
+ } : null;
94
+
80
95
  try {
81
96
  while (true) {
82
97
  const { value, done } = await reader.read();
@@ -87,16 +102,14 @@ export async function ingestStream(readableStream, opts) {
87
102
  tokenizer.feed(value);
88
103
  bytesIngested += value.length;
89
104
  chunkCount++;
90
- if (onProgress) {
91
- const now = (globalThis.performance && performance.now) ? performance.now() : Date.now();
92
- onProgress({
93
- bytesIngested, totalBytes,
94
- rowsWritten: writer.totalRows,
95
- shardsCommitted: writer.shardCount,
96
- chunkBytes: value.length,
97
- chunkCount,
98
- elapsedMs: now - t0,
99
- });
105
+ if (progress) {
106
+ progress.bytesIngested = bytesIngested;
107
+ progress.rowsWritten = writer.totalRows;
108
+ progress.shardsCommitted = writer.shardCount;
109
+ progress.chunkBytes = value.length;
110
+ progress.chunkCount = chunkCount;
111
+ progress.elapsedMs = nowFn() - t0;
112
+ onProgress(progress);
100
113
  }
101
114
  }
102
115
  } finally {
@@ -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.1';
28
+ export const VERSION = '1.4.1';
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.1';
35
+ export const VERSION = '1.4.1';
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.1';
35
+ export const VERSION = '1.4.1';
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.1';
21
+ export const VERSION = '1.4.1';
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.1';
45
+ export const VERSION = '1.4.1';
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.1';
28
+ export const VERSION = '1.4.1';
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.1';
40
+ export const VERSION = '1.4.1';
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.1';
23
+ export const VERSION = '1.4.1';
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,6 +4,13 @@
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
@@ -23,7 +30,7 @@
23
30
 
24
31
  import { checkOpts } from './Opts.js';
25
32
 
26
- export const VERSION = '1.3.1';
33
+ export const VERSION = '1.4.1';
27
34
 
28
35
  const U32_MAX = 4294967295;
29
36
  const TOKENIZER_OPTS = {
@@ -141,6 +148,12 @@ export class Tokenizer {
141
148
  this._onNull = sink.onNull ? sink.onNull.bind(sink) : NOOP;
142
149
  this._onEnd = sink.onEnd ? sink.onEnd.bind(sink) : NOOP;
143
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
+
144
157
  // Terminal state (BS-14): once ended or poisoned, the instance is dead.
145
158
  this._ended = false;
146
159
  this._poisoned = false;
@@ -368,6 +381,11 @@ export class Tokenizer {
368
381
  this._err('E_UNEXPECTED_EOF', 'input ended mid-token (state=' + this._state + ')');
369
382
  }
370
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
+
371
389
  // -------- top-level handling --------
372
390
 
373
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.1';
46
+ export const VERSION = '1.4.1';
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.1';
31
+ export const VERSION = '1.4.1';
32
32
 
33
33
  const encoder = new TextEncoder();
34
34
 
@@ -6,6 +6,11 @@ import type { WriterOptions } from './Writer.d.ts';
6
6
 
7
7
  export const VERSION: string;
8
8
 
9
+ /**
10
+ * Progress state passed to onProgress. The SAME object is reused across every
11
+ * chunk callback (mutated in place, zero per-chunk allocation): it is valid only
12
+ * during the callback -- copy it if you retain it past the call.
13
+ */
9
14
  export interface IngestProgress {
10
15
  bytesIngested: number;
11
16
  totalBytes: number;
@@ -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