ValueSeries deep dive
ValueSeries is the value-keyed counterpart of TimeSeries — the third of
the core's three series types. Its key is a monotonic non-time axis
(cumulative distance, cumulative work, lap number, sample index, strike price,
frequency) rather than the wall clock. It carries the ordering-based slice
of the series algebra — read the axis, read value columns, find the nearest row,
slice a range — and, by type, none of the calendar/clock operators, because a
value axis has no wall-clock meaning.
This page is the API tour of the ValueSeries type. For when and why you
reach for a value axis — the bike-ride "splits line up on distance, not time"
intuition, and the closed-vs-projected-out story — read the
Value axis concept first. Exhaustive signatures live in
the API reference.
Doors in
You never call the constructor directly (it's trusted-construction only). One
door projects an existing TimeSeries; the other three construct
directly, one per shape the data arrives in.
// 1. PROJECT a TimeSeries onto one of its monotonic columns.
// For data that starts life time-keyed — a ride sampled every second,
// re-keyed by the cumulative-distance column it already carries.
const byDistance = ride.byValue('cumDist'); // TimeSeries → ValueSeries
// 2. CONSTRUCT directly. For data that is NATIVELY value-keyed and never had a
// per-row time key — an options chain keyed by strike, a spectrum keyed by
// frequency. Pick the door that matches what you're holding:
const schema = [
{ name: 'strike', kind: 'value' }, // schema[0] is the axis
{ name: 'iv', kind: 'number' },
{ name: 'oi', kind: 'number' },
] as const;
ValueSeries.fromJSON({ name: 'chain', schema, rows }); // row tuples / objects
ValueSeries.fromColumns({ name: 'chain', schema, columns }); // struct-of-arrays
ValueSeries.fromArrow(table, { axis: 'strike' }); // an Arrow Table
All four share one ingest engine and the same monotonicity contract; they
differ only in where the axis comes from and how the payload is laid out.
Before the direct doors existed, cross-sectional callers had to launder their
axis through a fake time column just to reach TimeSeries.fromColumns and
project — the detour is gone.
| You have | Door |
|---|---|
A TimeSeries with a monotonic column | series.byValue(axis) |
| One record per row (a JSON API, a CSV parse) | ValueSeries.fromJSON |
| One array per column (a columnar wire format) | ValueSeries.fromColumns |
A decoded Apache Arrow Table | ValueSeries.fromArrow |
byValue(axis) — projection
series.byValue(axis) re-keys a TimeSeries onto one of its numeric columns.
The named column becomes the index and is dropped from the value columns — it
is the key now. The axis column must be defined, finite, and non-decreasing
at every row; byValue validates this and throws otherwise. The usual way to
build such a column is scan, a running fold
that turns per-sample deltas into a cumulative, monotonic column. See
byValue in the aggregation reference.
ValueSeries.fromJSON({ … }) — row ingest
The shape data arrives in from an ordinary JSON API or a CSV parse: one record per row, as tuples aligned with the schema or as objects keyed by column name.
ValueSeries.fromJSON({
name: 'chain',
schema,
rows: [
[90, 0.31, 1200],
[95, 0.28, 3400],
[100, 0.26, 8800],
],
// or: [{ strike: 90, iv: 0.31, oi: 1200 }, …]
});
The TimeSeries.fromJSON contract minus the one thing a value axis has no use
for: there is no timestamp parsing and no parse.timeZone. The axis cell
must be a finite number — a value axis has no calendar to interpret
'2026-01-01' against — so a string axis is an error naming the row, not a
silent NaN.
This is the strict door. Every defined cell is checked against its declared
kind, so a NaN/Infinity in a number column or a number in a string
column is rejected, and a column declared required (the default) rejects a
missing cell. null and undefined both mean missing and are fine on a
required: false column. The columnar doors below are deliberately looser —
they read a non-finite number as a gap and don't check required, because a
decoded buffer can't tell "absent" from "not a number".
ValueSeries.fromColumns({ … }) — direct columnar ingest
The exact TimeSeries.fromColumns contract, with the axis in place of time:
schema[0]is the'value'-kind axis column; the rest are value columns. Eachcolumnsentry is one column's values, keyed by schema column name and aligned by index.- Values may be a plain
number[]or aFloat64Array(or astring[]for astringcolumn). A cell is a gap (missing) iff it isnull/undefinedor non-finite — identical for every input type. Float64Arrayinputs are adopted, not copied (zero-copy): the resulting columns alias your buffers — pass a fresh buffer if that matters. Passingsort: truedisables the adoption (a reorder needs its own buffers).- Ordering. The axis must be defined, finite, and non-decreasing — it becomes
the index. An out-of-order axis throws by default; pass
sort: trueto stably sort rows by axis value first (the right move for an unordered snapshot, e.g. a keyed live feed that arrives in update order, not axis order). - Value columns:
numberandstring, matchingTimeSeries.fromColumns.
It throws ValidationError on a non-'value' axis kind, a missing column, a
length mismatch, an unsupported value-column kind, or an out-of-order axis
without sort; and RangeError on a non-finite axis cell (sorting can't rescue
it) or a duplicate column name. See
Creating series → Columnar ingest.
ValueSeries.fromArrow(table, { axis }) — Arrow ingest
A decoded Apache Arrow Table — often how a cross-sectional payload arrives in
the first place, since an options chain or a spectrum reaching you over Arrow
has no time column to key on.
import { tableFromIPC } from 'apache-arrow';
const chain = ValueSeries.fromArrow(tableFromIPC(bytes), { axis: 'strike' });
pond takes no dependency on apache-arrow — bring your own and hand the
Table over; the input is duck-typed against the small slice pond reads.
Ingest is the zero-copy path: a single-chunk Float64 column's backing
Float64Array is adopted as-is, nulls and all (Arrow's validity bitmap is
bit-identical to pond's).
axisis required. Unlike the time door there is no conventional field name to fall back on —strike,frequency,depthandcumDistare all equally plausible, and keying on the wrong one silently is worse than an error. The axis is read unscaled (an axis carries noTimeUnit); a null in it throws.- Value columns: every non-axis field by default, or the subset named by
columns(in order). Numeric andUtf8string columns are supported; any other Arrow type (list/struct/…) throws, naming it. sort: truefor an unordered table, which disables the adoption.
Reading a ValueSeries
The operator surface is small and deliberately ordering-based — the part of the algebra that was never about time:
byDistance.length; // number of rows
byDistance.axisName; // 'cumDist' — the key column's name
byDistance.axisValues(); // Float64Array of the axis, in order (zero-copy, read-only)
byDistance.axisAt(3); // the axis value at row 3
byDistance.column('hr'); // a value column → read with .read(i) / .values()
byDistance.nearestIndex(40_000); // row nearest the 40 km mark (binary search)
byDistance.sliceByValue(40_000, 60_000); // the [40 km, 60 km) sub-series (zero-copy)
axisValues()returns the live key buffer — zero-copy, so treat it as read-only.column(name)returns the columnarColumn(orundefined), for direct.read(i)/.values()reads.nearestIndex(value)is the value-axis cursor primitive: a binary search over the non-decreasing axis. It returns-1for an empty series and clamps to the first / last row whenvaluefalls outside the axis extent.sliceByValue(lo, hi)is the value-axis cull (pan / zoom on a value x): the contiguous sub-series with axis in[lo, hi), zero-copy sliced.lo >= hi(or a range outside the extent) yields an empty series.
Doors out
Every ingest door has a matching export door, so a ValueSeries is never a
dead end — fromX(series.toX()) reconstructs the series.
chain.toRows(); // [[90, 0.31, 1200], …] — tuples, gaps as undefined
chain.toObjects(); // [{ strike: 90, iv: 0.31, oi: 1200 }, …]
chain.toJSON(); // { name, schema, rows } — the wire envelope, gaps as null
chain.toJSON({ rowFormat: 'object' }); // …with object rows
chain.toColumns(); // { name, schema, columns } — one array per column
chain.toArrow(); // { length, fields } — Arrow's layout, zero copy
| You want | Door | Cost |
|---|---|---|
| To read rows in JS | toRows() / toObjects() | One object per row |
| To send rows over the wire | toJSON() | One object per row; JSON.stringify-safe |
| To send a columnar payload over the wire | toColumns() | One array per column — no per-row alloc |
| To hand the data to another columnar engine | toArrow() | A buffer handoff — no per-row work at all |
Rows vs columns on the wire. toJSON and toColumns carry the same data
in the same envelope shape ({ name, schema, … }); they differ only in the
transpose. Prefer toColumns when the consumer is itself column-oriented, or
when the payload is dense enough that C arrays beat N×C-element rows on size
and parse time — measured at 100k rows × 7 columns, the columnar export
allocates 7 arrays where the row exports mint 100 000 objects.
Gaps. toRows / toObjects spell a gap undefined (the JS reading
shape); toJSON and toColumns spell it null (the wire shape — NaN isn't
JSON, and a Float64Array doesn't stringify as an array).
Round trips are typed, not just documented.
ValueSeries.fromColumns(chain.toColumns()) and
ValueSeries.fromJSON(chain.toJSON()) compile with no cast — that
assignability is the contract, for the number / string columns the ingest
engine carries (which is every series built through a direct door).
A boolean or array-kind column can only reach a ValueSeries by byValue
projection — the direct doors take number and string. It exports on
every door, but no door takes it back, and the two legs report that
differently: fromColumns refuses the payload at compile time (the
columnar return type isn't assignable to the ingest type), while toJSON
types its boolean cells honestly, so fromJSON accepts the shape and
throws at ingest, naming the column and its kind. Drop the column before
projecting (series.select(…).byValue(axis)) if you need the round trip.
toArrow() — the zero-copy handoff
toArrow hands back the buffers pond is already holding, in Arrow's memory
layout, with no copy — the same { length, fields } shape TimeSeries.toArrow
returns (the exporter is shared; a 'value' axis simply exports as a plain
float64 field where a time key would come out timestamp). pond takes no
dependency on apache-arrow, so the caller assembles the Table:
import { makeData, makeVector, Float64 } from 'apache-arrow';
const { fields } = chain.toArrow();
const f = fields.find((x) => x.name === 'iv')!;
if (f.type !== 'float64') throw new Error(f.type);
const vec = makeVector(
makeData({
type: new Float64(),
length: f.length,
nullCount: f.nullCount,
nullBitmap: f.nullBitmap,
data: f.values,
}),
);
From there another engine — polars, DuckDB, arrow-js — is reachable without a
re-ingest. The returned buffers are pond's live storage, not copies: the
same read-only contract column(name) and axisValues() already carry. Copy
first if the consumer mutates in place.
When not to reach for it
A ValueSeries keeps the full-resolution channel and lets you read/plot it
against a value. When you instead want to collapse the axis into per-bin
rollups — per-kilometre splits, a histogram, time-in-zone — use
byColumn / rollingByColumn, which project
out of the algebra to plain bin records rather than returning a series. The
Value axis concept
walks the split in full.
Where it fits
- Under the hood —
ValueSerieswraps the columnar store directly. A value row is an(axis, …values)tuple, not aTime-keyedEvent, so it skips the time-only event layer entirely. - Plotting —
@pond-ts/chartsaccepts aValueSerieson a linear x scale with a synced value cursor. See the value axis chart reference. - Minimal by design — the type carries only the operators a value axis
actually needs; the algebra grows as a second value-axis consumer earns it.
The ingest / export surface is the exception: it is deliberately at full
parity with
TimeSeries, because a door you can't come back through isn't a smaller API, it's a trap.