Search
The two optimizers that reach a good parameter set in fewer backtests than the grid, successive halving and TPE Bayesian optimization.
A cartesian sweep evaluates every point of every axis, so resolution costs exponentially. Two search lanes buy resolution cheaper, both over the same [sweep] table, both returning the same ranked SweepReport, both scoring through the same objective seam. Neither reimplements scoring or backtesting.
Successive halving
crates/vike-backtest/src/search.rs is the pure, store-free core of Euler search. Evaluate the coarse grid once; then, up to max_depth times, halve every axis's step and evaluate the {best − step, best, best + step} neighbourhood of the incumbent — at most 3^d − 1 new points per depth, clamped to each axis's box and deduplicated against everything already seen. After D halvings the effective step is step / 2^D, the resolution of a grid with 2^D·(n_i − 1) + 1 values per axis. The module's own arithmetic: a two-axis 5×5 grid at depth 3 costs at most 25 + 24 = 49 evaluations against 33×33 = 1,089 for the equal-resolution grid.
A halving that fails to improve the incumbent is not a stop condition — it is the signal to shrink again, since only a smaller step can reach the points between the incumbent and the ones just rejected. The search runs its full depth budget and stops early only when a depth produces no unseen candidate at all. SearchTrace::depth_reached therefore reports the resolution actually attained, and EulerConfig caps depth at MAX_DEPTH_CAP of 16 (already 65,536× the coarse resolution), with DEFAULT_MAX_DEPTH of 3; depth 0 evaluates exactly the grid. A SearchAxis refines at its own smallest existing gap, an integral axis rounds every candidate and stops once its step falls below 1, and a single-valued axis stays pinned. Scoring follows the objective convention: higher is better, NaN is unrankable and can never displace a finite incumbent — it is only ever the incumbent while nothing finite has been evaluated.
It is a local refinement
Successive halving can only descend into the basin its coarse grid already found; a grid too sparse to see a narrow optimum refines the wrong peak, pinned by refinement_stays_in_the_basin_the_coarse_grid_chose. Keep the coarse grid wide, the depth bounded, and treat this as a refinement of a grid, not a replacement for one.
crates/vike-backtest/src/harness/euler.rs is the store-backed wrapper, reached with --search euler; the default --search grid is untouched. run_sweep_euler returns the grid path's SweepReport — only evaluated points appear as rows, which is the saving, and a failed backtest scores NaN, which can never displace a finite incumbent as the refinement centre — plus an EulerBudget whose equivalent_grid is computed at the depth actually reached, never the requested max_depth, so an early-stopped run cannot advertise a saving it did not buy. It prints as euler: N evaluations at depth d/D (grid at the depth reached would be G); equivalent_grid_evaluations states its precondition, a uniformly spaced axis, and that a non-uniform axis overstates what the search covered. Every [sweep] axis must be numeric and type-homogeneous: a mixed [1, 2.5] axis would silently change the TOML type the strategy receives versus the grid path, so a non-numeric or mixed axis is a HarnessError::Validation pointing at --search grid, and MAX_AXES of 8 is where the 3^d neighbourhood stops being cheaper than the grid. Points within one depth are independent backtests and run on the bounded sweep pool; depths stay sequential, and the trace, rows, budget and ranking are identical to the one-at-a-time path.
TPE Bayesian optimization
crates/vike-backtest/src/harness/tpe.rs is the ask/tell alternative: it converges on good strategy.params by modelling which regions of the space produce good scores and proposing there. It is a clean-room implementation of the mathematics of Bergstra, Bardenet, Bengio and Kégl, Algorithms for Hyper-Parameter Optimization (NeurIPS 2011), §4, restated for the maximise convention. The history is split at the gamma quantile of score into good and bad; per parameter, independently, two Parzen densities l(x) and g(x) are built; expected improvement is monotone in l(x)/g(x), so a continuous parameter draws n_candidates samples from l and proposes the argmax of the ratio, while a discrete parameter samples its next category from l directly, because an argmax over a small category set would lock onto the incumbent. A continuous parameter uses an adaptive Parzen estimator — one Gaussian per observation plus a broad prior at the domain midpoint, each bandwidth the maximum distance to its sorted neighbours, clipped — and a discrete one a prior-smoothed frequency histogram. The first n_startup asks are random, to seed the model.
TpeConfig carries n_trials (DEFAULT_TRIALS 64), seed, gamma (0.25), n_startup (10) and n_candidates (24). A profile's [sweep] axes become continuous ranges bounded by each axis's minimum and maximum, integral axes staying whole — TPE explores between the grid points, the same philosophy as successive halving — through the same numeric_sweep_axes reader and the same rejection of non-numeric or mixed axes. run_tpe asks, runs the proposal through run_backtest, scores it and tells the result back, for n_trials trials; the loop is inherently sequential because each proposal reads the whole history, so there is no rayon fan-out and determinism rests entirely on the seed.
Same seed, same proposals — now on every platform
Every stochastic step draws from a seeded SplitMix64 the optimizer owns, chosen over a library generator because its ten lines are fixed forever. The module records a correction dated 2026-08-28: the contract claimed byte-for-byte reproducibility and did not hold across platforms. The integer stream was never the leak; the two float transforms on top of it were — a Box–Muller ln and cos, and the density's exp — because IEEE 754 requires nothing of those functions, so an f64 method call reaches the platform's libm and two boxes disagree in the last bit. One perturbed draw moves a candidate, which moves the argmax, which moves every later proposal: the sequences fork. All three now call the libm crate, one pure-Rust implementation compiled into the binary; .sqrt() stays a method because IEEE 754 does require it correctly rounded. same_seed_same_proposals pins within-box determinism only.
From the command line
backtest --profile sweep.toml --search euler [--euler-depth N] (default 3, capped at 16) writes a one-line budget summary to stderr; --optimizer tpe [--trials N] [--seed S] (defaults 64 and 0) takes precedence over --search. Both reuse --rank-by as the objective and stamp a score on every row whose backtest succeeded; a failed row carries none. The TPE parameter search tutorial runs both over one profile.