~/posts/pit-window-brute-force-search

brute-forcing the pit window: when 'just try them all' is the right call

an exhaustive search over every pit lap and compound to pick a race strategy — why brute force beats a clever heuristic at this scale, and the r-squared-as-confidence wart

lights out. and this time the car has to stop.

two posts ago i drew stintlab’s charts in rust on canvas2d and swore a chart was just rectangles and lines. one post ago i fit the tire-degradation model by hand — ordinary least squares, cramer’s rule, and a singular-matrix guard that turned a collinearity bug into an honest None. both of those were setup. this is the payoff: the thing that takes that model and actually makes a decision.

because a degradation model, on its own, just draws a number — how much slower a tire gets, lap after lap. lovely. now what? a real race is decided in one call from the pit wall: when do you box, and onto what rubber? stop too early and you throw away tire life; too late and you bleed seconds to a fresher car behind. that call wins races. i wanted to make it automatically, and the algorithm i reached for is the single dumbest one in the book: try every option and keep the best.

and i’m going to argue that’s exactly right.

quick framing, same as ever: stintlab ↗ is a personal side project. i pull historical formula 1 data off a free public api ↗ , fit tire models in rust in stintlab-core, and render the results in the browser straight from rust on canvas . no users, no deadline, no clever product story — just me finding out what these algorithms actually feel like when you build them from the arithmetic up. last time the “paper” was a stats identity. this time it’s an optimization problem, and the honest answer to it turns out to be the least sophisticated tool on the shelf.

the whole search space fits on a napkin

before you reach for a clever algorithm you should count the thing you’re searching. here’s the entire space, encoded as three constants at the top of the strategy engine:

stintlab-core/src/strategy.rs

rust
/// Default pit stop time loss in milliseconds (22 seconds).
const DEFAULT_PIT_STOP_LOSS_MS: u32 = 22_000;

/// Minimum laps before the end of the race to consider pitting.
const MIN_LAPS_AFTER_PIT: u16 = 5;

/// Dry tire compounds to evaluate as the next stint compound.
const DRY_COMPOUNDS: [Compound; 3] = [Compound::Soft, Compound::Medium, Compound::Hard];

two axes. which lap you pit on — somewhere between next lap and five-from-the-flag — and which of three dry compounds you bolt on afterwards. a grand prix is what, 50 to 70 laps. so from any point in the race you’re looking at maybe forty-odd candidate laps times three compounds. call it a couple hundred combinations, tops. that’s not a search problem. that’s a shopping list.

when the space is this small, the entire literature on clever search — branch and bound, gradient descent, simulated annealing, a hand-tuned heuristic that mutters “pit around lap 30ish” — is answering a question i don’t have. the NIST dictionary of algorithms ↗ defines brute force, a little sniffily, as “an algorithm that inefficiently solves a problem, often by trying every one of a wide range of possible solutions.” the wikipedia entry on brute-force search ↗ is kinder and more useful: it’s the right move “when the problem size is limited.” mine is limited. it’s a couple hundred rows.

just try them all

so here’s the whole engine. two nested loops, a running best, and no cleverness whatsoever:

stintlab-core/src/strategy.rs

rust
let mut best_total_time = f64::MAX;
let mut found_candidate = false;
let mut best_pit_lap: u16 = current_lap + 1;
let mut best_next_compound = current_compound;
let mut best_r_squared_min = 0.0_f64;

