Functional Weave
Code in Python

inventory.abc-classification

ABC classes by annual consumption value (Pareto), with configurable cut-offs such as 80/15/5 and deterministic ties.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 17 tests, run in TypeScript, Python and Rust.

What it does

ABC analysis ranks stock items by annual consumption value (annual quantity x unit cost) and splits the ranking into classes by cumulative share of the total, on the Pareto observation that a few items carry most of the value. Class A items get the tightest control and most frequent counts (see `inventory.cycle-count-schedule`).

**Cut-offs** are cumulative basis points, ascending, each from 1 to 9999: `[8000, 9500]` is the usual 80/15/5 split into A, B and C. Pass more to get more classes, lettered A, B, C, D ... (up to 25 cut-offs).

For example

  • abc_classification(items ×10, 80%, 95%) → ×10 80/15/5 over ten items: P3 ends exactly on 80% and is A, P4 starts on 80% and is B, P6 starts at 94.5% and is still B; P6 and P7 tie and rank by sku
  • abc_classification(items ×10, 50%, 80%, 95%) → ×10 four classes from three cut-offs: P6 starts at 94.5%, so C, and P7 at 96.5%, so D
  • abc_classification(items ×2, 80%, 95%) → ×2 one item holding 90% is A, not B, and the next starts at 90% so it is B

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def abc_classification(items: Sequence[ConsumptionItem], cutoff_basis_points: Sequence[int]) -> List[AbcItem]
itemsConsumptionItem[]every stock item, with its annual usage and unit cost
cutoff_basis_pointsint[]cumulative shares where each class ends, ascending: [8000, 9500] is A to 80%, B to 95%, C the rest
returnsAbcItem[]every item, highest annual value first

The types it declares, generated into your project

@dataclass(frozen=True)
class ConsumptionItem:
    """One stock item and a year's usage of it."""

    #: unique
    sku: str
    #: units used in the year, not negative
    annual_quantity: int
    #: not negative; every item in one currency
    unit_cost: Money

@dataclass(frozen=True)
class AbcItem:
    """One item's place in the ranking."""

    sku: str
    #: annualQuantity x unitCost
    annual_value: Money
    #: 1 for the highest annual value
    rank: int
    #: share of the total value up to and including this item, rounded half-up
    cumulative_basis_points: int
    #: "A", "B", "C" ... one letter per class
    abc_class: str

Your code names it in one line, in the file that uses it

from fune.inventory.abc_classification import abc_classification  # inventory.abc-classification@^1
impl/python.py · 69 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from functools import cmp_to_key
from typing import List, Sequence

from .inventory_abc_classification_types import AbcItem, ConsumptionItem
from .math_round_div import round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import assert_same_currency, money  ← from money.amount ^1.0.0 · built alongside by fune

LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
MAX_SAFE = 2**53 - 1


def _by_value_then_sku(a: tuple, b: tuple) -> int:
    if a[1] != b[1]:
        return -1 if a[1] > b[1] else 1
    return -1 if a[0] < b[0] else 1 if a[0] > b[0] else 0


def abc_classification(items: Sequence[ConsumptionItem], cutoff_basis_points: Sequence[int]) -> List[AbcItem]:
    """Rank items by annual consumption value and class them by the band of the
    cumulative share each one starts in, so the item that crosses a cut-off
    stays in the higher class.
    """
    if len(cutoff_basis_points) == 0 or len(cutoff_basis_points) > 25:
        raise ValueError("cutoffBasisPoints needs 1 to 25 cut-offs, received %d" % len(cutoff_basis_points))
    previous = 0
    for cutoff in cutoff_basis_points:
        if isinstance(cutoff, bool) or not isinstance(cutoff, int) or cutoff <= previous or cutoff >= 10000:
            raise ValueError(
                "cutoffBasisPoints must ascend strictly within 1..9999: received %s after %s" % (cutoff, previous)
            )
        previous = cutoff
    seen = set()
    valued = []
    for item in items:
        if item.sku in seen:
            raise ValueError('duplicate sku "%s"' % item.sku)
        seen.add(item.sku)
        q = item.annual_quantity
        if isinstance(q, bool) or not isinstance(q, int) or q < 0:
            raise ValueError(
                'annualQuantity must be a whole number, not negative, received %r for "%s"' % (q, item.sku)
            )
        if item.unit_cost.minor < 0:
            raise ValueError('unitCost must not be negative, received %d for "%s"' % (item.unit_cost.minor, item.sku))
        assert_same_currency(items[0].unit_cost, item.unit_cost)
        valued.append((item.sku, q * item.unit_cost.minor, item.unit_cost.currency))
    total = sum(v[1] for v in valued)
    if total * 10000 > MAX_SAFE:
        raise ValueError("the total annual value is too large: it must stay within (2^53 - 1) / 10000 minor units")
    valued.sort(key=cmp_to_key(_by_value_then_sku))
    result: List[AbcItem] = []
    before = 0
    for index, (sku, value, currency) in enumerate(valued):
        band = len(cutoff_basis_points)
        for i, cutoff in enumerate(cutoff_basis_points):
            if before * 10000 < cutoff * total:
                band = i
                break
        before += value
        result.append(
            AbcItem(
                sku=sku,
                annual_value=money(value, currency),
                rank=index + 1,
                cumulative_basis_points=0 if total == 0 else round_div(before * 10000, total, "half-up"),
                abc_class=LETTERS[band],
            )
        )
    return result

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add inventory.abc-classification
Download for Python inventory.abc-classification-1.0.0-python.fune · 18,889 bytes sha256 9088a5941c56ffafd5ffd679ab86c441e14cfa205dce18689f2b46ddd26acf6d

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./inventory.abc-classification-1.0.0-python.fune, or fetch it from a terminal with fune pull inventory.abc-classification@1.0.0:python.

