DEV Community

KMJ Tire Calgary
KMJ Tire Calgary

Posted on

Encoding Tire Repairability as a Versioned Decision Table Instead of Nested Conditionals

Encoding Tire Repairability as a Versioned Decision Table Instead of Nested Conditionals

KMJ Tire is a Calgary operation whose entire service catalogue is tires and oil changes. Sitting beside a business shaped like that, the obvious piece of software to want is a work-order pre-screen: feed it a damage assessment, get back a signal about whether a damaged tire is likely to go back on the vehicle or likely to be scrapped. The first version of such a thing is always a function with a lot of if statements in it. Four hundred lines later it has an ordering bug nobody can see, and it produces answers nobody can explain afterwards.

What follows is a design study of the replacement: an ordered, data-driven rule set — a decision table with explicit precedence, three-valued outcomes, per-rule provenance, and version pinning so a verdict issued eleven months ago can be reproduced under the criteria that were in force at the time. The domain grounding is real; the system is a design exercise rather than a description of live production infrastructure, and every figure in it is illustrative. The domain is tires, but the shape of the problem is not: safety-adjacent classification with incomplete inputs, where the wrong answer is worse than no answer.

One boundary up front, because it matters more than any code in this article. The criteria below are the industry criteria a tire service business works from, encoded here for illustration. This table is not an authoritative safety standard, and nothing here overrides a manufacturer's own guidance. The final authority is a trained technician with the tire off the rim and the inner liner under a light. The software exists to be consistent and explainable, not to be right on its own.

The four hundred lines that lied to us

The original pre-screen was written the way these things always get written. Someone had the criteria on a laminated sheet, transcribed them top to bottom, and each criterion became a branch that returned early.

def can_repair(t):
    if t["zone"] == "sidewall":
        return False
    if t["zone"] == "shoulder":
        return False
    if t["hole_mm"] > 6:
        return False
    if t.get("prior_repairs"):
        for r in t["prior_repairs"]:
            if abs(r["pos"] - t["pos"]) < 40:
                return False
    if t.get("tread_mm", 99) < 1.6:
        return False
    return True
Enter fullscreen mode Exit fullscreen mode

Four failure modes, and they compound.

First, early return hides ordering. The moment one branch answers, none of the later branches run. That is fine when every branch produces the same verdict, and it is a trap the instant one branch produces a different one. Ours did: a later revision added a run-flat check that returned True for a specific construction, placed above the prior-repair loop by whoever merged it. A unit came in with a 5 mm injury 62 mm off the tread centreline on a 225-section tire, with an existing repair roughly 14 cm away around the circumference. The function said repairable. The technician who demounted it said no, twice, and was right both times.

Second, a boolean cannot express doubt. Look at t.get("tread_mm", 99). Someone defaulted a missing measurement to a value that guarantees the check passes. That is not a typo; it is the only thing you can do when your return type has two inhabitants and one of them means "go ahead." Missing data silently became permission.

Third, there is no provenance. The function returns False. Which criterion produced that? You can only find out by re-reading the code and mentally replaying it against the input. When a fleet coordinator asks why a $340 casing was condemned, "the system said so" is not an answer anyone accepts.

Fourth, there is no version. The criteria drift. Manufacturers publish revised guidance, internal practice tightens, a legal threshold changes. Six months later you cannot reproduce a past verdict, because the only copy of the old logic is in git history nobody wants to run.

Every one of those is a structural property of "criteria as control flow." You do not fix them by writing more careful conditionals. You fix them by making the criteria data.

The criteria a service business actually works from

Before any modelling, the plain-language version. Whether a punctured passenger or light-truck tire may be repaired is governed by a cluster of criteria that most of the trade agrees on, with variation at the edges:

  • The injury must fall in the crown — the central portion of the tread — not in the shoulder and never in the sidewall. Shoulder and sidewall flex through their entire duty cycle, and a patch there works loose.
  • The injury diameter has a ceiling. Six millimetres (about a quarter inch) is the common passenger-tire figure. Beyond that, the belt and body-ply damage is no longer something a patch spans.
  • The injury angle matters. A nail that entered at a steep angle travels laterally inside the casing, so the entry point on the tread and the actual damage path are not the same place. Steeply angled injuries are treated as larger than they measure.
  • A legitimate repair requires demounting. The tire comes off the rim, the inner liner gets inspected, the injury channel is filled and the inner surface is sealed with a patch. An externally inserted plug with the tire still on the wheel is a get-home measure, not a repair.
  • Repairs must not overlap, and there are limits on how many a casing may carry and how close together they may sit.
  • Run-flat construction and high speed ratings carry manufacturer-specific caveats; some brands decline repair entirely on certain lines.
  • Evidence that the tire was driven while deflated — a scuffed or wrinkled inner liner, rubber dust inside the casing — condemns it regardless of how small the puncture is.
  • Remaining tread depth and casing age can make the whole question moot.
  • A tire previously plugged from the outside without demounting has an unknown internal condition until somebody looks.

If you want the customer-facing version of the same material, we keep an explainer on how a proper demounted repair is performed, and a separate reference on reading what is moulded into the sidewall, which is where half of these inputs come from in the first place.

Notice how many of those clauses are about evidence rather than about geometry. That distribution is the single most important thing to carry into the data model.

Geometry: turning "crown" and "shoulder" into numbers

