Slotting Architecture · 12 min read

ABC vs ABCD Velocity Tiers: When to Use Four Bands

You have a working three-band ABC scheme, and someone asks whether a fourth band would slot the warehouse better. The honest answer is “sometimes” — a fourth cut buys real placement precision on some SKU distributions and buys nothing but reclassification thrash on others. This page gives you the decision criteria for choosing between 3-band ABC and 4-band ABCD, defines exactly what the D band should isolate, and hands you one config-driven classifier that produces either scheme from a band list. It is the “how many bands” question inside the ABC Classification Tuning guide, which sits under the Location Assignment & ABC Classification Algorithms architecture.

What the D Band Actually Buys You

ABC collapses every SKU into three velocity tiers: A (fastest, gets the golden zone), B (moderate), C (everything slow). The problem is that C is a dumping ground. A live-but-slow SKU picked twice a week and a dead SKU untouched for six months both land in C, yet they demand opposite placement: the slow-but-live item still needs an accessible reserve slot, while the dead item should be swept to bulk overflow or flagged for de-stocking. ABCD adds a fourth cut so those two populations stop sharing bins.

There are two conventions for where the D band sits, and you must pick one explicitly:

  • D as the dead-stock tail (the common case). D splits the bottom of C into slow-but-live © and near-dead / obsolete (D). This is the convention this page defaults to, because it directly drives a de-stocking and reserve-consolidation decision that a flat C hides.
  • D as the hyper-mover head (the rarer case). Some high-throughput e-commerce nodes carve a D band above A for a handful of hyper-velocity SKUs that justify dedicated pick-to-belt or forward-pick automation. Same machinery, opposite end of the curve.

The classifier below does not care which convention you use — the band list is just a partition of the cumulative-velocity axis, so you point the extra cut wherever your operation needs it.

Pareto curve showing where 3-band ABC and 4-band ABCD cut the velocity distribution A Pareto (Lorenz) curve plotting cumulative share of velocity on the vertical axis against cumulative share of SKUs on the horizontal axis. The curve is steeply convex: roughly the fastest 20 percent of SKUs account for 70 percent of velocity, the fastest 50 percent for 90 percent, and the fastest 80 percent for 98 percent. Three vertical dashed cut lines drop from the curve. The 70 percent-velocity cut at 20 percent of SKUs is the A-to-B boundary and the 90 percent-velocity cut at 50 percent of SKUs is the B-to-C boundary; both are shared by ABC and ABCD. A third cut at 98 percent-velocity and 80 percent of SKUs is the C-to-D boundary used only by ABCD, splitting the slow-but-live tail from the near-dead stock. Four shaded regions along the horizontal axis are labelled A, B, C and D; three-band ABC merges the C and D regions. Pareto curve — where ABC and ABCD cut 0% 50% 100% 0% 20% 50% 80% 100% 70%v 90%v 98%v cut · ABCD-only splits the dead-stock tail A B C D Cumulative share of SKUs → Cumulative velocity
The A|B and B|C cuts (70% and 90% of cumulative velocity) are shared by both schemes. ABCD adds one more cut at ~98% velocity, splitting the long, flat tail of slow SKUs into a still-worth-slotting C band and a near-dead D band that a flat ABC scheme buries inside C.

The Decision: Three Bands or Four

Add the D band only when the tail is both large and operationally distinct. Four criteria decide it:

Criterion Favors 3-band ABC Favors 4-band ABCD
SKU count Under ~2,000 active SKUs; a 4th band splits into groups too small to slot differently Tens of thousands of SKUs, where even the tail holds hundreds of items
Pareto steepness Shallow curve — velocity is spread evenly, so extra cuts land on arbitrary boundaries Steep curve with a long flat tail; the tail is a genuine population, not a rounding error
Golden-zone scarcity Prime pick face is abundant relative to A-count; fine tail distinctions do not change placement Prime slots are scarce and contested; you need to evict dead stock from reserve to free B/C bins
Dead-stock volume Obsolete stock is swept by a separate FSN/obsolescence job already No obsolescence process exists; the D band becomes your de-stocking trigger

