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.
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.
| Tool | Use it when |
|---|---|
itertools.product and itertools.combinations | First pass: enumerate pit laps and compound sequences, then filter illegal plans. |
more-itertools | The iterator code is still the right model, but repeated helper logic is getting noisy. |
| OR-Tools CP-SAT | The legal space is mostly discrete constraints: ordered stops, stint windows, tyre inventory, and compound rules. |
| CPMpy | You want a constraint model that stays close to Python and reads more like the problem. |
| MiniZinc | The 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:
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:
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:
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:
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 TrueThis 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:
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 reasonsThis 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:
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:
@dataclass(frozen=True)
class Action:
kind: str
compound: str | None = None
push_mode: str = "normal"And the first action generator can stay plain:
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:
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:
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 modelThat is more verbose than the itertools version. It is also doing a different job.