Skip to content

Single-apply feature kernels, numpy-exact parity and tsfresh fuzzing - #17

Merged
wilswer merged 47 commits into
mainfrom
perf/efficient-minimal
Oct 12, 2026
Merged

wilswer merged 47 commits into
mainfrom
perf/efficient-minimal

Conversation

@wilswer

@wilswer wilswer commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Makes extraction substantially faster and brings every feature to bit-for-bit parity with tsfresh's extract_features, checked by a new differential fuzzer that runs in CI.

  • Speed: every feature except length is now computed in a single apply per value column (a Kernel per feature over a shared per-group context), instead of one apply per feature (~60 for Efficient). Earlier commits remove the plan serialisation for in-memory data and several per-feature copies.
  • Parity: mean, variance, sums, median and quantile are computed exactly as numpy does them, and a Hypothesis fuzzer compares all 49 features against tsfresh. All pass.

⚠️ Behaviour changes

  • Output column order: length first, then all features of each value column in turn, in tier order. Column names are unchanged. If you select columns by position (e.g. df.to_numpy() into a trained model), select by name or retrain.
  • agg_linear_trend follows tsfresh's extract_features, which passes the calculator a numpy array. The var aggregator is now the population variance (ddof = 0, was ddof = 1), and a NaN in a chunk makes the result NaN instead of being skipped. tsfx had followed tsfresh's unit tests, which pass a pandas Series. The median aggregator (a tsfx addition) follows np.median.
  • mean_n_absolute_max is NaN unless there are more than n values, and NaN if the series contains NaN (as tsfresh).
  • median / quantile of a group without non-null values is NaN (was null), like every other feature.
  • Last-bit changes wherever tsfx summed or interpolated differently from numpy: mean, variance, standard_deviation and everything built on them, sum_values, absolute_sum_of_changes, mean_absolute_change, root_mean_square, autocorrelation, c3, time_reversal_asymmetry_statistic, index_mass_quantile, symmetry_looking, sum_of_reoccurring_values, sum_of_reoccurring_data_points, median, quantile, linear_trend, agg_linear_trend. Threshold features can change where a value sits exactly on the threshold: ratio_beyond_r_sigma([1, 3, 3, -2, 7, 7, 0, 1], r=1.5) is now 0, as tsfresh (was 0.375).

Every change has a regression test that failed before the fix. See CHANGELOG.md.

Performance

Criterion, 2× Xeon Gold 6230 (80 threads), against the state after the in-place-read refactor (baseline views), measured at the final commit e6b5c72:

Bench Before After Change
stocks Efficient (619k rows, 505 groups, 5 columns) 113.5 ms 44.6 ms −61%
1BRC Efficient (100M rows, ~10k groups) 8.80 s 4.63 s −47%
1BRC Minimal 4.99 s 4.31 s −14%
stocks Minimal 19.2 ms 22.5 ms +16%

The parity fixes are speed-neutral (within noise of the measurement before them). The stocks Minimal slowdown is accepted. Polars' lazy group-by alone costs ~12.6 ms there (computing the groups ~4.4 ms), and on short, already-sorted groups merging applys saves nothing to offset the per-apply overhead.

Earlier commits on this branch, measured on an M1 Max (hyperfine, Python end to end): in-memory Minimal 681 → 183 ms and Efficient 953 → 323 ms, by handing in-memory data to Rust without serialising the query plan; plus faster reoccurrence, number_peaks and agg_linear_trend.

Testing

  • pytest: 384 passed.
  • New: python_tests/test_fuzz_tsfresh.py, differential fuzzing of every feature against tsfresh with Hypothesis. Series include ones built to hit thresholds exactly (a block followed by permuted copies), where only the last bit decides a comparison. numpy-based features must match bit for bit; pandas/scipy/BLAS-based families use a 1e-9 tolerance; documented deviations are excluded. Opt-in (pytest -m fuzz, 200 examples per feature; HYPOTHESIS_PROFILE=thorough runs 5000 for targeted hunts); CI runs a 10-example pass with pinned tsfresh 0.21.2 / numpy 2.4.4 / pandas 3.0.2 / scipy 1.17.1 (49 passed).
  • Each refactor commit was checked with an output snapshot (3 configs × 3 tiers + a dynamic group-by, Int64 column, edge-case groups) compared by column name. Values were bit-identical except where a commit says otherwise.
  • cargo test, cargo clippy --all-targets -- -D warnings, rustdoc with -D warnings, cargo fmt --check.

