Back to notes
$ cat ~/notes/f1-strategy-prototype-decision-model-part-3.mdx

Building an F1 Strategy Prototype: The Decision Model, Part 3

How to generate legal F1 strategy branches, why itertools is the right first tool, and where constraint programming starts to fit.

July 4, 202617 min read

If you are coming in here first, Part 1 explains the decision-space framing, and Part 2 builds the simulator this post assumes.

A simulator can evolve a race state. It can answer:

If we follow this strategy, what race state do we get?

It should not also be responsible for inventing every possible strategy worth testing.

That belongs in the decision model: the layer that decides which actions, plans, or branches exist from a given state. In this part, the interesting question is not yet "which strategy wins?" It is more basic:

Which futures are legal, sensible, and worth sending into the simulator?

That boundary matters because an F1 strategy space is large before it is intelligent. If the branch generator is sloppy, the search layer will spend most of its time simulating nonsense.

The tool progression for this prototype is the same shape as Part 2: start with a simple pure-Python approach, then reach for more advanced packages once the simple version exposes its limit.

ToolUse it when
itertools.product and itertools.combinationsFirst pass: enumerate pit laps and compound sequences, then filter illegal plans.
more-itertoolsThe iterator code is still the right model, but repeated helper logic is getting noisy.
OR-Tools CP-SATThe legal space is mostly discrete constraints: ordered stops, stint windows, tyre inventory, and compound rules.
CPMpyYou want a constraint model that stays close to Python and reads more like the problem.
MiniZincThe constraints should stand alone as a declarative model instead of living inside the Python prototype.

The main recommendation is simple: start with itertools, then move to OR-Tools CP-SAT when the constraints become the thing you are trying to model. CPMpy is the readability-friendly Python option. MiniZinc is the option to keep in view if the constraint model should become a separate mathematical artefact instead of just another Python module.

Start with combinations

The first version should be almost boring. A strategy plan can be represented as pit laps and the compounds fitted at those stops:

codepython
from dataclasses import dataclass

@dataclass(frozen=True)
class StrategyPlan:
    pit_laps: tuple[int, ...]
    compounds: tuple[str, ...]

For a dry race with a known starting compound, the simplest generator is itertools:

codepython
from itertools import combinations, product

def enumerate_plans(total_laps, compounds, max_stops):
    for stops in range(1, max_stops + 1):
        for pit_laps in combinations(range(1, total_laps), stops):
            for fitted_compounds in product(compounds, repeat=stops):
                yield StrategyPlan(pit_laps, fitted_compounds)

This is the correct first move. It is visible, deterministic, easy to test, and honest about the shape of the space.

It also makes the explosion concrete. With 50 possible pit laps, three compounds, and up to three stops, the raw space is already:

one stop:C(50,1)×31=150two stops:C(50,2)×32=11,025three stops:C(50,3)×33=529,200\begin{aligned} \text{one stop:} & C(50, 1) \times 3^1 = 150 \\ \text{two stops:} & C(50, 2) \times 3^2 = 11,025 \\ \text{three stops:} & C(50, 3) \times 3^3 = 529,200 \end{aligned}

Those numbers are still toy numbers. They ignore rival reactions, Safety Car timing, traffic, weather, team orders, and two-car interactions. But they are useful because they show what Part 1 argued in abstract form: the problem gets large through sequencing, not because any single pit decision is complicated.

So I would start with itertools. It is the lap loop of decision-space generation.

Then filter hard

The raw generator produces combinations. A decision model needs to produce admissible branches.

That means the next layer is legality:

codepython
def stint_lengths(total_laps, pit_laps):
    boundaries = (0, *pit_laps, total_laps)
    return tuple(
        end - start
        for start, end in zip(boundaries, boundaries[1:])
    )

def is_legal(plan, total_laps, start_compound, tyre_sets, tyre_window):
    compounds_used = (start_compound, *plan.compounds)
    lengths = stint_lengths(total_laps, plan.pit_laps)

    if len(set(compounds_used)) < 2:
        return False

    for compound in set(plan.compounds):
        if plan.compounds.count(compound) > tyre_sets.get(compound, 0):
            return False

    for compound, length in zip(compounds_used, lengths):
        min_laps, max_laps = tyre_window[compound]
        if length < min_laps or length > max_laps:
            return False

    return True

This small function already contains several different kinds of rule.

The dry-compound rule is a race regulation. Tyre-set availability is a resource constraint. Stint length is partly physical, partly a modelling choice, because the exact useful life of a tyre depends on degradation, traffic, track temperature, driver management, and the pace target.

That distinction matters. I do not want the model to treat every rejection as the same kind of truth.

