Parsing Tire Size Codes: Building a Fitment Data Model That Survives Real-World Inputs
A tire size designation looks trivial until you try to parse one that a human typed. P215/60R16 94H reads like a product code, and most developers who encounter it for the first time reach for a single regular expression, ship it, and move on. Then the intake queue delivers LT265/70R17 121/118S E, followed by 35x12.50R17, followed by 225 60 16 pasted from a text message, followed by 2256016 with no separators at all, and the single regex becomes a graveyard of nested optional groups that nobody on the team wants to touch.
I work on the software side of KMJ Tire, a small Calgary operation that handles tire work and oil changes and nothing else — no mechanical repair, no diagnostics, just rubber, wheels, and lubricant. That narrow scope means our data problems are narrow too, and the deepest one is fitment: given a string a customer typed into a web form or a size a technician read off a sidewall, what tire is actually being described, does it fit the vehicle in question, and is it safe relative to what the manufacturer specified? This article walks through the grammar of tire sizes, a tokenizer and parser that handle the real-world mess, a normalized data model, the diameter math behind equivalence and plus-sizing, and the validation layer that refuses dangerous fitments. All code is Python and runnable in spirit; illustrative examples are labelled as such, and none of the numbers here are business statistics.
A Sidewall Is a Grammar, Not a String
The designation moulded into a sidewall is a compact sentence in a formal language. P215/60R16 94H decomposes into:
-
P— an optional service-type prefix (Pfor passenger,LTfor light truck,Tfor temporary spare,STfor special trailer,Csuffix conventions in Europe). -
215— section width in millimetres, the widest point of the inflated tire. -
60— aspect ratio, sidewall height as a percentage of section width. -
R— internal construction (Rradial,Ddiagonal/bias,Bbelted bias). You will also meetZR, which historically encoded a speed capability above 240 km/h and still appears combined with a modern speed symbol. -
16— rim diameter in inches. Yes, the same designation mixes millimetres, percentages, and inches. This is the industry standard and it is never going away. -
94— load index, an integer that maps through a standardized table to a maximum load in kilograms. -
H— speed symbol, a letter mapping to a maximum sustained speed.
Light-truck sizes extend the sentence. LT265/70R17 121/118S E carries a dual load index — 121 for single-wheel fitment, 118 per tire when mounted as duals on one axle end — plus a load range letter (E) that encodes ply rating and inflation ceiling. Flotation sizes abandon the metric pattern entirely: 35x12.50R17 states overall diameter in inches, then section width in inches, then construction and rim. Three families, three grammars, one input field on your intake form.
Anyone who wants the customer-facing version of this decoding can read the sidewall markings walkthrough we keep for drivers; this article is the engineering companion to that page.
Three Families of Size, One Input Field
Before writing any code, pin down the three concrete syntaxes you must accept.
Metric (P-metric and Euro-metric). P215/60R16 94H, 215/60R16 94H, 215/60ZR16, 215/60R16 94H XL. The P prefix is optional and its absence changes load calculation standards (Euro-metric tires at the same size often carry slightly different maximum loads). XL or RF marks reinforced/extra-load casings.
LT-metric. LT265/70R17 121/118S E. Prefix mandatory, dual load index common, load range letter (C, D, E, F) frequently appended. These dominate the three-quarter-ton pickups that fill Calgary driveways, and they matter commercially because commercial tire work is disproportionately LT-metric.
Flotation. 35x12.50R17LT 121Q. Diameter-first, inches throughout, LT sometimes trailing the rim diameter rather than prefixing the string. The decimal in 12.50 is load-bearing syntax: 12.5 and 12.50 must normalize to the same value.
A grammar in EBNF-ish form:
size := metric | lt_metric | flotation
metric := [prefix] width "/" aspect construction rim [service_desc]
lt_metric := "LT" width "/" aspect construction rim [dual_service] [load_range]
flotation := diameter "x" fwidth construction rim ["LT"] [service_desc]
prefix := "P" | "T" | "ST"
construction:= ["Z"] ("R" | "D" | "B")
service_desc:= load_index [ "/" load_index ] speed_symbol ["XL" | "RF"]
That sketch is already more honest than a regex, because it names the alternatives instead of burying them in (?:...)? groups.
Why the One-Regex Approach Collapses
The naive implementation looks like this, and versions of it exist in production at plenty of companies:
import re
NAIVE = re.compile(r"([A-Z]*)(\d{3})/(\d{2})R(\d{2})\s*(\d{2,3})([A-Z])")
Enumerate its failures against genuine intake strings (illustrative examples drawn from the kinds of input any tire retailer sees, not a statistical sample):
-
Optional everything.
215/60R16with no service description is a valid size. The regex demands load index and speed symbol. -
Dual load index.
121/118Sbreaks the single(\d{2,3})group, and a greedy fix silently swallows the second index into the aspect of a mangled parse. -
ZR markers.
245/40ZR18 97Yputs aZbefore theR. Patterns anchored on a bareReither miss it or mis-split the aspect ratio. -
Flotation.
35x12.50R17shares almost no surface syntax with metric sizes. One regex covering both families becomes unreadable. -
Human separators. Web forms deliver
215-60-16,215 60 16,215/60/16,215/60-R16, and the gloriously ambiguous2156016. A rigid pattern rejects all of them, and rejected input at intake means a person re-keys data, which is where transcription errors breed. -
Case and whitespace.
p215/60r16from a phone keyboard, tabs from spreadsheet paste, trailingM+Sor3PMSFbadges the customer helpfully included. -
Three-digit aspect and two-digit width don't exist — but 82-series legacy sizes and rim diameters like
22.5(heavy truck) do. Half-inch rim diameters destroy(\d{2}).
The failure mode that hurts most is not rejection — it is a confident wrong parse. LT265/70R17 121/118S parsed by a greedy single-index pattern can yield load index 121 and drop 118S on the floor, and the record now overstates nothing but silently loses the dual rating that a dually fitment decision needs. Wrong-but-plausible data is worse than an error, which is the core argument for a real tokenizer.
Lexing the Sidewall: Tokens Before Meaning
Treat the input the way a compiler treats source: normalize, tokenize, then parse. The tokenizer's only job is to slice the string into typed chunks without deciding what they mean.
import re
from dataclasses import dataclass
from typing import Iterator
@dataclass(frozen=True)
class Token:
kind: str # NUM, DEC, SEP, X, CONSTRUCT, WORD
text: str
pos: int
TOKEN_SPEC = [
("DEC", r"\d{1,3}\.\d{1,2}"), # 12.50, 22.5
("NUM", r"\d{1,4}"), # 215, 60, 16, 94, 121
("X", r"[xX×]"), # flotation separator
("CONSTRUCT", r"ZR|R|D|B(?![A-Z])"), # construction markers
("SEP", r"[/\-\s]+"), # slash, dash, spaces
("WORD", r"[A-Z]{1,5}"), # P, LT, XL, S, E, TL...
]
MASTER = re.compile(
"|".join(f"(?P<{k}>{p})" for k, p in TOKEN_SPEC), re.IGNORECASE
)
def tokenize(raw: str) -> Iterator[Token]:
s = raw.strip().upper().replace("×", "X")
for m in MASTER.finditer(s):
kind = m.lastgroup
if kind == "SEP":
yield Token("SEP", "/", m.start())
else:
yield Token(kind, m.group(), m.start())
Two deliberate choices deserve comment. First, DEC outranks NUM in the alternation so 12.50 never fractures into 12, ., 50. Second, every separator collapses to a canonical /, which is how 215-60-16 and 215 60 16 stop being special cases before the parser ever sees them.
There is a subtlety in CONSTRUCT versus WORD: the letter R is both a construction marker and a possible fragment of a word token. Resolving that requires context, and context is the parser's job, not the lexer's. The lexer emits CONSTRUCT greedily and the parser is written to re-interpret when the grammar says a construction marker cannot appear at that position. This division of labour is exactly why the two-stage design stays maintainable while the one-regex design rots.
A Recursive-Descent Parser for Three Grammars
With tokens in hand, the parser is a small recursive-descent routine with one lookahead decision: does the stream look flotation-shaped (NUM/DEC, X, ...) or metric-shaped (WORD?, NUM, SEP, ...)?
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ParsedSize:
family: str # "metric" | "lt_metric" | "flotation"
prefix: Optional[str] = None # P, LT, T, ST
section_width_mm: Optional[float] = None
aspect_ratio: Optional[float] = None
overall_diameter_in: Optional[float] = None # flotation only
section_width_in: Optional[float] = None # flotation only
construction: str = "R"
zr_marker: bool = False
rim_diameter_in: float = 0.0
load_index_single: Optional[int] = None
load_index_dual: Optional[int] = None
speed_symbol: Optional[str] = None
load_range: Optional[str] = None # C, D, E, F
reinforced: bool = False # XL / RF
warnings: list = field(default_factory=list)
class SizeParseError(ValueError):
pass
class SizeParser:
def __init__(self, tokens):
self.toks = [t for t in tokens if t.kind != "SEP" or True]
self.i = 0
def peek(self, k=0):
j = self.i + k
return self.toks[j] if j < len(self.toks) else None
def take(self, kind=None):
tok = self.peek()
if tok is None or (kind and tok.kind != kind):
want = kind or "any token"
raise SizeParseError(f"expected {want} at position {self.i}")
self.i += 1
return tok
def skip_seps(self):
while self.peek() and self.peek().kind == "SEP":
self.i += 1
def parse(self) -> ParsedSize:
self.skip_seps()
if self.looks_flotation():
return self.parse_flotation()
return self.parse_metric_family()
def looks_flotation(self) -> bool:
a, b = self.peek(0), self.peek(1)
return (
a is not None and b is not None
and a.kind in ("NUM", "DEC") and b.kind == "X"
)
The metric branch shows how optional prefixes and dual indexes are handled without regex contortions:
def parse_metric_family(self) -> ParsedSize:
out = ParsedSize(family="metric")
tok = self.peek()
if tok and tok.kind == "WORD" and tok.text in ("P", "LT", "T", "ST"):
out.prefix = self.take().text
if out.prefix == "LT":
out.family = "lt_metric"
out.section_width_mm = float(self.take("NUM").text)
self.skip_seps()
out.aspect_ratio = float(self.take("NUM").text)
self.skip_seps()
c = self.peek()
if c and c.kind == "CONSTRUCT":
marker = self.take().text
out.zr_marker = marker.startswith("Z")
out.construction = marker[-1]
else:
out.warnings.append("construction marker missing; assumed R")
rim = self.take()
if rim.kind not in ("NUM", "DEC"):
raise SizeParseError("rim diameter expected after construction")
out.rim_diameter_in = float(rim.text)
self.parse_service_description(out)
return out
def parse_service_description(self, out: ParsedSize) -> None:
self.skip_seps()
nxt = self.peek()
if nxt and nxt.kind == "NUM":
out.load_index_single = int(self.take().text)
self.skip_seps()
if self.peek() and self.peek().kind == "NUM":
out.load_index_dual = int(self.take().text)
self.skip_seps()
while self.peek() and self.peek().kind in ("WORD", "CONSTRUCT"):
word = self.take().text
if word in ("XL", "RF"):
out.reinforced = True
elif word in ("C", "D", "E", "F") and out.family == "lt_metric":
out.load_range = word
elif len(word) <= 2 and out.speed_symbol is None:
out.speed_symbol = word
else:
out.warnings.append(f"unrecognized suffix token: {word}")
Notice the re-interpretation trick: in service-description position, a CONSTRUCT token like a stray R (a speed symbol!) or D (a load range!) is consumed as a word. The lexer's greedy guess gets corrected by grammatical context. 245/40ZR18 sets zr_marker=True and construction="R", preserving the historical marker without letting it pollute the structural fields.
The flotation branch is short because its shape is rigid:
def parse_flotation(self) -> ParsedSize:
out = ParsedSize(family="flotation")
out.overall_diameter_in = float(self.take().text)
self.take("X")
out.section_width_in = float(self.take().text)
c = self.peek()
if c and c.kind == "CONSTRUCT":
out.construction = self.take().text[-1]
out.rim_diameter_in = float(self.take().text)
nxt = self.peek()
if nxt and nxt.kind == "WORD" and nxt.text == "LT":
out.prefix = self.take().text
self.parse_service_description(out)
return out
Wrap the pipeline in one function and every call site stays clean:
def parse_size(raw: str) -> ParsedSize:
return SizeParser(list(tokenize(raw))).parse()
parse_size("P215/60R16 94H") # metric, LI 94, speed H
parse_size("lt265/70r17 121/118S E") # lt_metric, dual LI, load range E
parse_size("35x12.50R17LT 121Q") # flotation
parse_size("215 60 16") # metric with warnings, no service desc
Run-Together Digits and the Ambiguity Budget
2156016 is the intake string that teaches humility. Seven digits, no separators, typed by someone reading their own sidewall in a parkade. A parser cannot split it purely syntactically — but it can generate candidates and score them against physical plausibility:
VALID_WIDTHS = set(range(125, 455, 10)) | {145, 155, 165} # metric widths
VALID_ASPECTS = set(range(25, 90, 5)) | {82}
VALID_RIMS = {13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 24}
def split_run_together(digits: str):
"""Yield plausible (width, aspect, rim) splits of an unseparated digit run."""
n = len(digits)
for wi in (3,):
for ai in (2,):
ri_len = n - wi - ai
if ri_len not in (2,):
continue
w, a, r = int(digits[:wi]), int(digits[wi:wi+ai]), int(digits[wi+ai:])
if w in VALID_WIDTHS and a in VALID_ASPECTS and r in VALID_RIMS:
yield (w, a, r)
list(split_run_together("2156016")) # [(215, 60, 16)]
For seven digits the split is usually unique; six digits (2256016 minus a digit, say 225616) can be genuinely ambiguous, and the correct system behaviour is to refuse to guess silently. Return all candidates, force a human confirmation at intake, and record which candidate was chosen. The design principle: spend your ambiguity budget on asking one good question rather than on being wrong quietly. The same principle underlies the plain-language advice on our be-tire-smart explainer — when a marking is unclear, verify before acting on it.
The Normalized Fitment Model
Parsing produces a struct; a business needs entities. The model that has held up for us separates five concerns that tutorials usually mash into one table.
1. tire_size — the pure geometric designation, deduplicated. 215/60R16 is one row no matter how many products or vehicles reference it.
2. service_rating — load index (single and dual), speed symbol, load range, reinforced flag. Kept apart from size because one geometric size ships with many ratings: 215/60R16 exists as 94H, 95H XL, 99T winter, and conflating them is precisely how unsafe substitutions sneak into records.
3. vehicle_fitment — vehicle (year/make/model/trim) joined to an OE (original equipment) size-and-rating pair, with a flag for front/rear staggered setups.
4. fitment_option — approved alternatives per vehicle: plus-size and minus-size equivalents with computed diameter deviation. Minus-sizing shows up constantly in winter: many drivers run a smaller rim with a taller sidewall on their winter set for pothole resilience and narrower contact patch.
5. intake_record — the raw string as typed, the parse result, parser warnings, and the resolved tire_size id. Never discard the raw input; it is your regression-test corpus and your audit trail.
Schema Sketch in SQL
CREATE TABLE tire_size (
id INTEGER PRIMARY KEY,
family TEXT NOT NULL CHECK (family IN ('metric','lt_metric','flotation')),
prefix TEXT,
section_width_mm REAL,
aspect_ratio REAL,
diameter_in REAL, -- flotation overall diameter
width_in REAL, -- flotation section width
construction TEXT NOT NULL DEFAULT 'R',
rim_diameter_in REAL NOT NULL,
canonical_text TEXT NOT NULL UNIQUE
);
CREATE TABLE service_rating (
id INTEGER PRIMARY KEY,
size_id INTEGER NOT NULL REFERENCES tire_size(id),
load_index INTEGER,
load_index_dual INTEGER,
speed_symbol TEXT,
load_range TEXT,
reinforced INTEGER NOT NULL DEFAULT 0,
UNIQUE (size_id, load_index, load_index_dual, speed_symbol, load_range, reinforced)
);
CREATE TABLE vehicle_fitment (
id INTEGER PRIMARY KEY,
vehicle_key TEXT NOT NULL, -- e.g. '2019|toyota|rav4|xle'
axle TEXT NOT NULL DEFAULT 'all', -- 'all' | 'front' | 'rear'
oe_rating_id INTEGER NOT NULL REFERENCES service_rating(id)
);
CREATE TABLE fitment_option (
id INTEGER PRIMARY KEY,
fitment_id INTEGER NOT NULL REFERENCES vehicle_fitment(id),
rating_id INTEGER NOT NULL REFERENCES service_rating(id),
kind TEXT NOT NULL CHECK (kind IN ('oe','plus1','plus2','minus1','winter_alt')),
diam_deviation_pct REAL NOT NULL
);
CREATE TABLE intake_record (
id INTEGER PRIMARY KEY,
received_at TEXT NOT NULL,
raw_text TEXT NOT NULL,
channel TEXT NOT NULL, -- 'web_form','counter','phone','mobile_unit'
parse_ok INTEGER NOT NULL,
parse_warnings TEXT,
resolved_rating_id INTEGER REFERENCES service_rating(id),
resolved_by TEXT -- 'auto' | 'staff_confirmed'
);
The canonical_text uniqueness constraint is the workhorse. Every parse serializes back to one canonical string, and the database — not application code — enforces that 215/60 R 16, 215-60-16, and P215/60R16 collapse toward the intended identity (prefix retained where it is semantically meaningful, since P-metric and Euro-metric load standards differ).
def canonical(p: ParsedSize) -> str:
if p.family == "flotation":
core = f"{p.overall_diameter_in:g}x{p.section_width_in:.2f}{p.construction}{p.rim_diameter_in:g}"
core += p.prefix or ""
else:
pre = p.prefix or ""
z = "Z" if p.zr_marker else ""
core = f"{pre}{p.section_width_mm:g}/{p.aspect_ratio:g}{z}{p.construction}{p.rim_diameter_in:g}"
svc = ""
if p.load_index_single is not None:
svc = f" {p.load_index_single}"
if p.load_index_dual is not None:
svc += f"/{p.load_index_dual}"
if p.speed_symbol:
svc += p.speed_symbol
if p.load_range:
svc += f" {p.load_range}"
if p.reinforced:
svc += " XL"
return core + svc
Round-tripping — canonical(parse_size(canonical(parse_size(x)))) equals canonical(parse_size(x)) — is a property test worth wiring into CI on day one.
Overall Diameter: The Equivalence Math
Two sizes are interchangeable candidates only when their overall diameters land close together, because diameter drives speedometer accuracy, gearing, drivetrain stress on AWD systems, and body clearance. The formula for metric sizes:
sidewall_height_mm = section_width_mm × (aspect_ratio / 100)
overall_diameter_mm = rim_diameter_in × 25.4 + 2 × sidewall_height_mm
In code, with flotation handled in the same function:
def overall_diameter_mm(p: ParsedSize) -> float:
if p.family == "flotation":
return p.overall_diameter_in * 25.4
sidewall = p.section_width_mm * (p.aspect_ratio / 100.0)
return p.rim_diameter_in * 25.4 + 2.0 * sidewall
def deviation_pct(candidate: ParsedSize, reference: ParsedSize) -> float:
ref = overall_diameter_mm(reference)
return (overall_diameter_mm(candidate) - ref) / ref * 100.0
Worked example — a plus-one conversion, numbers shown in full so you can check the arithmetic:
Reference: 205/55R16
sidewall = 205 × 0.55 = 112.75 mm
diameter = 16 × 25.4 + 2×112.75 = 406.40 + 225.50 = 631.90 mm (24.88 in)
Candidate: 225/45R17
sidewall = 225 × 0.45 = 101.25 mm
diameter = 17 × 25.4 + 2×101.25 = 431.80 + 202.50 = 634.30 mm (24.97 in)
Deviation = (634.30 − 631.90) / 631.90 = +0.38 % → excellent match
And a flotation-to-metric comparison, which the unified function makes one-line work:
35x12.50R17 → 35 × 25.4 = 889.00 mm
Nearest LT-metric: LT315/70R17
sidewall = 315 × 0.70 = 220.50 mm
diameter = 431.80 + 441.00 = 872.80 mm
Deviation = (872.80 − 889.00) / 889.00 = −1.82 % → acceptable, flag for clearance
Revolutions per kilometre fall out of the same value and matter for odometer-sensitive fleet records:
import math
def revs_per_km(p: ParsedSize) -> float:
return 1_000_000.0 / (math.pi * overall_diameter_mm(p))
# 205/55R16 → 1,000,000 / (π × 631.90) ≈ 503.8 rev/km
A practical threshold set, encoded as policy rather than folklore: deviation within ±1.5 % auto-approves as an equivalence candidate, ±1.5–3 % flags for human review with a clearance note, beyond ±3 % rejects. Those bands are a policy choice for our operation, not a universal standard — the point is that they live in one table, not scattered through if statements.
Load Index and Speed Symbol: Small Tables, Big Consequences
Load index is a lookup, not a formula. A slice of the standardized table:
LOAD_INDEX_KG = {
88: 560, 89: 580, 90: 600, 91: 615, 92: 630, 93: 650,
94: 670, 95: 690, 96: 710, 97: 730, 98: 750, 99: 775,
100: 800, 104: 900, 110: 1060, 116: 1250, 118: 1320, 121: 1450,
}
SPEED_KMH = {
"N": 140, "P": 150, "Q": 160, "R": 170, "S": 180, "T": 190,
"U": 200, "H": 210, "V": 240, "W": 270, "Y": 300,
}
Two consequences follow. First, load index 94 means 670 kg per tire, so an OE spec of 94H on a crossover establishes a floor: a candidate rated 91 (615 kg) carries 55 kg less per corner than the manufacturer engineered for, and that difference compounds under a roof box, five passengers, and a Highway 2 grade. The plain-English version of this lives on our load index explainer; the systems version is a hard validation rule below. Second, speed symbols are ordered but not alphabetically — H sits between U and V for historical reasons — so comparisons must go through the table, never through ord().
Dual-index LT ratings add a wrinkle worth encoding: 121/118S means 1450 kg in single fitment but 1320 kg per tire when run as duals, because heat dissipation between paired tires is worse. If your model stores only the single figure, dually capacity math silently overstates by roughly 10 %.
Validation Rules That Refuse Dangerous Fitments
Validation is where the data model pays rent. Every quote line and every changeover record passes through a rule engine before a size is accepted against a vehicle:
from enum import Enum
class Severity(Enum):
BLOCK = "block"
REVIEW = "review"
NOTE = "note"
def validate_fitment(candidate: ParsedSize, oe: ParsedSize, season: str) -> list:
findings = []
li_c, li_o = candidate.load_index_single, oe.load_index_single
if li_c is not None and li_o is not None and li_c < li_o:
findings.append((Severity.BLOCK,
f"load index {li_c} below OE spec {li_o}: "
f"{LOAD_INDEX_KG.get(li_c,'?')} kg vs {LOAD_INDEX_KG.get(li_o,'?')} kg"))
sc, so = candidate.speed_symbol, oe.speed_symbol
if sc in SPEED_KMH and so in SPEED_KMH and SPEED_KMH[sc] < SPEED_KMH[so]:
sev = Severity.REVIEW if season == "winter" else Severity.BLOCK
findings.append((sev,
f"speed rating downgrade {so}→{sc} "
f"({SPEED_KMH[so]}→{SPEED_KMH[sc]} km/h)"))
dev = deviation_pct(candidate, oe)
if abs(dev) > 3.0:
findings.append((Severity.BLOCK, f"diameter deviation {dev:+.1f}% exceeds 3%"))
elif abs(dev) > 1.5:
findings.append((Severity.REVIEW, f"diameter deviation {dev:+.1f}%; check clearance"))
if oe.family == "lt_metric" and candidate.family == "metric":
findings.append((Severity.BLOCK,
"P-metric candidate against LT OE spec; derate rules not satisfied"))
return findings
The winter carve-out on speed downgrades reflects a widely accepted industry convention: dedicated winter tires are commonly permitted one step below the OE speed symbol because winter compounds rarely reach the highest ratings, and the trade is disclosed to the driver. Encoding it as REVIEW rather than auto-pass keeps a human in that disclosure loop. The LT-versus-P-metric rule guards the opposite direction: passenger-metric load figures cannot be compared one-to-one against light-truck specs without derating math, so the safe default is refusal.
What the engine deliberately does not do is silently "fix" anything. A blocked fitment produces a human-readable finding that a staff member resolves with the customer — usually by steering toward the correct spec on our guide to buying tires in Calgary terms: match or exceed OE load, respect the diameter envelope.
Fuzzy Matching the Typos People Actually Make
Real intake text contains a small, learnable set of corruption patterns (illustrative catalogue, not a frequency claim):
- Character confusions:
O↔0,l↔1,S↔5,B↔8. - Transpositions:
215/06R16for215/60R16. - Dropped construction letter:
215/6016. - Slash-for-R:
225/60/16. - Star-for-x in flotation:
35*12.5*17. - Unit smuggling:
215/60R16"with a stray inch mark.
A correction layer sits in front of the tokenizer and proposes, never imposes:
CONFUSIONS = str.maketrans({"O": "0", "o": "0", "l": "1", "I": "1", "*": "x"})
def prenormalize(raw: str) -> str:
s = raw.translate(CONFUSIONS).strip().rstrip('"').upper()
s = re.sub(r"(\d{3})/(\d{2})/(\d{2}(?:\.5)?)$", r"\1/\2R\3", s) # slash-for-R
return s
def levenshtein(a: str, b: str) -> int:
if len(a) < len(b):
a, b = b, a
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j-1] + 1, prev[j-1] + (ca != cb)))
prev = cur
return prev[-1]
def suggest(raw: str, catalog: list[str], max_dist: int = 2) -> list[str]:
cleaned = prenormalize(raw)
scored = sorted(
((levenshtein(cleaned, c), c) for c in catalog),
key=lambda t: t[0],
)
return [c for d, c in scored if d <= max_dist][:3]
The catalog is not "all syntactically valid sizes" — it is the finite set of sizes that physically exist in the market plus everything previously confirmed in intake_record. That last part matters: aspect ratio 06 is syntactically fine and physically absurd, and a market catalog rejects it while pure grammar cannot. Suggestions above edit distance 2 are discarded because a wrong confident suggestion is the same disease as a wrong confident parse. When nothing scores, the record routes to staff, exactly like an ambiguous digit run.
Transposition detection earns a special case. Damerau-Levenshtein (which counts adjacent swaps as one edit) treats 215/06R16 → 215/60R16 as distance 1, and combined with the plausibility check — 06 is not a valid aspect ratio, 60 is — the system can rank that correction first with high confidence while still routing it through confirmation.
How a Small Operation Actually Uses This
Concrete flows, because architecture without usage is decoration.
Quote intake. A driver submits a size through the web form or the online request page. The string hits prenormalize → tokenize → parse → validate, and one of three things happens: a clean parse attaches a service_rating id to the quote; a parse-with-warnings shows staff the raw string beside the interpretation for one-click confirmation; a failed parse presents fuzzy suggestions. The measurable win is that staff review interpretations instead of re-keying strings, and every confirmation enriches the catalog.
Seasonal changeover records. Calgary compresses tire logistics into two brutal windows — the October scramble onto winter rubber and the April–May return to three-season sets, the annual rhythm described on our seasonal changeover page. During those weeks, the interesting data problem is that each customer has two recorded sets, and the model must answer: which set is on the vehicle, which is in the customer's garage, and do the two sets agree with the vehicle's OE envelope? Storing both as service_rating references against the same vehicle_fitment row makes the winter/summer diff a query, not a phone conversation. Minus-one winter setups are the norm here, so the fitment_option.kind = 'winter_alt' rows with their precomputed diameter deviations do real work every October.
Mixed-set detection. When a mobile unit records sizes wheel-by-wheel at a customer's driveway, four independent parses land per vehicle. A trivial GROUP BY catches the vehicle running 215/60R16 on three corners and 205/60R16 on the fourth — a mismatch the owner usually inherited from a roadside change and never noticed. Same parser, zero extra code, genuinely useful safety catch.
Fleet records. For fleet accounts, revs_per_km from the diameter function reconciles hub odometer readings across mixed LT-metric and flotation fitments, and dual-index handling stops dually capacity from being overstated. None of this requires the operation to be large; it requires the model to be honest.
Edge Cases From the Intake Pile
A grab bag of real-shaped inputs (again illustrative, anonymized to their structure) and how the pipeline handles each:
-
225/65R17 102H 102H— the customer pasted the rating twice. The parser reads the second102as a dual index, then a plausibility rule fires: dual index on a non-LT family is a warning, and equal single/dual values collapse to single with a note. -
235/55R19 105V XL (front) 255/50R19 107V XL (rear)— a staggered fitment in one field. The tokenizer's leftover-token count tells the parser input remains after a complete parse; the pipeline re-enters and emits two records tagged front/rear from the parenthetical words. -
205/55R16 91H M+S—M+Sis an all-season mud-and-snow badge, not part of the size grammar. It lands inwarningsand maps to a season attribute, which is exactly the metadata that distinguishes a three-season set on our all-season overview from a mountain-snowflake-rated one covered by the all-weather overview. -
LT235/85R16 120/116Q LRE—LREis "load range E" glued into one token. A tiny suffix rewrite (LR([C-F]) → \1) in prenormalization handles the whole family of glued load ranges. -
31x10.5R15versus31x10.50R15— decimal-width normalization; both canonicalize to31x10.50R15. -
275/65R18 en hiver— French annotations appear in Alberta intake more often than you'd guess. Unknown words fall intowarningsand never corrupt structural fields; the parse succeeds. -
225,60,16— comma separators from European-formatted spreadsheets. One character class addition toSEP, plus a guard so commas inside digit groups (1,450 kg) don't split numbers.
Every one of these started life as a parser bug. The intake_record table is why they stopped being bugs: raw strings became regression fixtures the same day they were mis-handled.
Property Tests Beat Example Tests Here
Example-based tests freeze yesterday's bugs; property-based tests hunt tomorrow's. Three properties carry most of the weight:
from hypothesis import given, strategies as st
widths = st.sampled_from(sorted(VALID_WIDTHS))
aspects = st.sampled_from(sorted(VALID_ASPECTS))
rims = st.sampled_from(sorted(VALID_RIMS))
@given(widths, aspects, rims)
def test_roundtrip_is_stable(w, a, r):
s = f"{w}/{a}R{r}"
once = canonical(parse_size(s))
twice = canonical(parse_size(once))
assert once == twice
@given(widths, aspects, rims, st.sampled_from(["-", " ", "/", " "]))
def test_separator_invariance(w, a, r, sep):
messy = f"{w}{sep}{a}{sep}{r}"
clean = f"{w}/{a}R{r}"
assert canonical(parse_size(messy)) == canonical(parse_size(clean))
@given(widths, aspects, rims)
def test_diameter_positive_and_sane(w, a, r):
p = parse_size(f"{w}/{a}R{r}")
d = overall_diameter_mm(p)
assert 450.0 < d < 1400.0 # physical envelope for road tires
The separator-invariance property alone caught more regressions than the entire example suite, because every tokenizer refactor risks quietly changing how junk whitespace is folded. A fourth suite fuzzes pure garbage — emoji, SQL fragments, 10 kB strings — asserting only that the parser raises SizeParseError cleanly rather than looping or mis-parsing. Hostile-input discipline is cheap insurance for anything wired to a public form.
Boundaries: What This Model Refuses to Know
A data model is defined by its exclusions as much as its entities, and ours excludes anything beyond tire and lubricant work, because that is the entire service surface at our storefront. There is no suspension geometry table, no brake-clearance calculator for oversized fitments, no steering-angle model — when a plus-two fitment raises clearance questions on lowered vehicles, the finding text tells the customer to verify with a mechanical specialist, full stop. Encoding your operational boundary into the schema keeps software from promising what the business doesn't perform, and it also keeps the model small enough that one developer can hold it in their head.
The parser itself has boundaries too. It does not attempt speed-symbol inference from ZR markers (a ZR with no explicit symbol stores speed_symbol=None plus a warning, because guessing Y versus W is a 30 km/h error). It does not auto-resolve P-metric versus Euro-metric load standards; it stores the prefix and lets the rating table carry the truth. And it never deletes a raw input string, ever.
Takeaways for Anyone Building One of These
- Model the designation as a grammar with three families; the moment you write
metric | lt_metric | flotationas branches, ZR markers and dual load indexes become local problems instead of regex-wide ones. - Lex first, parse second, and let grammatical context re-interpret ambiguous tokens like a service-position
R. - Keep geometric size and service rating as separate entities; every dangerous substitution I've seen traces back to conflating them.
- Make canonical text a database constraint and round-tripping a CI property.
- Do the diameter math in one function that spans families, and put approval thresholds in data.
- Validate with severities — block, review, note — and never silently repair safety-relevant fields.
- Constrain fuzzy suggestions with a physical-market catalog and route low confidence to humans.
- Hoard raw intake strings; they are the best test corpus your system will ever get for free.
The whole apparatus — lexer, parser, schema, validators, fuzzers — is maybe twelve hundred lines. Small enough for a tire-and-oil operation in Calgary, rigorous enough that a mis-typed aspect ratio gets caught before it becomes a wrong set of tires on a real vehicle driving down Deerfoot in January. That trade is the entire reason to treat a sidewall string as a language instead of a product code.
Top comments (0)