@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.
- package/CHANGELOG.md +864 -4
- package/README.md +25 -21
- 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/
|
|
8
|
-
tag, so this file covers them all. Pre-1.0: minor bumps may
|
|
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.
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
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.
|
|
36
|
+
"pond-ts": "^0.54.0",
|
|
37
37
|
"react": "^18.0.0 || ^19.0.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|