Legal is not the same as useful

The decision model should separate hard constraints from pruning rules.

Hard constraints rule out actions that cannot be taken:

  • the tyre compound is unavailable
  • the mandatory dry-compound rule would be violated
  • the car has already used the available set
  • the pit lane is closed or unsafe in the modelled scenario
  • the pit stop would happen after the race is effectively over

Pruning rules remove branches that are legal but not worth exploring in this prototype:

  • a stint sits far outside the tyre's useful window
  • a pit stop rejoins into severe traffic without enough tyre delta
  • a two-stop plan cannot plausibly recover the extra pit loss
  • a double-stack creates queue delay for too little gain
  • a branch only works under an unrealistic Safety Car assumption

The first group is correctness. The second group is judgement.

That split is useful when the model produces a strange answer. If a branch disappeared because it violated a regulation, good. If it disappeared because I assumed the hard tyre cannot survive beyond a certain stint length, that assumption should remain visible and easy to change.

Use reasons, not just booleans

A plain True or False legality check is fine for a tiny script. For a decision model, I would rather keep the rejection reason:

codepython
from enum import StrEnum

class RejectionReason(StrEnum):
    SAME_DRY_COMPOUND = "same_dry_compound"
    TYRE_SET_UNAVAILABLE = "tyre_set_unavailable"
    STINT_TOO_SHORT = "stint_too_short"
    STINT_TOO_LONG = "stint_too_long"
    TRAFFIC_PRUNE = "traffic_prune"

def rejection_reasons(plan, total_laps, start_compound, tyre_sets, tyre_window):
    reasons = []
    compounds_used = (start_compound, *plan.compounds)
    lengths = stint_lengths(total_laps, plan.pit_laps)

    if len(set(compounds_used)) < 2:
        reasons.append(RejectionReason.SAME_DRY_COMPOUND)

    for compound in set(plan.compounds):
        if plan.compounds.count(compound) > tyre_sets.get(compound, 0):
            reasons.append(RejectionReason.TYRE_SET_UNAVAILABLE)

    for compound, length in zip(compounds_used, lengths):
        min_laps, max_laps = tyre_window[compound]
        if length < min_laps:
            reasons.append(RejectionReason.STINT_TOO_SHORT)
        if length > max_laps:
            reasons.append(RejectionReason.STINT_TOO_LONG)

    return reasons

This is less elegant than a one-line filter, but it is more useful. It lets the prototype report what kind of branches it is throwing away.

That matters because the size of the space is not the only diagnostic. The shape of the rejected space is also important information.

If 80 percent of two-stop plans are being rejected for short final stints, the pit-lap generator may be too broad. If nearly every aggressive strategy is rejected by a tyre-life rule, the tyre window may be too conservative. If traffic pruning kills everything, the traffic model may be doing more work than intended.

A decision model should make those failures inspectable.

Immediate actions and full plans

There are two related objects here:

codetext
legal_actions(state)
legal_plans(state)

They are not the same thing.

legal_actions(state) answers the live strategy question:

On this lap, can we stay out, pit for medium, pit for hard, push, manage, or cover?

legal_plans(state) answers the planning question:

From this state, what complete candidate strategies should we simulate?

For a small prototype, both can live in the same decision-model module. They should still be conceptually separate. Immediate action generation is what a future search algorithm will call at every node. Full-plan generation is useful for batch comparisons: enumerate a set of one-stop and two-stop plans, run each through the simulator, then rank the outputs.

A minimal immediate action type might look like this:

codepython
@dataclass(frozen=True)
class Action:
    kind: str
    compound: str | None = None
    push_mode: str = "normal"

And the first action generator can stay plain:

codepython
def available_actions(state, car, rules, params):
    actions = [Action(kind="stay_out")]

    for compound in rules.available_compounds(car):
        if can_pit_for_compound(state, car, compound, rules, params):
            actions.append(Action(kind="pit", compound=compound))

    return prune_actions(actions, state, car, params)

This keeps the simulator clean. The simulator applies actions. The decision model decides which actions are possible. The search layer, when it arrives, can ask the same decision model for branches without knowing every regulation and pruning rule itself.

Putting it all together and running the script, we get the following output:

codetext
itertools decision model
scenario: 24 laps, start=medium, max_stops=2
raw candidates:       2346
legal candidates:       22
rejected candidates:  2324

stops     raw   legal  rejected
    1      69      10        59
    2    2277      12      2265

top rejection reasons (non-exclusive)
stint_too_short         3385
tyre_set_unavailable    1794
stint_too_long           845
same_dry_compound        276