The cost of the extra band is not free. Each additional boundary is one more line SKUs can oscillate across, so a 4-band scheme generates more reclassification events — and therefore more re-slotting labor — than a 3-band one on the same data. It also adds a slot-policy branch to maintain everywhere downstream. If your operation cannot act differently on a D-band SKU than on a C-band SKU (different reserve strategy, de-stocking queue, or automation lane), the fourth band is pure complexity with no payback. Band count is a velocity-taxonomy decision, so align it with the scheme you set in SKU Velocity Taxonomy Design rather than choosing it in isolation.

Prerequisites

  • Python 3.10+ — the classifier uses list[...] generics, X | None unions, and frozen dataclass records.
  • A per-SKU velocity score — a single float per SKU, already blended and normalized. The classifier consumes scores, not raw pick counts; produce them with the composite scoring in the parent tuning guide.
  • A chosen convention for D — dead-stock tail or hyper-mover head — decided before you write the band list.
  • Capacity numbers — the pick-face and reserve counts that anchor each band’s cumulative-share ceiling, so the cuts reflect physical slots rather than a textbook 80/20.

Configuration Block

Both schemes are just a list of bands, each closing a slice of the cumulative-velocity axis. Keep both lists in one profile and select one with scheme; the final band must close the axis at 1.00.

# velocity_bands.yaml — one profile per facility; select a scheme by name
scheme: abcd               # abc | abcd
schemes:
  abc:
    - {label: A, max_cumulative: 0.70}   # golden-zone tier
    - {label: B, max_cumulative: 0.90}   # mid reserve
    - {label: C, max_cumulative: 1.00}   # everything slow (dead stock hidden here)
  abcd:
    - {label: A, max_cumulative: 0.70}   # golden-zone tier (shared cut)
    - {label: B, max_cumulative: 0.90}   # mid reserve (shared cut)
    - {label: C, max_cumulative: 0.98}   # slow-but-live tail
    - {label: D, max_cumulative: 1.00}   # near-dead / obsolete -> de-stock queue
# Equivalent Python config dict consumed by the classifier
VELOCITY_BANDS = {
    "scheme": "abcd",
    "schemes": {
        "abc": [
            {"label": "A", "max_cumulative": 0.70},
            {"label": "B", "max_cumulative": 0.90},
            {"label": "C", "max_cumulative": 1.00},
        ],
        "abcd": [
            {"label": "A", "max_cumulative": 0.70},
            {"label": "B", "max_cumulative": 0.90},
            {"label": "C", "max_cumulative": 0.98},
            {"label": "D", "max_cumulative": 1.00},
        ],
    },
}

Implementation

One function drives either scheme. It sorts SKUs by descending velocity, walks the cumulative-share axis, and assigns each SKU to the first band whose ceiling it has not yet passed. Because the scheme is entirely in the band list, switching from ABC to ABCD is a config change, not a code change.

from __future__ import annotations

import logging
from dataclasses import dataclass

logger = logging.getLogger("velocity.banding")


@dataclass(frozen=True)
class Band:
    """One velocity band closing a slice of the cumulative-share axis."""
    label: str
    max_cumulative: float  # inclusive upper bound in [0, 1]


@dataclass(frozen=True)
class BandedSku:
    sku_id: str
    velocity_score: float
    cumulative_share: float
    band: str


def classify_bands(
    scored: list[tuple[str, float]],
    bands: list[Band],
) -> list[BandedSku]:
    """Cut velocity-scored SKUs into config-driven bands (ABC, ABCD, or any N).

    `scored` is a list of (sku_id, velocity_score). `bands` is an ordered list
    whose ``max_cumulative`` ceilings partition [0, 1]; the final band must
    close the axis at 1.0. The same call produces 3-band ABC or 4-band ABCD —
    only the band list changes.
    """
    if not bands or abs(bands[-1].max_cumulative - 1.0) > 1e-9:
        raise ValueError("final band must close the axis at max_cumulative=1.0")

    ordered = sorted(scored, key=lambda row: row[1], reverse=True)
    total = sum(score for _, score in ordered)
    if total <= 0:
        raise ValueError("total velocity must be positive")

    result: list[BandedSku] = []
    counts: dict[str, int] = {b.label: 0 for b in bands}
    running = 0.0
    for sku_id, score in ordered:
        running += score
        share = running / total
        band = next(b.label for b in bands if share <= b.max_cumulative + 1e-9)
        counts[band] += 1
        result.append(BandedSku(sku_id, score, share, band))

    logger.info(
        "Classified %d SKUs into %d bands (%s): %s",
        len(result), len(bands), "/".join(b.label for b in bands), counts,
    )
    return result