"In the crown" is not a machine-checkable predicate. We need a coordinate system.

We measure lateral offset in millimetres from the tread centreline, positive outboard, and we derive the crown boundary from tread width rather than section width. Section width is the moulded figure on the sidewall and includes the bulge; tread width is the part that actually meets the road. For a typical passenger fitment, tread width runs somewhere around 78–82 % of section width, and we store a per-fitment override when we have the real number from the manufacturer's data sheet.

The illustrative geometry we use in the examples below:

  • tread_width_mm = round(0.80 * section_width_mm) unless overridden
  • crown_half_width_mm = 0.5 * tread_width_mm - shoulder_band_mm
  • shoulder_band_mm = 12 for passenger fitments in this illustration

So a 225-section tire gets a 180 mm tread width, a 90 mm half-width, and a crown that extends to 78 mm either side of centre. An injury at 62 mm is comfortably inside. An injury at 79 mm is not. An injury at 78 mm is the interesting one, and we will come back to it.

Angular position around the circumference is a second coordinate, in degrees, with an arbitrary but recorded datum — we use the DOT stamp as zero, because it is the one landmark on the tire that is guaranteed to exist and guaranteed not to move. Arc distance between two points is then a wrap-aware computation on the rolling circumference, which we derive from the fitment. Anyone who has worked through the diameter math will recognize this; the load index and speed symbol reference we keep for technicians covers the sidewall codes those figures come from.

A typed record for the assessment, not a dict

Here is the input model. It is deliberately verbose about the difference between "we measured this" and "nobody wrote it down."

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum, IntEnum
from typing import Callable, Optional, Sequence


class Zone(Enum):
    CROWN = "crown"
    SHOULDER = "shoulder"
    SIDEWALL = "sidewall"
    BEAD = "bead"


class Construction(Enum):
    STANDARD = "standard"
    RUN_FLAT = "run_flat"
    UNDETERMINED = "undetermined"


class PriorRepairKind(Enum):
    INTERNAL_PATCH_PLUG = "internal_patch_plug"
    EXTERNAL_PLUG_ONLY = "external_plug_only"
    UNIDENTIFIED = "unidentified"


@dataclass(frozen=True)
class Measured:
    """A number that remembers how it was obtained and how wrong it might be."""

    value: float
    tolerance: float          # half-width of the plausible band, same units
    method: str               # steel_rule | digital_caliper | visual_estimate

    def below(self, threshold: float) -> Optional[bool]:
        if self.value + self.tolerance < threshold:
            return True
        if self.value - self.tolerance >= threshold:
            return False
        return None           # band straddles the threshold; refuse to guess

    def above(self, threshold: float) -> Optional[bool]:
        flipped = self.below(threshold)
        return None if flipped is None else not flipped
Enter fullscreen mode Exit fullscreen mode

The Measured type is doing real work. A tread-depth reading taken with a steel rule by a technician wearing gloves in an unheated bay in February has a tolerance around ±0.4 mm. A digital caliper on an injury diameter is closer to ±0.2 mm. When the plausible band crosses a threshold, below returns None, and that None will propagate all the way to the verdict rather than being rounded away. Three-valued logic starts at the measurement, not at the rule.

The rest of the record:

@dataclass(frozen=True)
class PriorRepair:
    angular_position_deg: float
    lateral_offset_mm: float
    kind: PriorRepairKind


@dataclass(frozen=True)
class Assessment:
    assessment_id: str
    captured_at: str                        # RFC 3339, always with offset
    section_width_mm: int
    rim_diameter_in: float
    tread_width_mm: Optional[int]           # override when known
    construction: Construction
    speed_symbol: Optional[str]
    zone: Optional[Zone]
    injury_diameter: Optional[Measured]
    injury_angle_deg: Optional[Measured]    # 0 = perpendicular to the surface
    lateral_offset: Optional[Measured]      # from the tread centreline
    angular_position_deg: Optional[float]
    tread_depth: Optional[Measured]
    dot_week: Optional[int]
    dot_year: Optional[int]
    liner_inspected: bool = False
    liner_shows_deflated_running: Optional[bool] = None
    prior_repairs: Sequence[PriorRepair] = field(default_factory=tuple)
Enter fullscreen mode Exit fullscreen mode

Every field that a technician might fail to record is Optional. There are no sentinel defaults, no -1, no 99. If you take one idea from this article, take that one: the absence of a measurement must be representable, and it must be distinguishable from a measurement of zero.

Three verdicts, because two is a bug

class Verdict(Enum):
    REPAIRABLE = "repairable"
    REPLACE = "replace"
    INSUFFICIENT_EVIDENCE = "insufficient_evidence"
Enter fullscreen mode Exit fullscreen mode

INSUFFICIENT_EVIDENCE is not an error state and it is not a failure of the engine. It is a legitimate, frequently correct answer that carries a payload: the list of inputs that would resolve it. Run the table across an illustrative assessment set and it lands on roughly a fifth of them, with about two-thirds of those resolving to a definite verdict once a single additional field arrives.

Two design commitments follow from having three values.