sample legal plans
stops  pit_laps   compounds    stints
    1  (8)        (hard)       (8, 16)
    1  (9)        (hard)       (9, 15)
    1  (10)       (hard)       (10, 14)
    1  (11)       (hard)       (11, 13)
    1  (12)       (hard)       (12, 12)
    1  (13)       (hard)       (13, 11)
    1  (14)       (soft)       (14, 10)
    1  (14)       (hard)       (14, 10)

The point where itertools starts to creak

itertools.product is a good way to list the strategy space. It is not always a good way to describe the strategy space.

The difference appears when the rules stop being small filters:

pit_lap_1 < pit_lap_2

each stint must fit the compound's useful window

at least two dry compounds must be used

available tyre sets cannot be exceeded

avoid pitting into known traffic windows

avoid pit stops too close to a Safety Car restart

do not create a double-stack unless the expected gain is large enough

You can express all of that as generate-then-filter Python. For a prototype, that may be fine. But the code tends to drift towards a pile of conditionals where the actual model is hard to read.

This is the same architectural pattern as Part 2. A lap loop is useful while the race can be treated as one clean update per lap. SimPy starts to fit when timing, waiting, and shared resources become part of the result.

For decision-space generation:

Use itertools while the space is "all combinations, then filter."

Use constraint programming when the legal-action model itself becomes the point.

more-itertools can make some enumeration cleaner. Its distinct_permutations helper, for example, avoids generating duplicate permutations when repeated values exist. That is useful, but it is still enumeration. The conceptual jump is constraint programming.

Constraint programming as the next abstraction

Constraint programming changes the interface. Instead of writing:

generate every candidate, then reject the bad ones

the model says:

these are the variables, these are their domains, and these constraints must hold

For this kind of F1 strategy prototype, the variables are naturally discrete:

  • pit lap 1, pit lap 2, pit lap 3
  • compound fitted after each stop
  • stint length for each stint
  • whether a stop is used
  • whether a compound appears in the race
  • whether a traffic window is avoided

The constraints are also natural:

  • pit stops must be ordered
  • stint lengths must sit inside compound windows
  • dry races must use at least two compounds
  • tyre allocations cannot be exceeded
  • pit stops cannot happen after the race ends
  • certain laps may be blocked by traffic or race-control assumptions

That maps well to OR-Tools CP-SAT, Google's constraint-programming solver for discrete feasibility and optimisation problems. For this prototype, CP-SAT is the main "better tool" after itertools: not because the toy example needs a powerful solver, but because it changes the model from loops plus filters into a formal feasibility problem.

It also maps well to CPMpy, a Python modelling layer for combinatorial problems over discrete decision variables.

The important part is not the brand of solver. The important part is that constraints become first-class objects instead of being scattered through filters.

A tiny CP-SAT prototype

A real CP-SAT model for race strategy would take more care with optional stops, variable stint counts, tyre inventory, and objective scoring. But even a fixed two-stop sketch shows the shape:

codepython
from ortools.sat.python import cp_model

COMPOUNDS = {
    "soft": 0,
    "medium": 1,
    "hard": 2,
}

TYRE_WINDOW = {
    "soft": (4, 18),
    "medium": (8, 30),
    "hard": (12, 42),
}

def build_two_stop_model(total_laps, start_compound):
    model = cp_model.CpModel()

    pit_1 = model.NewIntVar(1, total_laps - 2, "pit_1")
    pit_2 = model.NewIntVar(2, total_laps - 1, "pit_2")
    compound_1 = model.NewIntVar(0, 2, "compound_1")
    compound_2 = model.NewIntVar(0, 2, "compound_2")

    stint_1 = pit_1
    stint_2 = model.NewIntVar(1, total_laps, "stint_2")
    stint_3 = model.NewIntVar(1, total_laps, "stint_3")

    model.Add(pit_1 < pit_2)
    model.Add(stint_2 == pit_2 - pit_1)
    model.Add(stint_3 == total_laps - pit_2)

    min_start, max_start = TYRE_WINDOW[start_compound]
    model.Add(stint_1 >= min_start)
    model.Add(stint_1 <= max_start)

    for name, compound_id in COMPOUNDS.items():
        min_laps, max_laps = TYRE_WINDOW[name]

        is_compound_1 = model.NewBoolVar(f"compound_1_is_{name}")
        model.Add(compound_1 == compound_id).OnlyEnforceIf(is_compound_1)
        model.Add(compound_1 != compound_id).OnlyEnforceIf(is_compound_1.Not())
        model.Add(stint_2 >= min_laps).OnlyEnforceIf(is_compound_1)
        model.Add(stint_2 <= max_laps).OnlyEnforceIf(is_compound_1)

        is_compound_2 = model.NewBoolVar(f"compound_2_is_{name}")
        model.Add(compound_2 == compound_id).OnlyEnforceIf(is_compound_2)
        model.Add(compound_2 != compound_id).OnlyEnforceIf(is_compound_2.Not())
        model.Add(stint_3 >= min_laps).OnlyEnforceIf(is_compound_2)
        model.Add(stint_3 <= max_laps).OnlyEnforceIf(is_compound_2)

    start_id = COMPOUNDS[start_compound]
    different_1 = model.NewBoolVar("compound_1_differs_from_start")
    different_2 = model.NewBoolVar("compound_2_differs_from_start")

    model.Add(compound_1 != start_id).OnlyEnforceIf(different_1)
    model.Add(compound_1 == start_id).OnlyEnforceIf(different_1.Not())
    model.Add(compound_2 != start_id).OnlyEnforceIf(different_2)
    model.Add(compound_2 == start_id).OnlyEnforceIf(different_2.Not())

    model.AddBoolOr([different_1, different_2])

    return model

