Derived from the v1.16.2 audit (14 confirmed bugs, 8 measured bottlenecks). All findings
were reproduced against build/main; finding numbers below (B1–B14, P1–P8) refer to
the audit report.
The library's typical inputs drive every priority and performance budget in this plan:
| Workload | Points | Notes |
|---|---|---|
| 15-min serie, 1 year | 35,040 | common case |
| 1-min serie, 1 year | 525,600 | worst common case |
Extrapolating the audit benchmarks to the 1-min/1-year serie (525k points):
| Operation | Today (extrapolated) | Budget after fix |
|---|---|---|
new TimeSerie / map / filter (pre-sorted input) |
~450 ms per derived serie | ≤ 50 ms |
betweenTime (first call) |
~800 ms | ≤ 5 ms |
serie.add(serie) / combine |
~2.2 s | ≤ 50 ms |
interpolate (sparse) |
hours (O(n²)) | ≤ 150 ms |
resample to 15-min buckets |
~2.5 s | ≤ 300 ms |
Every batch below ends with the full test suite green and the benchmark suite re-run.
Goal: make every later batch verifiable. Nothing else lands before this.
- 0.1 Regression tests for all 14 bugs. One failing test per audit finding
(B1–B14), marked
test.failingin ava until the fixing batch lands, then flipped. The audit repro script is the source for these cases. - 0.2 Benchmark harness committed under
scripts/bench.js, covering P1–P8 at 35k and 525k points, runnable vianpm run bench. Record baseline numbers in the script's header comment so regressions are visible in review. - 0.3 Audit existing specs for assertions that encode buggy behavior (e.g. group-by-day, fill semantics). List them in the PR description of each later batch rather than silently rewriting them.
Effort: S (half a day). Risk: none.
Goal: aggregations return correct numbers. Fixes B1, B8; fixes perf P2 as a side effect.
- 1.1 Drop the Float32/SIMD path (B1, P2). Replace
simd.sum_ver+padArray+Float32Arrayinsum()and theadd/avgcombiners with a plain Float64 accumulation loop. Audit measured the plain loop ~70× faster at 10k points (allocation dominates) — this is a perf win, not a trade-off. Remove the@fatmatto/simd-arraydependency. - 1.2 Rewrite the
sub/divcombiners without the seed hacks (points[0] * 2,points[0] * points[0]) (B8).divof a 0 numerator must return 0, not NaN. Use an explicit first-element-then-fold loop. - 1.3 Optional: Kahan/pairwise summation — resolved: plain Float64 is sufficient; all reproduced precision cases pass with wide margin. Revisit only if very long money series surface drift in practice.
Effort: S. Risk: low — outputs change (become correct); flag in changelog. Semver: patch.
Goal: 0 is a value, fill works, reindex doesn't crash or mutate inputs.
Fixes B5, B6, B10; fixes perf P8 (O(n²) forward-fill) as a side effect.
- 2.1 Presence checks use
hasValue()inreindexWithBackfill/reindexWithForwardFillinstead of truthiness (B5).0,false,""are values. - 2.2
fill: 0honored everywhere (B6): replacefillValue || nullinatTimeandoptions?.fill || nullinreindexwithhasValueOr(...). Sweep for other|| nullfill sites (fromTimeseries,combine). - 2.3
reindex(idx)without options works (B10): defaultoptions = {}. - 2.4 Rewrite
reindexWithForwardFill(B10, P8): iterate the index backwards by position (noidx.reverse()mutation of the caller's array), build the output array in forward order (no per-elementunshift), callatTimeonce per element.
Effort: S–M. Risk: low. Semver: patch.
Goal: one canonical time representation, sorted once, compared numerically. Fixes B2; fixes perf P4, P5, P7 and most of the constructor tax that every other operation pays. This is the highest-payoff and highest-risk batch — land it alone, after Batches 1–2, with the benchmark run in the PR.
- 3.1 Normalize before sorting (B2). Constructor converts every timestamp to
epoch-ms first, then sorts numerically, then renders ISO strings. Mixed input
formats and non-UTC offsets become chronological.
TimeFrame'sCONSTRUCTOR_SORTERgets the same treatment. - 3.2 Single consistent internal index — implemented differently than
planned: instead of a parallel
number[], the constructor guarantees every stored timestamp is a canonical ISO-UTC string, so lexicographic order equals chronological order. All internal comparisons and binary searches run on the strings with zero Date allocations; the separately-sorted_indexes.time/ tree pair on TimeSerie is gone entirely. - 3.3 Sorted fast path (P5): O(n) is-sorted check; skip the sort when input is
already ordered (true for every internal
recreate/map/filter/partition call). Skip re-normalization when timestamps are already ISO strings. Remove the redundant second.sort()in the constructor. - 3.4 Replace the red-black tree with binary search (P4):
betweenTimeon serie and frame becomes two binary searches over the epoch-ms array + oneslice. DeletebuildTimeTree, dropfunctional-red-black-treeand@types/functional-red-black-treedependencies. (Also removes the duplicatedtest/buildIndexTesthelpers — keep one, inutils.ts.) - 3.5 Benchmark gate: constructor ≤ 50 ms and
betweenTime≤ 5 ms at 525k points;resample525k → 15-min buckets ≤ 300 ms (P6 falls out of 3.3).
Effort: L (2–3 days incl. test fallout). Risk: medium — touches every code path; mitigated by Batch 0 tests + benchmark gate. Semver: minor (behavior only changes where it was wrong).
Goal: linear-time algorithms for the remaining hot paths.
Fixes B9, B11; fixes perf P1, P3 and the last* copy-reverse pattern.
- 4.1 Rewrite
linearInterpolationas one forward pass (B9, P1): carryprevValidindex while walking, look ahead fornextValidonly when entering a hole (resume from there). Explicitly handle boundary holes (leading/trailing nulls stay null — no crash) and interpolateNaNthe same asnull. Budget: ≤ 150 ms at 525k points (from 26.6 s at 20k today). - 4.2
last*accessors iterate backwards (lastValidIndex,lastValidValue,lastValidAt,lastAt) instead ofconcat([]).reverse().find(...). With 3.2,lastAt/firstAtbecome binary searches. - 4.3
betweenIndexesusesslice, not a full-arrayfilter. - 4.4
resample/firston empty serie (B11): use the same guardedfirst()?.[0]aspartitionso the intended "Cannot infer a lower bound" error is thrown, not a TypeError.
Effort: M. Risk: low–medium (interpolation edge cases; covered by 0.1 tests). Semver: patch (crash fixes) — NaN interpolation noted in changelog.
Goal: frames are actually immutable and cheap to read.
Fixes B3, B4, B12; fixes perf P3 (atIndex/rows() waste).
- 5.1
renameColumnsclones (B4): build newTimeSerieinstances (recreate+ new name) instead ofrename()-ing the cached_columnsof the source frame. Auditrename()call sites for the same aliasing. - 5.2 Fix
fromTelemetryV1Output(B3): normalize the timestamp once into a variable and use it for both the existence check and the write (currently checks the raw key, writes the normalized key → wipes earlier columns). Stop mutating the caller'smetadataargument — copy it. - 5.3 Cache
rows()(P3) and makeatIndexO(1) via direct lookup on the cached array (_indexes.time[i]+data[time]), computinglengthwithout materializing rows. Invalidation is trivial: frames are immutable after construction once 5.1 lands. - 5.4
reducewith partialoperations(B12): implement the documented behavior — columns not covered byoperationsand with no defaultoperationare omitted from the result. - 5.5
fromTimeseriesmerge-walk (added after Batch 3 profiling of examples/random): replace the per-cellts.atTime()hash lookups with the same sorted merge-walk used byTimeSerie.internals.combine, and cut per-partition frame re-derivation inresample/aggregate. These now dominate the example's runtime (initTimeFrame 2.9 s, resample 4.4 s, aggregate 3.0 s at 525k×6).
Effort: M. Risk: low. Semver: patch.
These fixes change documented or long-standing observable behavior. Each needs a decision; recommendations included. Ship together as the semver-major (2.0) if any breaking option is chosen.
- 6.1
group()semantics (B7). Three stacked problems:by: "day"usesgetDay()(day-of-week, almost certainly agetDate()typo); keys are computed in local server time (results change per deployment TZ); keys don't compose across periods (Jan 2020 and Jan 2021 share a "month" bucket). Recommendation: compute keys in UTC from the epoch-ms index; make buckets calendar-composed (2020-01,2020-01-15,2020-01-15T10); keep a documented escape hatch if grouping-across-years (e.g. "all Mondays") is a real use case. Breaking. - 6.2
TimeFrame.concatoverlap (B13): docs say throw, code merges silently. Recommendation: implement the throw (cheap with sorted epoch-ms indexes: compare boundary timestamps) — silent last-write-wins is the dangerous default. Breaking for anyone relying on the merge; they should usejoin/merge. - 6.3
TimeFrame.atTimecontract (B14): docs sayRow | null, code returns{time}for misses. Recommendation: returnnullon miss. Breaking (mildly). - 6.4 Interpolate NaN policy (ties to 4.1): treat NaN as a hole (recommended, pandas-like) — confirm here, implement in Batch 4.
Effort: S–M once decided. Semver: major (2.0.0).
Batch 0 (guardrails)
└─► Batch 1 (numeric) ─► release 1.16.3 (patch)
└─► Batch 2 (fill) ─► release 1.16.4 (patch) [parallel-safe with 1]
└─► Batch 3 (time core) ─► release 1.17.0 (minor) [alone, benchmark-gated]
└─► Batch 4 (O(n²)) ─► release 1.17.1
└─► Batch 5 (frames) ─► release 1.17.2 [parallel-safe with 4]
└─► Batch 6 (contract changes) ─► release 2.0.0
- Batches 1 and 2 are independent quick wins; either order.
- Batch 3 is the structural change everything later leans on (binary search in 4.2, cheap concat-overlap check in 6.2). It goes in alone with before/after benchmarks in the PR.
- Batches 4 and 5 are independent of each other.
- Batch 6 waits for explicit sign-off on each decision.
All six batches landed (commits 0fb8f12..HEAD). 97 tests pass with zero known failures; every audit finding B1-B14 has an expected-pass regression test. Remaining release step: run standard-version for 2.0.0 (breaking: group() bucket semantics, concat() overlap throw, atTime() null on miss, engines >=12).
- All 14 regression tests from 0.1 pass un-
failing-ed. - Benchmark suite meets every budget in the workload table at 35k and 525k points.
npm testgreen; coverage thresholds unchanged.- Dependencies removed:
@fatmatto/simd-array,functional-red-black-tree,@types/functional-red-black-tree(andtimsortif 3.3 makes it redundant — nativeArray.prototype.sortis stable in all supported Node versions; noteengines.node >= 10in package.json predates stable sort guarantees (Node 11+) and should be bumped anyway). - CHANGELOG entries per batch, breaking changes called out for 2.0.0.