Step-by-Step Walkthrough

  1. Validate the axis closes. The guard rejects any band list whose last max_cumulative is not 1.0. A list that stops at 0.98 would leave the dead-stock tail unclassified — exactly the SKUs the D band exists to catch — so this fails fast rather than silently dropping them.
  2. Sort descending, sum the total. SKUs are ordered fastest-first and total is the denominator for cumulative share. A non-positive total (an empty or zero-velocity window) raises rather than dividing by zero.
  3. Walk the cumulative axis. For each SKU, running accumulates its score and share is the fraction of total velocity consumed so far. This is the y-axis of the Pareto curve in the figure above.
  4. Assign to the first unclosed band. next(b.label for b in bands if share <= b.max_cumulative) picks the first band whose ceiling the running share has not exceeded. Under abc a share of 0.99 lands in C; under abcd the same share lands in D. The + 1e-9 tolerance keeps the boundary SKU on the correct side under float rounding.
  5. Log the band histogram. The counts dict reports how many SKUs fell in each band. Watch it across runs — a D band holding a handful of SKUs is telling you the fourth cut is not earning its keep.

Verification

Drive the same 200-SKU distribution through both schemes and confirm the invariants: every SKU is banded, the shared A and B cuts produce identical counts, and ABCD’s C splits cleanly into C plus D.

import logging

logging.basicConfig(level=logging.INFO)

# Synthetic Pareto scores: geometric decay -> a few fast movers, a long slow tail.
scored = [(f"SKU{i}", 0.8 ** i) for i in range(200)]

abc = [Band(**b) for b in VELOCITY_BANDS["schemes"]["abc"]]
abcd = [Band(**b) for b in VELOCITY_BANDS["schemes"]["abcd"]]

out_abc = classify_bands(scored, abc)
out_abcd = classify_bands(scored, abcd)


def counts(rows: list[BandedSku]) -> dict[str, int]:
    tally: dict[str, int] = {}
    for r in rows:
        tally[r.band] = tally.get(r.band, 0) + 1
    return tally


c_abc, c_abcd = counts(out_abc), counts(out_abcd)

# Shared cuts: A and B populations are identical across schemes.
assert c_abc["A"] == c_abcd["A"]
assert c_abc["B"] == c_abcd["B"]
# ABCD only re-partitions the C tail — A+B+C+D must reconcile to ABC's C.
assert c_abcd["C"] + c_abcd["D"] == c_abc["C"]
print("ABC :", c_abc)
print("ABCD:", c_abcd)

Sample expected output:

INFO:velocity.banding:Classified 200 SKUs into 3 bands (A/B/C): {'A': 5, 'B': 5, 'C': 190}
INFO:velocity.banding:Classified 200 SKUs into 4 bands (A/B/C/D): {'A': 5, 'B': 5, 'C': 7, 'D': 183}
ABC : {'A': 5, 'B': 5, 'C': 190}
ABCD: {'A': 5, 'B': 5, 'C': 7, 'D': 183}

The output makes the decision concrete: ABC buries 190 SKUs in one undifferentiated C band, while ABCD peels off 183 near-dead SKUs into D — a population that large is worth a separate de-stock and reserve policy, which is exactly the signal that the fourth band pays off here.

Common Pitfalls

  • Splitting a tail that is too small to act on. If D holds a dozen SKUs, you have added a boundary, a slot policy, and reclassification churn to move almost nothing. Check the band histogram: unless D is a population you route differently, drop back to three bands.
  • Cutting by SKU count instead of velocity. “Bottom 10% of SKUs” is not the same as “bottom 2% of velocity.” Anchor max_cumulative on cumulative velocity share so the cut tracks throughput, and let the SKU count fall where the Pareto curve puts it.
  • Adding bands without adding hysteresis. Every new boundary is a new line to oscillate across. A 4-band scheme without a tolerance band thrashes more than a 3-band one; carry the hysteresis gate from the parent tuning guide onto all boundaries, not just A|B.
  • Ignoring which end D lives on. Copy-pasting a dead-stock band list into a hyper-mover operation puts the extra precision at the wrong end of the curve. Fix the convention first, then write the ceilings.