Fail closed on ambiguity that leans dangerous. Where an unknown could only make the situation worse, we do not return INSUFFICIENT_EVIDENCE; we return REPLACE. Evidence of running deflated is the clean example: if the liner was never inspected and the vehicle arrived on a tire that was flat at the roadside, the engine does not ask politely for more data, it condemns. The cost asymmetry is not close. A wrongly scrapped casing costs a few hundred dollars. A wrongly returned casing carries a belt separation down Stoney Trail at 100 km/h.

Never let a gap become a permission. There is exactly one rule in the table that can emit REPAIRABLE, it sits at the lowest precedence tier, and it fires only when every field it depends on is present and every exclusion has been evaluated to a definite False. Permission is the residue left after all the ways of saying no have been exhausted.

The table itself

This is the artifact that matters. It is reviewed by people who do not read Python, printed and marked up, and argued over. The code is downstream of it.

Rule Tier Condition Verdict Notes
R-010 EXCLUSION Zone is sidewall or bead REPLACE No patch survives full-cycle flex there
R-020 EXCLUSION Zone is shoulder REPLACE Belt edge region, excluded by practice
R-030 EXCLUSION Lateral offset beyond crown half-width REPLACE Geometric restatement of R-020 for measured inputs
R-040 EXCLUSION Injury diameter above 6.0 mm REPLACE Illustrative passenger ceiling
R-050 EXCLUSION Injury angle above 25° from perpendicular REPLACE Damage path is longer than the entry hole suggests
R-060 EXCLUSION Liner shows deflated running REPLACE Casing compromised independent of the puncture
R-070 EXCLUSION Any prior repair within 40 cm arc of the new injury REPLACE Illustrative spacing floor; overlap is unconditional
R-080 EXCLUSION Three or more prior internal repairs present REPLACE Cumulative casing load
R-090 EXCLUSION Tread depth below 1.6 mm REPLACE Legal minimum reached; the repair question is moot
R-100 EXCLUSION Casing age at or beyond 10 years from DOT date REPLACE Illustrative retirement horizon
R-200 REFERRAL Construction is run-flat REPLACE_UNLESS_OEM_PERMITS Escalates to manufacturer guidance, not to the engine
R-210 REFERRAL Speed symbol W, Y or above REPLACE_UNLESS_OEM_PERMITS Same escalation path
R-300 EVIDENCE Prior external plug present, liner not inspected INSUFFICIENT_EVIDENCE Internal condition unknown until demounted
R-310 EVIDENCE Tread depth not recorded INSUFFICIENT_EVIDENCE R-090 cannot be evaluated
R-320 EVIDENCE Zone and lateral offset both absent INSUFFICIENT_EVIDENCE R-020 and R-030 both unevaluable
R-330 EVIDENCE Injury diameter not recorded INSUFFICIENT_EVIDENCE R-040 cannot be evaluated
R-340 EVIDENCE DOT date unreadable INSUFFICIENT_EVIDENCE R-100 cannot be evaluated
R-900 PERMIT All exclusions definitely false, no gaps outstanding REPAIRABLE The residual case

Eighteen rows. The whole domain fits on one page, which is the point — the nested version was four hundred lines because control flow forces you to spell out the interactions between rules, whereas a table lets the evaluator handle interactions once, generically.

Rules as values

class Tier(IntEnum):
    EXCLUSION = 0
    REFERRAL = 1
    EVIDENCE = 2
    PERMIT = 3


Predicate = Callable[["Assessment"], Optional[bool]]


@dataclass(frozen=True)
class Rule:
    rule_id: str
    tier: Tier
    predicate: Predicate
    verdict: Verdict
    rationale: str                       # printed verbatim on the work order
    requires: tuple[str, ...] = ()       # fields whose absence makes it moot
    introduced_in: str = ""
    retired_in: Optional[str] = None
Enter fullscreen mode Exit fullscreen mode

rationale is a string on the rule, not a lookup keyed by rule id in some other module. Anything that separates a rule from its explanation will drift; the explanation will describe a criterion that the predicate stopped checking two refactors ago, and nobody will notice because no test asserts on prose.

Predicate returns Optional[bool], which is the Kleene three-valued logic move. True means the rule fires. False means it definitely does not. None means we cannot tell from what we were given, and the evaluator must treat that as an outstanding gap rather than as a quiet False.

The predicates are ordinary named functions, not lambdas embedded in the table literal. That is a deliberate reversal of our first attempt. Lambdas in a table read beautifully for about a week and then become impossible to unit-test individually, impossible to name in a stack trace, and impossible to reuse across two rules that share a sub-condition.

CROWN_SHOULDER_BAND_MM = 12.0
MAX_INJURY_DIAMETER_MM = 6.0
MAX_INJURY_ANGLE_DEG = 25.0
MIN_TREAD_DEPTH_MM = 1.6
MIN_REPAIR_SEPARATION_MM = 400.0


def crown_half_width(a: Assessment) -> float:
    tread = a.tread_width_mm or round(0.80 * a.section_width_mm)
    return 0.5 * tread - CROWN_SHOULDER_BAND_MM


def in_sidewall_or_bead(a: Assessment) -> Optional[bool]:
    if a.zone is None:
        return None
    return a.zone in (Zone.SIDEWALL, Zone.BEAD)


def offset_outside_crown(a: Assessment) -> Optional[bool]:
    if a.lateral_offset is None:
        return None
    return a.lateral_offset.above(crown_half_width(a))