Also in this PR

  • benches/: a stocks criterion bench, a 1BRC bench on the first 100M rows (skips itself without the data), and benches/tools/ (output snapshot and comparison, samply hot-spot and before/after summaries).
  • hypothesis joins the dev dependencies; ordered-float, ndarray-stats, noisy_float and num are no longer used.
  • python_tests/TSFRESH_LICENSE: tsfresh's MIT licence for the test vectors copied from its test suite.

No version bump in this PR (still 0.3.3); the behaviour changes warrant a minor bump at release.

🤖 Generated with Claude Code

wilswer and others added 30 commits October 9, 2026 14:53
Criterion benches stocks_minimal / stocks_efficient time
lazy_feature_df(...).collect() on test_data/all_stocks_5yr.csv (619k
rows, 505 groups, 5 value columns), loaded outside the timed loop.
python_benchmarks/stocks_bench.py runs the same extraction end to end
for hyperfine, from in-memory data or from scan_csv.

Baseline on an M1 Max:
  criterion  stocks_minimal 16.5 ms, stocks_efficient 267 ms
  hyperfine  minimal memory 681 ms, scan 186 ms
             efficient memory 953 ms, scan 501 ms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
extract_features passed the LazyFrame to Rust, which pyo3-polars
serialises together with any in-memory data in its plan (52 MB and
~0.5 s for the 619k-row stocks data). The non-streaming path now
collects only the needed columns in Python (free for in-memory data;
projection pushdown still applies to scan_* sources) and hands Rust a
DataFrame, which crosses via the Arrow C interface without copying.
streaming=True keeps the LazyFrame path.

Adds the internal ExtractionSettings._input_columns and
extract_features_df; the public API is unchanged. A new test asserts
both paths give identical frames for every tier, with and without
dynamic group-by.

hyperfine, stocks data, M1 Max:
  minimal   in-memory 681 -> 209 ms   scan 210 -> 203 ms (A/B, noise)
  efficient in-memory 953 -> 514 ms   scan 502 -> 480 ms (A/B, noise)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The six counting-based reoccurrence features each hashed every value of
every group separately. They are now one expression: the group is sorted
once, runs of equal values are counted, and every enabled feature is
computed from those runs (each keeping its own NaN rule). The individual
public functions remain and delegate to it.

The six columns now appear together, so the output column order changes
(names and values do not); noted in the new CHANGELOG.md. Verified by
comparing every output value by name for 3 configs x 3 tiers.

criterion A/B, stocks data, M1 Max:
  stocks_efficient 264 -> 198 ms (-25%); stocks_minimal unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
number_peaks copied and rotated the whole series twice for every shift
1..n and every configured n (about 138 copies per group with the default
n = 1, 3, 5, 10, 50). It now computes each position's support (how many
neighbours it beats on both sides, capped at the largest n, stopping at
the first neighbour that is not smaller) in one pass and counts peaks for
all n from it. The public number_peaks delegates to the new
number_peaks_multi; output columns, order and values are unchanged.

Adds a randomised comparison against tsfresh (200 series with ties, NaN
and ±inf, n = 1, 3, 5, 10, 50), skipped when tsfresh is not installed.

criterion A/B, stocks data, M1 Max:
  stocks_efficient 202 -> 178 ms (-16%); stocks_minimal unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aggregate_on_chunks copied every chunk (chunk.to_owned()) before
aggregating it, and skip_nan_sample_var collected each chunk's non-NaN
values into a new array just to call var(). Chunks are now passed as
views, and the variance runs Welford's algorithm with exactly the
operations of ndarray's var over the filtered iterator, so results stay
bit-identical (verified by exact output snapshots for 3 configs x 3
tiers). The median aggregator still collects one Vec per chunk to sort.

criterion A/B, stocks data, M1 Max:
  stocks_efficient 169 -> 144 ms (-14%); stocks_minimal unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the merge commit of PR #16; no content changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add f64_values, which hands an extractor the group's values as one