for candidate_pit_lap in earliest_pit..=latest_pit {
    for &next_compound in &DRY_COMPOUNDS {
        let Some(next_model) = models.get(&next_compound) else {
            continue;
        };

        // Time on current tires: current_lap+1 through candidate_pit_lap
        let current_stint_time = compute_stint_time(
            current_model,
            tire_age,
            current_lap + 1,
            candidate_pit_lap,
            laps_total,
        )?;

        // Time on new tires: candidate_pit_lap+1 through laps_total (fresh, age 1)
        let next_stint_time =
            compute_stint_time(next_model, 1, candidate_pit_lap + 1, laps_total, laps_total)?;

        let total = current_stint_time + f64::from(pit_loss) + next_stint_time;
        let r_sq_min = current_model.r_squared.min(next_model.r_squared);

        if total < best_total_time {
            best_total_time = total;
            best_pit_lap = candidate_pit_lap;
            best_next_compound = next_compound;
            best_r_squared_min = r_sq_min;
            found_candidate = true;
        }
    }
}

read it once and you’ve read all of it. for every candidate pit lap, for every compound, simulate the rest of the race — time on the current tires until the stop, plus the pit loss, plus time on fresh tires to the flag — and keep whichever total is smallest. best_total_time starts at f64::MAX, so the first candidate always wins the slot and every later one has to actually beat it. that’s the strategist.

here’s the opinion i’ll defend: at this scale, exhaustive search isn’t the lazy choice, it’s the honest one. a heuristic — “soft-then-hard is usually optimal,” “pit at one-third distance” — is a compression of somebody’s intuition, and the whole point of stintlab is that i don’t trust my intuition, i want to see the numbers. worse, a wrong heuristic is wrong quietly: it hands you a plausible lap and you never learn what it skipped. brute force can’t do that. it looked at every option, by construction, so when it says lap 34 on the hard is fastest, i know nothing beat it — because everything raced. a clever answer i can’t verify is worse than a dumb answer i can. it’s a lie with better PR.

scoring a candidate: replay the whole race

the loop leans on one function to turn a strategy into a number, and that function is where last post’s work comes back to earn its keep:

stintlab-core/src/degradation.rs

rust
pub fn compute_stint_time(
    model: &DegradationModel,
    start_tire_age: u16,
    start_lap: u16,
    end_lap: u16,
    laps_total: u16,
) -> Result<f64, StintlabError> {
    // ...
    let mut total = 0.0_f64;
    for lap in start_lap..=end_lap {
        let tire_age = start_tire_age + (lap - start_lap);
        let time = predict_lap_time(model, tire_age, lap, laps_total);
        total += f64::from(time);
    }
    Ok(total)
}

a stint’s time is just the sum of its lap times, walked one lap at a time, ageing the tire by a lap on every step. and each individual lap time comes from the degradation model i fit in the previous post :

stintlab-core/src/degradation.rs

rust
pub fn predict_lap_time(
    model: &DegradationModel,
    tire_age: u16,
    lap_number: u16,
    laps_total: u16,
) -> u32 {
    let deg = model.degradation_slope_ms * f64::from(tire_age);
    let fuel = model.fuel_correction_ms * f64::from(laps_total.saturating_sub(lap_number));
    let predicted = f64::from(model.base_lap_time_ms) + deg - fuel;
    predicted.round().max(0.0) as u32
}

there’s the whole physical claim from last time, in three lines: base pace, plus a tire penalty that grows with tire_age, minus a fuel term that shrinks as the tank empties. the two slopes i fought so hard to separate — the ones that went collinear inside a single stint and blew the matrix up — this is where they finally get spent. the strategy engine never re-derives any of it. it just asks the fitted model “how long is a lap on this tire, at this age, at this fuel load?” a couple hundred times, and adds the answers up. the math from two posts is now a subroutine inside a for loop. that’s the whole arc.

and the cost? for a 57-lap race, a few thousand predict_lap_time calls, each one a multiply, a multiply, an add. it runs in microseconds. i could brute-force this on every lap of every car in the field and never feel it.

confidence is r-squared wearing a lanyard

now the part that keeps me up. the engine has to report how sure it is, and here’s where that number comes from:

stintlab-core/src/strategy.rs

rust
// Confidence derived from R-squared of the models used
let confidence = best_r_squared_min;

best_r_squared_min is the smaller r-squared of the two models involved in the winning stop. and it doesn’t just decorate the output — it sets how wide a window the engine draws around the optimal lap:

stintlab-core/src/strategy.rs

rust
// Window: optimal +/- adjusted by confidence
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let window_half = ((2.0 * (1.0 - confidence)).ceil() as u16).max(1);
let window_start = best_pit_lap
    .saturating_sub(window_half)
    .max(current_lap + 1);
let window_end = (best_pit_lap + window_half).min(latest_pit);

so a model at r-squared 0.9 draws a ±1-lap window; a shakier 0.4 model draws ±2. the interface reads like a prediction interval — “pit on lap 34, give or take a lap, and here’s my confidence.” and it’s bullshit precision. r-squared ↗ is the fraction of variance the model explains on data it already saw. it says nothing — nothing — about a lap that hasn’t happened yet. a model can hug its own history at r-squared 0.97 and be a terrible prophet for lap 40, because degradation stops being linear the moment a tire falls off a cliff, and last race’s track temperature isn’t this race’s. i’ve got a goodness-of-fit number cosplaying as a prediction interval, and the window it draws is confidence theater. i flagged this at the end of the last two posts; writing the line out in full does not make me trust it more.

what i cut, and what i’m side-eyeing

the rule around here is that i don’t get to pretend. so, the warts, in order of how much they bug me:

one stop only, and that’s load-bearing. the whole “the space is tiny” argument holds because i search a single pit stop. the moment i model two stops, the space becomes every (lap, compound) followed by every other (lap, compound) — the candidate count squares. three stops, it cubes. that’s the exact combinatorial blow-up the brute-force literature ↗ warns about, and it’s why real race-strategy software leans on pruning and dynamic programming instead of my honest double loop. my thesis has a footnote in bold: exhaustive search is the right call at this scale, and multi-stop is a different scale. i’d have to earn the cleverness there, not assume it.

confidence is still r-squared in a lanyard. covered above. it overstates certainty by construction, because a number about the past is standing in for a claim about the future. the honest fix is a real out-of-sample prediction interval — hold out laps the model never saw, measure how far off it actually lands, and report that spread. i haven’t built it. until i do, treat the window as decoration, not a guarantee.

it only knows what already happened. every model feeding this thing was fit on historical data. no live track temp, no safety car, no “it started raining on lap 12.” the engine will confidently plan a one-stopper into a race that a virtual safety car quietly turns into a two-stopper, and never know the world moved.

and a small one, for honesty: current_stint_time doesn’t depend on the next compound, but it’s computed inside the compound loop, so i recompute the identical number three times per candidate lap. at a couple hundred iterations that’s free and i left it — but it’s exactly the kind of waste that would bite the instant the search space grew, which is the same scale where this whole approach needs rewriting anyway.

none of these are hidden. the engine tries everything and keeps the best, and that’s a thing i’ll stand behind. the confidence number is a thing i keep a wary eye on.

lessons learned

  • count the search space before you get clever. a couple hundred candidates doesn’t need branch-and-bound, it needs a for loop. reaching for a sophisticated optimizer here would’ve been cosplay — effort spent looking smart instead of being right.
  • a verifiable dumb answer beats an unverifiable clever one. brute force’s superpower isn’t speed, it’s that it examined everything by construction. when it names a lap, nothing beat that lap. a heuristic can’t promise you that, and at this size it doesn’t need to try.
  • naming a number “confidence” is a promise the math didn’t make. r-squared is fit, not forecast. the second you wire it into a ± window it starts making claims about the future it has no standing to make. write down where your certainty actually comes from before you print it next to a decision.

next up: getting this off my laptop — wiring stintlab-core up to openf1’s live session data so the engine runs on a race as it happens, and the first wall that plan hits is the last wart above: everything i just built only knows the past.

the code’s on github ↗ , one-stop-only and confidence-theater and all. it’s a work in progress, like everything here.

letters to the editor

no letters yet. the editor is patient.