Tire Rotation as Permutations: Wheel-Position Addressing, Pattern Validation, and Impossible Histories
Scope note: this is a design exercise with illustrative code. It does not describe any specific production system, and the tread depths, tire IDs and vehicle layouts in the examples are made up to show the mechanics.
Every fleet-maintenance product I have looked at stores tire rotations the same way: a work-order line that says "rotated tires" and, if you are lucky, a free-text field where someone typed "LF to RR, RR to LF". Then, eighteen months later, someone asks why a directional winter tire is on the wrong side of a van, or why the ledger says the same tire is on two trucks at once, and nobody can answer because the data was never structured enough to reason about. A rotation is not a note. It is a permutation of a finite set of positions, and once you treat it as one, a surprising amount of validation, reconstruction and recommendation falls out of a few dozen lines of code. This piece works through that model in Python and SQL, from addressing wheel positions properly, through composing patterns and reading their cycle structure, to catching histories that could not have happened in the real world. The seasonal side of it is very real in this city: the seasonal changeover service that happens twice a year is itself a permutation between two tire sets, and a fleet that runs the same trucks up and down Deerfoot all week produces exactly the kind of position history this model exists to keep honest.
Photo from KMJ Tire's approved image collection, shown for general context; it does not show current inventory or a customer event.
Why "LF" and "RR" strings fail before you even reach the interesting problems
Start with the failure that motivates everything else. A free-text position field accepts "LF", "L/F", "Left Front", "front left", "FL", "driver front" and "DF". Those all mean the same corner on a North American vehicle, and none of them mean it in a way software can check. Worse, "driver front" is on the opposite side in a right-hand-drive import, and the moment a fleet has one Japanese-market cab-over in it, half the vocabulary silently flips meaning.
Strings also cannot express structure that rotation logic needs. A pickup with dual rear wheels has six positions, not four, and two of them share a side and an axle and differ only by whether they are inboard or outboard. A tandem-drive highway tractor has ten on the tractor alone. A full-size spare is a position with no axle, no side, and zero load until it is rotated in. A string field lets you write "spare" but gives you no way to say that the spare cannot be the target of a same-axle swap because it has no axle to share.
The last problem is the one that hurts fleets most: strings cannot be validated for completeness. If a technician records "LF to RR" and stops, the system has no idea whether the tire that used to be at RR went to LF, went to the spare well, went into the scrap pile, or is still bolted to the truck alongside its replacement in some impossible superposition. A structured position set plus a permutation catches that in one line, because a permutation must send every position somewhere and receive exactly one tire back.
An addressing scheme built from axle, side and dual rank
The scheme I would use is a small immutable value with three fields: an axle index counting from the front starting at 1, a side (left or right, with a neutral value reserved for the spare), and a dual rank that is single for a normal wheel end and inner or outer where the axle carries duals. The spare is the position with axle 0. Everything else in the model keys off this value, so it needs to be hashable, orderable and printable in a form a technician can read off a work order.
from dataclasses import dataclass
from enum import Enum
from typing import Dict, FrozenSet
class Side(str, Enum):
L = "L"
R = "R"
NA = "-"
class Rank(str, Enum):
SINGLE = "single"
INNER = "inner"
OUTER = "outer"
@dataclass(frozen=True, order=True)
class Pos:
axle: int
side: Side
rank: Rank = Rank.SINGLE
def __str__(self) -> str:
if self.axle == 0:
return "SP"
tag = f"A{self.axle}{self.side.value}"
if self.rank is Rank.SINGLE:
return tag
return tag + ("i" if self.rank is Rank.INNER else "o")
SPARE = Pos(0, Side.NA)
LF, RF, LR, RR = Pos(1, Side.L), Pos(1, Side.R), Pos(2, Side.L), Pos(2, Side.R)
def four_wheel(with_spare: bool = False) -> FrozenSet[Pos]:
base = {LF, RF, LR, RR}
if with_spare:
base.add(SPARE)
return frozenset(base)
def dually_six() -> FrozenSet[Pos]:
rear = {Pos(2, s, r) for s in (Side.L, Side.R) for r in (Rank.INNER, Rank.OUTER)}
return frozenset({LF, RF} | rear)
def tractor_ten() -> FrozenSet[Pos]:
drive = {Pos(a, s, r) for a in (2, 3) for s in (Side.L, Side.R)
for r in (Rank.INNER, Rank.OUTER)}
return frozenset({LF, RF} | drive)
The printable form (A1L, A2Ri, SP) is deliberately not the industry shorthand. A2Ri reads as "axle two, right, inner", and it extends without ambiguity to axle five on a trailer, which "RR" never could. The layout functions return frozen sets because a vehicle's position set is a fact about the vehicle, not something an event should be able to mutate.
Three layouts cover most of what a Calgary fleet actually runs. The four-wheel layout with an optional spare is every sedan, crossover and half-ton. The six-position dually layout is the one-ton with a flat deck or a service body, and it is where inner/outer awareness first matters. The ten-position tractor is a tandem-drive highway unit, and the trailer behind it is deliberately not folded into the same layout. An eighteen-wheel combination is two vehicles with two position sets, because the trailer gets dropped at a yard in Balzac and picked up by a different tractor on Thursday. Modelling the trailer as its own vehicle is what later makes "a tire moved between vehicles" a normal event rather than a corruption.
Notice what the scheme refuses to encode: steer versus drive versus trailer tire type. That is an attribute of the tire (and of what the position expects), not of the address. A steer position on a tractor is A1L; whether the tire currently mounted there has a rib pattern suited to steering is a constraint question, and constraints come later.
A rotation pattern is a permutation, and that changes what you can ask
With a position set in hand, a rotation pattern is simply a bijection from the set to itself: a mapping that says, for each position, where the tire currently sitting there goes. The convention matters and should be written down once and enforced everywhere: perm[src] == dst means the tire at src moves to dst. Storing the reverse ("which position did the tire at dst come from") is equally valid but mixing the two in one codebase is the single most common source of rotation bugs I have seen, because the inverse of a cross pattern is a different cross pattern and the error is invisible on an X swap.
Here are the standard four-wheel patterns, the five-tire forward cross that includes the spare, and two dually patterns, all expressed as mappings and checked for bijectivity at construction time so a typo cannot survive import.
Perm = Dict[Pos, Pos]
def is_bijection(mapping: Perm, universe) -> bool:
return set(mapping) == set(universe) and set(mapping.values()) == set(universe)
def perm_from_pairs(pairs, universe) -> Perm:
mapping = {a: b for a, b in pairs}
for p in universe:
mapping.setdefault(p, p)
if not is_bijection(mapping, universe):
raise ValueError("pattern is not a bijection over the layout")
return mapping
FORWARD_CROSS = perm_from_pairs([(LF, LR), (RF, RR), (LR, RF), (RR, LF)], four_wheel())
REARWARD_CROSS = perm_from_pairs([(LF, RR), (RF, LR), (LR, LF), (RR, RF)], four_wheel())
X_PATTERN = perm_from_pairs([(LF, RR), (RR, LF), (RF, LR), (LR, RF)], four_wheel())
SAME_SIDE = perm_from_pairs([(LF, LR), (LR, LF), (RF, RR), (RR, RF)], four_wheel())
FIVE_FORWARD = perm_from_pairs(
[(LF, SPARE), (SPARE, LR), (LR, RF), (RF, RR), (RR, LF)],
four_wheel(with_spare=True),
)
LRi, LRo = Pos(2, Side.L, Rank.INNER), Pos(2, Side.L, Rank.OUTER)
RRi, RRo = Pos(2, Side.R, Rank.INNER), Pos(2, Side.R, Rank.OUTER)
DUALLY_SAME_SIDE = perm_from_pairs(
[(LF, LRi), (LRi, LRo), (LRo, LF), (RF, RRi), (RRi, RRo), (RRo, RF)], dually_six()
)
DUALLY_CROSS = perm_from_pairs(
[(LF, RRi), (RRi, RRo), (RRo, LF), (RF, LRi), (LRi, LRo), (LRo, RF)], dually_six()
)
Read the forward cross carefully: the fronts go straight back on their own side, the rears come forward and cross. That is the pattern generally used on front-drive vehicles, where the fronts do the steering, most of the braking and all of the driving and therefore wear fastest. The rearward cross is its mirror image for rear-drive and many all-wheel-drive vehicles: fronts cross to the back, rears come straight forward. The X pattern crosses everything. The same-side pattern crosses nothing, which is exactly why it is the one you reach for when the tires are directional.
The five-tire forward cross is worth a second look because it is the pattern that gets misrecorded most often. The spare goes to the left rear, the left front goes into the spare well, and the other three move as in a four-tire forward cross. Written as pairs it looks like five unrelated moves. Written as a permutation it turns out to be one single cycle, and that single fact is the whole reason a five-tire rotation exists.
Composition, cycles and the order of a pattern
Once patterns are mappings, composing two of them is a one-liner, and the composition is itself a valid pattern. That gives you a way to answer a question fleets ask constantly without ever being able to answer it: "if we keep doing this rotation, when does each tire get back to where it started, and does every tire visit every position along the way?"
from math import gcd
from typing import List, Tuple
def compose(first: Perm, second: Perm) -> Perm:
"""Result of performing `first`, then `second`."""
return {p: second[first[p]] for p in first}
def inverse(perm: Perm) -> Perm:
return {v: k for k, v in perm.items()}
def identity(universe) -> Perm:
return {p: p for p in universe}
def cycles(perm: Perm) -> List[Tuple[Pos, ...]]:
seen, out = set(), []
for start in sorted(perm):
if start in seen:
continue
cyc, cur = [], start
while cur not in seen:
seen.add(cur)
cyc.append(cur)
cur = perm[cur]
out.append(tuple(cyc))
return out
def order(perm: Perm) -> int:
n = 1
for cyc in cycles(perm):
n = n * len(cyc) // gcd(n, len(cyc))
return n
The cycles function walks each position forward until it returns to itself, and the order is the least common multiple of the cycle lengths: the number of times you must apply the pattern before every tire is back at its starting position. Running the patterns above through it produces this table, which I generated rather than typed:
| Pattern | Cycle structure | Order |
|---|---|---|
| Forward cross | (A1L A2L A1R A2R) | 4 |
| Rearward cross | (A1L A2R A1R A2L) | 4 |
| X | (A1L A2R)(A1R A2L) | 2 |
| Same side | (A1L A2L)(A1R A2R) | 2 |
| Five-tire forward | (SP A2L A1R A2R A1L) | 5 |
| Dually same side | (A1L A2Li A2Lo)(A1R A2Ri A2Ro) | 3 |
| Dually cross | (A1L A2Ri A2Ro)(A1R A2Li A2Lo) | 3 |
Several practical facts hide in that table. The forward cross is a single four-cycle, so under repeated forward crosses every tire visits all four corners before returning home, and after four services the set is back to its original arrangement. The X and same-side patterns have order 2: apply either one twice and you have done nothing. That is not a criticism, it is a property a scheduler needs to know, because alternating X with same-side gives more mixing than repeating either. The five-tire forward pattern is one five-cycle, which means each of the five tires spends exactly one interval in the spare well per five rotations and the set wears as evenly as a five-tire set can. The dually patterns are two three-cycles that never mix left and right in the same-side variant and never mix a front with its own side's duals in the cross variant.
Composition produces results that are genuinely useful and, in two cases, slightly surprising. Compose a forward cross with itself and you get (A1L A1R)(A2L A2R): two forward crosses in a row amount to a straight side-to-side swap on both axles, with nothing changing axle at all. Compose a forward cross with a rearward cross and you get the identity, and checking inverse(FORWARD_CROSS) == REARWARD_CROSS returns True. The rearward cross is precisely the undo of the forward cross. If a technician did a forward cross on a rear-drive truck by mistake, the correction is a rearward cross, and the software can say so rather than leaving someone to work it out on a whiteboard. Compose a forward cross with an X and you get a single transposition, (A1L A1R): the fronts have swapped sides and the rears are exactly where they began, which is a state nobody would choose on purpose and which a validator should be able to name.
What "back where it started" is worth to a fleet
A group-theory aside is only worth including if it changes a decision, so here is the decision it changes. Fleet policies are often written as "rotate every 10,000 km using pattern X". The order of a pattern tells you the length of the wear cycle that policy produces. With an order-2 pattern, each tire alternates between two positions for its whole life, and if those two positions wear differently the tire ends up with a two-position wear signature: a front-left tire that has only ever been front-left or rear-right sees gravel-road shoulder scrub on one side of the vehicle every second interval and never on the other. With an order-4 pattern every tire spends a quarter of its life at each corner, which is what "even wear" actually means for a fleet that spends half its week on secondary roads west of the city.
For duals it changes the decision in a different way. An order-3 dually pattern moves a front tire into an inner dual position on the very first rotation. The front tire has been steering and has worn its shoulders; the inner dual it is about to pair with has been carrying load in a straight line. Their diameters may differ, and duals that differ in diameter beyond a small tolerance share load unevenly, with the larger one doing more than its share. That is not a reason to avoid the pattern; it is a reason the constraint layer below has to check dual matching at every rotation, not only at installation. Both the cycle structure and the dual-matching rule fall directly out of treating the pattern as a permutation rather than a note, and a fleet operator who understands them will get more from a commercial tire program than one who does not.
Constraints as predicates that explain their own objections
Not every bijection is an allowed rotation. Whether a permutation is acceptable depends on the tires currently mounted, and the cleanest way to encode that is a list of predicates, each taking the current state (a map from position to tire), the proposed permutation, and a context dictionary of vehicle-level facts, and each returning a list of violations. An empty list means the rule has no objection. A violation carries a rule name, the tire it concerns, and a sentence a technician can read.
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class Tire:
tire_id: str
directional: bool = False
allowed_side: Optional[Side] = None
size: str = "225/65R17"
tread_mm: float = 8.0
lt_rated: bool = False
@dataclass(frozen=True)
class Violation:
rule: str
tire_id: Optional[str]
detail: str
State = Dict[Pos, Tire]
def rule_directional(state: State, perm: Perm, ctx: dict) -> List[Violation]:
out = []
for src, dst in perm.items():
tire = state.get(src)
if not tire or not tire.directional or dst.side is Side.NA:
continue
if tire.allowed_side not in (None, dst.side):
out.append(Violation(
"directional_side", tire.tire_id,
f"{tire.tire_id} is directional for side {tire.allowed_side.value} "
f"and would land on {dst} without a remount"))
return out
def rule_staggered(state: State, perm: Perm, ctx: dict) -> List[Violation]:
if not ctx.get("staggered"):
return []
return [
Violation("staggered_axle", state[s].tire_id,
f"{state[s].tire_id} would leave axle {s.axle} on a staggered fitment")
for s, d in perm.items() if s in state and d.axle not in (s.axle, 0)
]
def rule_dual_match(state: State, perm: Perm, ctx: dict, limit_mm: float = 3.0) -> List[Violation]:
landing = {perm[s]: state[s] for s in perm if s in state}
out = []
for p, tire in landing.items():
if p.rank is not Rank.INNER:
continue
mate = landing.get(Pos(p.axle, p.side, Rank.OUTER))
if mate and abs(mate.tread_mm - tire.tread_mm) > limit_mm:
out.append(Violation(
"dual_mismatch", tire.tire_id,
f"{tire.tire_id} ({tire.tread_mm} mm) would pair with {mate.tire_id} "
f"({mate.tread_mm} mm) at axle {p.axle} {p.side.value}"))
return out
def rule_awd_spread(state: State, perm: Perm, ctx: dict, limit_mm: float = 2.0) -> List[Violation]:
if not ctx.get("awd"):
return []
depths = [t.tread_mm for s, t in state.items() if perm[s] != SPARE]
spread = max(depths) - min(depths)
if spread <= limit_mm:
return []
return [Violation("awd_spread", None,
f"tread spread on the road after rotation would be {spread:.1f} mm, "
f"over the {limit_mm} mm policy")]
RULES = [rule_directional, rule_staggered, rule_dual_match, rule_awd_spread]
def validate(state: State, perm: Perm, ctx: Optional[dict] = None) -> List[Violation]:
ctx = ctx or {}
if not is_bijection(perm, set(state) | set(perm)):
return [Violation("not_bijection", None,
"moves do not form a permutation of the vehicle's positions")]
found: List[Violation] = []
for rule in RULES:
found.extend(rule(state, perm, ctx))
return found
def apply(state: State, perm: Perm) -> State:
return {perm[p]: t for p, t in state.items()}
Each rule encodes a real-world reason, and the reasons are worth spelling out because they are what the explanation strings are for.
Directional tires have a tread designed to rotate one way. Mounted on the left side of the car they roll correctly; move the same wheel-and-tire assembly to the right side and the tread now runs backwards, which hurts wet evacuation and can make the tire noisy. The rule therefore says a directional tire may not change side. It does not say the tire can never be on the other side, because it can: it just has to be dismounted from its wheel, flipped, and remounted, which is a separate event handled in the next section. The allowed_side attribute belongs to the tire, not the position, and None means "not yet committed", which is how a brand-new directional tire arrives from the supplier.
Asymmetric tires have an inside and an outside but no preferred direction of travel, so they can cross sides freely and there is no rule for them at all. The absence of a rule is worth a comment in the codebase, because someone will eventually assume asymmetric means directional and add a rule that blocks perfectly legitimate cross patterns. The sidewall markings that distinguish the two cases are covered well in KMJ's guide to reading a tire sidewall, and "OUTSIDE" stamped on a sidewall is an asymmetric marker, not a directional one.
Staggered fitments have wider or larger-diameter tires on one axle than the other, which is common on rear-drive performance cars. A tire simply will not fit, or will not clear, on the other axle, so the only permutations available are same-axle swaps, and those only for non-directional tires. The rule reads a vehicle-level flag from the context rather than comparing tire sizes, because the sizes are known at fitment time and the flag is the durable fact.
Dual matching is the LT and commercial concern. Inner and outer duals share the load on an axle end, and if one is taller than the other it carries more than half. Tread depth is a serviceable proxy for diameter within a single size, so the rule compares tread depth between the tires that would land as a pair, not the ones currently paired. That distinction is the subtle part: you have to compute the post-rotation pairing to validate it. A tire specialist working through load-index and LT ratings will tell you the same thing in plainer words: duals need to be matched, every time, not just once.
AWD tread spread is a policy rule rather than a physical one. Many all-wheel-drive systems object to tires of noticeably different rolling circumference on different axles, and the usual policy is a maximum tread-depth spread across the four on-road positions. The rule ignores the spare deliberately, since a full-size spare that has never touched the road will always be deeper than the four that have, and looks at the state after the rotation rather than before, because a five-tire rotation changes which four tires are on the road.
The rules are functions in a list, so adding a fleet-specific policy is appending a function. Ordering does not matter, every rule runs, and whoever invoked the validator gets every objection at once rather than the first one, which matters when a technician is standing at the vehicle deciding what to do.
The remount event that flips a directional tire's side
The directional rule, taken alone, would trap every directional tire on the side where it was first mounted for its entire life. That is not how the physical world works. A tire specialist can break the bead, flip the tire on its wheel and remount it so the tread runs the other way, and then it is legitimately a right-side tire. What the model must not do is let a rotation event silently perform that flip, because a rotation does not touch the bead and cannot have done it.
So remount is its own event kind. It names one tire and one new allowed side, and its only effect on state is to replace the tire's allowed_side. It says nothing about position. A sequence that moves a left-side directional tire to the right therefore has to look like: remount (T3, side R), then rotation (a pattern that carries T3 across). Recorded the other way round, the rotation comes first and the validator raises directional_side on it, because at the moment of the rotation the tire was still committed to the left.
That ordering requirement is what makes the check honest rather than pedantic. A remount is real work with a real cost: the wheel has to be dismounted, the tire flipped, the assembly balanced again because the weights that were correct before will be wrong now, and the whole thing torqued back on. If a fleet's history shows directional tires wandering across sides with no remount events, either the work was done and not recorded, or it was not done and the tires are running backwards. Both are worth knowing, and the model surfaces them for free.
Tire identity is not position, and the ledger has to keep them apart
The other half of the model is the tire itself. Each tire needs a durable identity that survives being dismounted, stored, moved to another vehicle or sent for repair. Two candidates exist, and a good system records both. The DOT code moulded into the sidewall identifies the plant, size code and production week, and on many tires the full string is only on one sidewall, which means it may be facing inward and unreadable without removing the wheel. A store-assigned tag, a bay tag if you like, is a serial the business applies at first mounting, usually a small etched or painted number on the inner sidewall or a sticker on the wheel. The tag is unique and readable; the DOT code is authoritative but often duplicated across a batch of four bought together, since four tires from the same production week share the same string.
The important modelling decision is that a tire's position is never stored as a column on the tire. It is derived. The tire has an identity and a history of events; the position at any moment is the result of replaying those events. Storing current_position on the tire row is tempting because it makes the obvious query fast, and it is precisely the shortcut that produces "the same tire on two trucks", because two code paths update the column and neither knows about the other. If you want the fast query, materialise a current_mounts view from the event table and rebuild it, rather than letting anything write to it directly.
A rotation event, then, records two things: the permutation that was applied, as a list of (from, to) position pairs, and the resulting position map, as a list of (position, tire_id) pairs. The second is redundant with the first given the prior state, and that redundancy is the point. It is the checksum. If replaying the permutation over the prior state does not reproduce the recorded map, something is wrong with either the event or its predecessor, and the disagreement is caught at write time instead of at audit time.
A rotation event as JSON
Here is what one event looks like on the wire, for a four-wheel vehicle with a spare, recording a five-tire forward cross. The moves list is the permutation; the resulting_map is the post-state; layout is what the vehicle is declared to have; and prior_event_id chains it to the previous event on the same vehicle so a gap is detectable.
{
"event_id": "evt_01J8Q2Y0M4",
"vehicle_id": "veh_f150_unit_07",
"kind": "rotation",
"layout": "four_wheel_with_spare",
"pattern": "five_tire_forward_cross",
"prior_event_id": "evt_01J7X9S2KP",
"occurred_at": "2026-09-17T14:20:00-06:00",
"odometer_km": 61840,
"moves": [
{"from": "A1L", "to": "SP"},
{"from": "SP", "to": "A2L"},
{"from": "A2L", "to": "A1R"},
{"from": "A1R", "to": "A2R"},
{"from": "A2R", "to": "A1L"}
],
"resulting_map": [
{"position": "A1L", "tire_id": "tag-4471"},
{"position": "A1R", "tire_id": "tag-4470"},
{"position": "A2L", "tire_id": "tag-4473"},
{"position": "A2R", "tire_id": "tag-4469"},
{"position": "SP", "tire_id": "tag-4468"}
],
"tread_mm_before": {"A1L": 5.6, "A1R": 5.8, "A2L": 7.1, "A2R": 7.0, "SP": 8.9},
"recorded_by": "tech_ml",
"notes": "spare had never been on the road; pressure set before mounting"
}
Two small design choices here earn their keep. Recording tread_mm_before on the event, keyed by position, means the wear history can later be joined to tires through the position map without a second table. And carrying the layout name on the event, rather than only on the vehicle, means the "wheel count mismatch" check below can run even if someone later edits the vehicle's declared layout.
Impossible histories and the four checks that catch them
An event log is only as trustworthy as the checks run on it. There are four classes of impossibility this model catches directly, and each corresponds to a specific mistake I have seen in real fleet data.
-
A tire appearing in two positions. A
mountevent names a tire that the replayed state already has mounted somewhere on the same vehicle. Almost always this is a re-keyed tag or a tire that was moved from another vehicle without adismounton the source. - A permutation that is not a bijection. The moves list has two sources going to one destination, or a destination that nothing goes to. This is the partial-record problem from the opening section, and it is the most common of the four by a wide margin.
- A directional tire that changed side with no remount. The replay runs the directional rule at every rotation using the tire attributes in force at that moment, so a rotation that crosses a committed directional tire is flagged with the event number, even if a remount was recorded afterwards.
-
A rotation recorded against the wrong wheel count. The moves name a position the vehicle's layout does not have, such as
A2Rion a four-wheel layout, or fail to cover positions it does have. This is what you see when a work-order template for a dually is reused for a half-ton.
Here is a replay function that produces all four, plus remount handling, and keeps going after each problem so the reader of the report sees the full list rather than the first failure.
from dataclasses import replace
@dataclass(frozen=True)
class Event:
seq: int
kind: str
vehicle_id: str
moves: Optional[Perm] = None
tire_id: Optional[str] = None
position: Optional[Pos] = None
new_side: Optional[Side] = None
tire: Optional[Tire] = None
def replay(layout, events: List[Event]) -> Tuple[State, List[Violation]]:
state: State = {}
problems: List[Violation] = []
for ev in events:
if ev.kind == "mount":
if ev.position in state:
problems.append(Violation("double_mount", ev.tire_id,
f"event {ev.seq}: {ev.position} already holds {state[ev.position].tire_id}"))
if any(t.tire_id == ev.tire_id for t in state.values()):
problems.append(Violation("tire_in_two_positions", ev.tire_id,
f"event {ev.seq}: {ev.tire_id} is already mounted elsewhere on {ev.vehicle_id}"))
state[ev.position] = ev.tire
elif ev.kind == "dismount":
state.pop(ev.position, None)
elif ev.kind == "remount":
for p, t in list(state.items()):
if t.tire_id == ev.tire_id:
state[p] = replace(t, allowed_side=ev.new_side)
elif ev.kind == "rotation":
named = set(ev.moves) | set(ev.moves.values())
if named - set(layout):
problems.append(Violation("wheel_count_mismatch", None,
f"event {ev.seq}: moves name positions this layout lacks: "
f"{sorted(str(p) for p in named - set(layout))}"))
continue
perm = {**identity(layout), **ev.moves}
if not is_bijection(perm, layout):
problems.append(Violation("not_bijection", None,
f"event {ev.seq}: moves are not a permutation"))
continue
for v in rule_directional(state, perm, {}):
problems.append(replace(v, detail=f"event {ev.seq}: " + v.detail))
state = apply(state, perm)
return state, problems
A deliberately broken nine-event history for a four-wheel vehicle with four directional tires produced these findings when I ran it: event 6 (an X pattern) raised directional_side for all four tires, because every one of them would cross; event 7 raised wheel_count_mismatch because it named A2Ri; event 8 raised both double_mount (the position already held a tire) and tire_in_two_positions (the tire ID was already on the vehicle); event 9 raised not_bijection because two sources pointed at A1L. The replay still finished and returned a state, which is what you want: the state is wrong, but it is the best available reconstruction, and the problem list tells you exactly where to start fixing.
One rule I would enforce at the API boundary rather than in replay: events are append-only, and a correction is a new event that references the one it corrects. Editing event 6 in place would make the problem disappear from the list without anyone having gone out to the truck to look. The fleet-management side of KMJ's service is, in the end, a stream of physical actions on physical trucks, and the ledger should only ever grow.
Reconstructing state and reconciling it against a physical audit
Replay gives you what the ledger believes is on the vehicle. An audit gives you what is actually on it: a technician walks around the truck and reads the tag or DOT string at each position. Reconciliation is the diff between those two maps, and the interesting part is not detecting that they differ but classifying how, because each class has a different fix.
def reconcile(expected: State, observed: Dict[Pos, str]) -> List[str]:
notes = []
exp_ids = {p: t.tire_id for p, t in expected.items()}
placed = {tid: p for p, tid in exp_ids.items()}
for p in sorted(set(exp_ids) | set(observed)):
e, o = exp_ids.get(p), observed.get(p)
if e == o:
continue
if o and o in placed:
notes.append(f"{p}: expected {e}, found {o} (ledger has it at {placed[o]}) "
"-> unrecorded move")
elif o:
notes.append(f"{p}: found {o}, unknown to this vehicle "
"-> unrecorded mount or transfer from another unit")
elif e:
notes.append(f"{p}: expected {e}, nothing readable "
"-> unrecorded dismount, or re-read the DOT on the inner sidewall")
return notes
Three outcomes come out of that function, and here is what each should trigger.
An unrecorded move means the tire is on the vehicle but not where the ledger says. The set of such findings will usually form a permutation on its own, and when it does, the fix is to record one rotation event with that permutation, back-dated to the best estimate and flagged as an audit correction. The validator then runs the directional rule on it, and if the unrecorded move crossed a directional tire, you have found a remount that also was not recorded, or a tire running backwards, and a person needs to look at it.
An unknown tire means a tire from outside this vehicle's history is mounted on it. In a fleet the common cause is a transfer: a spare from unit 12 went onto unit 7 at the side of Stoney Trail one afternoon and the paperwork never caught up. The fix is a dismount on the source unit and a mount on this one, both flagged as audit-derived. If the tire ID is unknown to the whole fleet, it is either a new purchase with no intake record or a mis-read, and the technician should read it again before anything is written.
A missing tire means the ledger has something at a position the technician could not confirm. Before writing anything, the first action is a re-read, because the most frequent cause is an inward-facing DOT string on a dual that cannot be seen without pulling the outer wheel. Only if the position is genuinely empty (a truck sitting on a spare-tire well with no spare, say) does a dismount get recorded.
The rule that keeps this honest is the same as before: reconciliation never edits history. It appends correction events with a reason, and those events are as visible as any other. A fleet that audits a truck twice a year at the seasonal change and a third time at a mid-summer rotation will find that the ledger converges on reality quickly, because every gap gets closed within a few months of opening.
A wear-based recommendation as a small optimizer
With validation in place, recommendation is almost trivial: enumerate the allowed patterns for the vehicle, reject the ones with violations, project each remaining one forward by an interval using a per-axle wear rate, and rank by how even the tread will be at the next service. The projection matters because the goal is not to equalise tread today; it is to have the set arrive at the next service as even as possible, and the fronts on a front-drive vehicle will lose more in the interval than the rears.
WEAR_PER_INTERVAL = {1: 1.0, 2: 0.6} # illustrative mm lost per interval, by axle
def project(state: State, perm: Perm) -> Dict[Pos, float]:
after = apply(state, perm)
return {p: t.tread_mm - WEAR_PER_INTERVAL.get(p.axle, 0.0)
for p, t in after.items() if p.axle != 0}
def recommend(state: State, patterns: Dict[str, Perm], ctx: dict):
ranked = []
for name, perm in patterns.items():
problems = validate(state, perm, ctx)
if problems:
ranked.append((None, name, "rejected: " + "; ".join(v.detail for v in problems)))
continue
depths = project(state, perm)
spread = max(depths.values()) - min(depths.values())
why = (f"{name}: projected spread {spread:.1f} mm at next service "
f"(min {min(depths.values()):.1f}, max {max(depths.values()):.1f}), "
f"cycle order {order(perm)}")
ranked.append(((round(spread, 2), -order(perm)), name, why))
ok = sorted(r for r in ranked if r[0] is not None)
return (ok[0] if ok else None), ranked
The ranking key is a tuple: projected spread first, then negative cycle order. The second element is the tiebreaker, and it is there because of a result from earlier. On a four-wheel vehicle with worn fronts and fresh rears, every pattern that swaps axles produces the same projected spread, since they all move the same two tires forward and the same two back. What distinguishes them is what happens over the next several rotations, and a higher-order pattern mixes positions over more of the tire's life. So the optimizer prefers the forward cross (order 4) over the same-side swap (order 2) when both are allowed, and it says so in the explanation string.
With illustrative depths of 5.1 and 5.3 mm on the fronts and 7.6 and 7.4 mm on the rears, and non-directional tires, the run I did ranked the forward cross first with a projected spread of 2.1 mm, followed by the rearward cross at the same spread and order, then X and same-side at the same spread but order 2, and finally "do nothing" at 2.9 mm. Make the same four tires directional and the output changes shape: forward cross, rearward cross and X are all rejected with a sentence naming which tires would cross, and same-side becomes the recommendation. The rejection strings are the same strings the validator produces, so the technician sees one vocabulary everywhere.
Two things are deliberately left out of this optimizer. It does not consider replacing a tire, because that is a purchasing decision with information the model does not have. And it does not weight positions by which one carries the drive load, because that is already baked into the wear rates, and doubling it up makes the explanation harder to follow without changing the ranking much.
A relational schema for positions, tires and moves
The event JSON above is the wire format. On disk I would use five tables and let the database enforce the shape of a permutation with constraints rather than trusting application code. The DDL below is PostgreSQL-flavoured.
CREATE TABLE vehicles (
vehicle_id TEXT PRIMARY KEY,
fleet_id TEXT NOT NULL,
layout TEXT NOT NULL,
drive_type TEXT NOT NULL CHECK (drive_type IN ('FWD','RWD','AWD','4WD')),
staggered BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE positions (
layout TEXT NOT NULL,
code TEXT NOT NULL,
axle SMALLINT NOT NULL CHECK (axle >= 0),
side CHAR(1) NOT NULL CHECK (side IN ('L','R','-')),
dual_rank TEXT NOT NULL CHECK (dual_rank IN ('single','inner','outer')),
PRIMARY KEY (layout, code),
UNIQUE (layout, axle, side, dual_rank)
);
CREATE TABLE tires (
tire_id TEXT PRIMARY KEY,
dot_code TEXT,
size TEXT NOT NULL,
directional BOOLEAN NOT NULL DEFAULT FALSE,
allowed_side CHAR(1) CHECK (allowed_side IN ('L','R')),
lt_rated BOOLEAN NOT NULL DEFAULT FALSE,
retired_at TIMESTAMPTZ
);
CREATE TABLE rotation_events (
event_id TEXT PRIMARY KEY,
vehicle_id TEXT NOT NULL REFERENCES vehicles(vehicle_id),
kind TEXT NOT NULL CHECK (kind IN ('mount','dismount','rotation','remount','audit_fix')),
layout TEXT NOT NULL,
prior_event_id TEXT REFERENCES rotation_events(event_id),
occurred_at TIMESTAMPTZ NOT NULL,
odometer_km INTEGER,
pattern_name TEXT,
corrects_event TEXT REFERENCES rotation_events(event_id),
recorded_by TEXT NOT NULL,
UNIQUE (vehicle_id, prior_event_id)
);
CREATE TABLE rotation_event_moves (
event_id TEXT NOT NULL REFERENCES rotation_events(event_id),
from_code TEXT NOT NULL,
to_code TEXT NOT NULL,
tire_id TEXT NOT NULL REFERENCES tires(tire_id),
tread_mm_before NUMERIC(4,1),
PRIMARY KEY (event_id, from_code),
UNIQUE (event_id, to_code),
UNIQUE (event_id, tire_id)
);
CREATE INDEX ON rotation_events (vehicle_id, occurred_at);
CREATE INDEX ON rotation_event_moves (tire_id, event_id);
The constraints on rotation_event_moves are doing most of the work. The primary key on (event_id, from_code) means no source position is listed twice; the unique constraint on (event_id, to_code) means no destination receives two tires; the unique constraint on (event_id, tire_id) means no tire is listed as moving twice in one event. Together those are the definition of an injective mapping, and since the source and target sets are the same finite layout, injective is enough for bijective. A partial rotation that omits positions is still representable, because untouched positions are simply absent from the moves table and the replay treats absence as identity.
The UNIQUE (vehicle_id, prior_event_id) constraint on events is the chain guard: two events cannot both claim to follow the same predecessor on one vehicle, so a race between two technicians writing at once fails loudly instead of forking history. The corrects_event column is how audit fixes point at what they fix without editing it.
What the schema does not enforce is the directional rule, because that depends on the tire's allowed_side at the time of the event, and the tires table only holds the current value. Remount events change it, and the replay reads the remount history to know what the value was at each rotation. This is the one place the application has to carry state the database cannot check, and it is worth a comment in the migration.
Property-based tests for the permutation core
The composition, inverse and order functions are exactly the kind of code that deserves property-based testing, because the properties are algebraic and a handful of hand-written examples will not exercise the odd cases. Here they are in Hypothesis style. The strategy builds a random bijection over a layout by shuffling the position list.
from hypothesis import given, strategies as st
LAYOUT = sorted(four_wheel(with_spare=True))
@st.composite
def perms(draw):
targets = draw(st.permutations(LAYOUT))
return dict(zip(LAYOUT, targets))
@given(perms())
def test_generated_mapping_is_a_bijection(p):
assert is_bijection(p, LAYOUT)
@given(perms())
def test_inverse_undoes_on_both_sides(p):
assert compose(p, inverse(p)) == identity(LAYOUT)
assert compose(inverse(p), p) == identity(LAYOUT)
@given(perms(), perms(), perms())
def test_composition_is_associative(p, q, r):
assert compose(compose(p, q), r) == compose(p, compose(q, r))
@given(perms(), perms())
def test_inverse_of_composition_reverses_order(p, q):
assert inverse(compose(p, q)) == compose(inverse(q), inverse(p))
@given(perms())
def test_order_is_the_least_power_returning_identity(p):
k = order(p)
acc = identity(LAYOUT)
for i in range(1, k + 1):
acc = compose(acc, p)
assert (acc == identity(LAYOUT)) == (i == k)
@given(perms())
def test_cycles_partition_the_layout(p):
flat = [pos for cyc in cycles(p) for pos in cyc]
assert sorted(flat) == LAYOUT
The last two are the ones that catch real bugs. The order test checks not only that p raised to its order is the identity, but that no smaller power is, which is the difference between "an order" and "the order". The cycle-partition test catches a cycles implementation that visits a position twice or drops one, which happens easily if the seen set is updated at the wrong point in the loop. I ran the same six properties through a plain random-shuffle harness over two thousand permutations of the five-position layout while writing this, and all six held.
A small example-based suite for the named patterns
Properties cover the algebra; examples cover the facts about specific patterns that a fleet would actually be relying on. These are deliberately short and each asserts one thing a domain expert would recognise.
import unittest
class NamedPatternFacts(unittest.TestCase):
def test_forward_cross_is_one_four_cycle(self):
self.assertEqual([len(c) for c in cycles(FORWARD_CROSS)], [4])
def test_rearward_cross_undoes_forward_cross(self):
self.assertEqual(inverse(FORWARD_CROSS), REARWARD_CROSS)
self.assertEqual(compose(FORWARD_CROSS, REARWARD_CROSS), identity(four_wheel()))
def test_two_forward_crosses_are_a_side_swap(self):
twice = compose(FORWARD_CROSS, FORWARD_CROSS)
self.assertEqual(twice[LF], RF)
self.assertEqual(twice[LR], RR)
def test_five_tire_pattern_visits_every_position(self):
self.assertEqual(order(FIVE_FORWARD), 5)
self.assertEqual(len(cycles(FIVE_FORWARD)), 1)
def test_same_side_never_crosses(self):
for src, dst in SAME_SIDE.items():
self.assertEqual(src.side, dst.side)
def test_dually_pattern_moves_front_to_inner(self):
self.assertEqual(DUALLY_SAME_SIDE[LF].rank, Rank.INNER)
self.assertEqual(order(DUALLY_SAME_SIDE), 3)
def test_directional_set_rejects_x_and_accepts_same_side(self):
st = {p: Tire(f"T{i}", directional=True, allowed_side=p.side)
for i, p in enumerate(sorted(four_wheel()))}
self.assertTrue(validate(st, X_PATTERN, {}))
self.assertEqual(validate(st, SAME_SIDE, {}), [])
The test_same_side_never_crosses case is the one I would keep even if every other test were deleted, because it is the assertion that the pattern named "same side" actually is what the directional rule assumes it is. If someone edits the pairs and swaps a side by accident, this fails before the validator ever gets to lie to a technician.
Edge cases that stretch the model without breaking it
A model earns trust on the awkward cases, so here are the four I would put in the acceptance suite, with how each maps onto events.
A tire replaced mid-life. A puncture on the Trans-Canada past Cochrane turns out to be a shoulder cut that cannot be repaired, and one new tire goes on with the other three at 5 mm. The events are dismount at the position, mount of the new tire at the same position, and the old tire gets retired_at set. The next recommendation run will see a 3-plus-mm spread and, on an AWD vehicle, will reject every pattern with an awd_spread violation, which is the correct outcome: the model is telling the fleet manager that the vehicle is now outside its own policy and that a rotation cannot fix it. The remedy is a policy decision (shave the new tire, replace a pair, or accept the spread), not a permutation.
A spare rotated in. The five-tire pattern handles this as an ordinary rotation. The subtle case is a vehicle that has been running four-tire rotations for two years and now wants to bring the spare in. The spare's tread is at 8.9 mm and the four on the road are near 5 mm. On an AWD vehicle the spread rule blocks it, and on a front-drive vehicle the optimizer will accept it but the projection will show the spread widening at the next interval, because the deep tire now sits on a fast-wearing axle. The explanation string carries that number, and the sensible reading is that the spare should have been in the cycle from the start.
A tire moved between vehicles. The trailer-as-separate-vehicle decision pays off here. Moving a tire from unit 12 to unit 7 is dismount on 12 and mount on 7, each with its own event chain, and the tire's own history (joined through rotation_event_moves.tire_id) spans both. A directional tire keeps its allowed_side across the transfer, so mounting a left-committed tire at A1R on the new unit is rejected at the mount unless a remount precedes it. Nothing about the model had to change to support this; it only had to avoid storing position on the tire.
A partial rotation. Only the fronts swapped, say because the rear duals on a one-ton were mismatched and the technician chose not to disturb them. The moves list contains two rows, A1L -> A1R and A1R -> A1L, and the replay fills the untouched positions with identity. The event is a legitimate permutation, validate runs the directional rule on it (and rejects it for directional fronts), and the cycle structure is one transposition plus fixed points. The order is 2. A fleet dashboard that tracks "rotations since each tire last changed axle" will correctly show the rears getting older, which is the information the partial rotation was hiding when it was a free-text note.
The seasonal changeover as a permutation between two sets
Calgary adds one more case that most fleet software written elsewhere never sees: twice a year, every tire on the vehicle is replaced by a tire from a second set, and the first set goes into storage. Modelled as events, a changeover is four (or five, or six) dismount events followed by the same number of mount events, and the interesting question is whether the winter set goes back on in the same arrangement it came off in.
It should not, usually. A winter set that spent last season with two tires on the front of a front-drive car came off with those two worn more than the other two. The right move at the October changeover is to mount them in a rotated arrangement, which means the changeover itself carries a permutation: the mapping from where each winter tire was when it came off in April to where it goes on now. That permutation is subject to every rule above. Directional winter tires (and a great many of them are directional) cannot cross sides at the changeover any more than at a rotation, so the pattern applied across the storage gap is restricted to same-side moves unless remounts are done at the same time. Fleets that run dedicated winter tires on the whole fleet should record the storage-gap permutation explicitly, because "we put them back on the same corners" for four consecutive winters is how a set ends up with two tires at the wear bar and two barely used.
Fleets that run all-weather tires year-round avoid the changeover events entirely and go straight to the rotation cadence, which is simpler for the ledger but means the rotation pattern is the only mechanism evening out wear, so the order of the pattern matters more.
The other seasonal detail worth encoding is that a changeover is when the physical audit is cheapest, because every wheel is off the vehicle and every DOT string is readable. If a fleet does one reconciliation a year, it should be at the changeover, and the model's audit_fix event kind exists so that the corrections it produces are distinguishable from ordinary service work.
Questions that came up when I walked someone through this
Why not store the permutation as a list of tire moves instead of position moves? Because the position set is fixed and small and the tire set is not. A position permutation can be validated as a bijection without knowing anything about which tires are present; a tire-keyed version cannot be validated until you join it to the state. Position moves are also what a technician actually does: they pick up the wheel at A2L and carry it to A1R, whatever tire is on it.
What happens when a vehicle's layout changes, such as a pickup that gains a dually conversion? The vehicle row's layout changes, but every event carries the layout it was recorded against, so replay uses the historical layout for historical events. Any rotation after the conversion that still uses four-wheel positions is caught by the wheel-count check, which is the whole reason the layout is on the event.
Is tread depth really a good enough proxy for diameter in the dual-matching rule? Within a single size and construction, close enough for a policy threshold. Across sizes it is not, which is why the rule assumes a single size on an axle and a separate fitment check enforces that. If the fleet mixes sizes on an axle, that is a fitment problem, not a rotation problem, and this model does not try to solve it.
Does the model need to know about the wheels? Not for rotation. A rotation moves wheel-and-tire assemblies, and the wheel travels with the tire. A remount is where the wheel and tire separate, and if the fleet tracks wheels (for balance history or for aluminium versus steel winter wheels), the remount event is where a wheel ID would be attached. It is a clean extension and I left it out to keep the core small.
How does this interact with KMJ's mobile tire service doing the rotation in a fleet yard rather than at the store? In exactly the same way. The event is the same whether it was recorded in a bay in the southeast or on a tablet in a yard by the airport. The only practical difference is that a mobile rotation is a good place to enforce the resulting-map checksum at write time, because there is no second person to notice a mis-keyed position later in the day.
What I would ship first
If I had a week to get something useful into a fleet's hands, this is the order I would build it in, and the reasoning is that each step makes the previous step's data trustworthy before the next step depends on it.
- The
Postype and the three layouts, with the string form on every work-order line. Even before any validation exists, the act of choosing from a fixed set instead of typing kills most of the ambiguity. - The event tables with the bijection constraints. The database rejecting a non-permutation on insert is worth more than any later reporting, because it is the only check that runs while the technician is still standing at the vehicle.
- Replay and the four impossibility checks, run nightly across the fleet with a short report listing every vehicle whose history does not reconstruct cleanly. Expect the first run to be ugly; that is the backlog of unrecorded moves surfacing.
- The directional rule and the remount event, in that order, because they are the pair that catches the most consequential mistake in the whole model.
- Reconciliation against a physical audit, timed for the next changeover when the wheels are already off.
- The optimizer last, because a recommendation built on an unreliable ledger is worse than no recommendation.
Everything above the optimizer is record-keeping in the honest sense: it makes the record match the vehicle. The optimizer is the only part that tries to be clever, and it is deliberately the smallest. The fleets that get the most from a tool like this are the ones that arrive with a clean ledger and let the small optimizer do a small job well, and the ones that arrange a visit to KMJ's Calgary location or use the online arrangement form with a printed position map in hand tend to spend less time explaining which tire they mean and more time getting the work done. If the model in this piece does nothing else, it should make the phrase "LF to RR, RR to LF" disappear from the work orders of anyone who reads it, replaced by a permutation the software can check.