contiguous Float64 chunk; ArrayView1 over it replaces the per-group
to_ndarray copy (and the 2-D to 1-D conversion in symmetry_looking).
Output is byte-identical on the snapshot configs; the speed difference
is within criterion noise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ndarray

Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rray

Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. Output is byte-identical on
the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use f64_values and an ArrayView1 over the group's values instead of the
per-group to_ndarray copy, as in statistics. sample_entropy does not drop
nulls, so nulls are filled with NaN as to_ndarray did (the result stays
NaN), and the two unwraps on the conversion are gone. Output is
byte-identical on the snapshot configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
benches/tools holds the scripts used for the performance work: an output
snapshot for bit-identical checks, and hot-spot and before/after summaries
of samply profiles (macOS and Linux idle/copy/alloc frame names).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux, perf samples only running threads, so idle time shows up as
rayon spinning (crossbeam epoch/deque) rather than sleeping; count it as
idle. glibc has no symbols, so its allocator internals appear as fun_<addr>
under malloc/free; classify a sample by its innermost named frame. Lower
the documented sampling rate to 500 Hz, which samply keeps up with on an
80-thread machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About 10,000 stations with about 10,000 values each, in random order:
long, interleaved series, where per-value cost and gathering each group
dominate (the stocks bench has short series that are already sorted by
key). The data is generated by python_benchmarks/1brc and not in git, so
the bench skips itself when test_data/measurements.txt is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The single-apply rework changes column order, so add a compare mode that
matches columns by name and reports every value that is not bit-identical,
and snapshot a dynamic (time-window) group-by too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every apply makes Polars collect each group's values into a list; on
unsorted data (1BRC) that was half of extraction time. Add kernels: a
feature is a Kernel computed from a per-group GroupContext, which shares
mean, variance, min and max (computed once, with the same code as before).
One apply per value column runs every kernel and returns the group's
features as a list, which lazy_feature_df unpacks into named columns
after the group-by.