def injury_too_large(a: Assessment) -> Optional[bool]:
    if a.injury_diameter is None:
        return None
    return a.injury_diameter.above(MAX_INJURY_DIAMETER_MM)


def deflated_running_seen(a: Assessment) -> Optional[bool]:
    if a.liner_shows_deflated_running is True:
        return True
    if a.liner_inspected and a.liner_shows_deflated_running is False:
        return False
    return None
Enter fullscreen mode Exit fullscreen mode

Read deflated_running_seen closely. It is the one predicate whose None we later convert to REPLACE rather than to a gap, and it is written so that a False is only reachable when somebody actually looked. You cannot clear that flag by leaving the field blank.

Arc separation, and why 14 cm is not 14 cm

Two repair sites 14 cm apart earns a permanent place in the fixture set, because it exposes a modelling error that is unusually easy to ship.

The first implementation compared angular positions directly and applied a degree threshold. That is wrong, because the same angular gap is a different physical distance on a 15-inch fitment than on a 20-inch one. What the criterion is really about is the amount of intact casing between two weakened points, which is arc length.

import math


def rolling_circumference_mm(a: Assessment) -> float:
    """Illustrative: rim diameter plus twice a nominal sidewall height."""
    rim_mm = a.rim_diameter_in * 25.4
    aspect = 0.55                              # placeholder for the example
    sidewall_mm = aspect * a.section_width_mm
    return math.pi * (rim_mm + 2.0 * sidewall_mm)


def arc_gap_mm(a: Assessment, other_deg: float) -> Optional[float]:
    if a.angular_position_deg is None:
        return None
    delta = abs(a.angular_position_deg - other_deg) % 360.0
    delta = min(delta, 360.0 - delta)           # shortest way round
    return (delta / 360.0) * rolling_circumference_mm(a)


def crowded_by_prior_repair(a: Assessment) -> Optional[bool]:
    if not a.prior_repairs:
        return False
    gaps = [arc_gap_mm(a, r.angular_position_deg) for r in a.prior_repairs]
    if any(g is None for g in gaps):
        return None
    return min(gaps) < MIN_REPAIR_SEPARATION_MM
Enter fullscreen mode Exit fullscreen mode

Two bugs are fixed here and both were real. The % 360.0 followed by min(delta, 360 - delta) handles the wrap: a repair at 355° and an injury at 5° are 10° apart, not 350°. And returning None when any position is missing stops a partially-recorded repair history from producing a confident answer about spacing.

For a 225/55R17 in the example, rolling circumference lands near 1,940 mm. Two sites 14 cm apart occupy about 26° of arc and leave 140 mm of casing between them, well under the 400 mm illustrative floor. Verdict: REPLACE, cited to R-070. The nested version got this wrong not because the arithmetic was hard but because the run-flat branch above it had already returned.

An evaluator you can read in one sitting

@dataclass(frozen=True)
class Firing:
    rule_id: str
    verdict: Verdict
    rationale: str
    tier: Tier


@dataclass(frozen=True)
class Decision:
    verdict: Verdict
    table_version: str
    firings: tuple[Firing, ...]
    outstanding: tuple[str, ...]
    evaluated_at: str
    input_digest: str


def evaluate(a: Assessment, rules: Sequence[Rule], version: str,
             now: str, digest: str) -> Decision:
    fired: list[Firing] = []
    gaps: list[str] = []

    for rule in rules:
        outcome = rule.predicate(a)
        if outcome is True:
            fired.append(Firing(rule.rule_id, rule.verdict,
                                rule.rationale, rule.tier))
        elif outcome is None:
            gaps.extend(rule.requires or (rule.rule_id,))

    fired.sort(key=lambda f: (f.tier, f.rule_id))

    if fired and fired[0].tier <= Tier.REFERRAL:
        chosen = fired[0].verdict
    elif gaps:
        chosen = Verdict.INSUFFICIENT_EVIDENCE
    elif fired:
        chosen = fired[0].verdict
    else:
        chosen = Verdict.INSUFFICIENT_EVIDENCE      # empty table, fail closed

    return Decision(
        verdict=chosen,
        table_version=version,
        firings=tuple(fired),
        outstanding=tuple(sorted(set(gaps))),
        evaluated_at=now,
        input_digest=digest,
    )
Enter fullscreen mode Exit fullscreen mode

Every rule is evaluated. Nothing short-circuits. That is the structural difference from the original, and it costs almost nothing — the whole table runs in single-digit microseconds, so there is no performance argument for early exit at this size.

The resolution order encodes a real policy judgement, so it is worth stating plainly rather than leaving it implicit in a comparison operator. A definite exclusion beats an outstanding gap: if the injury is in the sidewall, we do not need the tread depth to know the answer, and demanding it would waste a technician's time. A gap beats a permission: if anything at all is unresolved, permission is withheld. Referrals sit between exclusions and gaps because "the manufacturer has to answer this" is a definite finding about who decides, not an absence of information.

Provenance: every verdict names its parent rule

The Decision record carries firings — every rule that matched, in precedence order, with the rationale text attached. Rendering an explanation is then trivial, which is the whole point.