That is more verbose than the itertools version. It is also doing a different job.

The itertools version describes how to walk through candidates. The CP-SAT version describes what a candidate must satisfy. Once the constraints become the important part of the problem, that shift is useful.

The next step would be to attach an objective:

codetext
minimise estimated race time
maximise probability of beating a target rival
maximise expected points
minimise worst-case tyre-life risk

I would not go there just yet. For Part 3, feasibility is already enough. Before ranking strategies, the prototype needs to generate branches that deserve simulation.

Putting it all together and running the script, we get the following output:

codetext
OR-Tools CP-SAT decision model
scenario: 24 laps, start=medium, max_stops=2
feasible plans:    22

stops  feasible
    1        10
    2        12

sample feasible plans
stops  pit_laps   compounds    stints
    1  (8)        (hard)       (8, 16)
    1  (9)        (hard)       (9, 15)
    1  (10)       (hard)       (10, 14)
    1  (11)       (hard)       (11, 13)
    1  (12)       (hard)       (12, 12)
    1  (13)       (hard)       (13, 11)
    1  (14)       (hard)       (14, 10)
    1  (14)       (soft)       (14, 10)

The useful check is that both approaches find the same 22 feasible plans (10 plans for 1 stop and 12 plans for 2 stops). The difference is in the path they take. The itertools script walks 2,346 candidates and explains why most were rejected. The CP-SAT script encodes the same toy rules as constraints and asks the solver for feasible plans directly.

As always, the complete source code for both approaches is in the GitHub repo: f1-strategy-decisions. Both scripts follow PEP 723 inline script metadata and can be run with a simple uv run.

CPMpy is the readability option

CP-SAT is a strong default because OR-Tools is widely used and the solver is capable. The tradeoff is that the Python API can feel low-level in a blog post.

If the goal is to keep the decision model closer to the language of the problem, CPMpy is worth considering:

codepython
# Sketch only: the helper constraints would need to be defined.
from cpmpy import intvar, Model

pit_lap = intvar(1, total_laps - 1, shape=2, name="pit_lap")
compound = intvar(0, 2, shape=2, name="compound")

model = Model(
    pit_lap[0] < pit_lap[1],
    stint_lengths_are_valid(pit_lap, compound),
    dry_compound_rule_is_satisfied(start_compound, compound),
    tyre_sets_are_available(compound),
)

That is closer to how I want to think about the problem. The model reads like a list of constraints instead of a nested loop. For an educational prototype, that readability has value.

Where MiniZinc fits

MiniZinc belongs in this discussion because it answers a slightly different design question from CP-SAT and CPMpy. It is the architectural alternative to keep in view once the constraint model deserves to stand apart from the Python application.

CP-SAT gives the Python prototype direct access to a strong solver. CPMpy gives the Python prototype a friendlier modelling interface. MiniZinc asks whether the constraint model should live outside the application code at all.

That can be a useful boundary. The decision model in this post is not really application glue. It is a compact logical object:

given race length, available compounds, tyre windows, and stop limits, find pit laps and compound choices that satisfy the rules

There is a good argument for writing that object in a modelling language rather than embedding it in Python control flow. A MiniZinc model can keep the mathematical structure close to the surface:

codeminizinc
int: total_laps;
int: max_stops;
set of int: STOPS = 1..max_stops;
set of int: COMPOUNDS = 1..3;

array[STOPS] of var 1..total_laps-1: pit_lap;
array[STOPS] of var COMPOUNDS: compound;

constraint forall(i in 1..max_stops-1)(
  pit_lap[i] < pit_lap[i + 1]
);

constraint forall(i in STOPS)(
  pit_lap[i] >= 1 /\ pit_lap[i] < total_laps
);

solve satisfy;

That is only a sketch. A real model would add optional stops, stint lengths, dry-compound rules, tyre inventory, blocked pit windows, and output formatting. But even this small example shows the appeal: the model reads like a set of facts about the strategy space, not like a program walking through candidates.

The practical advantage is solver separation. The MiniZinc toolchain can target different solver backends from the same model. The MiniZinc handbook describes backends for constraint-programming solvers such as Gecode, Chuffed, and OR-Tools, and also mixed-integer programming solvers such as Gurobi when available. The broader MiniZinc ecosystem also includes open-source optimisation solvers such as HiGHS. That means the same race-strategy model can be tested against different solving technologies without rewriting the application.

For this series, that creates a clean split:

Python owns race state, simulation, scenario sampling, and search orchestration. MiniZinc owns the independent feasibility model. The boundary between them is data: inputs in, feasible plans out.

That boundary is attractive when the constraints become important enough to review, benchmark, or swap independently. It is also useful if the model needs to be discussed with someone who cares about the mathematics more than the surrounding software.

The cost is integration. Now the prototype has another language, another runtime, and another place where types and data schemas can drift. For a first Python-heavy build, I would still start with itertools, then CP-SAT or CPMpy. MiniZinc becomes more attractive as soon as the constraints deserve their own file, their own tests, and their own solver experiments.

The state-action boundary

Whether the implementation uses itertools or a solver, the state boundary should stay clean:

codepython
available_actions(state) # should not mutate state.
apply_action(state, action) # may produce the next state.

This is one of those boring rules that prevents strange bugs later.

Search algorithms constantly copy, score, and roll forward states. If asking for legal actions changes tyre age, pit history, sampled weather, or a random number generator, the branch tree becomes hard to trust.

For the same reason, action generation should be deterministic unless uncertainty is explicitly part of the scenario. Randomness belongs in scenario sampling or simulation rollout, not in the legality check.

The decision model should be a map from known state to allowed branches.

Policies are different from actions

An action is one immediate move. A policy is a rule for choosing actions over time.

codepython
def conservative_policy(state, car, rules, params):
    actions = available_actions(state, car, rules, params)

    if rival_just_pitted(state, car) and can_cover(actions):
        return cover_action(actions)

    if tyre_life_is_nearly_exhausted(state, car, params):
        return best_pit_action(actions)

    return stay_out_action(actions)

Policies are useful for simulating rivals and for testing the ego car against different strategic styles:

  • cover the closest rival if the gap is small
  • extend unless degradation crosses a threshold
  • pit under VSC if the remaining stint is legal
  • avoid pitting into traffic unless the tyre delta is large
  • choose the alternate compound when stuck in a train

This is where the model starts to feel like a decision system rather than a pit-lap calculator.

The search layer can use policies as opponents, rollout defaults, or baselines. But policy choice is not the same as action legality. Mixing those together is a good way to make the model quietly biased.

What Part 3 should produce

The output of this layer is not "the answer." It is a disciplined set of candidate futures.

For a first prototype, I want the decision model to return:

  • legal immediate actions from a race state
  • legal full-race plans for batch simulation
  • rejection reasons for discarded plans
  • counts by stop count, compound sequence, and rejection reason
  • enough metadata to explain why a branch was pruned

That gives the later search layer something solid to work with.

It also gives the human a way to audit the model. If the generator produces no viable two-stop plans, that may be a strategic conclusion. Or it may mean the stint windows are wrong. The decision model should make that distinction easy to investigate.

The useful boundary

The practical recommendation is:

Start with itertools.

Keep legality and pruning explicit.

Track rejection reasons.

Move to constraint programming when constraints become the main object.

That mirrors the simulator boundary from Part 2:

Use the lap loop while each lap can be treated as one clean update.

Use SimPy when timing, waiting, and shared resources become part of the result.

For the decision model:

Use itertools when the priority is a transparent first prototype and branches are easy to enumerate and filter.

Use CP-SAT or CPMpy when the priority is a cleaner Python modelling interface and the useful question becomes "describe the legal space."

Use MiniZinc when the legal-space model should be separate from the Python application.

The next part is search. That is where Monte Carlo Tree Search, dynamic programming approximations, heuristics, or other ranking methods can enter. This part stops one layer earlier: once the model can generate legal branches, the system needs a way to decide which branches to simulate deeply, which ones to approximate, and which ones to ignore.