Move the statistics features to kernels (median, quantile and length
stay Polars expressions for now). Column order changes: kernel features
now come after the remaining expressions. Values are bit-identical on
the snapshot configs; verified on the server (fmt, clippy, cargo test,
rustdoc, pytest 354 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lazy list.get per feature made every call plan and optimise a second
query, about 4 ms of the 24 ms of stocks Minimal. Split the packed lists
directly from their offsets and flat values into named columns.
stocks Minimal 24.5 -> 21.7 ms, Efficient 88.8 -> 84.5 ms (server).
Snapshot identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
count_above/below_mean, count_above/below, longest_strike_above/below_mean,
number_crossing_m, range_count and number_peaks become kernels; features
with parameters compute all parameter values in one kernel. Snapshot
identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
first/last_location_of_maximum/minimum and index_mass_quantile become
kernels. Snapshot identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mean_absolute_change, absolute_sum_of_changes, cid_ce and mean_change
become kernels; the consecutive differences are computed once per group
in the context. cid_ce keeps its column names (normalize_t/_f). Snapshot
identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has_duplicate_max/min become kernels, and ReoccurrenceFeatures is itself
the kernel for the six counting-based features (one sort per group, as
before); each feature's documentation moves to its field. Snapshot
identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linear_trend and agg_linear_trend become kernels; all agg_linear_trend
configurations share one kernel. Snapshot identical by name; verified on
the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context now records whether the group had nulls: sample_entropy
treats nulls as NaN, which always gives NaN, so it returns NaN for such
groups exactly as before. Snapshot identical by name; verified on the
server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
autocorrelation, c3 and time_reversal_asymmetry_statistic become kernels.
c3 and time_reversal_asymmetry_statistic index the lagged values directly
instead of copying and rotating the series twice per lag, multiplying in
the same order (bit-identical); roll is no longer needed. Snapshot
identical by name; verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l groups

Median and quantile become kernels sharing one sorted copy per group,
using Polars' linear-interpolation formula (linear_quantile). Two
behaviour changes, each with a regression test:

- A group without non-null values gave null for median and quantile but
  NaN for every other feature; now NaN, as tsfresh (which sees NaN).
- For groups with nulls, Polars counted the nulls into the quantile's
  interpolation index, losing the last bits; the result now equals
  np.quantile of the non-null values exactly.

Every other value is bit-identical on the snapshot configs. With this,
every feature except length runs in the single kernel apply per value
column; the unused NaN struct helpers are removed. Verified on the
server (fmt, clippy, cargo test, rustdoc, pytest 356 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full sort per group made stocks Minimal (where the median is the only
order statistic) 26.4 ms; select_nth, as Polars' quantile_slice, brings
it to 22.7 ms. Same neighbours, so bit-identical (snapshot identical to
the previous commit). Quantiles keep the shared sorted copy. Also
document the new column order on aggregators. Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wilswer and others added 17 commits October 12, 2026 12:59
New column order, the median/quantile changes for all-null groups and
groups with nulls, and the measured speed-ups (and the stocks Minimal
slowdown).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ndarray_stats checks every comparison for NaN through partial_cmp and
an error path, about 9 ms of CPU each on stocks Minimal. One pass for
both with exactly its semantics (NaN if any value is NaN, first value
wins on ties), also for absolute_maximum without its temporary array.
Snapshot identical to the previous commit; verified on the server.
Wall time of stocks Minimal is unchanged (23.5 ms, within noise): the
arithmetic is not what limits it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The variance used ndarray's Welford algorithm and the mean its unrolled
sum; both differed from tsfresh's np.mean / np.var in the last bits
(variance exact in 118 of 408 test series, mean in 266). Add numpy_sum,
numpy 2.x's pairwise summation, and compute the mean and the two-pass
population variance with it: 408 of 408 exact. Every feature built on
the context's mean or variance follows.

This also fixes threshold features when a value sits exactly on the
threshold: ratio_beyond_r_sigma of [1, 3, 3, -2, 7, 7, 0, 1] with r=1.5
gave 0.375 and is now 0, as tsfresh. As a side effect the variance no
longer goes through software FMA (mul_add without the fma target
feature).

Regression tests compare mean/variance/std with numpy bit for bit (3 of 5
failed before) and add the ratio_beyond_r_sigma case (0.375 before).
Verified on the server (fmt, clippy, cargo test, rustdoc, pytest 362).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Differential fuzzing with tsfresh as the specification: Hypothesis
generates series (ties, NaN, ±inf, signed zeros, long series for numpy's
pairwise summation) and each feature of the parity config is compared with
tsfresh's calculator, called as tsfresh's extract_features calls it (numpy
array, or pandas Series where the calculator asks for one). numpy-based
features must match bit for bit; pandas/scipy/BLAS-based families use a
1e-9 tolerance; documented deviations and tsfx-only columns are excluded.

Opt-in: marker fuzz, excluded by default (addopts), run with
pytest -m fuzz; Hypothesis profiles default (200 examples per feature) and
ci (10). hypothesis joins the dev dependencies. The first run finds 11
features that differ; they are fixed in the following commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer. tsfresh takes the np.mean of the last n
values of np.sort(np.abs(x)) and returns NaN unless len(x) > n:

- tsfx returned a value for exactly n values ([0.0] with n=1 gave 0.0);
- tsfx dropped NaN (it sorted with OrderedFloat), while np.sort puts NaN
  last, so it is the largest absolute value and the result is NaN.

Sort ascending with NaN last and take numpy's mean of the last n values.
Regression tests: three generated tsfresh vectors (all failed before),
and test_mean_n_absolute_max now expects NaN for 7 values with n=7, as
tsfresh. ordered-float is no longer needed. Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer: sum_values, absolute_sum_of_changes,
mean_absolute_change, root_mean_square, autocorrelation, c3 and
time_reversal_asymmetry_statistic summed straight through (ndarray sum,
mean or dot), while tsfresh's np.sum / np.mean sum pairwise, so results
differed in the last bits. Use numpy_sum with exactly tsfresh's terms.
The context no longer needs the shared differences.

Regression tests: the fuzzer's shrunk counterexamples with tsfresh's
values, compared exactly (all 7 failed before). Snapshot: only last-bit
changes (at most 3e-15 relative). Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer: the median and quantile used Polars'
interpolation formula, while tsfresh uses np.median (the np.mean of the
middle values) and np.quantile (numpy's _lerp, b - (b - a) * (1 - t) for
t >= 0.5), so results differed in the last bits. numpy_median (still a
selection, O(n)) and numpy_quantile copy numpy exactly, including that an
infinite neighbour at an exact index gives NaN in np.quantile.

Regression tests: the fuzzer's shrunk counterexamples and the infinity
case with tsfresh's values, compared exactly (3 failed before).
Snapshot: last-bit changes only. Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates)

tsfresh's extract_features passes agg_linear_trend a numpy array, so the
chunk aggregates are numpy's: var with ddof=0, and a NaN makes mean, min,
max and var NaN. tsfx followed tsfresh's unit tests instead, which pass a
pandas Series (var with ddof=1, NaN skipped), so tsfresh users got
different values. Found by the tsfresh fuzzer.

Aggregate chunks with numpy semantics (numpy_mean, population_var,
extremes); the median aggregator, a tsfx addition, follows np.median.
A group with chunk_size or fewer values gives NaN, as tsfresh. The
pandas helpers (skip_nan_*, aggregate_on_chunks) are gone.

Regression tests: the agg_linear_trend parity vectors regenerated with a
numpy array, as extract_features calls it (23 cases failed before).
Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer: quantile([inf], 0.1) gave inf, tsfresh NaN.
At or beyond the last index np.quantile uses the last value for both
neighbours but computes the weight from the replacement index -1, so it
evaluates b - (b - a) * (1 - t) with t = h + 1: the last value when finite,
NaN when infinite. numpy_quantile now does the same instead of returning
the last value directly.

Regression tests: [inf] at q=0.1 and [1, inf] at q=1.0 (both failed
before), and a finite case. Verified on the server; the full fuzz run
then passes quantile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer: agg_linear_trend's var slope for
[0, 0, 1, 0, 0, 24468, 0] was -0.1111111044883728, tsfresh
-0.1111111119389534 (7e-9 relative). The one-pass
sum(i * y) - n * mean_i * mean_y cancels when the values span many orders
of magnitude; linregress centres the data first. calculate_sequential_ols
now uses centred sums with numpy summation (used by linear_trend and
agg_linear_trend).

Regression test: that case against tsfresh with a 1e-9 relative tolerance
(failed before). Snapshot: changes up to 5e-13 relative. Verified on the
server; the full fuzz run (200 examples per feature) now passes all 49
features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the normal tests, install the pinned tsfresh/numpy/pandas/scipy
versions the bit-exact parity is checked against and run the fuzz tests
with the Hypothesis ci profile (10 examples per feature, about 30 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ip ci]

The parity tests copy input/expected pairs from tsfresh's test suite; MIT
asks for the copyright and permission notice with copies of substantial
portions. Add the full licence text next to the test file and point to it
from its header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the tsfresh fuzzer (in a later random run):
sum_of_reoccurring_values of
[0, 1, 2, 3, 131072, 131072, 393216, 393216, 1.5, 3.0127e-06, 3.0127e-06]
was 524288.0000030126, tsfresh 524288.0000030127. tsfresh takes
np.sum(counts * unique) over every distinct value (sorted, 0 for values
that occur once), pairwise; tsfx summed only the recurring values, one
after another. sum_of_reoccurring_data_points has the same structure.
Both now build the same terms in np.unique's order and use numpy_sum;
the documented deviation (a single NaN or ±inf is left out) stays.

Regression tests: that series for both features against tsfresh, exact
(both failed before). Snapshot unchanged; full fuzz and three extra seeds
for both features pass. Verified on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HYPOTHESIS_PROFILE=thorough runs 5000 examples per feature, for hunting
rare cases in a few features (pytest -m fuzz -k <feature>). Used to check
index_mass_quantile and symmetry_looking, whose comparisons could flip on
a last-bit summation difference: no counterexample in 5000 examples each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by a targeted search with series that hit the thresholds exactly
in real arithmetic (a block followed by permuted copies): the comparison
then depends on the last bit.

- index_mass_quantile divided the cumulative sum by ndarray's sum of
  |x|; tsfresh divides by np.sum (pairwise). 7538 of 20000 such series
  differed, e.g. [0.7, 1.7, 0.57, 0.4, 0.57, -1.7, -0.7, 0.4] at q=0.5
  gave 0.5, tsfresh 0.625. The total now uses numpy_sum.
- symmetry_looking compared |mean - median| from its own mean and median;
  tsfresh uses np.mean and np.median. 39 of 200000 differed. It now uses
  the context's numpy mean and numpy_median.

After the fix: 0 mismatches in 200000 series each. The old median_mut and
the ndarray-stats, noisy_float and num dependencies are no longer needed.
Regression tests: the shortest counterexample of each, against tsfresh
(both failed before). Snapshot unchanged; full fuzz passes. Verified on
the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A quarter of the generated series are now a block of decimals followed
by sign-flipped permutations of it, so cumulative shares of |x| and the
ratios of mean, median and range hit thresholds exactly in real
arithmetic, and only floating-point rounding decides comparisons such as
index_mass_quantile's and symmetry_looking's. The random series almost
never did, which is why the fuzzer missed the bugs fixed in the previous
commit. Full run passes all 49 features.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@wilswer
wilswer merged commit bf31528 into main Oct 12, 2026
14 checks passed
@wilswer
wilswer deleted the perf/efficient-minimal branch October 12, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant