@pond-ts/fit 0.53.1 → 0.55.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 (2) hide show
  1. package/CHANGELOG.md +1175 -4
  2. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,11 +4,13 @@ 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.55.0...HEAD
12
+ [0.55.0]: https://github.com/pond-ts/pond/compare/v0.54.0...v0.55.0
13
+ [0.54.0]: https://github.com/pond-ts/pond/compare/v0.53.1...v0.54.0
12
14
  [0.53.1]: https://github.com/pond-ts/pond/compare/v0.53.0...v0.53.1
13
15
  [0.53.0]: https://github.com/pond-ts/pond/compare/v0.52.0...v0.53.0
14
16
  [0.52.0]: https://github.com/pond-ts/pond/compare/v0.51.0...v0.52.0
@@ -54,6 +56,1175 @@ and type-level changes; patch bumps are strictly additive.
54
56
 
55
57
  ## [Unreleased]
56
58
 
59
+ ## [0.55.0] — 2026-08-04
60
+
61
+ ### Added
62
+
63
+ - **process:** **`@pond-ts/process` publishes for the first time —
64
+ experimental, pre-1.0.** Computations as data over pond-ts: a processing
65
+ graph authored fluently in application code (or composed as JSON by a saved
66
+ view or a tool-calling model) resolves against a declared op vocabulary and
67
+ runs over a bound `TimeSeries`, with content-addressed caching, provenance,
68
+ and per-node timings on every response. Two entry points: the plan layer at
69
+ `.` and the Node worker pool at `./pool`. The docs section
70
+ ([pond-ts.org/docs/process](https://pond-ts.org/docs/process/)) is listed on
71
+ the site with a TypeDoc API reference; the publication follows the 2026-08
72
+ external audit hardening (all P1 findings fixed and regression-pinned). The
73
+ API is expected to move as friction reports land — pin an exact version.
74
+
75
+ - **charts:** **`<BarChart categories>` now works horizontally** ([PND-HCAT],
76
+ the 2026-08 API review's #3 item) — the funnel / ranking / comparison shape.
77
+ `orientation="horizontal"` puts the categories on the **y** axis as unit
78
+ slots with the value on x, and a `<YAxis>` with no explicit `ticks` **derives
79
+ one label per category by itself**, so the chart no longer needs a
80
+ hand-built `i + 0.5` tick list. It previously threw ("horizontal category
81
+ axes are not yet supported"), which forced consumers to convert their
82
+ categories into ordinal `bins` records _and_ hand-place the labels — the
83
+ workaround the gallery funnel documents.
84
+
85
+ Explicit `<YAxis ticks>` still wins, and vertical categorical charts are
86
+ untouched (categories stay on the container's ordinal x band scale). A new
87
+ internal `RowLayer.binCategories()` channel carries the names to whichever
88
+ axis they land on.
89
+
90
+ - **charts:** **the list family's series door** — `<BarList series={splits}
91
+ label={…}>` / `<BoxList series>` take a `TimeSeries` / `ValueSeries`
92
+ directly (one row per event; exactly-one-of with `rows`), closing the
93
+ "required adapter" gap the 2026-08 API review named: starting from a pond
94
+ series there is no shaping step. The `listRowsFrom*` readers remain for
95
+ record rows. Docs across the charts hub, cheat sheet, and type pages now
96
+ present the series as the whole data contract, with the exported `from*`
97
+ builders re-documented as **interop escape hatches for non-pond data**.
98
+
99
+ - **charts:** **`<BarList>` + `<BoxList>` — standalone ranked row lists** (the
100
+ react-timeseries-charts `HorizontalBarChart` shape, rebuilt as what it
101
+ always was: a table). One DOM row per _entity_ — an interface, a split, a
102
+ symbol — with a label cell (any node, links included), one glyph line per
103
+ configured column on **one shared value scale**, optional data cells
104
+ before/after the glyphs, `sortBy`/`sortDirection` or a full custom
105
+ comparator (missing values sort last either direction), an optional per-row
106
+ expander (`renderExpanded`, keyed on row identity so it survives a re-sort),
107
+ and consumer-owned row selection with an accent edge in the marks register.
108
+ A vertical **baseline rule** at the scale origin anchors the rows to one
109
+ reference (on by default for `<BoxList>`, whose lines float at their lower
110
+ quantile; opt-in for `<BarList>`, whose tracks already show zero), and
111
+ reference **`markers`** (`{ value, label? }`) draw a labelled dotted rule
112
+ through every row in the annotation register — an SLA / capacity line —
113
+ with marker values joining the auto domain fit.
114
+ `<BarList>` draws proportional value bars; its sister `<BoxList>` draws a
115
+ five-number distribution per line — range band, `q1`→`q3` body, median line
116
+ — plus an optional **current-value tick** with a formatted inline label (the
117
+ esnet traffic-by-interface look), using the same quantile vocabulary as the
118
+ canvas `<BoxPlot>` (`lower`/`q1`/`median`/`q3`/`upper`, both-or-neither
119
+ body, quantiles computed upstream — `reduce` facts — never by the chart).
120
+ Styling stays on the one channel: bars resolve `theme.bar[as]`, boxes
121
+ `theme.box[as]`; both built-in themes gain a `box.secondary` role for the
122
+ paired-direction case. Readers `listRowsFromTimeSeries` /
123
+ `listRowsFromValueSeries` build one row per event / axis key. The in-plot
124
+ histogram remains `<BarChart orientation="horizontal">` — the lists are for
125
+ the table-shaped cases it can't be (link labels, cells, expanders, custom
126
+ sort).
127
+
128
+ - **charts:** **`BarStyle.hover` — a distinct hover colour for bars**
129
+ ([#577](https://github.com/pond-ts/pond/issues/577)). A theme may now give
130
+ bars a three-step emphasis — `fill` at rest → `hover` under the pointer →
131
+ `highlight` (plus the outline) when selected. Previously one `highlight`
132
+ served both live states, so hover and select differed only by the presence of
133
+ an outline; `ScatterStyle` has carried distinct rest / selected treatments
134
+ (`outline` vs `selectedOutline`) all along, making bars the less expressive
135
+ layer for the same two-state interaction.
136
+
137
+ **Optional, with a `highlight` fallback**, so no existing theme changes
138
+ meaning or rendering — a theme that wants the distinction adds one colour.
139
+ Selection outranks hover on a bar that is both. Single-series only: a stacked
140
+ or per-bin-coloured bar has no separate highlight colour to replace (it pops
141
+ its _own_ fill, so a red/green volume bar keeps its meaning while live), and
142
+ that convention is unchanged.
143
+
144
+ ### Changed
145
+
146
+ - **charts:** **a bar's capabilities now follow the mark it draws, not the
147
+ prop that fed it** ([PND-BARSEM], the 2026-08 API review's #2 item). A
148
+ **one-column vertical** histogram (`bins` + a single `column`) and a
149
+ one-entry `columns` draw exactly the mark a `series` + `column` chart
150
+ draws, but they used to route through the stacked path purely because of
151
+ which prop supplied them — and so silently lost whole-slot hit-testing
152
+ (#584), the `BarStyle.hover` colour, the cursor readout, stable per-bar
153
+ identity and per-bar decimation. They now take the single-series path, so
154
+ visually identical bars behave identically.
155
+
156
+ **What this changes in practice:** on a one-column histogram, hover and
157
+ click now hit the bar's **whole slot** rather than only the drawn
158
+ rectangle (so the space above a short bar is live, and slots tile the
159
+ axis); the layer gains a cursor readout; and `theme.bar.hover` applies.
160
+ A genuine multi-group stack, `categories`, and horizontal charts are
161
+ unchanged — their segments share a bin's x-range, so only y distinguishes
162
+ them. New reader `barsFromBins` backs the normalized path.
163
+
164
+ **Not a pure widening, in one respect:** dense-bar envelope decimation is
165
+ now live on a one-column histogram (it was single-series-only). It engages
166
+ only once bars fall under ~1px, where it is visually lossless, but at that
167
+ density the per-bar `gap` and highlight give way to envelope rects — pass
168
+ `decimate={false}` to keep every bar drawn. The `colors` map, the
169
+ `theme.bar[<column>]` role and `SelectInfo.label` are all preserved across
170
+ the reroute (each was a silent regression caught in review).
171
+
172
+ This shrinks `BarStyle.hover`'s scope warning from a list of five
173
+ path-accidents to the two real exclusions (a multi-group stack has no
174
+ hover channel on `StackStyle`; `binColors` keeps each bar's own colour by
175
+ design) — which was the acceptance test the task set itself.
176
+
177
+ - **charts:** **column names and source modes are now checked at compile
178
+ time** ([PND-CHARTAPI], the 2026-08 API review's #1 item). Every draw
179
+ layer's column props are derived from the series' schema, so
180
+ `<LineChart series={cpu} column="cpuu" />` — and a numeric prop pointed at
181
+ a string column, where the schema has some other numeric column — fail to
182
+ **compile** instead of throwing at render; the
183
+ same holds for `readout`, the band edges, the box quantiles, and the OHLC
184
+ prices. `<BarChart>`'s props became a **union of its legal source modes**,
185
+ so mixing sources (`series` + `bins`) or column forms (`column` +
186
+ `columns`), or passing `categories` a `column`, are compile errors too.
187
+ `<BarList>` / `<BoxList>` get the same treatment for `rows` XOR `series`,
188
+ which additionally closes the row-type hole #590 documented (annotating a
189
+ callback with a custom row type while passing `series` claimed a shape the
190
+ series door cannot produce).
191
+
192
+ **This narrows what compiles — deliberately.** Code carrying a typo, an
193
+ illegal mode mix, or a lying row annotation stops building; that is the
194
+ point, and each case was already a runtime failure. Two compatibility
195
+ behaviours are preserved on purpose: a **loosely-typed** series
196
+ (`TimeSeries<SeriesSchema>`, e.g. from a helper that doesn't narrow) still
197
+ accepts any column name, because an unparameterized schema leaves the name
198
+ union open and nothing can be checked against it; and `bins` column names
199
+ stay `string`, since they name aggregate fields of a bin record rather than
200
+ schema columns. Note the deliberate distinction: a schema that _does_ name
201
+ its columns but has **no numeric one** rejects every name — there is nothing
202
+ numeric to plot — which is not the same as the loose case.
203
+
204
+ **One new limitation.** Because a layer's props are a union _per series
205
+ kind_, a value typed as _either_ kind (`TimeSeries<A> | ValueSeries<B>` — a
206
+ wrapper that forwards whatever it is given) matches no single member and
207
+ must be narrowed or cast at the boundary. `DurationAxis.stories.tsx` is the
208
+ worked example. The alternative design (one generic over the series type)
209
+ handles that case but changes every props type's public generic parameters;
210
+ the trade is recorded in `spikes/charts-type-seam/REPORT.md`.
211
+
212
+ - **charts:** **a bar's hover / click target is now its whole slot**, not the
213
+ rectangle it draws. A bar _is_ the full width of its interval; the `gap` that
214
+ separates adjacent columns is a display affordance. Hit-testing the drawn
215
+ rect made that affordance interactive — the gap was a dead channel you could
216
+ point at and select nothing, and so was the empty plot space above a short
217
+ bar, even though the x-scrub cursor at that same x reported the bar quite
218
+ happily. `barAt` now tests the bar's full interval width and the full plot
219
+ height, so hover, click and the cursor readout all agree on which bar you are
220
+ on, and slots tile the axis.
221
+
222
+ **Widening, with one exception.** Points that previously selected _nothing_
223
+ now select the bar whose slot they fall in. The exception: a bar whose value
224
+ exceeds an explicit `<YAxis max>` used to draw — and be clickable — above the
225
+ plot top, in the strip a `'top'` axis title reserves; the slot stops at the
226
+ axis domain, so that sliver no longer hits. Everything inside the plot that
227
+ hit before still hits.
228
+
229
+ **It reaches across the full plot height, so it can shadow layers beneath
230
+ it.** The topmost hit wins, so a `<BarChart>` declared _after_ a
231
+ `<ScatterChart>` / `<BoxPlot>` / another `<BarChart>` in the same row now
232
+ claims every hit in its x-range at any y. Declare a bar layer **below** the
233
+ marks that should stay clickable.
234
+
235
+ **Single-series vertical only** — a stacked, `bins`, `categories` or
236
+ horizontal chart still hit-tests the drawn segment, because a stack's
237
+ segments share a bin's x-range and only y tells them apart.
238
+
239
+ Unchanged: a genuine hole between non-contiguous intervals still misses (the
240
+ change makes the drawing gap hittable, it doesn't invent coverage the data
241
+ lacks), a gap (`NaN`) bar owns no slot, and a shared edge goes to the left
242
+ bar — the rule `barIndexAtTime` already documented, so the two now agree by
243
+ construction.
244
+
245
+ ### Fixed
246
+
247
+ - **process:** **audit hardening — five wrong-answer / silent-acceptance paths
248
+ in the plan layer closed** (external Codex audit, 2026-08; all reproduced,
249
+ all regression-pinned). Unit validation of a **picked output** read
250
+ `outputs[0]` instead of the selected output, so a picked `variance` was
251
+ refused where variance was demanded and — worse, silently — accepted where
252
+ price was. `Registry.define()` now rejects duplicate input roles and
253
+ duplicate output ids (both used to _collapse_ at run time rather than fail:
254
+ inputs resolved to the last role, outputs dropped the earlier column),
255
+ invalid param defaults, and `dependsOn` naming unknown params. An op result
256
+ whose length does not match the bound series is refused at the producer —
257
+ it used to ride out unchecked whenever `assemble: false` skipped the only
258
+ length check. The column-selection loop now honours `onError` (an operator
259
+ exception escaped `'collect'`), and a selector naming a nonexistent output
260
+ is a `skipped` entry instead of silently surfacing nothing. Fact provenance
261
+ (`id`, `name`, `op`, `unit`) now wins over a custom fold body's fields, and
262
+ `columnBytes` sums a chunked column's chunks instead of reporting 0 — which
263
+ a byte budget would read as "free".
264
+
265
+ Also: the derived fold slots in both builders now key by **params** —
266
+ `shape({points: 100})` after `shape({points: 20})` silently returned the
267
+ 20-point node — and `shape` itself uses a `ceil` stride, so 200 points
268
+ asked of 399 rows returns ≤200 rather than all 399. The nested JSON Schema
269
+ projection can now express the `PickedOutput` input form, `Host` accepts
270
+ `budgetBytes` (the [PND-PROCCACHE] cap was unreachable from the long-lived
271
+ host shape) and grows `remove(id)`, and CI's package-content check covers
272
+ `@pond-ts/process`. Package remains **unpublished** (`private: true`).
273
+
274
+ A second audit round tightened the same seams. `columnBytes` now counts
275
+ what is actually retained: the **backing buffer's capacity** rather than
276
+ the column's logical length (core documents `_values` as possibly
277
+ oversized, so a one-row column viewing a 1000-slot buffer retains 8,000
278
+ bytes, not 8 — an undercount that defeats the budget), the chunk-offset
279
+ index and the bitmap's real bytes on chunked columns. The
280
+ **request-driven half of a `Host`'s footprint is now boundable**:
281
+ `runAsync` binds a graph per distinct caller-supplied `SourceRef`, so
282
+ `maxSources` caps registry-loaded sources LRU (author-added datasets are
283
+ never evicted), and a `remove()` racing an in-flight load now wins — the
284
+ landing load discards its result instead of resurrecting the dataset.
285
+ And an omitted param now unifies with its explicit default in the fluent
286
+ layer's derived fold slots (`shape()` ≡ `shape({points: 40})`, the same
287
+ rule `specId` applies), while the response labels a computation with the
288
+ **first** slot that named it rather than whichever was declared last.
289
+
290
+ - **charts:** **a hovered or selected single-series bar now pops to full
291
+ opacity** ([#576](https://github.com/pond-ts/pond/issues/576)). `drawBars`
292
+ set `globalAlpha` once to the resting `style.opacity` and never lifted it for
293
+ the highlight **fill** on the single-series path — only for the selected
294
+ bar's outline. The per-bar-`binColors` branch in the same function and
295
+ `drawStacks` both already popped to 1, and `drawBars`' own docstring claimed
296
+ it did too. So on a theme with `opacity < 1` a **hovered** bar (which has no
297
+ outline) barely changed, and a **selected** one read only by its outline.
298
+ All three paths now treat the highlight fill identically.
299
+
300
+ **This changes pixels** on any single-series `<BarChart>` with an alpha'd
301
+ theme: highlighted bars are brighter. A theme that raised its base `opacity`
302
+ to compensate may now want it back down.
303
+
304
+ **It also flattens select against hover**, which is worth knowing before you
305
+ upgrade. The selected bar's outline strokes in `highlight` — previously that
306
+ read as a brighter ring over an alpha'd fill, and it was the main thing
307
+ separating a selected bar from a hovered one. Now the fill underneath is the
308
+ same colour at the same alpha, so only the half of the stroke falling
309
+ outside the rect distinguishes them. Hover is no longer nearly invisible,
310
+ but the two live states are closer together. A theme that needs them clearly
311
+ apart should set the new `BarStyle.hover`.
312
+
313
+ ## [0.54.0] — 2026-08-02
314
+
315
+ ### Fixed
316
+
317
+ - **core:** **`fromArrow` now reads a field's declared Arrow type instead of
318
+ guessing from the runtime shape of `toArray()`** — closing a
319
+ silent-corruption class. The reader worked out what a column held from what
320
+ `toArray()` handed back, which is correct for the types it supports and
321
+ quietly wrong outside them, because Arrow's physical layouts do not all store
322
+ one machine word per logical value. Measured, before the fix: **`Float16`
323
+ ingested `1.5` as `15872`** (its half-float bit pattern — the length matched,
324
+ so nothing caught it), and a **`Decimal128` column with a single null
325
+ ingested `123.45` as `12345`** (the per-element path produced exactly `rows`
326
+ values, so the length check never fired). A dense `Decimal` merely threw the
327
+ wrong error, blaming a length mismatch.
328
+
329
+ The readable set is now an explicit allowlist — `Int` (any width),
330
+ `Float32`/`Float64`, `Date32`/`Date64`, `Time32`/`Time64`, `Timestamp`,
331
+ `Utf8`/`LargeUtf8`/`Utf8View`, `Null` (an all-missing value column), and a
332
+ `Dictionary` of any of those (the encoding is transparent; readability
333
+ follows the value type) — checked per field, on the key and value columns of every
334
+ Arrow door (`TimeSeries.fromArrow`, `ValueSeries.fromArrow`, and the
335
+ flattened key edges). Anything else is refused **by name**, with the cast
336
+ that would fix it: `Decimal` names the float64 precision trade-off, `Float16`
337
+ says to cast, `Bool` names the real reason (the columnar ingest engine
338
+ carries `number` and `string` value columns only). A duck-typed stand-in
339
+ carrying no `typeId` keeps working — the `ArrowTableLike` contract is
340
+ deliberately structural — and gains a width check that catches the Decimal
341
+ shape anyway.
342
+
343
+ Behavioural change worth noting: a `Utf8` **key** now throws on its declared
344
+ type rather than on its shape, so the message names the type and points at
345
+ passing it as a value column instead.
346
+
347
+ ### Changed
348
+
349
+ - **A fold no longer builds a `TimeSeries`** ([PND-PROCTERM]). Every node's
350
+ `compute` widened the source with `appendColumn` for each nested input, so
351
+ an op could call the corpus normally — the studies take
352
+ `(series, { column })`. For a fold that was waste twice over: the column it
353
+ reads is already in its inputs, and it was being packed into a series only
354
+ to be read straight back out.
355
+
356
+ The cost was not incidental. `appendColumn` **boxes a gapped column** on
357
+ the way in, because core's `withColumn` takes values rather than a column —
358
+ 22.4 ms per column at 1M rows. Every rolling study is gapped, so the
359
+ expensive path was the ordinary one.
360
+
361
+ 20 folds × 500k rows, on top of the columnar fold context below:
362
+ **383 → 129 ms** (2.96×), rss 173 → 113 MB. Against the boxed, assembling
363
+ baseline the two changes together are **606 → 129 ms**.
364
+
365
+ A facts-only request now returns no `series` at all, and the upstream
366
+ column still resolves through the node graph rather than the terminal's
367
+ `needed` set — so the failure the plan warned about, a fact silently
368
+ coming back with no value because its column was never selected, cannot
369
+ happen.
370
+
371
+ ### Added
372
+
373
+ - **`ctx.out` — prepared output buffers for a ranged recompute**
374
+ ([PND-PROCRANGE]), plus `prepareRange` / `sealRange` / `RangeOutput` on the
375
+ package surface. An op writes only `[from, to)` and returns nothing; the
376
+ rows it keeps arrive already copied, **values and validity both**.
377
+
378
+ 500k rows × 5 studies: **209 → 6.5 ms/tick, 32×**, bit-identical to a
379
+ from-scratch pass every tick. The mechanism shipped at 4× because the
380
+ example op rebuilt its whole output, carrying the prefix with a `.at(i)`
381
+ per cell into a boxed `Array`.
382
+
383
+ **The contract exists because the obvious shortcut is silently wrong.**
384
+ Copying the prefix as a typed-array block gets 13× — and 1,875 wrong
385
+ cells, because packed storage holds `0` at a missing cell rather than
386
+ `NaN`, so every warm-up gap becomes a defined zero. Nothing in the type
387
+ system objects. Validity has to move with the values, and doing that per
388
+ cell is the `O(n)` walk the ticket exists to remove — so the graph
389
+ prepares both as blocks and an op cannot get it wrong by omission.
390
+ `previousView` is also exposed for ops that want to read the prior
391
+ output directly.
392
+
393
+ - **Ranged recompute** ([PND-PROCRANGE]): `graph.setSourceFrom(series,
394
+ changedFrom)` declares which row first changed, and an op opts in with
395
+ `OpDef.runRange(ctx)` — which receives `{ from, to, previous }` and rebuilds
396
+ only that slice. `graph.recomputes` reports `{ ranged, full }`.
397
+
398
+ **The previous output is an argument, not state.** Letting a node reach for
399
+ its own last output would make `compute` a function of history: two callers
400
+ with the same data but different edit sequences could disagree, and
401
+ `explain` would stop describing what a value depends on. Passing it in keeps
402
+ the op a pure function of declared inputs; the mutable part stays in the
403
+ graph, which is a cache and was already stateful.
404
+
405
+ **It is opt-in because it is only safe for some ops.** An incremental result
406
+ must be _bit-identical_ to a from-scratch one, or answers start depending on
407
+ the sequence of edits that produced them — invisible to any test that only
408
+ computes from scratch. That holds for [PND-PROCKERN]'s range-exact kernel
409
+ and does **not** hold for `median`, percentiles, `min` or `max`, which still
410
+ sweep whole-series. An op that declares nothing gets full recomputes: always
411
+ correct, merely slower.
412
+
413
+ Measured 500k rows, 5 studies, 20 ticks: **209 → 55 ms/tick (4×)**, verified
414
+ bit-identical against a from-scratch pass every tick. That is short of the
415
+ plan's 26×, and the gap is in the _op_, not the graph — a `runRange` that
416
+ copies the whole prefix out of `previous` before patching is `O(n)` per
417
+ tick. Reaching the projected ceiling needs a capacity-buffer contract
418
+ letting an op _extend_ the previous column instead of rebuilding it.
419
+
420
+ For scale: [PND-PROCHIST] answers the same hot-edge workload at ~1.3 ms/tick
421
+ by slicing, with no incremental machinery. Ranging earns its keep where the
422
+ whole column must stay materialized — a chart drawing every point while one
423
+ row arrives.
424
+
425
+ - **An engine-wide byte budget over retained node values** ([PND-PROCCACHE]):
426
+ `bind(series, { registry, budgetBytes })`, plus `graph.retainedBytes`,
427
+ `graph.evictions` and `graph.enforceBudget()`. Unbounded when omitted, so
428
+ no existing caller changes behaviour.
429
+
430
+ Every distinct spec ever compiled was retained forever, so memory scaled
431
+ with _questions asked_. A session walking a slider from period 20 to 200
432
+ left 180 nodes holding 180 result columns and dropped none. 60 distinct
433
+ params × 200k rows, each configuration in its own process:
434
+ **arrayBuffers 104 → 42 MB** (2.5×), retained 93 → 11 MB, 60 nodes → 7 —
435
+ while a repeat-heavy sweep still hits, with no eviction churn and no
436
+ measurable penalty. Both halves matter: a budget that bounds memory by
437
+ discarding what the caller asks for next is not a cache.
438
+
439
+ **No `rss` figure is quoted, deliberately.** An earlier draft claimed
440
+ 5.6× on rss; that was a measurement-order artifact — two configurations
441
+ timed in one process, the second starting from the first's heap, and
442
+ reversing them inverted the result. Forking a process per configuration
443
+ fixed the ordering, but the replacement 1.2× did not survive either:
444
+ across five forked pairs, bounded rss exceeded unbounded in two. Freed
445
+ buffers are not promptly returned to the OS and the bound series is the
446
+ floor, so rss cannot support a direction here at this scale.
447
+ `arrayBuffers` and `retainedBytes` can, and are what the benchmark
448
+ reports.
449
+
450
+ The ticket framed this as an op-level cache where an op declares which
451
+ inputs key its result. **Half of that is already true and was not
452
+ rebuilt** — `specId` is content-addressed over op, params and inputs, so
453
+ asking the same question twice hits the same node by construction, and a
454
+ per-op key would be a second key beside a correct one. What was missing is
455
+ the capacity, and the ticket is right that it cannot belong to the op: a
456
+ per-op cap is a per-op promise, and nothing supervises the total.
457
+
458
+ Bounded in **bytes**, resolving the ticket's open question. Entries are not
459
+ the unit anyone has a limit in — one node over 1M rows outweighs fifty over
460
+ 5,000 — and bytes only became knowable once [PND-PROCCOL] made node values
461
+ columns with a reportable `columnBytes`. Eviction is LRU with one
462
+ constraint: a node whose consumer still holds its outlet is skipped,
463
+ because dropping it frees nothing and forces a recompile.
464
+
465
+ - **`requiredHistory(registry, plan)` in `@pond-ts/process`** ([PND-PROCHIST]),
466
+ with a per-op `OpDef.lookback`. The hot leading edge is the design's worst
467
+ cliff — an 8-study stack over 500k rows costs ~100 ms/tick — and the fix is
468
+ to slice a tail, which until now was the consumer's guess. The registry
469
+ already knows every op's lookback, so the minimum safe tail is derivable.
470
+
471
+ Measured on that stack: **97 → 1.3 ms/tick, 75×** (10 → 773 ticks/sec), with
472
+ **zero truncated cells** at the derived tail and **exactly one** at a tail
473
+ one row shorter. The bound is tight, not merely safe.
474
+
475
+ Two things it is careful about. Lookbacks **sum along a nested chain** —
476
+ `sma(20)` over `sma(50)` needs 69 rows, not 50, and taking the max
477
+ under-provisions in the way that produces defined, plausible, truncated
478
+ answers. And an op that declares no lookback yields `known: false` naming
479
+ it, rather than a number: a missing declaration and a genuinely
480
+ element-wise op are the same value with opposite meanings, so an
481
+ element-wise op should say `() => 0`.
482
+
483
+ Slicing a tail gives answers that agree to **≤5.8e-13**, not bit-for-bit.
484
+ An `ema` lookback (`4 × period`) is an approximation by construction, and
485
+ slicing builds a new shorter series, which re-indexes every row — the
486
+ rolling kernel pins its rebuilds to absolute row index, so
487
+ [PND-PROCKERN]'s bit-identity covers a range of the _same_ column, not a
488
+ re-indexed copy.
489
+
490
+ - **`columnView(column)` in `@pond-ts/process` — a zero-copy read view over a
491
+ packed numeric column** ([PND-PROCCOL]), and `FoldContext.numeric(role)`,
492
+ which hands one to a fold. `values` and `bits` are `subarray`s of the
493
+ column's own storage: read, never retain.
494
+
495
+ `FoldContext.values` — the boxed `(number | undefined)[]` — is now a **lazy
496
+ getter** rather than eagerly densified, so a fold that never touches it
497
+ never allocates. It was the graph's largest heap cost, and `last` reads a
498
+ single cell while paying to densify 500,000 of them.
499
+
500
+ 20 folds × 500k rows, `scripts/perf-proccol.mjs`:
501
+
502
+ | | boxed | columnar |
503
+ | ------------ | ------ | ---------- |
504
+ | warm run | 606 ms | **383 ms** |
505
+ | heap at peak | 35 MB | **25 MB** |
506
+ | rss | 204 MB | **173 MB** |
507
+
508
+ Read that as a fold-shape result, not a representation result. **Columnar
509
+ is not faster to read** — a buffer walk reaches parity with a boxed array,
510
+ and `Column.scan()` is 4.7× slower than either because it takes a callback
511
+ per cell. The 1.58× is the densify disappearing for folds that read a few
512
+ cells. A fold that walks the whole column should expect parity, and gets
513
+ the memory win only.
514
+
515
+ - **charts:** **`<LineChart readout>` / `<AreaChart readout>` — the tracked
516
+ value, decoupled from the plotted one** ([PND-READOUT]). Name a **second
517
+ column** and its value rides each tracker sample as the new
518
+ `TrackerSample.readout`, while the layer keeps plotting `column`. Plotting a
519
+ _derived_ series but reading the _source_ number is common — a log,
520
+ normalized, unit-transformed or smoothed line whose readout should show the
521
+ raw sample — and until now a consumer had to reconstruct that off-chart from
522
+ its own data. (Motivating case: estela's DATA chart plots pace-space,
523
+ Gaussian-smoothed, but the scrub readout wants the native m/s formatted as
524
+ pace.)
525
+
526
+ `value` is unchanged, so **the in-chart cursor dot still sits on the plotted
527
+ value** — as it must, or the dot would leave the line. In-chart flag / inline
528
+ chips likewise keep showing the plotted number; `readout` is for the
529
+ off-chart consumer, which shows `readout ?? value`. **Additive**: omit
530
+ `readout` and every sample is identical to before.
531
+
532
+ A mistyped `readout` throws the readers' `RangeError` / `TypeError` on
533
+ **both** axis kinds, rather than throwing on a value axis and silently
534
+ producing no readout on a time axis.
535
+
536
+ - **charts:** **single-series bars gained the stable per-bar `mark` identity**
537
+ the categorical stack already had. `<BarChart series column>`'s `hitTest` now
538
+ echoes a `SelectInfo.mark` — the bar's **own axis key**, stringified — and a
539
+ controlled `selected` / `hovered` carrying a `mark` matches on that name
540
+ instead of the bar's `key`. This closes a gap on **point-keyed** series,
541
+ where the bar span is synthesized from neighbour spacing so its `key` is a
542
+ _derived_ edge (`t - halfGap`), not the sample's time: pinning a selection
543
+ previously meant re-deriving that geometry, and now a caller matches on the
544
+ centre it already owns. The readers (`barsFromTimeSeries` /
545
+ `barsFromValueSeries`) supply the identity, exposed as the new optional
546
+ `BarSeries.marks`.
547
+
548
+ **The match rule is strictly additive.** A selection with no `mark` — every
549
+ one that exists today — still matches on the `key`, so key-pinned controlled
550
+ selections are untouched. (A deliberate divergence from `drawStacks`, which
551
+ switches on the _series_ carrying marks rather than the _selection_; bars have
552
+ shipped key-pinning, category stacks never did.) The **payload** does change,
553
+ additively: an interactive single-series bar's `SelectInfo` now carries a
554
+ `mark` where it previously carried none, so a consumer that round-trips a hit
555
+ back as a controlled `selected` pins by mark rather than key. Both resolve to
556
+ the same bar.
557
+
558
+ `marks` build lazily and memoize (~9 ms per 100k bars, on a ~0.8 ms reader).
559
+ A **non-interactive** layer never reads them; an **interactive** one echoes
560
+ the hovered bar's mark from `hitTest` on every pointer move, so its first
561
+ hover over a bar materializes the array — once per data identity, on the input
562
+ path (11.1 ms cold vs 1.7 ms warm, at 100k). Bounded and paid once, where an
563
+ eager array would charge every chart on every data update.
564
+ `scripts/perf-barmarks.mjs` pins both halves.
565
+
566
+ - **`parallelDispatches()`** in `@pond-ts/financial/parallel` — how many rolling
567
+ passes have actually run on worker threads. `withWorkers` is a silent no-op
568
+ when the pool declines a series, and a declined pass returns the same answer,
569
+ just slower than the caller expected; this is how you check. It also replaces
570
+ a test canary that had proved the parallel path ran _by it being wrong_ —
571
+ which stopped working the moment the wrongness was fixed.
572
+
573
+ - **financial:** **`withWorkers` (`@pond-ts/financial/parallel`) — rolling
574
+ studies partitioned across worker threads** ([PND-SCANKERN], Node-only,
575
+ opt-in). A rolling window is not a recurrence: output cell `i` reads only
576
+ rows `[i-period+1, i]`, so the output splits into ranges with a `period-1`
577
+ overlap and no communication between workers.
578
+
579
+ **Opt in once, at ingest** — `withWorkers(bars, { workers: 8 })` returns the
580
+ series unchanged, and every rolling study over it (or over anything derived
581
+ from it) is partitioned from then on. The studies keep their signatures and
582
+ stay **synchronous**: `Atomics.wait` lets the main thread dispatch and join
583
+ without yielding, which is also why this is Node-only and simply absent in a
584
+ browser. **Single-threaded is unchanged and remains the default**; the main
585
+ package never imports this entry point.
586
+
587
+ Measured over 500k bars, 8 workers: `sma` **1.83×**, `bollinger` **1.86×**,
588
+ `zScore` **2.45×**, a three-study stack **1.98×**.
589
+
590
+ **It changes the answer, and how much depends on the study.** Chunk 0
591
+ reproduces the sequential sweep exactly; later chunks start their Welford
592
+ state fresh. `sma`, `envelope` and `bollinger` shift by rounding error
593
+ (3.9e-14, 3.9e-14, 5.1e-13 observed; no cell beyond 1e-9).
594
+
595
+ **`zScore` is different in kind, not degree** — though not for the reason
596
+ first published here. The divergence is **catastrophic cancellation in the
597
+ numerator**, not the division by σ: at the worst row, σ differs by 0.97%
598
+ while `v − mean` differs by 60%, because `ulp(1e15)` is `0.125` and a window
599
+ spanning ±3 covers ~48 ulps. The sequential study computes the same
600
+ subtraction and carries the same exposure; partitioning only perturbs it.
601
+ A shifted-frame formulation removes it (650% → 8.8e-15, prototyped in
602
+ `spikes/shifted-frame/`, tracked as [PND-SHIFTFRAME]).
603
+ On a benign random walk the difference is ~2.6e-6 across ~0.8% of cells; on
604
+ a legal near-flat series at large magnitude it is **38%** (counterexample
605
+ from a Codex review, now a regression test). Do not opt in if you threshold
606
+ z-scores, reproduce the pandas oracle, or work with near-constant series.
607
+ Related: core rejects a non-finite rolling result, where this kernel can
608
+ emit `Infinity` or clamp a `NaN` variance to zero.
609
+
610
+ Below `MIN_ROWS` (100k) a registered series still runs sequentially and is
611
+ bit-identical.
612
+
613
+ - **process:** **`HostPool` (`@pond-ts/process/pool`) — whole requests across
614
+ resident worker threads** ([PND-PROCPAR], Node-only). N workers, each holding
615
+ a long-lived `Host`, with the pool as a router: a plan is already JSON, a
616
+ registry is a module both isolates import (functions cannot be structured-
617
+ cloned, so the caller names a `setup` module rather than passing a value),
618
+ and a result's columns cross as transferable buffers. No engine change.
619
+
620
+ Measured (`packages/process/scripts/perf-pool.mjs`, 32 requests/batch,
621
+ 8 workers, median of 3 distinct batches): **3.1–4.0× on distinct requests**
622
+ at every size from 0.5 ms to 10 ms each, and **~0.01× on repeated ones**.
623
+ What decides it is the cache-hit rate, not request size — in-process, a
624
+ re-asked question is a memo hit returning the same column for nothing, while
625
+ a pool copies and ships every answer however cheap it was, and each worker
626
+ warms its own graph. Pooling and caching compete rather than compose.
627
+
628
+ Worth checking before reaching for it: the same rolling mean writing a
629
+ `Float64Array` instead of `new Array(n)` runs **482 ms single-threaded where
630
+ the boxed version needs 632 ms across eight workers**. Fixing the op beat
631
+ adding eight cores, and boxing parallelises worse besides.
632
+
633
+ Supporting: `columnBuffers` / `columnFromBuffers` — a packed numeric column
634
+ as the buffer pair it already is, for crossing an isolate boundary. The
635
+ buffers are **copies**, deliberately: transferring a column's own buffer
636
+ detaches it in the sending isolate, which would silently empty the cache the
637
+ worker exists to keep warm.
638
+
639
+ - **core:** **the flattened key convention — two-edged keys now survive a
640
+ columnar round trip.** `toArrow` has always flattened a `timeRange` /
641
+ `interval` key into `<key>` + `<key>End` (+ `<key>Label`), because Arrow has
642
+ no interval-of-time type — but no ingest door read that shape back, so
643
+ anything aggregated was columnar-export-only. Now `fromColumns` reads it,
644
+ `toColumns` emits it (where it previously threw), and `fromArrow` gains
645
+ `{ keyKind: 'timeRange' | 'interval' }` to read it out of Arrow. One spelling
646
+ across all four doors, so `TimeSeries.fromColumns(daily.toColumns())`
647
+ round-trips an aggregated series, key and all.
648
+
649
+ The names are fully determined — a key column's name equals its kind — so
650
+ there is nothing to configure. The envelope's `schema` keeps declaring the
651
+ **logical** key; the edge columns are derived from it. Two rules follow: a
652
+ value column may not take a derived name (it throws on ingest, naming the
653
+ collision), and ordering for a two-edged key is by `(begin, end)`, matching
654
+ the row door — as does `sort: true`. Interval labels must be present in every
655
+ row and all of one type, again matching the row door (and throwing the same
656
+ `RangeError` when they aren't). Types: `FlatKeyColumns`, plus `keyKind` on
657
+ `FromArrowOptions`.
658
+
659
+ Two incidental improvements fell out: `TimeSeries.fromColumns` no longer
660
+ rejects non-`time` keys at all (it accepted only `'time'` since it shipped),
661
+ and the ingest engine's `makeKey` callback is gone — the schema's key kind
662
+ fully determines the column class, so every door stopped passing one.
663
+
664
+ - **core:** **`TimeSeries.toColumns()`** — the columnar-JSON export door, and
665
+ the inverse `fromColumns` never had. Returns the same
666
+ `{ name, schema, columns }` envelope `fromColumns` accepts (one plain array
667
+ per column, gaps as `null`), typed per column, so
668
+ `TimeSeries.fromColumns(series.toColumns())` round-trips **with no cast**.
669
+ It reads the columnar store directly where `toJSON` materialises a row per
670
+ event: measured **~2.5–3 ms vs ~26 ms** at 100k rows × 6 columns — an
671
+ 8–10× gap across runs, widening with row count
672
+ (`scripts/perf-to-columns.mjs`). Two deliberate
673
+ limits, both reported rather than hidden — a `timeRange` / `interval` key
674
+ spans two edges and no columnar ingest door reads it back, so it throws
675
+ naming the two ways out (`asTime({ at: 'begin' })` or `toJSON()`); and
676
+ `boolean` / array columns export fine but aren't ingestable, which the
677
+ return type encodes as a compile error rather than a runtime one. Types:
678
+ `TimeSeriesJsonColumns`, `TimeSeriesColumnarInput`,
679
+ `TimeSeriesColumnarOutput`.
680
+
681
+ - **core:** **`ValueSeries` gets the full ingest / export surface** — the
682
+ value-keyed series is no longer a one-way street with a single columnar door.
683
+ In: **`ValueSeries.fromJSON`** (row tuples _or_ objects; strict per-cell kind
684
+ checking, `required` enforced, and — unlike the time door — no timestamp
685
+ parsing, because a value axis has no calendar to read `'2026-01-01'`
686
+ against), and **`ValueSeries.fromArrow(table, { axis })`** (`axis` is
687
+ required: there is no `'time'` field convention to fall back on, and the axis
688
+ is read unscaled since it carries no `TimeUnit`). Out:
689
+ **`toRows()` / `toObjects()` / `toJSON({ rowFormat })`** (rows, gaps as
690
+ `undefined` / `null` respectively), **`toColumns()`** (columnar JSON — one
691
+ plain array per column, gaps as `null`, the exact envelope `fromColumns`
692
+ takes back), and **`toArrow()`** (Arrow's memory layout, no copy — the
693
+ exporter `TimeSeries.toArrow` already used; a `'value'` axis exports as a
694
+ plain `float64` field). Every door pairs with its inverse and the round trips
695
+ are **typed**: `ValueSeries.fromColumns(vs.toColumns())` and
696
+ `ValueSeries.fromJSON(vs.toJSON())` compile without a cast. All four ingest
697
+ doors share one engine, so the monotonic-axis contract, `sort: true`, and the
698
+ packing rules are identical whichever you use. Types:
699
+ `ValueSeriesJsonInput`, `ValueSeriesJsonRow`, `ValueSeriesJsonObjectRow`,
700
+ `ValueSeriesJsonOutputArray`, `ValueSeriesJsonOutputObject`,
701
+ `ValueSeriesJsonCell`, `ValueSeriesRow`, `ValueSeriesObjectRow`,
702
+ `ValueSeriesJsonColumns`, `ValueSeriesColumnarInput`,
703
+ `ValueSeriesColumnarOutput`, `FromArrowValueOptions`, `JsonColumn`.
704
+
705
+ - **core:** **`TimeSeries.toArrow(options?)` — zero-copy export to the Apache
706
+ Arrow memory layout**, the counterpart of `fromArrow`. Every other export
707
+ door is row-shaped, so reaching another columnar engine meant a full
708
+ re-materialisation; it never had to — pond's validity bitmap is LSB-first
709
+ one-bit-per-value (Arrow's layout exactly), numeric columns are a contiguous
710
+ `Float64Array`, booleans a packed bitmap, and dict-encoded strings
711
+ `Int32Array` indices plus a dictionary. `toArrow` hands those buffers over
712
+ as they stand and returns `{ length, fields }` rather than an Arrow `Table`
713
+ — pond does not depend on `apache-arrow`; the caller assembles with
714
+ `makeData` / `makeVector` in a few lines (shown on the method doc). The
715
+ buffers are **live storage, not copies** — the same read-only contract
716
+ `column()` / `keyColumn()` already carry. Two named non-zero-copy cases:
717
+ chunked columns materialize first, and a non-dict-encoded string column is
718
+ a plain JS array (Arrow `Utf8` wants offsets + bytes). A `timeRange` /
719
+ `interval` key exports as `<key>` + `<key>End` (+ `<key>Label` for interval
720
+ labels), and a value column already using one of those names throws rather
721
+ than producing duplicate field names. Types: `ArrowExport`,
722
+ `ArrowExportField`, `ArrowExportType`, `ToArrowOptions`.
723
+
724
+ - **process:** registry-bound fluent graph authoring via
725
+ `process(registry, from)`. Operation methods, params, named secondary inputs,
726
+ and multi-output suffixes are inferred from the registry while the result
727
+ remains the same plain slot request accepted over a wire. Added opaque async
728
+ sources (`defineSource`, `SourceRegistry`, `Host.runAsync`): requests carry
729
+ only `{ source, params }`, loaders and credentials stay host-side, and equal
730
+ remote revisions reuse the existing bound graph and all node caches.
731
+ Concurrent calls for one source identity share a single in-flight load and
732
+ revision update.
733
+ - **charts:** **`<BarChart binColors>` now works on the single-series
734
+ time-axis path** — per-bar colours for a plain `series={…} column="…"` bar
735
+ layer, the shape a **direction-coloured financial volume row** needs (derive
736
+ the array from open vs close and volume reads green / red under the
737
+ candles; the `Charts/Candlestick` price+volume scenario shows the recipe).
738
+ Previously `binColors` only applied to `bins` / horizontal (stacked-path)
739
+ charts. A per-bar-coloured bar keeps its own colour under hover / selection
740
+ (the highlight pops opacity instead of swapping the fill), the hover / click
741
+ readout reports the bar's own colour, and the dense-bar envelope decimation
742
+ is skipped (an envelope rect can't carry more than one colour), so every
743
+ visible bar draws.
744
+ - **process:** new **`@pond-ts/process`** package — **work in progress, not
745
+ published.** Marked `private: true`, so the release workflow skips it; it is
746
+ on `main` to be iterated on in the open against
747
+ [RFC #543](https://github.com/pond-ts/pond/pull/543), not to be consumed.
748
+ A typed dataflow engine over pond values: nodes with typed `in` / `out` port
749
+ fields (wiring a `string` output into a `number` input is a compile error),
750
+ pull-based memoized evaluation, connect-time cycle rejection, per-node error
751
+ caching, and a read-only `Graph` view. `fromLive()` binds a live source where
752
+ events only mark dirty, so a burst of N events costs one snapshot at the next
753
+ pull rather than N.
754
+
755
+ **The public shape is expected to change.** The RFC concludes that the
756
+ declarative plan layer is the consumer surface and this engine belongs
757
+ underneath it as an internal module — see **[PND-PROCSUB]** in
758
+ [PLAN.md](PLAN.md), and [PND_PROCESS_PLAN.md](docs/plans/PND_PROCESS_PLAN.md)
759
+ for the measured follow-ups (node identity/lifetime is blocking for
760
+ interactive use; column-valued nodes and dirty-per-range are the large wins).
761
+
762
+ - **process:** **`registry.toJsonSchema({ defs })`** replaces the `base` option
763
+ added earlier in this cycle — the recursive `$ref` now lives in `$defs` and
764
+ points at `#/$defs/<name>`, which a caller lifts to its own document root.
765
+ `base` produced a pointer _into_ the host schema; that passes local
766
+ validators and is rejected by a real tool API (_"reference can only point to
767
+ definitions defined at the top level of the schema"_). The projection also
768
+ now emits `anyOf` rather than `oneOf` (equivalent here — both branch sets are
769
+ disjoint — and the one tool APIs accept), and every `const` carries its
770
+ `type`. All three were 400s from live calls that a client-side strict
771
+ validator had passed. See **[PND-PROCSCHEMA]**.
772
+ - **process:** a **selector resolves its own inline spec**, whether or not the
773
+ plan also lists it at top level. Requiring both was bookkeeping no schema
774
+ could express, so it lived in prose — and a caller composing from the schema
775
+ alone duly selected a spec it had not listed and got a skip instead of an
776
+ answer.
777
+ - **process:** `columns` and `reduce` on one selector are **no longer
778
+ exclusive** — asking for both now returns both, which is the legend-chip case
779
+ [PND-PROCTERM] exists for. Previously the reduction was silently dropped.
780
+ - **process:** **`NodeTiming.inputs` and `NodeTiming.pulled`** — `nodes` now
781
+ describes the **graph** the plan resolved, not just the subset a selector
782
+ reached. `inputs` carries each node's upstream ids (a raw source column is
783
+ named by column), which a consumer cannot derive without reimplementing
784
+ `specId`'s canonicalization; `pulled` is false for a resolved node this
785
+ request never read, whose `ms` is therefore zero and says nothing. Reporting
786
+ the unpulled ones is free — no value is produced for them. Found by drawing
787
+ the pipeline for M4, which rendered a plan with whole branches missing.
788
+ - **process:** **`run({ assemble: false })` and `RunResult.columns`** — columns
789
+ are the wire shape; the assembled `TimeSeries` is the in-process convenience
790
+ over the top of them. A `columns` selector now always hands back the resolved
791
+ columns by name, and `assemble: false` skips building a widened series for a
792
+ consumer that could never receive one. The receiving side rebuilds with
793
+ `TimeSeries.fromColumns`, which adopts a `Float64Array` **zero-copy** and
794
+ reads NaN as a gap, so reassembly across a boundary is free. Measured at 1M
795
+ rows, the skipped `appendColumn` is 7.6 ms for a gapless column and 22.4 ms
796
+ for a gapped one — and every rolling study is gapped. See **[PND-PROCCOL]**.
797
+ - **process:** `registry.toJsonSchema({ base })` — the projection can now be
798
+ **embedded** in a larger schema. Its recursive `$ref` (the line that lets a
799
+ caller express _EMA of SMA of px_ without being taught a nesting concept)
800
+ resolves against the **document root**, so a projection emitted at `#` and
801
+ then dropped inside a tool's `input_schema` had a dangling pointer — silently,
802
+ since a `$ref` is not required to resolve. `base` names the pointer the
803
+ subschema will live at, and `$schema` is now emitted only at the root. Found
804
+ by putting a model-shaped caller in front of it; see **[PND-PROCSCHEMA]**.
805
+
806
+ - **process:** **slots** — a plan may now be written as `nodes` keyed by
807
+ caller-assigned names, with `outputs` keyed by the caller's name for each
808
+ surfaced result ([PND-PROCSLOT]). A node's `specId` is derived from its op,
809
+ params and inputs, so it keys the cache correctly and **changes the moment a
810
+ param does — even though the topology has not**. A slot is the missing
811
+ identity: `avg` survives a `period` edit that moves every derived id.
812
+
813
+ Slots are an alias layer, not a replacement. `specId` remains the cache key,
814
+ because it is what finds a node again across requests, sessions and callers;
815
+ one caller's `avg` means nothing to another's. Expansion produces exactly the
816
+ nested plan the equivalent would have been written as, so **a slot plan hits
817
+ the cache a nested plan built** — verified at 150k bars, where the slot form
818
+ of an already-resolved graph comes back `cached` at 0.002 ms per node — and
819
+ neither `compile` nor `specId` knows slots exist.
820
+
821
+ `NodeTiming` gains `slot`, and `Fact` / `OutputInfo` gain `name`. Naming does
822
+ not require slots: a `Select` in the original form can carry a `name` too.
823
+
824
+ - **process:** **`registry.toJsonSchema({ shape: 'slots' })`** — the projection
825
+ for the slot format, and notably **flat**. The nested projection's single most
826
+ load-bearing line is a recursive `$ref`, because an input may be another spec;
827
+ making that portable took three rounds against a live API (`oneOf` refused,
828
+ every node needing an explicit `type`, a body pointer rejected in favour of a
829
+ top-level `$defs`). With slots an input is a plain string, so the recursion is
830
+ gone and every one of those problems with it — no `$defs`, no `$ref`, nothing
831
+ to rebase when embedded.
832
+ - **process:** **`plan(from)`** — a builder that emits a plan
833
+ ([PND-PROCBUILD]). `add` returns a handle you pass as another node's input,
834
+ so a mistyped reference is a compile error rather than a resolution failure,
835
+ and `toJSON()` produces the same envelope a model would compose. It holds no
836
+ resolution logic and knows nothing about the registry, so there is one
837
+ resolution path, one cache, and the existing plan tests cover it.
838
+
839
+ - **process:** **`OpDescriptor.inputs` is the declared `InputDef[]`**, not a
840
+ count. A count checks arity and says nothing else — a consumer labelling a
841
+ two-input op could not tell which side was which, and one explaining a
842
+ rejection could not name the unit an input demands, both of which the
843
+ registry holds and `describe()` was dropping. **Breaking** for anything
844
+ reading `inputs` as a number; `inputs.length` is the same value.
845
+
846
+ - **process:** **`suggest` on a numeric param** — the range worth offering,
847
+ as distinct from `min`/`max`, the range that rejects. Sliders drawn on the
848
+ legal range spent 96% of their travel where nobody goes, and a param with
849
+ no `max` had no drawable range at all: `annualise.barsPerYear` defaults to
850
+ 105,120 against a fallback ceiling of 100, so its control sat pinned at the
851
+ edge and any drag silently destroyed the annualisation. Advisory — nothing
852
+ rejects a value outside it — but checked at `define()` time so an inverted
853
+ or escaping range fails in front of the op's author. It also reaches a
854
+ composing model, as `description` prose in the JSON Schema projection
855
+ rather than a custom keyword.
856
+
857
+ - **process:** **Reductions are nodes.** `last`, `extremes`,
858
+ `percentileRank` and `shape` were a fixed `reduce` enum on the selector,
859
+ computed after the graph finished — so the one thing every caller reads
860
+ sat outside the memo, at 10.85 ms of an 11.6 ms fully-cached run
861
+ (`percentileRank` alone 6.57 ms, densifying 150,000 values and filtering
862
+ them twice, every request). They are ordinary registry entries now, with
863
+ content-addressed ids, cache entries and badges like anything else:
864
+ **0.09 ms**, a 120× improvement on the warm path. **Breaking** — a
865
+ selector is `{on, output?}`; `reduce`, `points` and `columns: true` are
866
+ gone, and what a selector yields is decided by the node it points at.
867
+ - **process:** **`Input` admits `{from, output}`** — `slot#Output` in the
868
+ flat slot form — so a node can read one named output of a multi-output
869
+ upstream. A nested input had always read output 0, which nobody hit
870
+ while `select.output` could pick one at the end.
871
+ - **process:** **Slot-expansion failures are collectable.** They ran
872
+ before the error policy, so a mistyped input was the only class of bad
873
+ plan that threw instead of coming back as a `skipped` reason an agent
874
+ could retry against.
875
+
876
+ ### Changed
877
+
878
+ - **A fold no longer builds a `TimeSeries`** ([PND-PROCTERM]). Every node's
879
+ `compute` widened the source with `appendColumn` for each nested input, so
880
+ an op could call the corpus normally — the studies take
881
+ `(series, { column })`. For a fold that was waste twice over: the column it
882
+ reads is already in its inputs, and it was being packed into a series only
883
+ to be read straight back out.
884
+
885
+ The cost was not incidental. `appendColumn` **boxes a gapped column** on
886
+ the way in, because core's `withColumn` takes values rather than a column —
887
+ 22.4 ms per column at 1M rows. Every rolling study is gapped, so the
888
+ expensive path was the ordinary one.
889
+
890
+ 20 folds × 500k rows, on top of the columnar fold context below:
891
+ **383 → 129 ms** (2.96×), rss 173 → 113 MB. Against the boxed, assembling
892
+ baseline the two changes together are **606 → 129 ms**.
893
+
894
+ A facts-only request now returns no `series` at all, and the upstream
895
+ column still resolves through the node graph rather than the terminal's
896
+ `needed` set — so the failure the plan warned about, a fact silently
897
+ coming back with no value because its column was never selected, cannot
898
+ happen.
899
+
900
+ - **The rolling mean/σ kernel is now _range-exact_, and every rolling study
901
+ is faster and more accurate for it** ([PND-PROCKERN]). `sma`, `bollinger`
902
+ and `envelope` move off core's general sweep onto a dedicated
903
+ `rollingMeanSdInto`, which fills any `[lo, hi)` with **exactly the bits a
904
+ full pass would have written there** — not "within rounding", the same
905
+ doubles.
906
+
907
+ That property is the point. An ordinary sliding accumulator carries
908
+ rounding history from row 0, so restarting it mid-column lands a few ulps
909
+ off on _every_ cell of the range. Harmless-sounding, until you notice it
910
+ means the value depends on which ranges happened to be recomputed — on a
911
+ caller's edit history rather than their data. Two mechanisms get it: the
912
+ accumulators are rebuilt from the window every `period` rows so history
913
+ cannot accumulate, and those rebuilds are pinned to **absolute** row index
914
+ so a ranged sweep reconstructs the state a full sweep held. They also work
915
+ in a shifted frame, for the reason [PND-SHIFTFRAME] established — aligning
916
+ _without_ shifting made large-magnitude σ **worse** (3.6e-3 → 1.7e-2),
917
+ which is why the two ship together.
918
+
919
+ Worst relative error against an exact reference, 200k rows, period 20:
920
+
921
+ | input | before | after |
922
+ | ---------------------- | ------ | ------- |
923
+ | random walk ≈100 | 5.3e-9 | 3.9e-14 |
924
+ | `1e9 + sin` | 1.4e-3 | 6.3e-14 |
925
+ | `1e15 + ((i % 7) − 3)` | 3.6e-3 | 4.4e-16 |
926
+
927
+ **Values change** in the last ulps on ordinary data, and materially where
928
+ they were previously wrong — the `1e9` row is an ordinary notional, not a
929
+ contrived extreme. Faster too, on 500k bars: `bollinger(20)` **46.5 → 18.4
930
+ ms** (avg and σ now fuse into one sweep instead of core running two
931
+ reducers), `envelope(20)` 13.1 → 10.6, `sma(20)` 6.7 → 6.2, a five-study
932
+ stack 58.3 → 49.9. `scripts/perf-ranged-kernel.mjs`.
933
+
934
+ - **`withWorkers` no longer changes the answer at all.** The per-study
935
+ accuracy table is gone, replaced by one word: identical. Partitioned and
936
+ sequential results are bit-identical for every accelerated study, at
937
+ ordinary and large magnitudes, because a chunk starting anywhere now
938
+ reconstructs the state a whole-column pass held there. The previous
939
+ figures — `sma` 3.9e-14, `bollinger` 5.1e-13 — were observations on one
940
+ benign random walk presented as bounds, and a Codex pass had already
941
+ broken the `zScore` one with a legal input.
942
+
943
+ - **core:** **`fromArrow` now adopts a null-bearing numeric column's buffers
944
+ zero-copy** — 19.3 ms → 1.5 ms (**12.7×**) on 500k rows with 4% nulls.
945
+ Arrow's validity bitmap is byte-identical to pond's, so both the values
946
+ buffer and the bitmap become the column's storage as they stand; the old
947
+ per-element `vector.get(i)` walk remains only as the fallback. Adoption
948
+ declines — falling back with the same answer — for a sliced vector
949
+ (non-zero chunk offset), a multi-chunk vector, a non-`Float64Array` values
950
+ buffer, a `nullCount` disagreeing with the bitmap's popcount, or a defined
951
+ cell holding a non-finite value (which keeps pond's NaN-as-gap intake
952
+ semantics: adopting would have made the same table ingest differently
953
+ depending on whether adoption was possible). Aliasing note: like the dense
954
+ path's existing adopt, the resulting column shares memory with the Arrow
955
+ table — mutating the table's buffers afterwards corrupts the series.
956
+
957
+ - **core:** **`fromColumns` / `fromArrow` numeric columns with gaps now carry
958
+ `allFinite: true`.** The ingest predicate ("a cell is defined iff its value
959
+ is finite") _is_ the finiteness proof, but the flag was previously set only
960
+ for gap-free columns — so a single missing cell cost the column the
961
+ unguarded reduction fast path for the life of the series. Same answers,
962
+ faster reductions on gapped columns; observable as the column's `allFinite`
963
+ field now being `true` where it was `false`.
964
+
965
+ - **core:** **`sum` and `mean` are ~2.5× faster on long runs**, and their
966
+ results may differ from previous versions in the last ulp. Runs of **32 or
967
+ more** cells (range positions — a gapped range counts its gaps) now
968
+ accumulate into eight independent partial sums rather than one running
969
+ total, which breaks the loop's dependency chain — 2.51× on a dense column,
970
+ 2.22× through a validity bitmap, and `close.mean()` over 500k bars goes
971
+ from 0.47 ms to **0.19 ms**.
972
+
973
+ Floating-point addition is not associative, so this **can change the
974
+ answer** — worth being precise about the direction, though: the blocked
975
+ result is _generally more accurate_, not less. Sequential summation
976
+ accumulates rounding error as O(n·ε); eight partial sums accumulate it as
977
+ O((n/8)·ε + 8·ε). Summing 10⁶ copies of `0.1` lands strictly closer to the
978
+ true answer than before, and `1e16` followed by 8191 `1`s no longer absorbs
979
+ every `1` into the exponent gap.
980
+
981
+ What is guaranteed: runs of **fewer than 32** cells are unchanged bit for
982
+ bit; which cells contribute is unchanged (the validity bitmap and the
983
+ non-finite policy behave exactly as before — only the order of the
984
+ additions moved); `stdev`, the rolling-window kernel that backs
985
+ `@pond-ts/financial`'s studies, and the row-API path are all untouched.
986
+ pond-ts does not guarantee that a columnar sum and a row sum of the same
987
+ values agree bit for bit. Full rationale, measurements, and the threshold
988
+ reasoning in [`docs/notes/blocked-summation.md`](docs/notes/blocked-summation.md).
989
+
990
+ - **core:** **`aggregate()` is up to 2.5× faster**, from two changes to how it
991
+ produces its result. Neither changes the answer: same values, same interval
992
+ keys and labels, same `undefined` (not `NaN`) for an empty bucket, and the
993
+ same `ValidationError` if a reducer overflows to a non-finite result.
994
+ - It **builds the result columnar** instead of routing it back through row
995
+ intake. The columnar fast path already computed every bucket in typed
996
+ arrays, then boxed each one into a frozen `[Interval, …]` row so
997
+ `new TimeSeries({ rows })` could walk all of them back into columns; the
998
+ store is now assembled directly.
999
+ - It **reduces each bucket in place** rather than materialising a
1000
+ `Float64Column` slice for it. Reducers gained a range-scoped kernel
1001
+ (`reduceColumnRange`), so a bucket costs two integers instead of a column
1002
+ instance — plus, on a column with a validity bitmap, a `Uint8Array`
1003
+ allocation, an O(bucket) bit copy and an O(bucket/8) popcount that the
1004
+ slice's constructor performed and then threw away.
1005
+
1006
+ Measured on 1M events, 1-second grid: **2.10× at 1-minute buckets**
1007
+ (6.65 ms → 3.16 ms, one column) and **2.55× at 10-second buckets**
1008
+ (55.52 ms → 21.81 ms, four columns). The win is per output bucket, so it
1009
+ tapers to no change on hourly and daily rollups, where the reduction
1010
+ dominates and there was nothing to save. Whole-column reductions
1011
+ (`series.reduce`, `column.sum()`, …) are unaffected.
1012
+
1013
+ - **core:** **`median` / `percentile` are ~13× faster on the columnar path.**
1014
+ `reducePercentileColumn` densified the defined+finite cells and then sorted
1015
+ them; a percentile needs one or two order statistics, not a total order, so
1016
+ it now runs quickselect — O(n) expected instead of O(n log n). Measured at
1017
+ 1M rows: `median` 76.18 ms → 5.90 ms, `p95` 75.80 ms → 5.88 ms (**12.9×**).
1018
+ Applies to `series.reduce(col, 'median' | 'pNN')`, `column.median()`,
1019
+ `column.percentile(q)`, and the `aggregate` / `bin` / `binBy` percentile
1020
+ families. Other reducers are unchanged.
1021
+
1022
+ **One behaviour change, and it removes an inconsistency.** The old path used
1023
+ `Float64Array.prototype.sort()`, which places `-0` strictly before `+0`,
1024
+ while the row path sorts with `(a, b) => a - b` — a comparator that reads
1025
+ the pair as equal. On signed-zero input the two paths disagreed: `p0` of
1026
+ `[0, -0, 0, -0, 0]` was `-0` columnar and `+0` row-wise. Quickselect
1027
+ compares with `<` / `>`, under which they are equal, so the columnar path
1028
+ now returns `+0` and matches the row path.
1029
+
1030
+ - **core:** **`cumulative`, `diff`, `rate` and `pctChange` are 4–7× faster.**
1031
+ All four were column-native only in the sense of not materialising `Event`s:
1032
+ each still read every cell through the polymorphic `col.read(i)` into a boxed
1033
+ `Array<number | undefined>`, then handed that to `float64ColumnFromArray`,
1034
+ which walked the boxed array twice more — once for the values and once for
1035
+ the validity bitmap. They now walk the source's `Float64Array` and validity
1036
+ bits directly and write into typed output buffers.
1037
+
1038
+ Measured at 200k rows × 4 columns (`scripts/perf-operators-unboxed.mjs`):
1039
+
1040
+ | operation | dense | 4% missing |
1041
+ | ------------------- | ---------------------- | ---------------------- |
1042
+ | `cumulative('sum')` | 10.22 → 2.56 ms (4.0×) | 22.94 → 3.44 ms (6.7×) |
1043
+ | `cumulative('max')` | 11.84 → 2.54 ms (4.7×) | 22.12 → 3.43 ms (6.4×) |
1044
+ | `diff` | 19.18 → 2.71 ms (7.1×) | 20.29 → 3.87 ms (5.2×) |
1045
+ | `rate` | 21.18 → 3.96 ms (5.4×) | 21.92 → 5.14 ms (4.3×) |
1046
+ | `pctChange` | 19.87 → 2.85 ms (7.0×) | 21.85 → 3.95 ms (5.5×) |
1047
+
1048
+ Output is unchanged: same values, same missing cells, and `allFinite` still
1049
+ derived from the produced values rather than inherited from the source.
1050
+ Chunked and non-numeric sources keep the previous path.
1051
+
1052
+ - **core:** **`rolling()`'s per-row contributor test is inlined.** The
1053
+ per-column sweep evaluated it through a small helper — twice per row, once
1054
+ entering the window and once leaving — which is a call per row per column,
1055
+ the exact cost the sweep was restructured to remove. Both its operands are
1056
+ loop-invariant, so on a dense provably-finite column (an OHLCV bar series)
1057
+ the whole predicate now folds away. `sma(20)` over 500k bars: **10.41 →
1058
+ 6.48 ms**, and the five-study strategy pass 70.58 → 65.25 ms.
1059
+
1060
+ - **core:** **`rolling(count, 'stdev')` runs Welford inline.** `stdev` was the
1061
+ one reducer deliberately left on the reducer-state path when the kernel was
1062
+ restructured, because its order-independent delete has exact `n <= 1` and
1063
+ `n === 1` cases whose value is entirely numerical. The recurrence is now
1064
+ transcribed verbatim into the sweep, removing three virtual calls per row
1065
+ while keeping results **bit-identical** — asserted with `Object.is` against
1066
+ the real state object across 18 shapes (large offsets, gross-outlier
1067
+ eviction, denormals, gaps) plus 150 randomised trials, not with a closeness
1068
+ tolerance that a dropped special case could pass.
1069
+
1070
+ `bollinger(20)` 31.51 → 25.18 ms, `zScore(20)` 26.49 → 19.88 ms, and the
1071
+ five-study strategy pass 84.15 → 70.58 ms.
1072
+
1073
+ - **core:** **`rolling()`'s count-window kernel sweeps one column at a time**,
1074
+ making every reducer-state call monomorphic instead of megamorphic, and
1075
+ specialises `avg` inline. The window bounds never depended on the column, so
1076
+ the columns were only sharing a sweep — and sharing it meant a single
1077
+ `states[c].add(...)` site saw every reducer's state shape in turn, costing
1078
+ three uninlinable virtual calls per row per column for what is usually O(1)
1079
+ arithmetic.
1080
+
1081
+ Measured on 500k 1-minute bars through `@pond-ts/financial`
1082
+ (`packages/financial/scripts/perf-agent-queries.mjs`): `sma(20)` 21.48 →
1083
+ 15.20 ms, `bollinger(20)` 105.26 → 73.66 ms, `zScore(20)` 98.77 → 61.88 ms,
1084
+ `envelope(20)` 69.33 → 45.03 ms, and a five-study strategy pass **318.30 →
1085
+ 212.18 ms (1.50×)**.
1086
+
1087
+ Results are bit-identical: the same reducer states are fed the same values in
1088
+ the same order, and `avg`'s specialisation is a running sum with no accuracy
1089
+ argument to preserve (unlike `stdev`, whose order-independent Welford delete
1090
+ keeps its state path).
1091
+
1092
+ - **core:** **`withColumn` accepts a `Float64Array` where `NaN` means missing.**
1093
+ A typed buffer has no `undefined` slot, so `NaN` is the only way to express a
1094
+ gap in one — and requiring gaps to be spelled `undefined` forced every
1095
+ producer holding a typed buffer to box a whole column to say "no value here".
1096
+ A boxed `Array<number | undefined>` keeps the strict reading: it already has
1097
+ `undefined`, so a `NaN` in one is still rejected. `±Infinity` is rejected on
1098
+ both doors. The buffer is copied, not adopted (`fromColumns` remains the
1099
+ documented zero-copy door).
1100
+
1101
+ - **financial:** **studies are 2.0–5.6× faster.** The study kernel handed every
1102
+ study an `Array<number | undefined>` built by walking the column with the
1103
+ polymorphic `col.at(i)`, and each study then checked every input for
1104
+ `undefined` per cell — `bollinger` allocated four 500k boxed arrays before
1105
+ three `withColumn` re-ingests. The kernel now returns a `Float64Array` with
1106
+ `NaN` marking a gap, which propagates through arithmetic on its own, so only
1107
+ the genuinely study-specific guards survive (σ = 0 has no band; a zero base
1108
+ has no percent change).
1109
+
1110
+ Measured on 500k 1-minute bars, combined with the `rolling` change above:
1111
+
1112
+ | study | before | after | × |
1113
+ | --------------------- | --------- | ------------ | --------- |
1114
+ | 5-study strategy pass | 318.30 ms | **84.15 ms** | **3.78×** |
1115
+ | `envelope(20)` | 69.33 ms | 12.47 ms | 5.56× |
1116
+ | `percentChange()` | 23.66 ms | 4.51 ms | 5.24× |
1117
+ | `zScore(20)` | 98.77 ms | 26.49 ms | 3.73× |
1118
+ | `bollinger(20)` | 105.26 ms | 31.51 ms | 3.34× |
1119
+ | `sma(20)` | 21.48 ms | 10.54 ms | 2.04× |
1120
+
1121
+ Output is unchanged — verified against the committed pandas oracle fixtures
1122
+ and, for the gap placement the oracle doesn't cover, byte-identical to the
1123
+ pre-change build.
1124
+
1125
+ - **process:** `RunResult.explain` now covers **every id in `nodes`**, not only
1126
+ the plan's top-level entries — a nested spec is a node in the timing badges
1127
+ (and will be a node in the pipeline view) and had no lineage string to render.
1128
+ `Skipped.spec` also now carries `inputs`, because a plan may hold two specs of
1129
+ the same op and `{op, params}` alone does not say which one to fix. Both are
1130
+ additive to the response.
1131
+ - **charts (Storybook):** the `Charts/Histogram` story group moved to
1132
+ **`Charts/BarChart/Histogram`** — the histogram is `BarChart` in its `bins`
1133
+ mode, not a separate component, and the sidebar now says so. Story IDs under
1134
+ the group changed accordingly (`charts-histogram--*` →
1135
+ `charts-barchart-histogram--*`).
1136
+
1137
+ ### Fixed
1138
+
1139
+ - **core:** **`fromArrow` now reads a field's declared Arrow type instead of
1140
+ guessing from the runtime shape of `toArray()`** — closing a
1141
+ silent-corruption class. The reader worked out what a column held from what
1142
+ `toArray()` handed back, which is correct for the types it supports and
1143
+ quietly wrong outside them, because Arrow's physical layouts do not all store
1144
+ one machine word per logical value. Measured, before the fix: **`Float16`
1145
+ ingested `1.5` as `15872`** (its half-float bit pattern — the length matched,
1146
+ so nothing caught it), and a **`Decimal128` column with a single null
1147
+ ingested `123.45` as `12345`** (the per-element path produced exactly `rows`
1148
+ values, so the length check never fired). A dense `Decimal` merely threw the
1149
+ wrong error, blaming a length mismatch.
1150
+
1151
+ The readable set is now an explicit allowlist — `Int` (any width),
1152
+ `Float32`/`Float64`, `Date32`/`Date64`, `Time32`/`Time64`, `Timestamp`,
1153
+ `Utf8`/`LargeUtf8`/`Utf8View`, `Null` (an all-missing value column), and a
1154
+ `Dictionary` of any of those (the encoding is transparent; readability
1155
+ follows the value type) — checked per field, on the key and value columns of every
1156
+ Arrow door (`TimeSeries.fromArrow`, `ValueSeries.fromArrow`, and the
1157
+ flattened key edges). Anything else is refused **by name**, with the cast
1158
+ that would fix it: `Decimal` names the float64 precision trade-off, `Float16`
1159
+ says to cast, `Bool` names the real reason (the columnar ingest engine
1160
+ carries `number` and `string` value columns only). A duck-typed stand-in
1161
+ carrying no `typeId` keeps working — the `ArrowTableLike` contract is
1162
+ deliberately structural — and gains a width check that catches the Decimal
1163
+ shape anyway.
1164
+
1165
+ Behavioural change worth noting: a `Utf8` **key** now throws on its declared
1166
+ type rather than on its shape, so the message names the type and points at
1167
+ passing it as a value column instead.
1168
+
1169
+ - **Charts' affine fast path evaluates in a rebased frame, and survives deep
1170
+ zoom for the first time.** The canvas draw loops reconstructed each affine
1171
+ scale as `px = k·t + b` on absolute epoch-ms values — on a deeply zoomed
1172
+ window, `k·t` and `b` are huge near-cancelling terms whose rounding residue
1173
+ reaches ~0.16 px at a 1 ms window and ~24 px at 1 µs. The interior affinity
1174
+ probe detected the drift and rejected the scale, so every deep-zoomed frame
1175
+ (sub-second visible windows on an epoch-ms axis — the rejection crossover
1176
+ measures around a few hundred ms at typical plot widths) silently fell back
1177
+ to the slow per-point d3-scale path. The map is now recovered, verified, and evaluated
1178
+ in the rebased form `px = (t − t0)·k + px0` (the association d3 itself
1179
+ uses), which matches the exact scale to ≲1e-9 px at every zoom depth — the
1180
+ fast path stays engaged at the `minDuration` floor and below (line/area
1181
+ deep-zoom draws ~3.1–3.4× faster; wide-domain draws pay ~1–2%, one extra
1182
+ subtraction per point per axis).
1183
+
1184
+ - **`zScore` computes its deviation in a shifted frame, and is accurate at
1185
+ large magnitudes for the first time** ([PND-SHIFTFRAME]). The study derived
1186
+ its numerator as `value − rollingMean`, which is catastrophic cancellation
1187
+ whenever the values are large next to the window's spread: `ulp(1e15)` is
1188
+ `0.125`, so a window spanning ±3 leaves the deviation about three bits. The
1189
+ new `rollingDeviationSd` kernel accumulates `value − anchor` and emits the
1190
+ deviation directly — both operands small, nothing cancels — re-anchoring
1191
+ periodically, and on magnitude, so a trending series stays in frame.
1192
+
1193
+ Measured against an exact reference over 200k rows, worst relative error:
1194
+
1195
+ | input | before | after |
1196
+ | ------------------------- | ------ | ------- |
1197
+ | `1e15 + ((i % 7) − 3)` | 1.0e+0 | 4.1e-15 |
1198
+ | `1e9 + sin` (mid) | 4.1e+0 | 4.9e-12 |
1199
+ | random walk ≈100 (benign) | 3.9e-6 | 4.4e-11 |
1200
+ | `1e12·(1+i/N)` (trending) | 9.0e-6 | 4.9e-15 |
1201
+
1202
+ **This was never a parallelism bug**, though it was found through one and
1203
+ first documented as one. The sequential study computed the same subtraction
1204
+ and carried the same exposure; partitioning only made two equally-wrong
1205
+ answers visibly disagree. Anyone thresholding z-scores on large-magnitude
1206
+ data was affected on the default path.
1207
+
1208
+ Two consequences worth reading before upgrading. **`zScore` values change**
1209
+ — by rounding error on ordinary data, and by a lot on the cases above, where
1210
+ they were wrong. And **`zScore` is no longer accelerated by `withWorkers`**:
1211
+ the stable kernel is not the shape the worker pool hooks, so opting in no
1212
+ longer speeds it up (it was 2.44×, the fastest study there) and no longer
1213
+ changes its answer by a bit. The remaining accelerated studies — `sma`,
1214
+ `envelope`, `bollinger` — are exactly those whose error is bounded.
1215
+
1216
+ **One behaviour change at overflow scale**, verified by a Codex pass and
1217
+ left unguarded: the shifted frame computes `x - anchor`, which can
1218
+ overflow when both operands are near `Number.MAX_VALUE` even though each
1219
+ is finite. `[MAX_VALUE, -MAX_VALUE]` at period 2 gives a mean of
1220
+ `-Infinity` where the previous raw-sum kernel gave the true `0`. Not
1221
+ guarded, because the check is per row on a ~20 ns/row kernel to correct
1222
+ an input no price, size or rate series can produce.
1223
+
1224
+ `zScore` costs ~2.3× its previous formulation as an upper bound (~26 ns/row
1225
+ at 500k), and is flat in `period` — 22.8 to 26.1 ns/row from `period 2` to
1226
+ `period 100_000`. `scripts/perf-shifted-frame.mjs`.
1227
+
57
1228
  ## [0.53.1] — 2026-07-25
58
1229
 
59
1230
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pond-ts/fit",
3
- "version": "0.53.1",
3
+ "version": "0.55.0",
4
4
  "private": false,
5
5
  "description": "Fitness & activity domain library on pond-ts — quantities, canonical activity series, and analytics (geo, power, zones, splits)",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "verify": "npm run format:check && npm run build && npm test"
38
38
  },
39
39
  "peerDependencies": {
40
- "pond-ts": "^0.53.0"
40
+ "pond-ts": "^0.55.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "typescript": "^5.6.3",