@pond-ts/react 0.53.1 → 0.54.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +864 -4
  2. package/README.md +25 -21
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,11 +4,12 @@ All notable changes to this project are documented here.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  The `@pond-ts` packages — `pond-ts`, `@pond-ts/react`, `@pond-ts/charts`,
7
- `@pond-ts/fit`, and `@pond-ts/financial` — release together under a single `v*`
8
- tag, so this file covers them all. Pre-1.0: minor bumps may include new features
9
- and type-level changes; patch bumps are strictly additive.
7
+ `@pond-ts/fit`, `@pond-ts/financial`, and `@pond-ts/process` — release together
8
+ under a single `v*` tag, so this file covers them all. Pre-1.0: minor bumps may
9
+ include new features and type-level changes; patch bumps are strictly additive.
10
10
 
11
- [Unreleased]: https://github.com/pond-ts/pond/compare/v0.53.1...HEAD
11
+ [Unreleased]: https://github.com/pond-ts/pond/compare/v0.54.0...HEAD
12
+ [0.54.0]: https://github.com/pond-ts/pond/compare/v0.53.1...v0.54.0
12
13
  [0.53.1]: https://github.com/pond-ts/pond/compare/v0.53.0...v0.53.1
13
14
  [0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
14
15
  [0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
@@ -54,6 +55,865 @@ and type-level changes; patch bumps are strictly additive.
54
55
 
55
56
  ## [Unreleased]
56
57
 
58
+ ## [0.54.0] — 2026-08-02
59
+
60
+ ### Added
61
+
62
+ - **`ctx.out` — prepared output buffers for a ranged recompute**
63
+ ([PND-PROCRANGE]), plus `prepareRange` / `sealRange` / `RangeOutput` on the
64
+ package surface. An op writes only `[from, to)` and returns nothing; the
65
+ rows it keeps arrive already copied, **values and validity both**.
66
+
67
+ 500k rows × 5 studies: **209 → 6.5 ms/tick, 32×**, bit-identical to a
68
+ from-scratch pass every tick. The mechanism shipped at 4× because the
69
+ example op rebuilt its whole output, carrying the prefix with a `.at(i)`
70
+ per cell into a boxed `Array`.
71
+
72
+ **The contract exists because the obvious shortcut is silently wrong.**
73
+ Copying the prefix as a typed-array block gets 13× — and 1,875 wrong
74
+ cells, because packed storage holds `0` at a missing cell rather than
75
+ `NaN`, so every warm-up gap becomes a defined zero. Nothing in the type
76
+ system objects. Validity has to move with the values, and doing that per
77
+ cell is the `O(n)` walk the ticket exists to remove — so the graph
78
+ prepares both as blocks and an op cannot get it wrong by omission.
79
+ `previousView` is also exposed for ops that want to read the prior
80
+ output directly.
81
+
82
+ - **Ranged recompute** ([PND-PROCRANGE]): `graph.setSourceFrom(series,
83
+ changedFrom)` declares which row first changed, and an op opts in with
84
+ `OpDef.runRange(ctx)` — which receives `{ from, to, previous }` and rebuilds
85
+ only that slice. `graph.recomputes` reports `{ ranged, full }`.
86
+
87
+ **The previous output is an argument, not state.** Letting a node reach for
88
+ its own last output would make `compute` a function of history: two callers
89
+ with the same data but different edit sequences could disagree, and
90
+ `explain` would stop describing what a value depends on. Passing it in keeps
91
+ the op a pure function of declared inputs; the mutable part stays in the
92
+ graph, which is a cache and was already stateful.
93
+
94
+ **It is opt-in because it is only safe for some ops.** An incremental result
95
+ must be _bit-identical_ to a from-scratch one, or answers start depending on
96
+ the sequence of edits that produced them — invisible to any test that only
97
+ computes from scratch. That holds for [PND-PROCKERN]'s range-exact kernel
98
+ and does **not** hold for `median`, percentiles, `min` or `max`, which still
99
+ sweep whole-series. An op that declares nothing gets full recomputes: always
100
+ correct, merely slower.
101
+
102
+ Measured 500k rows, 5 studies, 20 ticks: **209 → 55 ms/tick (4×)**, verified
103
+ bit-identical against a from-scratch pass every tick. That is short of the
104
+ plan's 26×, and the gap is in the _op_, not the graph — a `runRange` that
105
+ copies the whole prefix out of `previous` before patching is `O(n)` per
106
+ tick. Reaching the projected ceiling needs a capacity-buffer contract
107
+ letting an op _extend_ the previous column instead of rebuilding it.
108
+
109
+ For scale: [PND-PROCHIST] answers the same hot-edge workload at ~1.3 ms/tick
110
+ by slicing, with no incremental machinery. Ranging earns its keep where the
111
+ whole column must stay materialized — a chart drawing every point while one
112
+ row arrives.
113
+
114
+ - **An engine-wide byte budget over retained node values** ([PND-PROCCACHE]):
115
+ `bind(series, { registry, budgetBytes })`, plus `graph.retainedBytes`,
116
+ `graph.evictions` and `graph.enforceBudget()`. Unbounded when omitted, so
117
+ no existing caller changes behaviour.
118
+
119
+ Every distinct spec ever compiled was retained forever, so memory scaled
120
+ with _questions asked_. A session walking a slider from period 20 to 200
121
+ left 180 nodes holding 180 result columns and dropped none. 60 distinct
122
+ params × 200k rows, each configuration in its own process:
123
+ **arrayBuffers 104 → 42 MB** (2.5×), retained 93 → 11 MB, 60 nodes → 7 —
124
+ while a repeat-heavy sweep still hits, with no eviction churn and no
125
+ measurable penalty. Both halves matter: a budget that bounds memory by
126
+ discarding what the caller asks for next is not a cache.
127
+
128
+ **No `rss` figure is quoted, deliberately.** An earlier draft claimed
129
+ 5.6× on rss; that was a measurement-order artifact — two configurations
130
+ timed in one process, the second starting from the first's heap, and
131
+ reversing them inverted the result. Forking a process per configuration
132
+ fixed the ordering, but the replacement 1.2× did not survive either:
133
+ across five forked pairs, bounded rss exceeded unbounded in two. Freed
134
+ buffers are not promptly returned to the OS and the bound series is the
135
+ floor, so rss cannot support a direction here at this scale.
136
+ `arrayBuffers` and `retainedBytes` can, and are what the benchmark
137
+ reports.
138
+
139
+ The ticket framed this as an op-level cache where an op declares which
140
+ inputs key its result. **Half of that is already true and was not
141
+ rebuilt** — `specId` is content-addressed over op, params and inputs, so
142
+ asking the same question twice hits the same node by construction, and a
143
+ per-op key would be a second key beside a correct one. What was missing is
144
+ the capacity, and the ticket is right that it cannot belong to the op: a
145
+ per-op cap is a per-op promise, and nothing supervises the total.
146
+
147
+ Bounded in **bytes**, resolving the ticket's open question. Entries are not
148
+ the unit anyone has a limit in — one node over 1M rows outweighs fifty over
149
+ 5,000 — and bytes only became knowable once [PND-PROCCOL] made node values
150
+ columns with a reportable `columnBytes`. Eviction is LRU with one
151
+ constraint: a node whose consumer still holds its outlet is skipped,
152
+ because dropping it frees nothing and forces a recompile.
153
+
154
+ - **`requiredHistory(registry, plan)` in `@pond-ts/process`** ([PND-PROCHIST]),
155
+ with a per-op `OpDef.lookback`. The hot leading edge is the design's worst
156
+ cliff — an 8-study stack over 500k rows costs ~100 ms/tick — and the fix is
157
+ to slice a tail, which until now was the consumer's guess. The registry
158
+ already knows every op's lookback, so the minimum safe tail is derivable.
159
+
160
+ Measured on that stack: **97 → 1.3 ms/tick, 75×** (10 → 773 ticks/sec), with
161
+ **zero truncated cells** at the derived tail and **exactly one** at a tail
162
+ one row shorter. The bound is tight, not merely safe.
163
+
164
+ Two things it is careful about. Lookbacks **sum along a nested chain** —
165
+ `sma(20)` over `sma(50)` needs 69 rows, not 50, and taking the max
166
+ under-provisions in the way that produces defined, plausible, truncated
167
+ answers. And an op that declares no lookback yields `known: false` naming
168
+ it, rather than a number: a missing declaration and a genuinely
169
+ element-wise op are the same value with opposite meanings, so an
170
+ element-wise op should say `() => 0`.
171
+
172
+ Slicing a tail gives answers that agree to **≤5.8e-13**, not bit-for-bit.
173
+ An `ema` lookback (`4 × period`) is an approximation by construction, and
174
+ slicing builds a new shorter series, which re-indexes every row — the
175
+ rolling kernel pins its rebuilds to absolute row index, so
176
+ [PND-PROCKERN]'s bit-identity covers a range of the _same_ column, not a
177
+ re-indexed copy.
178
+
179
+ - **`columnView(column)` in `@pond-ts/process` — a zero-copy read view over a
180
+ packed numeric column** ([PND-PROCCOL]), and `FoldContext.numeric(role)`,
181
+ which hands one to a fold. `values` and `bits` are `subarray`s of the
182
+ column's own storage: read, never retain.
183
+
184
+ `FoldContext.values` — the boxed `(number | undefined)[]` — is now a **lazy
185
+ getter** rather than eagerly densified, so a fold that never touches it
186
+ never allocates. It was the graph's largest heap cost, and `last` reads a
187
+ single cell while paying to densify 500,000 of them.
188
+
189
+ 20 folds × 500k rows, `scripts/perf-proccol.mjs`:
190
+
191
+ | | boxed | columnar |
192
+ | ------------ | ------ | ---------- |
193
+ | warm run | 606 ms | **383 ms** |
194
+ | heap at peak | 35 MB | **25 MB** |
195
+ | rss | 204 MB | **173 MB** |
196
+
197
+ Read that as a fold-shape result, not a representation result. **Columnar
198
+ is not faster to read** — a buffer walk reaches parity with a boxed array,
199
+ and `Column.scan()` is 4.7× slower than either because it takes a callback
200
+ per cell. The 1.58× is the densify disappearing for folds that read a few
201
+ cells. A fold that walks the whole column should expect parity, and gets
202
+ the memory win only.
203
+
204
+ - **charts:** **`<LineChart readout>` / `<AreaChart readout>` — the tracked
205
+ value, decoupled from the plotted one** ([PND-READOUT]). Name a **second
206
+ column** and its value rides each tracker sample as the new
207
+ `TrackerSample.readout`, while the layer keeps plotting `column`. Plotting a
208
+ _derived_ series but reading the _source_ number is common — a log,
209
+ normalized, unit-transformed or smoothed line whose readout should show the
210
+ raw sample — and until now a consumer had to reconstruct that off-chart from
211
+ its own data. (Motivating case: estela's DATA chart plots pace-space,
212
+ Gaussian-smoothed, but the scrub readout wants the native m/s formatted as
213
+ pace.)
214
+
215
+ `value` is unchanged, so **the in-chart cursor dot still sits on the plotted
216
+ value** — as it must, or the dot would leave the line. In-chart flag / inline
217
+ chips likewise keep showing the plotted number; `readout` is for the
218
+ off-chart consumer, which shows `readout ?? value`. **Additive**: omit
219
+ `readout` and every sample is identical to before.
220
+
221
+ A mistyped `readout` throws the readers' `RangeError` / `TypeError` on
222
+ **both** axis kinds, rather than throwing on a value axis and silently
223
+ producing no readout on a time axis.
224
+
225
+ - **charts:** **single-series bars gained the stable per-bar `mark` identity**
226
+ the categorical stack already had. `<BarChart series column>`'s `hitTest` now
227
+ echoes a `SelectInfo.mark` — the bar's **own axis key**, stringified — and a
228
+ controlled `selected` / `hovered` carrying a `mark` matches on that name
229
+ instead of the bar's `key`. This closes a gap on **point-keyed** series,
230
+ where the bar span is synthesized from neighbour spacing so its `key` is a
231
+ _derived_ edge (`t - halfGap`), not the sample's time: pinning a selection
232
+ previously meant re-deriving that geometry, and now a caller matches on the
233
+ centre it already owns. The readers (`barsFromTimeSeries` /
234
+ `barsFromValueSeries`) supply the identity, exposed as the new optional
235
+ `BarSeries.marks`.
236
+
237
+ **The match rule is strictly additive.** A selection with no `mark` — every
238
+ one that exists today — still matches on the `key`, so key-pinned controlled
239
+ selections are untouched. (A deliberate divergence from `drawStacks`, which
240
+ switches on the _series_ carrying marks rather than the _selection_; bars have
241
+ shipped key-pinning, category stacks never did.) The **payload** does change,
242
+ additively: an interactive single-series bar's `SelectInfo` now carries a
243
+ `mark` where it previously carried none, so a consumer that round-trips a hit
244
+ back as a controlled `selected` pins by mark rather than key. Both resolve to
245
+ the same bar.
246
+
247
+ `marks` build lazily and memoize (~9 ms per 100k bars, on a ~0.8 ms reader).
248
+ A **non-interactive** layer never reads them; an **interactive** one echoes
249
+ the hovered bar's mark from `hitTest` on every pointer move, so its first
250
+ hover over a bar materializes the array — once per data identity, on the input
251
+ path (11.1 ms cold vs 1.7 ms warm, at 100k). Bounded and paid once, where an
252
+ eager array would charge every chart on every data update.
253
+ `scripts/perf-barmarks.mjs` pins both halves.
254
+
255
+ - **`parallelDispatches()`** in `@pond-ts/financial/parallel` — how many rolling
256
+ passes have actually run on worker threads. `withWorkers` is a silent no-op
257
+ when the pool declines a series, and a declined pass returns the same answer,
258
+ just slower than the caller expected; this is how you check. It also replaces
259
+ a test canary that had proved the parallel path ran _by it being wrong_ —
260
+ which stopped working the moment the wrongness was fixed.
261
+
262
+ - **financial:** **`withWorkers` (`@pond-ts/financial/parallel`) — rolling
263
+ studies partitioned across worker threads** ([PND-SCANKERN], Node-only,
264
+ opt-in). A rolling window is not a recurrence: output cell `i` reads only
265
+ rows `[i-period+1, i]`, so the output splits into ranges with a `period-1`
266
+ overlap and no communication between workers.
267
+
268
+ **Opt in once, at ingest** — `withWorkers(bars, { workers: 8 })` returns the
269
+ series unchanged, and every rolling study over it (or over anything derived
270
+ from it) is partitioned from then on. The studies keep their signatures and
271
+ stay **synchronous**: `Atomics.wait` lets the main thread dispatch and join
272
+ without yielding, which is also why this is Node-only and simply absent in a
273
+ browser. **Single-threaded is unchanged and remains the default**; the main
274
+ package never imports this entry point.
275
+
276
+ Measured over 500k bars, 8 workers: `sma` **1.83×**, `bollinger` **1.86×**,
277
+ `zScore` **2.45×**, a three-study stack **1.98×**.
278
+
279
+ **It changes the answer, and how much depends on the study.** Chunk 0
280
+ reproduces the sequential sweep exactly; later chunks start their Welford
281
+ state fresh. `sma`, `envelope` and `bollinger` shift by rounding error
282
+ (3.9e-14, 3.9e-14, 5.1e-13 observed; no cell beyond 1e-9).
283
+
284
+ **`zScore` is different in kind, not degree** — though not for the reason
285
+ first published here. The divergence is **catastrophic cancellation in the
286
+ numerator**, not the division by σ: at the worst row, σ differs by 0.97%
287
+ while `v − mean` differs by 60%, because `ulp(1e15)` is `0.125` and a window
288
+ spanning ±3 covers ~48 ulps. The sequential study computes the same
289
+ subtraction and carries the same exposure; partitioning only perturbs it.
290
+ A shifted-frame formulation removes it (650% → 8.8e-15, prototyped in
291
+ `spikes/shifted-frame/`, tracked as [PND-SHIFTFRAME]).
292
+ On a benign random walk the difference is ~2.6e-6 across ~0.8% of cells; on
293
+ a legal near-flat series at large magnitude it is **38%** (counterexample
294
+ from a Codex review, now a regression test). Do not opt in if you threshold
295
+ z-scores, reproduce the pandas oracle, or work with near-constant series.
296
+ Related: core rejects a non-finite rolling result, where this kernel can
297
+ emit `Infinity` or clamp a `NaN` variance to zero.
298
+
299
+ Below `MIN_ROWS` (100k) a registered series still runs sequentially and is
300
+ bit-identical.
301
+
302
+ - **process:** **`HostPool` (`@pond-ts/process/pool`) — whole requests across
303
+ resident worker threads** ([PND-PROCPAR], Node-only). N workers, each holding
304
+ a long-lived `Host`, with the pool as a router: a plan is already JSON, a
305
+ registry is a module both isolates import (functions cannot be structured-
306
+ cloned, so the caller names a `setup` module rather than passing a value),
307
+ and a result's columns cross as transferable buffers. No engine change.
308
+
309
+ Measured (`packages/process/scripts/perf-pool.mjs`, 32 requests/batch,
310
+ 8 workers, median of 3 distinct batches): **3.1–4.0× on distinct requests**
311
+ at every size from 0.5 ms to 10 ms each, and **~0.01× on repeated ones**.
312
+ What decides it is the cache-hit rate, not request size — in-process, a
313
+ re-asked question is a memo hit returning the same column for nothing, while
314
+ a pool copies and ships every answer however cheap it was, and each worker
315
+ warms its own graph. Pooling and caching compete rather than compose.
316
+
317
+ Worth checking before reaching for it: the same rolling mean writing a
318
+ `Float64Array` instead of `new Array(n)` runs **482 ms single-threaded where
319
+ the boxed version needs 632 ms across eight workers**. Fixing the op beat
320
+ adding eight cores, and boxing parallelises worse besides.
321
+
322
+ Supporting: `columnBuffers` / `columnFromBuffers` — a packed numeric column
323
+ as the buffer pair it already is, for crossing an isolate boundary. The
324
+ buffers are **copies**, deliberately: transferring a column's own buffer
325
+ detaches it in the sending isolate, which would silently empty the cache the
326
+ worker exists to keep warm.
327
+
328
+ - **core:** **the flattened key convention — two-edged keys now survive a
329
+ columnar round trip.** `toArrow` has always flattened a `timeRange` /
330
+ `interval` key into `<key>` + `<key>End` (+ `<key>Label`), because Arrow has
331
+ no interval-of-time type — but no ingest door read that shape back, so
332
+ anything aggregated was columnar-export-only. Now `fromColumns` reads it,
333
+ `toColumns` emits it (where it previously threw), and `fromArrow` gains
334
+ `{ keyKind: 'timeRange' | 'interval' }` to read it out of Arrow. One spelling
335
+ across all four doors, so `TimeSeries.fromColumns(daily.toColumns())`
336
+ round-trips an aggregated series, key and all.
337
+
338
+ The names are fully determined — a key column's name equals its kind — so
339
+ there is nothing to configure. The envelope's `schema` keeps declaring the
340
+ **logical** key; the edge columns are derived from it. Two rules follow: a
341
+ value column may not take a derived name (it throws on ingest, naming the
342
+ collision), and ordering for a two-edged key is by `(begin, end)`, matching
343
+ the row door — as does `sort: true`. Interval labels must be present in every
344
+ row and all of one type, again matching the row door (and throwing the same
345
+ `RangeError` when they aren't). Types: `FlatKeyColumns`, plus `keyKind` on
346
+ `FromArrowOptions`.
347
+
348
+ Two incidental improvements fell out: `TimeSeries.fromColumns` no longer
349
+ rejects non-`time` keys at all (it accepted only `'time'` since it shipped),
350
+ and the ingest engine's `makeKey` callback is gone — the schema's key kind
351
+ fully determines the column class, so every door stopped passing one.
352
+
353
+ - **core:** **`TimeSeries.toColumns()`** — the columnar-JSON export door, and
354
+ the inverse `fromColumns` never had. Returns the same
355
+ `{ name, schema, columns }` envelope `fromColumns` accepts (one plain array
356
+ per column, gaps as `null`), typed per column, so
357
+ `TimeSeries.fromColumns(series.toColumns())` round-trips **with no cast**.
358
+ It reads the columnar store directly where `toJSON` materialises a row per
359
+ event: measured **~2.5–3 ms vs ~26 ms** at 100k rows × 6 columns — an
360
+ 8–10× gap across runs, widening with row count
361
+ (`scripts/perf-to-columns.mjs`). Two deliberate
362
+ limits, both reported rather than hidden — a `timeRange` / `interval` key
363
+ spans two edges and no columnar ingest door reads it back, so it throws
364
+ naming the two ways out (`asTime({ at: 'begin' })` or `toJSON()`); and
365
+ `boolean` / array columns export fine but aren't ingestable, which the
366
+ return type encodes as a compile error rather than a runtime one. Types:
367
+ `TimeSeriesJsonColumns`, `TimeSeriesColumnarInput`,
368
+ `TimeSeriesColumnarOutput`.
369
+
370
+ - **core:** **`ValueSeries` gets the full ingest / export surface** — the
371
+ value-keyed series is no longer a one-way street with a single columnar door.
372
+ In: **`ValueSeries.fromJSON`** (row tuples _or_ objects; strict per-cell kind
373
+ checking, `required` enforced, and — unlike the time door — no timestamp
374
+ parsing, because a value axis has no calendar to read `'2026-01-01'`
375
+ against), and **`ValueSeries.fromArrow(table, { axis })`** (`axis` is
376
+ required: there is no `'time'` field convention to fall back on, and the axis
377
+ is read unscaled since it carries no `TimeUnit`). Out:
378
+ **`toRows()` / `toObjects()` / `toJSON({ rowFormat })`** (rows, gaps as
379
+ `undefined` / `null` respectively), **`toColumns()`** (columnar JSON — one
380
+ plain array per column, gaps as `null`, the exact envelope `fromColumns`
381
+ takes back), and **`toArrow()`** (Arrow's memory layout, no copy — the
382
+ exporter `TimeSeries.toArrow` already used; a `'value'` axis exports as a
383
+ plain `float64` field). Every door pairs with its inverse and the round trips
384
+ are **typed**: `ValueSeries.fromColumns(vs.toColumns())` and
385
+ `ValueSeries.fromJSON(vs.toJSON())` compile without a cast. All four ingest
386
+ doors share one engine, so the monotonic-axis contract, `sort: true`, and the
387
+ packing rules are identical whichever you use. Types:
388
+ `ValueSeriesJsonInput`, `ValueSeriesJsonRow`, `ValueSeriesJsonObjectRow`,
389
+ `ValueSeriesJsonOutputArray`, `ValueSeriesJsonOutputObject`,
390
+ `ValueSeriesJsonCell`, `ValueSeriesRow`, `ValueSeriesObjectRow`,
391
+ `ValueSeriesJsonColumns`, `ValueSeriesColumnarInput`,
392
+ `ValueSeriesColumnarOutput`, `FromArrowValueOptions`, `JsonColumn`.
393
+
394
+ - **core:** **`TimeSeries.toArrow(options?)` — zero-copy export to the Apache
395
+ Arrow memory layout**, the counterpart of `fromArrow`. Every other export
396
+ door is row-shaped, so reaching another columnar engine meant a full
397
+ re-materialisation; it never had to — pond's validity bitmap is LSB-first
398
+ one-bit-per-value (Arrow's layout exactly), numeric columns are a contiguous
399
+ `Float64Array`, booleans a packed bitmap, and dict-encoded strings
400
+ `Int32Array` indices plus a dictionary. `toArrow` hands those buffers over
401
+ as they stand and returns `{ length, fields }` rather than an Arrow `Table`
402
+ — pond does not depend on `apache-arrow`; the caller assembles with
403
+ `makeData` / `makeVector` in a few lines (shown on the method doc). The
404
+ buffers are **live storage, not copies** — the same read-only contract
405
+ `column()` / `keyColumn()` already carry. Two named non-zero-copy cases:
406
+ chunked columns materialize first, and a non-dict-encoded string column is
407
+ a plain JS array (Arrow `Utf8` wants offsets + bytes). A `timeRange` /
408
+ `interval` key exports as `<key>` + `<key>End` (+ `<key>Label` for interval
409
+ labels), and a value column already using one of those names throws rather
410
+ than producing duplicate field names. Types: `ArrowExport`,
411
+ `ArrowExportField`, `ArrowExportType`, `ToArrowOptions`.
412
+
413
+ - **process:** registry-bound fluent graph authoring via
414
+ `process(registry, from)`. Operation methods, params, named secondary inputs,
415
+ and multi-output suffixes are inferred from the registry while the result
416
+ remains the same plain slot request accepted over a wire. Added opaque async
417
+ sources (`defineSource`, `SourceRegistry`, `Host.runAsync`): requests carry
418
+ only `{ source, params }`, loaders and credentials stay host-side, and equal
419
+ remote revisions reuse the existing bound graph and all node caches.
420
+ Concurrent calls for one source identity share a single in-flight load and
421
+ revision update.
422
+ - **charts:** **`<BarChart binColors>` now works on the single-series
423
+ time-axis path** — per-bar colours for a plain `series={…} column="…"` bar
424
+ layer, the shape a **direction-coloured financial volume row** needs (derive
425
+ the array from open vs close and volume reads green / red under the
426
+ candles; the `Charts/Candlestick` price+volume scenario shows the recipe).
427
+ Previously `binColors` only applied to `bins` / horizontal (stacked-path)
428
+ charts. A per-bar-coloured bar keeps its own colour under hover / selection
429
+ (the highlight pops opacity instead of swapping the fill), the hover / click
430
+ readout reports the bar's own colour, and the dense-bar envelope decimation
431
+ is skipped (an envelope rect can't carry more than one colour), so every
432
+ visible bar draws.
433
+ - **process:** new **`@pond-ts/process`** package — **work in progress, not
434
+ published.** Marked `private: true`, so the release workflow skips it; it is
435
+ on `main` to be iterated on in the open against
436
+ [RFC #543](https://github.com/pond-ts/pond/pull/543), not to be consumed.
437
+ A typed dataflow engine over pond values: nodes with typed `in` / `out` port
438
+ fields (wiring a `string` output into a `number` input is a compile error),
439
+ pull-based memoized evaluation, connect-time cycle rejection, per-node error
440
+ caching, and a read-only `Graph` view. `fromLive()` binds a live source where
441
+ events only mark dirty, so a burst of N events costs one snapshot at the next
442
+ pull rather than N.
443
+
444
+ **The public shape is expected to change.** The RFC concludes that the
445
+ declarative plan layer is the consumer surface and this engine belongs
446
+ underneath it as an internal module — see **[PND-PROCSUB]** in
447
+ [PLAN.md](PLAN.md), and [PND_PROCESS_PLAN.md](docs/plans/PND_PROCESS_PLAN.md)
448
+ for the measured follow-ups (node identity/lifetime is blocking for
449
+ interactive use; column-valued nodes and dirty-per-range are the large wins).
450
+
451
+ - **process:** **`registry.toJsonSchema({ defs })`** replaces the `base` option
452
+ added earlier in this cycle — the recursive `$ref` now lives in `$defs` and
453
+ points at `#/$defs/<name>`, which a caller lifts to its own document root.
454
+ `base` produced a pointer _into_ the host schema; that passes local
455
+ validators and is rejected by a real tool API (_"reference can only point to
456
+ definitions defined at the top level of the schema"_). The projection also
457
+ now emits `anyOf` rather than `oneOf` (equivalent here — both branch sets are
458
+ disjoint — and the one tool APIs accept), and every `const` carries its
459
+ `type`. All three were 400s from live calls that a client-side strict
460
+ validator had passed. See **[PND-PROCSCHEMA]**.
461
+ - **process:** a **selector resolves its own inline spec**, whether or not the
462
+ plan also lists it at top level. Requiring both was bookkeeping no schema
463
+ could express, so it lived in prose — and a caller composing from the schema
464
+ alone duly selected a spec it had not listed and got a skip instead of an
465
+ answer.
466
+ - **process:** `columns` and `reduce` on one selector are **no longer
467
+ exclusive** — asking for both now returns both, which is the legend-chip case
468
+ [PND-PROCTERM] exists for. Previously the reduction was silently dropped.
469
+ - **process:** **`NodeTiming.inputs` and `NodeTiming.pulled`** — `nodes` now
470
+ describes the **graph** the plan resolved, not just the subset a selector
471
+ reached. `inputs` carries each node's upstream ids (a raw source column is
472
+ named by column), which a consumer cannot derive without reimplementing
473
+ `specId`'s canonicalization; `pulled` is false for a resolved node this
474
+ request never read, whose `ms` is therefore zero and says nothing. Reporting
475
+ the unpulled ones is free — no value is produced for them. Found by drawing
476
+ the pipeline for M4, which rendered a plan with whole branches missing.
477
+ - **process:** **`run({ assemble: false })` and `RunResult.columns`** — columns
478
+ are the wire shape; the assembled `TimeSeries` is the in-process convenience
479
+ over the top of them. A `columns` selector now always hands back the resolved
480
+ columns by name, and `assemble: false` skips building a widened series for a
481
+ consumer that could never receive one. The receiving side rebuilds with
482
+ `TimeSeries.fromColumns`, which adopts a `Float64Array` **zero-copy** and
483
+ reads NaN as a gap, so reassembly across a boundary is free. Measured at 1M
484
+ rows, the skipped `appendColumn` is 7.6 ms for a gapless column and 22.4 ms
485
+ for a gapped one — and every rolling study is gapped. See **[PND-PROCCOL]**.
486
+ - **process:** `registry.toJsonSchema({ base })` — the projection can now be
487
+ **embedded** in a larger schema. Its recursive `$ref` (the line that lets a
488
+ caller express _EMA of SMA of px_ without being taught a nesting concept)
489
+ resolves against the **document root**, so a projection emitted at `#` and
490
+ then dropped inside a tool's `input_schema` had a dangling pointer — silently,
491
+ since a `$ref` is not required to resolve. `base` names the pointer the
492
+ subschema will live at, and `$schema` is now emitted only at the root. Found
493
+ by putting a model-shaped caller in front of it; see **[PND-PROCSCHEMA]**.
494
+
495
+ - **process:** **slots** — a plan may now be written as `nodes` keyed by
496
+ caller-assigned names, with `outputs` keyed by the caller's name for each
497
+ surfaced result ([PND-PROCSLOT]). A node's `specId` is derived from its op,
498
+ params and inputs, so it keys the cache correctly and **changes the moment a
499
+ param does — even though the topology has not**. A slot is the missing
500
+ identity: `avg` survives a `period` edit that moves every derived id.
501
+
502
+ Slots are an alias layer, not a replacement. `specId` remains the cache key,
503
+ because it is what finds a node again across requests, sessions and callers;
504
+ one caller's `avg` means nothing to another's. Expansion produces exactly the
505
+ nested plan the equivalent would have been written as, so **a slot plan hits
506
+ the cache a nested plan built** — verified at 150k bars, where the slot form
507
+ of an already-resolved graph comes back `cached` at 0.002 ms per node — and
508
+ neither `compile` nor `specId` knows slots exist.
509
+
510
+ `NodeTiming` gains `slot`, and `Fact` / `OutputInfo` gain `name`. Naming does
511
+ not require slots: a `Select` in the original form can carry a `name` too.
512
+
513
+ - **process:** **`registry.toJsonSchema({ shape: 'slots' })`** — the projection
514
+ for the slot format, and notably **flat**. The nested projection's single most
515
+ load-bearing line is a recursive `$ref`, because an input may be another spec;
516
+ making that portable took three rounds against a live API (`oneOf` refused,
517
+ every node needing an explicit `type`, a body pointer rejected in favour of a
518
+ top-level `$defs`). With slots an input is a plain string, so the recursion is
519
+ gone and every one of those problems with it — no `$defs`, no `$ref`, nothing
520
+ to rebase when embedded.
521
+ - **process:** **`plan(from)`** — a builder that emits a plan
522
+ ([PND-PROCBUILD]). `add` returns a handle you pass as another node's input,
523
+ so a mistyped reference is a compile error rather than a resolution failure,
524
+ and `toJSON()` produces the same envelope a model would compose. It holds no
525
+ resolution logic and knows nothing about the registry, so there is one
526
+ resolution path, one cache, and the existing plan tests cover it.
527
+
528
+ - **process:** **`OpDescriptor.inputs` is the declared `InputDef[]`**, not a
529
+ count. A count checks arity and says nothing else — a consumer labelling a
530
+ two-input op could not tell which side was which, and one explaining a
531
+ rejection could not name the unit an input demands, both of which the
532
+ registry holds and `describe()` was dropping. **Breaking** for anything
533
+ reading `inputs` as a number; `inputs.length` is the same value.
534
+
535
+ - **process:** **`suggest` on a numeric param** — the range worth offering,
536
+ as distinct from `min`/`max`, the range that rejects. Sliders drawn on the
537
+ legal range spent 96% of their travel where nobody goes, and a param with
538
+ no `max` had no drawable range at all: `annualise.barsPerYear` defaults to
539
+ 105,120 against a fallback ceiling of 100, so its control sat pinned at the
540
+ edge and any drag silently destroyed the annualisation. Advisory — nothing
541
+ rejects a value outside it — but checked at `define()` time so an inverted
542
+ or escaping range fails in front of the op's author. It also reaches a
543
+ composing model, as `description` prose in the JSON Schema projection
544
+ rather than a custom keyword.
545
+
546
+ - **process:** **Reductions are nodes.** `last`, `extremes`,
547
+ `percentileRank` and `shape` were a fixed `reduce` enum on the selector,
548
+ computed after the graph finished — so the one thing every caller reads
549
+ sat outside the memo, at 10.85 ms of an 11.6 ms fully-cached run
550
+ (`percentileRank` alone 6.57 ms, densifying 150,000 values and filtering
551
+ them twice, every request). They are ordinary registry entries now, with
552
+ content-addressed ids, cache entries and badges like anything else:
553
+ **0.09 ms**, a 120× improvement on the warm path. **Breaking** — a
554
+ selector is `{on, output?}`; `reduce`, `points` and `columns: true` are
555
+ gone, and what a selector yields is decided by the node it points at.
556
+ - **process:** **`Input` admits `{from, output}`** — `slot#Output` in the
557
+ flat slot form — so a node can read one named output of a multi-output
558
+ upstream. A nested input had always read output 0, which nobody hit
559
+ while `select.output` could pick one at the end.
560
+ - **process:** **Slot-expansion failures are collectable.** They ran
561
+ before the error policy, so a mistyped input was the only class of bad
562
+ plan that threw instead of coming back as a `skipped` reason an agent
563
+ could retry against.
564
+
565
+ ### Changed
566
+
567
+ - **A fold no longer builds a `TimeSeries`** ([PND-PROCTERM]). Every node's
568
+ `compute` widened the source with `appendColumn` for each nested input, so
569
+ an op could call the corpus normally — the studies take
570
+ `(series, { column })`. For a fold that was waste twice over: the column it
571
+ reads is already in its inputs, and it was being packed into a series only
572
+ to be read straight back out.
573
+
574
+ The cost was not incidental. `appendColumn` **boxes a gapped column** on
575
+ the way in, because core's `withColumn` takes values rather than a column —
576
+ 22.4 ms per column at 1M rows. Every rolling study is gapped, so the
577
+ expensive path was the ordinary one.
578
+
579
+ 20 folds × 500k rows, on top of the columnar fold context below:
580
+ **383 → 129 ms** (2.96×), rss 173 → 113 MB. Against the boxed, assembling
581
+ baseline the two changes together are **606 → 129 ms**.
582
+
583
+ A facts-only request now returns no `series` at all, and the upstream
584
+ column still resolves through the node graph rather than the terminal's
585
+ `needed` set — so the failure the plan warned about, a fact silently
586
+ coming back with no value because its column was never selected, cannot
587
+ happen.
588
+
589
+ - **The rolling mean/σ kernel is now _range-exact_, and every rolling study
590
+ is faster and more accurate for it** ([PND-PROCKERN]). `sma`, `bollinger`
591
+ and `envelope` move off core's general sweep onto a dedicated
592
+ `rollingMeanSdInto`, which fills any `[lo, hi)` with **exactly the bits a
593
+ full pass would have written there** — not "within rounding", the same
594
+ doubles.
595
+
596
+ That property is the point. An ordinary sliding accumulator carries
597
+ rounding history from row 0, so restarting it mid-column lands a few ulps
598
+ off on _every_ cell of the range. Harmless-sounding, until you notice it
599
+ means the value depends on which ranges happened to be recomputed — on a
600
+ caller's edit history rather than their data. Two mechanisms get it: the
601
+ accumulators are rebuilt from the window every `period` rows so history
602
+ cannot accumulate, and those rebuilds are pinned to **absolute** row index
603
+ so a ranged sweep reconstructs the state a full sweep held. They also work
604
+ in a shifted frame, for the reason [PND-SHIFTFRAME] established — aligning
605
+ _without_ shifting made large-magnitude σ **worse** (3.6e-3 → 1.7e-2),
606
+ which is why the two ship together.
607
+
608
+ Worst relative error against an exact reference, 200k rows, period 20:
609
+
610
+ | input | before | after |
611
+ | ---------------------- | ------ | ------- |
612
+ | random walk ≈100 | 5.3e-9 | 3.9e-14 |
613
+ | `1e9 + sin` | 1.4e-3 | 6.3e-14 |
614
+ | `1e15 + ((i % 7) − 3)` | 3.6e-3 | 4.4e-16 |
615
+
616
+ **Values change** in the last ulps on ordinary data, and materially where
617
+ they were previously wrong — the `1e9` row is an ordinary notional, not a
618
+ contrived extreme. Faster too, on 500k bars: `bollinger(20)` **46.5 → 18.4
619
+ ms** (avg and σ now fuse into one sweep instead of core running two
620
+ reducers), `envelope(20)` 13.1 → 10.6, `sma(20)` 6.7 → 6.2, a five-study
621
+ stack 58.3 → 49.9. `scripts/perf-ranged-kernel.mjs`.
622
+
623
+ - **`withWorkers` no longer changes the answer at all.** The per-study
624
+ accuracy table is gone, replaced by one word: identical. Partitioned and
625
+ sequential results are bit-identical for every accelerated study, at
626
+ ordinary and large magnitudes, because a chunk starting anywhere now
627
+ reconstructs the state a whole-column pass held there. The previous
628
+ figures — `sma` 3.9e-14, `bollinger` 5.1e-13 — were observations on one
629
+ benign random walk presented as bounds, and a Codex pass had already
630
+ broken the `zScore` one with a legal input.
631
+
632
+ - **core:** **`fromArrow` now adopts a null-bearing numeric column's buffers
633
+ zero-copy** — 19.3 ms → 1.5 ms (**12.7×**) on 500k rows with 4% nulls.
634
+ Arrow's validity bitmap is byte-identical to pond's, so both the values
635
+ buffer and the bitmap become the column's storage as they stand; the old
636
+ per-element `vector.get(i)` walk remains only as the fallback. Adoption
637
+ declines — falling back with the same answer — for a sliced vector
638
+ (non-zero chunk offset), a multi-chunk vector, a non-`Float64Array` values
639
+ buffer, a `nullCount` disagreeing with the bitmap's popcount, or a defined
640
+ cell holding a non-finite value (which keeps pond's NaN-as-gap intake
641
+ semantics: adopting would have made the same table ingest differently
642
+ depending on whether adoption was possible). Aliasing note: like the dense
643
+ path's existing adopt, the resulting column shares memory with the Arrow
644
+ table — mutating the table's buffers afterwards corrupts the series.
645
+
646
+ - **core:** **`fromColumns` / `fromArrow` numeric columns with gaps now carry
647
+ `allFinite: true`.** The ingest predicate ("a cell is defined iff its value
648
+ is finite") _is_ the finiteness proof, but the flag was previously set only
649
+ for gap-free columns — so a single missing cell cost the column the
650
+ unguarded reduction fast path for the life of the series. Same answers,
651
+ faster reductions on gapped columns; observable as the column's `allFinite`
652
+ field now being `true` where it was `false`.
653
+
654
+ - **core:** **`sum` and `mean` are ~2.5× faster on long runs**, and their
655
+ results may differ from previous versions in the last ulp. Runs of **32 or
656
+ more** cells (range positions — a gapped range counts its gaps) now
657
+ accumulate into eight independent partial sums rather than one running
658
+ total, which breaks the loop's dependency chain — 2.51× on a dense column,
659
+ 2.22× through a validity bitmap, and `close.mean()` over 500k bars goes
660
+ from 0.47 ms to **0.19 ms**.
661
+
662
+ Floating-point addition is not associative, so this **can change the
663
+ answer** — worth being precise about the direction, though: the blocked
664
+ result is _generally more accurate_, not less. Sequential summation
665
+ accumulates rounding error as O(n·ε); eight partial sums accumulate it as
666
+ O((n/8)·ε + 8·ε). Summing 10⁶ copies of `0.1` lands strictly closer to the
667
+ true answer than before, and `1e16` followed by 8191 `1`s no longer absorbs
668
+ every `1` into the exponent gap.
669
+
670
+ What is guaranteed: runs of **fewer than 32** cells are unchanged bit for
671
+ bit; which cells contribute is unchanged (the validity bitmap and the
672
+ non-finite policy behave exactly as before — only the order of the
673
+ additions moved); `stdev`, the rolling-window kernel that backs
674
+ `@pond-ts/financial`'s studies, and the row-API path are all untouched.
675
+ pond-ts does not guarantee that a columnar sum and a row sum of the same
676
+ values agree bit for bit. Full rationale, measurements, and the threshold
677
+ reasoning in [`docs/notes/blocked-summation.md`](docs/notes/blocked-summation.md).
678
+
679
+ - **core:** **`aggregate()` is up to 2.5× faster**, from two changes to how it
680
+ produces its result. Neither changes the answer: same values, same interval
681
+ keys and labels, same `undefined` (not `NaN`) for an empty bucket, and the
682
+ same `ValidationError` if a reducer overflows to a non-finite result.
683
+ - It **builds the result columnar** instead of routing it back through row
684
+ intake. The columnar fast path already computed every bucket in typed
685
+ arrays, then boxed each one into a frozen `[Interval, …]` row so
686
+ `new TimeSeries({ rows })` could walk all of them back into columns; the
687
+ store is now assembled directly.
688
+ - It **reduces each bucket in place** rather than materialising a
689
+ `Float64Column` slice for it. Reducers gained a range-scoped kernel
690
+ (`reduceColumnRange`), so a bucket costs two integers instead of a column
691
+ instance — plus, on a column with a validity bitmap, a `Uint8Array`
692
+ allocation, an O(bucket) bit copy and an O(bucket/8) popcount that the
693
+ slice's constructor performed and then threw away.
694
+
695
+ Measured on 1M events, 1-second grid: **2.10× at 1-minute buckets**
696
+ (6.65 ms → 3.16 ms, one column) and **2.55× at 10-second buckets**
697
+ (55.52 ms → 21.81 ms, four columns). The win is per output bucket, so it
698
+ tapers to no change on hourly and daily rollups, where the reduction
699
+ dominates and there was nothing to save. Whole-column reductions
700
+ (`series.reduce`, `column.sum()`, …) are unaffected.
701
+
702
+ - **core:** **`median` / `percentile` are ~13× faster on the columnar path.**
703
+ `reducePercentileColumn` densified the defined+finite cells and then sorted
704
+ them; a percentile needs one or two order statistics, not a total order, so
705
+ it now runs quickselect — O(n) expected instead of O(n log n). Measured at
706
+ 1M rows: `median` 76.18 ms → 5.90 ms, `p95` 75.80 ms → 5.88 ms (**12.9×**).
707
+ Applies to `series.reduce(col, 'median' | 'pNN')`, `column.median()`,
708
+ `column.percentile(q)`, and the `aggregate` / `bin` / `binBy` percentile
709
+ families. Other reducers are unchanged.
710
+
711
+ **One behaviour change, and it removes an inconsistency.** The old path used
712
+ `Float64Array.prototype.sort()`, which places `-0` strictly before `+0`,
713
+ while the row path sorts with `(a, b) => a - b` — a comparator that reads
714
+ the pair as equal. On signed-zero input the two paths disagreed: `p0` of
715
+ `[0, -0, 0, -0, 0]` was `-0` columnar and `+0` row-wise. Quickselect
716
+ compares with `<` / `>`, under which they are equal, so the columnar path
717
+ now returns `+0` and matches the row path.
718
+
719
+ - **core:** **`cumulative`, `diff`, `rate` and `pctChange` are 4–7× faster.**
720
+ All four were column-native only in the sense of not materialising `Event`s:
721
+ each still read every cell through the polymorphic `col.read(i)` into a boxed
722
+ `Array<number | undefined>`, then handed that to `float64ColumnFromArray`,
723
+ which walked the boxed array twice more — once for the values and once for
724
+ the validity bitmap. They now walk the source's `Float64Array` and validity
725
+ bits directly and write into typed output buffers.
726
+
727
+ Measured at 200k rows × 4 columns (`scripts/perf-operators-unboxed.mjs`):
728
+
729
+ | operation | dense | 4% missing |
730
+ | ------------------- | ---------------------- | ---------------------- |
731
+ | `cumulative('sum')` | 10.22 → 2.56 ms (4.0×) | 22.94 → 3.44 ms (6.7×) |
732
+ | `cumulative('max')` | 11.84 → 2.54 ms (4.7×) | 22.12 → 3.43 ms (6.4×) |
733
+ | `diff` | 19.18 → 2.71 ms (7.1×) | 20.29 → 3.87 ms (5.2×) |
734
+ | `rate` | 21.18 → 3.96 ms (5.4×) | 21.92 → 5.14 ms (4.3×) |
735
+ | `pctChange` | 19.87 → 2.85 ms (7.0×) | 21.85 → 3.95 ms (5.5×) |
736
+
737
+ Output is unchanged: same values, same missing cells, and `allFinite` still
738
+ derived from the produced values rather than inherited from the source.
739
+ Chunked and non-numeric sources keep the previous path.
740
+
741
+ - **core:** **`rolling()`'s per-row contributor test is inlined.** The
742
+ per-column sweep evaluated it through a small helper — twice per row, once
743
+ entering the window and once leaving — which is a call per row per column,
744
+ the exact cost the sweep was restructured to remove. Both its operands are
745
+ loop-invariant, so on a dense provably-finite column (an OHLCV bar series)
746
+ the whole predicate now folds away. `sma(20)` over 500k bars: **10.41 →
747
+ 6.48 ms**, and the five-study strategy pass 70.58 → 65.25 ms.
748
+
749
+ - **core:** **`rolling(count, 'stdev')` runs Welford inline.** `stdev` was the
750
+ one reducer deliberately left on the reducer-state path when the kernel was
751
+ restructured, because its order-independent delete has exact `n <= 1` and
752
+ `n === 1` cases whose value is entirely numerical. The recurrence is now
753
+ transcribed verbatim into the sweep, removing three virtual calls per row
754
+ while keeping results **bit-identical** — asserted with `Object.is` against
755
+ the real state object across 18 shapes (large offsets, gross-outlier
756
+ eviction, denormals, gaps) plus 150 randomised trials, not with a closeness
757
+ tolerance that a dropped special case could pass.
758
+
759
+ `bollinger(20)` 31.51 → 25.18 ms, `zScore(20)` 26.49 → 19.88 ms, and the
760
+ five-study strategy pass 84.15 → 70.58 ms.
761
+
762
+ - **core:** **`rolling()`'s count-window kernel sweeps one column at a time**,
763
+ making every reducer-state call monomorphic instead of megamorphic, and
764
+ specialises `avg` inline. The window bounds never depended on the column, so
765
+ the columns were only sharing a sweep — and sharing it meant a single
766
+ `states[c].add(...)` site saw every reducer's state shape in turn, costing
767
+ three uninlinable virtual calls per row per column for what is usually O(1)
768
+ arithmetic.
769
+
770
+ Measured on 500k 1-minute bars through `@pond-ts/financial`
771
+ (`packages/financial/scripts/perf-agent-queries.mjs`): `sma(20)` 21.48 →
772
+ 15.20 ms, `bollinger(20)` 105.26 → 73.66 ms, `zScore(20)` 98.77 → 61.88 ms,
773
+ `envelope(20)` 69.33 → 45.03 ms, and a five-study strategy pass **318.30 →
774
+ 212.18 ms (1.50×)**.
775
+
776
+ Results are bit-identical: the same reducer states are fed the same values in
777
+ the same order, and `avg`'s specialisation is a running sum with no accuracy
778
+ argument to preserve (unlike `stdev`, whose order-independent Welford delete
779
+ keeps its state path).
780
+
781
+ - **core:** **`withColumn` accepts a `Float64Array` where `NaN` means missing.**
782
+ A typed buffer has no `undefined` slot, so `NaN` is the only way to express a
783
+ gap in one — and requiring gaps to be spelled `undefined` forced every
784
+ producer holding a typed buffer to box a whole column to say "no value here".
785
+ A boxed `Array<number | undefined>` keeps the strict reading: it already has
786
+ `undefined`, so a `NaN` in one is still rejected. `±Infinity` is rejected on
787
+ both doors. The buffer is copied, not adopted (`fromColumns` remains the
788
+ documented zero-copy door).
789
+
790
+ - **financial:** **studies are 2.0–5.6× faster.** The study kernel handed every
791
+ study an `Array<number | undefined>` built by walking the column with the
792
+ polymorphic `col.at(i)`, and each study then checked every input for
793
+ `undefined` per cell — `bollinger` allocated four 500k boxed arrays before
794
+ three `withColumn` re-ingests. The kernel now returns a `Float64Array` with
795
+ `NaN` marking a gap, which propagates through arithmetic on its own, so only
796
+ the genuinely study-specific guards survive (σ = 0 has no band; a zero base
797
+ has no percent change).
798
+
799
+ Measured on 500k 1-minute bars, combined with the `rolling` change above:
800
+
801
+ | study | before | after | × |
802
+ | --------------------- | --------- | ------------ | --------- |
803
+ | 5-study strategy pass | 318.30 ms | **84.15 ms** | **3.78×** |
804
+ | `envelope(20)` | 69.33 ms | 12.47 ms | 5.56× |
805
+ | `percentChange()` | 23.66 ms | 4.51 ms | 5.24× |
806
+ | `zScore(20)` | 98.77 ms | 26.49 ms | 3.73× |
807
+ | `bollinger(20)` | 105.26 ms | 31.51 ms | 3.34× |
808
+ | `sma(20)` | 21.48 ms | 10.54 ms | 2.04× |
809
+
810
+ Output is unchanged — verified against the committed pandas oracle fixtures
811
+ and, for the gap placement the oracle doesn't cover, byte-identical to the
812
+ pre-change build.
813
+
814
+ - **process:** `RunResult.explain` now covers **every id in `nodes`**, not only
815
+ the plan's top-level entries — a nested spec is a node in the timing badges
816
+ (and will be a node in the pipeline view) and had no lineage string to render.
817
+ `Skipped.spec` also now carries `inputs`, because a plan may hold two specs of
818
+ the same op and `{op, params}` alone does not say which one to fix. Both are
819
+ additive to the response.
820
+ - **charts (Storybook):** the `Charts/Histogram` story group moved to
821
+ **`Charts/BarChart/Histogram`** — the histogram is `BarChart` in its `bins`
822
+ mode, not a separate component, and the sidebar now says so. Story IDs under
823
+ the group changed accordingly (`charts-histogram--*` →
824
+ `charts-barchart-histogram--*`).
825
+
826
+ ### Fixed
827
+
828
+ - **core:** **`fromArrow` now reads a field's declared Arrow type instead of
829
+ guessing from the runtime shape of `toArray()`** — closing a
830
+ silent-corruption class. The reader worked out what a column held from what
831
+ `toArray()` handed back, which is correct for the types it supports and
832
+ quietly wrong outside them, because Arrow's physical layouts do not all store
833
+ one machine word per logical value. Measured, before the fix: **`Float16`
834
+ ingested `1.5` as `15872`** (its half-float bit pattern — the length matched,
835
+ so nothing caught it), and a **`Decimal128` column with a single null
836
+ ingested `123.45` as `12345`** (the per-element path produced exactly `rows`
837
+ values, so the length check never fired). A dense `Decimal` merely threw the
838
+ wrong error, blaming a length mismatch.
839
+
840
+ The readable set is now an explicit allowlist — `Int` (any width),
841
+ `Float32`/`Float64`, `Date32`/`Date64`, `Time32`/`Time64`, `Timestamp`,
842
+ `Utf8`/`LargeUtf8`/`Utf8View`, `Null` (an all-missing value column), and a
843
+ `Dictionary` of any of those (the encoding is transparent; readability
844
+ follows the value type) — checked per field, on the key and value columns of every
845
+ Arrow door (`TimeSeries.fromArrow`, `ValueSeries.fromArrow`, and the
846
+ flattened key edges). Anything else is refused **by name**, with the cast
847
+ that would fix it: `Decimal` names the float64 precision trade-off, `Float16`
848
+ says to cast, `Bool` names the real reason (the columnar ingest engine
849
+ carries `number` and `string` value columns only). A duck-typed stand-in
850
+ carrying no `typeId` keeps working — the `ArrowTableLike` contract is
851
+ deliberately structural — and gains a width check that catches the Decimal
852
+ shape anyway.
853
+
854
+ Behavioural change worth noting: a `Utf8` **key** now throws on its declared
855
+ type rather than on its shape, so the message names the type and points at
856
+ passing it as a value column instead.
857
+
858
+ - **Charts' affine fast path evaluates in a rebased frame, and survives deep
859
+ zoom for the first time.** The canvas draw loops reconstructed each affine
860
+ scale as `px = k·t + b` on absolute epoch-ms values — on a deeply zoomed
861
+ window, `k·t` and `b` are huge near-cancelling terms whose rounding residue
862
+ reaches ~0.16 px at a 1 ms window and ~24 px at 1 µs. The interior affinity
863
+ probe detected the drift and rejected the scale, so every deep-zoomed frame
864
+ (sub-second visible windows on an epoch-ms axis — the rejection crossover
865
+ measures around a few hundred ms at typical plot widths) silently fell back
866
+ to the slow per-point d3-scale path. The map is now recovered, verified, and evaluated
867
+ in the rebased form `px = (t − t0)·k + px0` (the association d3 itself
868
+ uses), which matches the exact scale to ≲1e-9 px at every zoom depth — the
869
+ fast path stays engaged at the `minDuration` floor and below (line/area
870
+ deep-zoom draws ~3.1–3.4× faster; wide-domain draws pay ~1–2%, one extra
871
+ subtraction per point per axis).
872
+
873
+ - **`zScore` computes its deviation in a shifted frame, and is accurate at
874
+ large magnitudes for the first time** ([PND-SHIFTFRAME]). The study derived
875
+ its numerator as `value − rollingMean`, which is catastrophic cancellation
876
+ whenever the values are large next to the window's spread: `ulp(1e15)` is
877
+ `0.125`, so a window spanning ±3 leaves the deviation about three bits. The
878
+ new `rollingDeviationSd` kernel accumulates `value − anchor` and emits the
879
+ deviation directly — both operands small, nothing cancels — re-anchoring
880
+ periodically, and on magnitude, so a trending series stays in frame.
881
+
882
+ Measured against an exact reference over 200k rows, worst relative error:
883
+
884
+ | input | before | after |
885
+ | ------------------------- | ------ | ------- |
886
+ | `1e15 + ((i % 7) − 3)` | 1.0e+0 | 4.1e-15 |
887
+ | `1e9 + sin` (mid) | 4.1e+0 | 4.9e-12 |
888
+ | random walk ≈100 (benign) | 3.9e-6 | 4.4e-11 |
889
+ | `1e12·(1+i/N)` (trending) | 9.0e-6 | 4.9e-15 |
890
+
891
+ **This was never a parallelism bug**, though it was found through one and
892
+ first documented as one. The sequential study computed the same subtraction
893
+ and carried the same exposure; partitioning only made two equally-wrong
894
+ answers visibly disagree. Anyone thresholding z-scores on large-magnitude
895
+ data was affected on the default path.
896
+
897
+ Two consequences worth reading before upgrading. **`zScore` values change**
898
+ — by rounding error on ordinary data, and by a lot on the cases above, where
899
+ they were wrong. And **`zScore` is no longer accelerated by `withWorkers`**:
900
+ the stable kernel is not the shape the worker pool hooks, so opting in no
901
+ longer speeds it up (it was 2.44×, the fastest study there) and no longer
902
+ changes its answer by a bit. The remaining accelerated studies — `sma`,
903
+ `envelope`, `bollinger` — are exactly those whose error is bounded.
904
+
905
+ **One behaviour change at overflow scale**, verified by a Codex pass and
906
+ left unguarded: the shifted frame computes `x - anchor`, which can
907
+ overflow when both operands are near `Number.MAX_VALUE` even though each
908
+ is finite. `[MAX_VALUE, -MAX_VALUE]` at period 2 gives a mean of
909
+ `-Infinity` where the previous raw-sum kernel gave the true `0`. Not
910
+ guarded, because the check is per row on a ~20 ns/row kernel to correct
911
+ an input no price, size or rate series can produce.
912
+
913
+ `zScore` costs ~2.3× its previous formulation as an upper bound (~26 ns/row
914
+ at 500k), and is flat in `period` — 22.8 to 26.1 ns/row from `period 2` to
915
+ `period 100_000`. `scripts/perf-shifted-frame.mjs`.
916
+
57
917
  ## [0.53.1] — 2026-07-25
58
918
 
59
919
  ### Fixed
package/README.md CHANGED
@@ -152,27 +152,31 @@ const points = series.sample({ reservoir: { size: 500 } }).toRows();
152
152
 
153
153
  ## Performance
154
154
 
155
- pond-ts is faster on **every** comparable operation, with no regressions —
156
- a **~17x** geometric-mean speedup across the measurable ops, plus a handful
157
- of transforms (`select` / `rename`) that are **effectively instant** (O(1)
158
- column rebinds, below the timer's resolution). The advantage grows with data
159
- size.
160
-
161
- | Category | Speedup (N=16k) | Notes |
162
- | ----------------- | ---------------------------------------------------- | --------------------------------------------- |
163
- | **Rate** | ~120x | Single columnar walk vs Pipeline |
164
- | **Fill** | 77–87x | Single columnar pass vs Pipeline per strategy |
165
- | **Aggregation** | 57–82x | O(N+B) bucketing vs O(N×B) Pipeline |
166
- | **Statistics** | 18–80x | Typed-array reduce vs ImmutableJS iteration |
167
- | **Alignment** | 42x | Forward cursor vs repeated binary search |
168
- | **Construction** | 13x | Columnar intake vs ImmutableJS wrapping |
169
- | **Chained** | 8x | Derived constructors vs per-step Pipeline |
170
- | **Transforms** | `select`/`rename` instant; `collapse` 30x; `map` ~4x | Column reshapes vs Pipeline |
171
- | **Event access** | 6x | Array indexing vs ImmutableJS `get()` |
172
- | **Serialization** | 4x | Lightweight columnar representation |
173
-
174
- See the [full benchmark results](website/docs/reference/benchmarks.mdx)
175
- for detailed numbers. Run locally:
155
+ Measured against three reference points (snapshot 2026-07-30; full tables,
156
+ methodology, and every losing number in the
157
+ [benchmark reference](website/docs/reference/benchmarks.mdx)):
158
+
159
+ - **vs pondjs** (the predecessor): faster on all 54 measurable shared
160
+ operations — geometric mean **20.7×**, `aggregate` up to 453×,
161
+ `median` 157×, and `select` / `rename` effectively instant (O(1)
162
+ column rebinds).
163
+ - **vs pandas** (500k-bar workload): roughly **even** ahead on `ema`,
164
+ `mean`, `median` / `percentile`; behind ~1.1–1.9× on the rolling
165
+ studies. A five-study strategy pass runs 1.28× slower than pandas'
166
+ Cython kernels.
167
+ - **vs polars, single-threaded**: **ahead on composite studies**
168
+ (`bollinger` 0.53×, strategy stack 0.85×, `ema` 0.32× — lower is
169
+ pond-ts faster), behind 4–9× on whole-column reductions (the SIMD gap;
170
+ half-closed already by blocked summation) and on raw ingest, where
171
+ pond-ts front-loads validation the dataframe engines defer.
172
+ - **vs polars on all 10 cores**: behind ~3.5× on the strategy stack —
173
+ pond-ts has no parallelism today; a measured 2.42× worker-thread path
174
+ is on the roadmap (`[PND-PROCPAR]`).
175
+
176
+ The honest one-line version: the rewrite beat its predecessor by an order
177
+ of magnitude, holds its own per-core against the native engines on the
178
+ composite queries that dominate real workloads, and knows exactly where
179
+ it is behind. Run locally:
176
180
 
177
181
  ```sh
178
182
  npm run build && node packages/core/bench/vs-pondjs.cjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/react",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "description": "React hooks for pond-ts live time series",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "test:runtime": "vitest run"
34
34
  },
35
35
  "peerDependencies": {
36
- "pond-ts": "^0.53.0",
36
+ "pond-ts": "^0.54.0",
37
37
  "react": "^18.0.0 || ^19.0.0"
38
38
  },
39
39
  "devDependencies": {