def explain(d: Decision) -> str:
    if d.verdict is Verdict.INSUFFICIENT_EVIDENCE and d.outstanding:
        missing = ", ".join(d.outstanding)
        return (f"Cannot decide under {d.table_version}. "
                f"Outstanding evidence: {missing}.")
    if not d.firings:
        return f"No criterion matched under {d.table_version}."
    head = d.firings[0]
    extra = len(d.firings) - 1
    tail = f" ({extra} further criterion matched)" if extra else ""
    return f"{head.verdict.value} per {head.rule_id}: {head.rationale}{tail}"
Enter fullscreen mode Exit fullscreen mode

That string goes on the work order and into the fleet coordinator's export. It has settled more disputes than any amount of accuracy improvement, because the argument stops being "your system is wrong" and becomes "R-070 uses a 40 cm separation floor and we think that is too conservative for our application" — which is a productive conversation about a number in a table, not a defence of a codebase.

Reporting every match rather than just the decisive one also gives you a cheap monitoring signal. If R-050 has never once been the top firing but appears as a secondary match on 40 % of assessments, either the threshold is miscalibrated or technicians are entering a default angle. Both are worth knowing.

Checking the table, not just the code

Once criteria are data, you can interrogate the whole rule set as an object. Three checks run in CI on every change to the table.

Reachability. Enumerate a coarse grid over the input space and confirm each rule is the decisive firing for at least one cell. A rule that never decides anything is either dead or shadowed by a higher tier, and both deserve a look.

Conflict. Within a single tier, find cells where two rules fire with different verdicts. Across tiers this is expected and resolved by precedence; within a tier it is an authoring mistake, because tier membership is a claim that the rules are commensurable.

Coverage of the permit path. Confirm the grid contains at least one cell that reaches REPAIRABLE. An over-eager tightening of R-030 can make permission unreachable, and nothing fails loudly when it does. The engine simply says REPLACE to everything, which from the outside looks exactly like a bad week for punctures.

import itertools


def sweep(rules, version):
    zones = [Zone.CROWN, Zone.SHOULDER, Zone.SIDEWALL, None]
    diameters = [None, 2.0, 5.9, 6.1, 9.0]
    depths = [None, 1.4, 1.7, 6.0]
    decisive = {}
    conflicts = []

    for zone, dia, depth in itertools.product(zones, diameters, depths):
        a = fixture(zone=zone, diameter=dia, depth=depth)
        d = evaluate(a, rules, version, "1970-01-01T00:00:00Z", "grid")
        if d.firings:
            decisive.setdefault(d.firings[0].rule_id, 0)
            decisive[d.firings[0].rule_id] += 1
            top = [f for f in d.firings if f.tier == d.firings[0].tier]
            if len({f.verdict for f in top}) > 1:
                conflicts.append((zone, dia, depth,
                                  [f.rule_id for f in top]))

    unreached = [r.rule_id for r in rules if r.rule_id not in decisive]
    return decisive, unreached, conflicts
Enter fullscreen mode Exit fullscreen mode

A coarse grid is not a proof. It catches the class of error that matters, which is a rule that can never win.

Properties worth asserting

Example-based tests pin down the cases you thought of. Properties pin down the shape of the function, and in a safety-adjacent classifier the shape is where the real invariants live.

from hypothesis import given, strategies as st


@given(delta=st.floats(min_value=0.01, max_value=20.0))
def test_monotone_in_injury_size(delta):
    """Making the injury bigger can never make it more permissible."""
    small = fixture(diameter=4.0)
    large = fixture(diameter=4.0 + delta)
    a = evaluate(small, RULES, TABLE_VERSION, NOW, "p1").verdict
    b = evaluate(large, RULES, TABLE_VERSION, NOW, "p2").verdict
    if a is not Verdict.REPAIRABLE:
        assert b is not Verdict.REPAIRABLE


@given(st.sampled_from(["tread_depth", "injury_diameter", "zone"]))
def test_erasure_never_grants_permission(name):
    """Deleting a field must not upgrade the verdict."""
    full = fixture(diameter=3.0, depth=5.0, zone=Zone.CROWN)
    thin = replace(full, **{name: None})
    before = evaluate(full, RULES, TABLE_VERSION, NOW, "e1").verdict
    after = evaluate(thin, RULES, TABLE_VERSION, NOW, "e2").verdict
    if before is not Verdict.REPAIRABLE:
        assert after is not Verdict.REPAIRABLE
    else:
        assert after in (Verdict.REPAIRABLE, Verdict.INSUFFICIENT_EVIDENCE,
                         Verdict.REPLACE)


@given(st.integers(min_value=0, max_value=5000))
def test_determinism_under_rule_order(seed):
    """Verdict must not depend on the order rules sit in the tuple."""
    rng = random.Random(seed)
    shuffled = list(RULES)
    rng.shuffle(shuffled)
    a = fixture(diameter=5.0, depth=4.0, zone=Zone.CROWN)
    left = evaluate(a, RULES, TABLE_VERSION, NOW, "d1")
    right = evaluate(a, tuple(shuffled), TABLE_VERSION, NOW, "d2")
    assert left.verdict is right.verdict
    assert [f.rule_id for f in left.firings] == \
           [f.rule_id for f in right.firings]
Enter fullscreen mode Exit fullscreen mode

That third property is the one that found a genuine defect. An early version sorted firings by tier alone, so two exclusions in the same tier resolved in whatever order the tuple happened to hold — stable, but dependent on authoring sequence. The verdict was identical either way, yet the cited rule changed, which meant the explanation printed on two identical work orders could differ. Sorting by (tier, rule_id) fixed it. Monotonicity and erasure-safety, by contrast, have never failed; they are regression armour for the day somebody adds a clever optimisation.

