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 skuabc_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 Dabc_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.
pub fn abc_classification(items: &[ConsumptionItem], cutoff_basis_points: &[i64]) -> Vec<AbcItem>
| items | ConsumptionItem[] | every stock item, with its annual usage and unit cost |
| cutoff_basis_points | int[] | cumulative shares where each class ends, ascending: [8000, 9500] is A to 80%, B to 95%, C the rest |
| returns | AbcItem[] | every item, highest annual value first |
The types it declares, generated into your project
/// One stock item and a year's usage of it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumptionItem {
/// unique
pub sku: String,
/// units used in the year, not negative
pub annual_quantity: i64,
/// not negative; every item in one currency
pub unit_cost: Money,
}
/// One item's place in the ranking.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbcItem {
pub sku: String,
/// annualQuantity x unitCost
pub annual_value: Money,
/// 1 for the highest annual value
pub rank: i64,
/// share of the total value up to and including this item, rounded half-up
pub cumulative_basis_points: i64,
/// "A", "B", "C" ... one letter per class
pub abc_class: String,
}
Your code names it in one line, in the file that uses it
fune!(inventory.abc-classification@^1); // then call abc_classification(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use std::collections::HashSet;
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{assert_same_currency, money, money_from_value, money_to_value}; ← from money.amount ^1.0.0 · built alongside by fune
const LETTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const MAX_SAFE: i128 = (1i128 << 53) - 1;
/// 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.
///
/// # Panics
/// Panics on bad cut-offs, a duplicate SKU, a negative quantity or cost, mixed
/// currencies, or a total too large to scale by 10000.
pub fn abc_classification(items: &[ConsumptionItem], cutoff_basis_points: &[i64]) -> Vec<AbcItem> {
if cutoff_basis_points.is_empty() || cutoff_basis_points.len() > 25 {
panic!("cutoffBasisPoints needs 1 to 25 cut-offs, received {}", cutoff_basis_points.len());
}
let mut previous = 0;
for &cutoff in cutoff_basis_points {
if cutoff <= previous || cutoff >= 10000 {
panic!(
"cutoffBasisPoints must ascend strictly within 1..9999: received {} after {}",
cutoff, previous
);
}
previous = cutoff;
}
let mut seen: HashSet<&str> = HashSet::new();
let mut valued: Vec<(String, i64, String)> = Vec::new();
for item in items {
if !seen.insert(item.sku.as_str()) {
panic!("duplicate sku \"{}\"", item.sku);
}
if item.annual_quantity < 0 {
panic!(
"annualQuantity must be a whole number, not negative, received {} for \"{}\"",
item.annual_quantity, item.sku
);
}
if item.unit_cost.minor < 0 {
panic!("unitCost must not be negative, received {} for \"{}\"", item.unit_cost.minor, item.sku);
}
assert_same_currency(&items[0].unit_cost, &item.unit_cost);
valued.push((item.sku.clone(), item.annual_quantity * item.unit_cost.minor, item.unit_cost.currency.clone()));
}
let total: i64 = valued.iter().map(|v| v.1).sum();
if total as i128 * 10000 > MAX_SAFE {
panic!("the total annual value is too large: it must stay within (2^53 - 1) / 10000 minor units");
}
valued.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut before: i64 = 0;
let mut result = Vec::with_capacity(valued.len());
for (index, (sku, value, currency)) in valued.into_iter().enumerate() {
let band = cutoff_basis_points
.iter()
.position(|&cutoff| (before as i128) * 10000 < (cutoff as i128) * (total as i128))
.unwrap_or(cutoff_basis_points.len());
before += value;
result.push(AbcItem {
sku,
annual_value: money(value, ¤cy),
rank: index as i64 + 1,
cumulative_basis_points: if total == 0 { 0 } else { round_div(before * 10000, total, "half-up") },
abc_class: (LETTERS[band] as char).to_string(),
});
}
result
}
pub fn consumption_item_from_value(v: &Value) -> ConsumptionItem {
ConsumptionItem {
sku: v.get("sku").as_str().to_string(),
annual_quantity: v.get("annualQuantity").as_i64(),
unit_cost: money_from_value(v.get("unitCost")),
}
}
pub fn abc_item_to_value(item: &AbcItem) -> Value {
Value::obj(vec![
("sku", Value::str(&item.sku)),
("annualValue", money_to_value(&item.annual_value)),
("rank", Value::Int(item.rank)),
("cumulativeBasisPoints", Value::Int(item.cumulative_basis_points)),
("abcClass", Value::str(&item.abc_class)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let items: Vec<ConsumptionItem> = args[0]
.as_arr()
.iter()
.map(|v| {
if let Value::Float(f) = v.get("annualQuantity") {
panic!(
"annualQuantity must be a whole number, not negative, received {} for \"{}\"",
f,
v.get("sku").as_str()
);
}
consumption_item_from_value(v)
})
.collect();
let cutoffs: Vec<i64> = args[1].as_arr().iter().map(|v| v.as_i64()).collect();
Value::Arr(abc_classification(&items, &cutoffs).iter().map(abc_item_to_value).collect())
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 2 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add inventory.abc-classification
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./inventory.abc-classification-1.0.0-rust.fune, or fetch it from a terminal with fune pull inventory.abc-classification@1.0.0:rust.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 1,829 |
| impl/python.py | 2,940 |
| impl/rust.rs | 4,348 |
| impl/typescript.ts | 2,597 |
| vectors.json | 9,567 |