The whole function, every language, is one file too: inventory.abc-classification-1.0.0.fune, 26,098 bytes, sha256 90bb26438e7d1b6baed85d767cb3ab5755635015364d1fea9ec3bfcb4a987677. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before inventory.abc-classification

after — your function gets the result and the arguments, and returns the final result.

# fune: after inventory.abc-classification

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace math.round-div in inventory.abc-classification
# fune: replace money.amount in inventory.abc-classification

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show inventory.abc-classification --steps.

# fune: step inventory.abc-classification after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
80/15/5 over ten items: P3 ends exactly on 80% and is A, P4 starts on 80% and is B, P6 starts at 94.5% and is still B; P6 and P7 tie and rank by sku items ×10, 80%, 95% → ×10
four classes from three cut-offs: P6 starts at 94.5%, so C, and P7 at 96.5%, so D items ×10, 50%, 80%, 95% → ×10
one item holding 90% is A, not B, and the next starts at 90% so it is B items ×2, 80%, 95% → ×2
a single item is A with the whole value items ×1, 80%, 95% → ×1
items with no value fall in the last class, ranked by sku items ×3, 80%, 95% → ×3
when nothing has value everything is in the last class items ×2, 80%, 95% → ×2
cumulative share rounds half-up for display (3333, 6667); the class uses the exact share, and C starts at 2/3, past 60% items ×3, 60% → ×3
an empty list is an empty ranking , 80%, 95% →
a duplicate sku is an error items ×2, 80%, 95% → error: duplicate sku "A"
cut-offs out of order are an error items ×1, 95%, 80% → error: cutoffBasisPoints must ascend strictly within 1..9999
Show the other 7 tests
CaseArgumentsExpected
a cut-off of 100% is an error items ×1, 80%, 100% → error: cutoffBasisPoints must ascend strictly within 1..9999
no cut-offs is an error items ×1, → error: cutoffBasisPoints needs 1 to 25 cut-offs
a negative quantity is an error items ×1, 80% → error: annualQuantity must be a whole number, not negative
a fractional quantity is an error items ×1, 80% → error: annualQuantity must be a whole number, not negative
a negative unit cost is an error items ×1, 80% → error: unitCost must not be negative
items in two currencies are an error items ×2, 80% → error: currency mismatch
a total too large to scale is an error items ×1, 80% → error: the total annual value is too large

More from the author

**Which class an item on a boundary falls in.** An item belongs to the class whose band its value *starts* in: it is in class A when the items ranked above it hold less than 80% of the total. So the item that carries the ranking across 80% is still an A item, the top item is always an A item, and an item that starts exactly at 80% is a B. (The other common rule, "cumulative share including the item is at most 80%", can leave class A empty when one item is most of the value.) Every comparison is on whole minor units, never on a rounded percentage; `cumulativeBasisPoints` is reported rounded half-up, for display.

**Ties are deterministic.** Items of equal value are ranked by SKU, ascending (by character code; keep SKUs ASCII for the same order in every language). Items with no value fall in the last class. The result lists every item, highest value first.

Errors: a duplicate SKU, a negative quantity or cost, items in more than one currency, cut-offs that are empty, out of order or outside 1..9999, and totals too large to multiply by 10000 within 2^53 - 1.

Source: the method as described in APICS Dictionary ("ABC classification") and Silver, Pyke and Thomas, *Inventory and Production Management in Supply Chains*, 4th ed., section 2.3.

Files

PathBytes
README.md1,829
impl/python.py2,940
impl/rust.rs4,348
impl/typescript.ts2,597
vectors.json9,567