Golden cases, kept in a format the bay can read

Properties do not replace worked examples, because the examples are how non-programmers review the logic. Ours live in YAML, one file, reviewed by whoever is running the service floor that quarter.

- name: clean_crown_nail
  note: routine gravel-season puncture, everything measured
  input:
    zone: crown
    section_width_mm: 215
    injury_diameter: {value: 4.2, tolerance: 0.2, method: digital_caliper}
    injury_angle_deg: {value: 6.0, tolerance: 3.0, method: visual_estimate}
    lateral_offset: {value: 41.0, tolerance: 2.0, method: steel_rule}
    tread_depth: {value: 6.4, tolerance: 0.4, method: steel_rule}
    liner_inspected: true
    liner_shows_deflated_running: false
    dot_week: 22
    dot_year: 2022
  expect:
    verdict: repairable
    cited: R-900

- name: shoulder_boundary_exact
  note: offset lands precisely on the computed crown limit
  input:
    zone: crown
    section_width_mm: 225
    lateral_offset: {value: 78.0, tolerance: 1.5, method: steel_rule}
    injury_diameter: {value: 3.1, tolerance: 0.2, method: digital_caliper}
    tread_depth: {value: 7.0, tolerance: 0.4, method: steel_rule}
    liner_inspected: true
    liner_shows_deflated_running: false
  expect:
    verdict: insufficient_evidence
    outstanding: [lateral_offset]

- name: two_repairs_fourteen_centimetres
  note: prior repair too close around the circumference
  input:
    zone: crown
    section_width_mm: 225
    rim_diameter_in: 17
    angular_position_deg: 12.0
    prior_repairs:
      - {angular_position_deg: 38.0, lateral_offset_mm: 30.0,
         kind: internal_patch_plug}
  expect:
    verdict: replace
    cited: R-070
Enter fullscreen mode Exit fullscreen mode

The runner is fifteen lines and does nothing clever. What earns its keep is the note field, which forces whoever adds a case to say why it exists. Half of our regressions have been caught by somebody reading a note during review and saying that is not what we do any more.

The boundary case that changed our mind about rounding

Take shoulder_boundary_exact above. A 225-section fitment gives a crown half-width of exactly 78.0 mm. The technician records a lateral offset of 78.0 mm using a steel rule with a ±1.5 mm tolerance. Is the injury in the crown or the shoulder?

The honest answer is that we do not know, and the plausible band spans both. Measured.above(78.0) returns None because 76.5 < 78.0 <= 79.5. R-030 contributes a gap rather than a firing, and the verdict is INSUFFICIENT_EVIDENCE with lateral_offset named as the outstanding item. The remedy is a caliper measurement, which takes about thirty seconds and collapses the tolerance to ±0.2 mm.

We debated failing closed to REPLACE here instead, on the theory that the safe default should apply. We did not, and the reasoning is worth spelling out because it is the one place we deliberately do not fail closed. A REPLACE verdict is terminal — the casing goes in the scrap pile and nobody re-measures. INSUFFICIENT_EVIDENCE is a request that costs half a minute and produces a defensible answer either way. Failing closed is correct when better evidence is unobtainable or when the unknown is itself a danger signal; it is not correct when the unknown is simply a measurement nobody has bothered to take yet. Those are different situations and collapsing them wastes casings.

Note also the half-open interval. above uses >= on the lower comparison, so an offset of exactly 78.0 mm with zero tolerance would be treated as outside the crown. Boundary inclusivity is a decision, it should be written down in the table, and it should have a golden case pinning it. Ours does.

Missing tread depth: a gap with a name

The third nasty fixture is the simplest and the most common. A technician records everything about the injury and never gets a tread-depth reading, because the vehicle was on the hoist for an oil change and the tire never came off.

R-310 fires — tread_depth is None — contributing the outstanding field. No exclusion has fired definitely, so the verdict is INSUFFICIENT_EVIDENCE and the explanation reads: Cannot decide under repairability/2026.02.1. Outstanding evidence: tread_depth.

Compare that with the original code, where t.get("tread_mm", 99) turned an unmeasured tire into one with 99 mm of tread. The difference is not that the new engine is smarter. It is that the new engine has somewhere to put "I do not know," and the old one did not, so the unknown had to masquerade as something.

This shows up disproportionately in work performed by our mobile service unit, where the assessment happens in a parking lot rather than a bay. Mobile assessments produce roughly twice the INSUFFICIENT_EVIDENCE rate of in-bay ones, and that is not a defect in the software — it is the software correctly reporting that a driveway in a February wind is a worse measurement environment than a lift. The coverage map for that unit determines how often it happens.

Versioning, so last year's verdict can be replayed

Criteria change. When they do, every decision already issued was issued under the old table, and you need to be able to say so.

@dataclass(frozen=True)
class RuleSet:
    version: str                 # e.g. repairability/2026.02.1
    effective_from: str
    effective_to: Optional[str]
    rules: tuple[Rule, ...]

    def digest(self) -> str:
        payload = "|".join(
            f"{r.rule_id}:{r.tier}:{r.verdict.value}:"
            f"{r.predicate.__name__}:{r.rationale}"
            for r in self.rules
        )
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


REGISTRY: dict[str, RuleSet] = {}


def ruleset_in_force(at: str) -> RuleSet:
    matches = [rs for rs in REGISTRY.values()
               if rs.effective_from <= at
               and (rs.effective_to is None or at < rs.effective_to)]
    if len(matches) != 1:
        raise LookupError(f"{len(matches)} rule sets in force at {at}")
    return matches[0]
Enter fullscreen mode Exit fullscreen mode

Three properties fall out. Persisted decisions store table_version, so replay is a registry lookup rather than an archaeology expedition. The digest covers predicate names and rationale text, which means renaming a predicate or editing an explanation forces a version bump — annoying by design, because a changed explanation is a changed decision from the reader's point of view. And ruleset_in_force raising on ambiguity means a botched effective-date edit fails at startup instead of silently picking whichever set the dict happened to yield first.

The migration test is the payoff. Before a new table goes live, we replay the last twelve months of stored assessments under both versions and diff the verdicts.

def replay_diff(records, old: RuleSet, new: RuleSet):
    changed = []
    for rec in records:
        a = deserialize(rec["input"])
        before = evaluate(a, old.rules, old.version, rec["at"], rec["digest"])
        after = evaluate(a, new.rules, new.version, rec["at"], rec["digest"])
        if before.verdict is not after.verdict:
            changed.append({
                "assessment_id": a.assessment_id,
                "from": before.verdict.value,
                "to": after.verdict.value,
                "was_cited": before.firings[0].rule_id if before.firings else None,
                "now_cited": after.firings[0].rule_id if after.firings else None,
            })
    return changed
Enter fullscreen mode Exit fullscreen mode

The first time we ran this against a proposed revision, it flipped 11 % of historical verdicts from REPLACE to REPAIRABLE. That was not a bug in the new table; it was an accurate report that we had been over-conservative on injury angle for a year. Knowing the magnitude before shipping changed how we communicated the revision to fleet customers, which is worth more than the code.

The decision log

Decisions are appended, never updated. A correction is a new record referencing the one it supersedes.

{
  "decision_id": "dec_01HQ4R9K2",
  "assessment_id": "asm_2026_08_10_0113",
  "supersedes": null,
  "verdict": "replace",
  "table_version": "repairability/2026.02.1",
  "table_digest": "9f2a7c1e04b3d8aa",
  "firings": [
    {"rule_id": "R-070", "tier": 0,
     "rationale": "Prior repair within the illustrative 40 cm arc floor"}
  ],
  "outstanding": [],
  "input_digest": "sha256:4b1d...",
  "evaluated_at": "2026-08-10T09:41:22-06:00",
  "engine_build": "rules-engine 0.9.3",
  "actor": "prescreen-service"
}
Enter fullscreen mode Exit fullscreen mode

input_digest is a hash over the canonicalised assessment, which lets you prove that a replay used identical inputs without storing a second copy. engine_build is separate from table_version because the evaluator and the criteria change independently, and you will eventually have a defect that lives in one and not the other.

We keep decision records for the life of the casing plus two years. Retention is not a compliance box here so much as the only way to answer "has this casing been repaired before, and where" when it comes back on a different vehicle eighteen months later — which happens constantly on the fleet side of the business, where wheels and tires migrate between units.

Where Calgary actually presses on the model

Some of this is genuinely local, and it changes the numbers rather than the structure.

Gravel season is the obvious one. Chip-seal work and the winter aggregate that lingers into spring produce a puncture wave from roughly April into June, and the injuries skew small, shallow, and squarely in the crown — exactly the population where a decision table earns its money, because volume plus a clear-cut criterion is where human inconsistency shows up. Two technicians will not disagree about a sidewall gash. They will absolutely disagree about a 5.8 mm injury measured with a rule.

Changeover volume is the second. The compression of seasonal work into a few weeks each spring and fall — the seasonal changeover service is the single busiest thing the business does — means a large number of tires get inspected in a short window by people who are tired. Consistency under fatigue is precisely what you buy with a rule table, and it is also when the pre-screen's INSUFFICIENT_EVIDENCE rate climbs, which is a useful staffing signal in its own right.

Road salt and brine change what "prior repair" evidence looks like. Corrosion around the bead seat and valve area can obscure or mimic damage, and a patch applied three seasons ago may sit under a layer of deposit. That is a data-capture problem, not a rules problem, but it is why liner_inspected is a separate boolean from liner_shows_deflated_running — "we looked and saw nothing" and "we could not see" are different states.

Then there is the fleet population. Units running Deerfoot and Stoney Trail accumulate distance fast, so casing age and remaining depth interact with the repair question more often than they do on a family vehicle that does 12,000 km a year. A casing that is technically repairable but has 3 mm left is a different economic proposition, and the engine deliberately does not model that — economics belong to whoever is paying, and mixing them into a safety table is how safety tables get quietly relaxed. What we do instead is emit the verdict plus the measured depth, and let the commercial service side make the money argument separately. Seasonal fitment choice interacts too, since dedicated winter rubber spends half the year in storage and comes back with a service history the pre-screen has to reconstruct.

One more operational note: after any repair the wheel assembly goes back on the balancer, because a patch and the material removed to prepare for it change the mass distribution. That is downstream of the engine entirely, but it is the reason our work-order template links the verdict to the balancing step rather than treating the repair as the end of the job.

Cost, placement, and what the engine is not

The evaluator is cheap. Eighteen rules over a frozen dataclass runs in a few microseconds; we have never profiled it because there has never been a reason to. If your rule set grows into the thousands you would index predicates by the fields they read and skip the ones whose inputs are absent, but at this scale that optimisation would be pure complexity.

The expensive part is data capture, and it is expensive in minutes rather than milliseconds. Getting a caliper reading, a DOT date, and a liner inspection onto a record adds two or three minutes per tire during the busiest weeks of the year. That is the actual cost of the system, and it is worth being honest that a rules engine does not reduce it — it relocates it, from an argument after the fact to a measurement before it.

The engine also sits in front of a human, never behind one. It runs at intake and on fleet pre-screens, producing a recommendation with a citation. It does not gate the technician. If R-040 says replace and the technician demounts the tire and finds a 4 mm injury rather than the 7 mm somebody eyeballed in the lane, the technician's finding wins and the assessment gets corrected, which produces a new decision record superseding the old one. General background on how any of this looks from the driver's side lives in our tire-safety primer, and the roadside cases that skip the pre-screen entirely go through emergency service dispatch.

What we would do differently

Write the table before the evaluator. We did it the other way and spent a week retrofitting a data structure onto assumptions the code had already baked in. The markdown table in this article existed as a printed sheet with pen marks on it before a line of Python was written the second time round, and that ordering was worth more than any refactor.

Do not build a DSL. Our first design had a small expression language so that criteria could be edited without a deploy. It was seductive and it was wrong: we ended up with an interpreter, a parser, an error-reporting story, and a testing story, all to avoid a five-minute deploy. Predicates as plain named functions, versioned in the same repository as everything else, has cost us nothing.

Version from the first commit. We added table_version in month four and have a gap in the record where earlier decisions cannot be replayed. There is no way to backfill that. It costs eight lines to do on day one.

Resist the fourth verdict. There has been steady pressure to add something like REPAIRABLE_WITH_CAVEAT. Every proposed instance turned out to be either a referral (the manufacturer decides) or an economic judgement (the owner decides), and both belong outside the safety classification. Three values is not a limitation; it is the discipline.

Treat the explanation as an interface. As soon as fleet coordinators start reading rationale strings, those strings become a published surface. Edit one and somebody notices. That is a feature, and it is why the digest covers them.

Questions we get asked

Why not an off-the-shelf rules engine? Eighteen rules over one flat record does not justify a Rete network, a separate language, or a runtime whose evaluation semantics we would have to explain to a technician. The list-of-predicates approach fits in a file, is trivially testable, and has no dependency to upgrade. Reach for the heavy tooling when rules number in the hundreds and are authored by people outside the engineering group.

Why not learn the rules from historical outcomes? Two reasons. The training signal is contaminated — historical verdicts were produced by the buggy nested code and by inconsistent human judgement — and a model cannot cite R-070. Explainability here is not a nice-to-have; it is most of the product's value. There is a defensible role for vision models in measuring injury diameter from a photograph, feeding the table better inputs. That is a perception problem, and it should stay separate from the decision.

How do manufacturer-specific caveats fit? They are the referral tier. R-200 and R-210 do not decide anything; they route to a human with the manufacturer's documentation. Attempting to encode every brand's line-by-line position on run-flat repair would produce a table that is wrong the moment a bulletin is revised, and wrong in a way nobody notices for months.

What happens when a technician disagrees with the verdict? The technician wins, and the disagreement is recorded with a reason code. We review overrides quarterly. A rule that is overridden 30 % of the time is a mis-specified rule, and the override log is the highest-value dataset the system produces — better than any accuracy metric, because it points at the specific criterion under dispute.

Does the table get bigger over time? Slower than you would expect. It went from fourteen rules to eighteen in two years. Most requested additions turn out to be a threshold change to an existing rule rather than a new criterion, which is a good sign that the decomposition is roughly right.

Where do the input values come from? Mostly a tablet form in the bay, with the fitment fields pre-populated from the size code read off the sidewall. The parsing of that code is its own small problem, and getting it wrong poisons the geometry — which is why the crown half-width calculation carries an override field rather than trusting a derived tread width when a real figure exists.

Where the engine's authority ends

The table does not know that the casing in front of you was sitting in a puddle of coolant, or that the inner liner has a wrinkle pattern the technician has seen four times and associates with a particular failure, or that this specific fleet runs a load profile the criteria were never written for. It knows eighteen things and it knows them consistently, which is precisely the value: it removes the variance that comes from the fourteenth tire of a Saturday in November, and it leaves the judgement that comes from ten years of demounting tires exactly where it belongs.

That division is the design. Encode what is codifiable, refuse to guess at what is not, name every unknown out loud, and make every answer traceable to the criterion that produced it. The nested version failed not because its criteria were wrong — most of them were fine — but because control flow is a terrible medium for a rule set that has to be reviewed, explained, and replayed. Data is a better medium. The evaluator is forty lines and has not needed to change in a year, while the table has been revised five times, which is exactly the ratio you want.

If you are building something in this shape, the test I would apply is narrow: can a domain expert who does not read your language review the criteria, and can you reproduce any past answer exactly? If either is a no, the criteria are still hiding in the code.

Top comments (0)