@logistics-ts/classification 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,188 @@
1
+ import { Explained, DemandSeries } from '@logistics-ts/core';
2
+
3
+ /**
4
+ * ABC classification — a Pareto split of items by their contribution to total
5
+ * consumption value (or volume). A-items are the vital few that dominate the
6
+ * total and warrant the tightest inventory control; C-items are the trivial
7
+ * many.
8
+ *
9
+ * @see Silver, E.A., Pyke, D.F. & Thomas, D.J. (2017). Inventory and Production
10
+ * Management in Supply Chains, 4th ed.
11
+ */
12
+
13
+ /** An item to be ranked, with its annual volume and (optionally) unit value. */
14
+ interface AbcItem {
15
+ itemId: string;
16
+ /** Annual consumption volume (units). */
17
+ volume: number;
18
+ /** Unit value/cost. Required when classifying `by: 'value'`. */
19
+ unitValue?: number;
20
+ }
21
+ interface AbcOptions {
22
+ /** Rank by consumption value (`volume × unitValue`) or by raw volume. Default `'value'`. */
23
+ by?: 'value' | 'volume';
24
+ /**
25
+ * Cumulative-share cutoffs `[aMax, bMax]` separating A|B and B|C. Defaults to
26
+ * the conventional `[0.8, 0.95]`. Cutoffs are convention, not theory.
27
+ */
28
+ cutoffs?: [number, number];
29
+ }
30
+ interface AbcClassification {
31
+ itemId: string;
32
+ class: 'A' | 'B' | 'C';
33
+ /** The ranked metric for this item (value or volume). */
34
+ metric: number;
35
+ /** This item's share of the total metric. */
36
+ share: number;
37
+ /** Cumulative share up to and including this item, in ranked order. */
38
+ cumulativeShare: number;
39
+ }
40
+ /**
41
+ * Classifies items into A/B/C by cumulative share of the ranked metric.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * abc([
46
+ * { itemId: 'A', volume: 100, unitValue: 50 },
47
+ * { itemId: 'B', volume: 500, unitValue: 1 },
48
+ * ]).value
49
+ * ```
50
+ */
51
+ declare function abc(items: readonly AbcItem[], options?: AbcOptions): Explained<AbcClassification[]>;
52
+
53
+ /**
54
+ * XYZ classification — groups items by demand variability, measured as the
55
+ * coefficient of variation (CV) of demand across periods. X-items have stable,
56
+ * predictable demand; Z-items are erratic and hard to forecast.
57
+ *
58
+ * Pair with {@link abc} (see {@link abcXyzMatrix}) to set inventory policy: the
59
+ * value axis says how much control an item deserves, the variability axis says
60
+ * how much buffer it needs.
61
+ */
62
+
63
+ interface XyzOptions {
64
+ /**
65
+ * CV cutoffs `[xMax, yMax]` separating X|Y and Y|Z. Defaults to `[0.5, 1.0]`.
66
+ * Cutoffs are convention; tune them to your catalogue.
67
+ */
68
+ cutoffs?: [number, number];
69
+ }
70
+ interface XyzClassification {
71
+ itemId: string;
72
+ class: 'X' | 'Y' | 'Z';
73
+ /** Coefficient of variation of the item's per-period demand. */
74
+ coefficientOfVariation: number;
75
+ }
76
+ /**
77
+ * Classifies bucketed demand series into X/Y/Z by coefficient of variation.
78
+ * Feed the dense, zero-filled output of {@link bucketize} so the CV reflects the
79
+ * true period-to-period variability.
80
+ *
81
+ * An item with no demand (undefined CV) is classified `Z` with a warning.
82
+ */
83
+ declare function xyz(series: readonly DemandSeries[], options?: XyzOptions): Explained<XyzClassification[]>;
84
+
85
+ /**
86
+ * FSN classification — Fast-, Slow-, and Non-moving items by how frequently they
87
+ * see demand. It surfaces dead stock (N) and high-turnover items (F) that need
88
+ * different replenishment and review policies.
89
+ *
90
+ * This implementation measures **movement frequency** (the fraction of periods
91
+ * with demand) from a demand series alone. Turnover-based FSN (demand ÷ average
92
+ * stock) is an alternative that requires stock data and is a future addition.
93
+ */
94
+
95
+ interface FsnOptions {
96
+ /**
97
+ * Movement-frequency boundary between Slow and Fast (fraction of periods with
98
+ * demand). Default 0.5. Items with no demand at all are Non-moving.
99
+ */
100
+ fastCutoff?: number;
101
+ }
102
+ interface FsnClassification {
103
+ itemId: string;
104
+ class: 'F' | 'S' | 'N';
105
+ /** Fraction of periods with demand (0 = never moved, 1 = every period). */
106
+ movementRatio: number;
107
+ }
108
+ /**
109
+ * Classifies bucketed demand series into Fast / Slow / Non-moving.
110
+ * Feed the dense, zero-filled output of {@link bucketize}.
111
+ */
112
+ declare function fsn(series: readonly DemandSeries[], options?: FsnOptions): Explained<FsnClassification[]>;
113
+
114
+ /**
115
+ * ABC-XYZ matrix — joins the value axis (ABC) with the variability axis (XYZ)
116
+ * into nine cells, each with a concrete inventory-policy hint. This is the
117
+ * payoff of running both classifications: it turns two labels into an action.
118
+ */
119
+
120
+ /** A `${ABC}${XYZ}` cell label, e.g. `'AX'`. */
121
+ type AbcXyzClass = 'AX' | 'AY' | 'AZ' | 'BX' | 'BY' | 'BZ' | 'CX' | 'CY' | 'CZ';
122
+ interface AbcXyzCell {
123
+ itemId: string;
124
+ abc: AbcClassification['class'];
125
+ xyz: XyzClassification['class'];
126
+ class: AbcXyzClass;
127
+ /** Recommended inventory policy for this cell. */
128
+ policyHint: string;
129
+ }
130
+ /**
131
+ * Combines {@link abc} and {@link xyz} results into the nine-cell matrix. Items
132
+ * present in only one of the two inputs are skipped and reported as a warning.
133
+ *
134
+ * @example
135
+ * ```ts
136
+ * const cells = abcXyzMatrix(abc(items).value, xyz(series).value).value
137
+ * ```
138
+ */
139
+ declare function abcXyzMatrix(abcResult: readonly AbcClassification[], xyzResult: readonly XyzClassification[]): Explained<AbcXyzCell[]>;
140
+
141
+ /**
142
+ * Demand-pattern classification via the Syntetos–Boylan–Croston (SBC) scheme.
143
+ * Two statistics place a series in one of four quadrants:
144
+ *
145
+ * - **ADI** — average demand interval (periods per demand occurrence);
146
+ * - **CV²** — squared coefficient of variation of the non-zero demand sizes.
147
+ *
148
+ * The quadrant drives forecasting method selection (see M3): smooth/erratic
149
+ * series suit exponential smoothing, intermittent/lumpy series suit Croston and
150
+ * its variants.
151
+ *
152
+ * @see Syntetos, A.A., Boylan, J.E. & Croston, J.D. (2005). On the categorization
153
+ * of demand patterns. Journal of the Operational Research Society, 56(5),
154
+ * 495–503.
155
+ */
156
+
157
+ /** One of the four SBC demand patterns. */
158
+ type DemandPattern = 'smooth' | 'erratic' | 'intermittent' | 'lumpy';
159
+ interface DemandPatternOptions {
160
+ /** ADI boundary between frequent and intermittent demand. Default 1.32. */
161
+ adiCutoff?: number;
162
+ /** CV² boundary between stable and variable demand size. Default 0.49. */
163
+ cv2Cutoff?: number;
164
+ }
165
+ interface DemandPatternResult {
166
+ pattern: DemandPattern;
167
+ /** Average demand interval. */
168
+ adi: number;
169
+ /** Squared coefficient of variation of non-zero demand sizes. */
170
+ cv2: number;
171
+ /** Forecasting method families suited to this pattern (see M3). */
172
+ recommendedMethods: string[];
173
+ }
174
+ /**
175
+ * Classifies a demand series into an SBC pattern.
176
+ *
177
+ * @param series - Demand per period, **including zero periods** (use
178
+ * {@link bucketize} from core to produce a dense, zero-filled series).
179
+ * @param options - Optional cutoff overrides.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * classifyDemandPattern([0, 4, 0, 0, 6, 0, 5]).value.pattern // 'intermittent'
184
+ * ```
185
+ */
186
+ declare function classifyDemandPattern(series: readonly number[], options?: DemandPatternOptions): Explained<DemandPatternResult>;
187
+
188
+ export { type AbcClassification, type AbcItem, type AbcOptions, type AbcXyzCell, type AbcXyzClass, type DemandPattern, type DemandPatternOptions, type DemandPatternResult, type FsnClassification, type FsnOptions, type XyzClassification, type XyzOptions, abc, abcXyzMatrix, classifyDemandPattern, fsn, xyz };
package/dist/index.js ADDED
@@ -0,0 +1,194 @@
1
+ import { explain, coefficientOfVariation, averageDemandInterval, squaredCvOfNonZero } from '@logistics-ts/core';
2
+
3
+ // src/abc.ts
4
+ function abc(items, options = {}) {
5
+ const { by = "value", cutoffs = [0.8, 0.95] } = options;
6
+ const [aMax, bMax] = cutoffs;
7
+ const metricOf = (item) => {
8
+ if (by === "value" && item.unitValue === void 0) {
9
+ throw new Error(`abc by 'value' requires unitValue; item "${item.itemId}" has none`);
10
+ }
11
+ const metric = by === "volume" ? item.volume : item.volume * item.unitValue;
12
+ if (!Number.isFinite(metric) || metric < 0) {
13
+ throw new Error(`abc: item "${item.itemId}" has an invalid ${by} metric (${metric})`);
14
+ }
15
+ return metric;
16
+ };
17
+ const ranked = items.map((item) => ({ itemId: item.itemId, metric: metricOf(item) })).sort((a, b) => b.metric - a.metric);
18
+ const total = ranked.reduce((sum, r) => sum + r.metric, 0);
19
+ const result = [];
20
+ let cumulative = 0;
21
+ const counts = { A: 0, B: 0, C: 0 };
22
+ for (const { itemId, metric } of ranked) {
23
+ let cls;
24
+ if (total === 0) {
25
+ cls = "C";
26
+ } else {
27
+ const cumulativeBefore = cumulative / total;
28
+ cls = cumulativeBefore < aMax ? "A" : cumulativeBefore < bMax ? "B" : "C";
29
+ }
30
+ cumulative += metric;
31
+ const share = total === 0 ? 0 : metric / total;
32
+ counts[cls]++;
33
+ result.push({
34
+ itemId,
35
+ class: cls,
36
+ metric,
37
+ share,
38
+ cumulativeShare: total === 0 ? 0 : cumulative / total
39
+ });
40
+ }
41
+ const warnings = total === 0 ? ["no consumption across any item; all classified C"] : void 0;
42
+ return explain(result, {
43
+ method: `abc-by-${by}`,
44
+ inputs: { items: items.length, aMax, bMax, total },
45
+ reasoning: [
46
+ `ranked ${items.length} items by ${by === "value" ? "consumption value" : "volume"}`,
47
+ `A until cumulative share reaches ${aMax * 100}%, then B until ${bMax * 100}%, then C; the item that crosses a cutoff is promoted to the higher class`,
48
+ `${counts.A} A-items, ${counts.B} B-items, ${counts.C} C-items`
49
+ ],
50
+ citations: ["Silver, Pyke & Thomas (2017), Inventory and Production Management"],
51
+ ...warnings ? { warnings } : {}
52
+ });
53
+ }
54
+ function xyz(series, options = {}) {
55
+ const { cutoffs = [0.5, 1] } = options;
56
+ const [xMax, yMax] = cutoffs;
57
+ const warnings = [];
58
+ const counts = { X: 0, Y: 0, Z: 0 };
59
+ const result = series.map((s) => {
60
+ const quantities = s.buckets.map((b) => b.quantity);
61
+ const cv = coefficientOfVariation(quantities);
62
+ let cls;
63
+ if (Number.isNaN(cv)) {
64
+ cls = "Z";
65
+ const hasDemand = quantities.some((q) => q > 0);
66
+ warnings.push(
67
+ hasDemand ? `item "${s.itemId}" has fewer than two periods; CV is undefined, classified Z` : `item "${s.itemId}" has no demand; classified Z`
68
+ );
69
+ } else {
70
+ cls = cv < xMax ? "X" : cv < yMax ? "Y" : "Z";
71
+ }
72
+ counts[cls]++;
73
+ return { itemId: s.itemId, class: cls, coefficientOfVariation: cv };
74
+ });
75
+ return explain(result, {
76
+ method: "xyz-coefficient-of-variation",
77
+ inputs: { items: series.length, xMax, yMax },
78
+ reasoning: [
79
+ `X < ${xMax} CV (stable), Y < ${yMax} (variable), Z \u2265 ${yMax} (erratic)`,
80
+ `${counts.X} X-items, ${counts.Y} Y-items, ${counts.Z} Z-items`
81
+ ],
82
+ ...warnings.length > 0 ? { warnings } : {}
83
+ });
84
+ }
85
+ function fsn(series, options = {}) {
86
+ const { fastCutoff = 0.5 } = options;
87
+ const counts = { F: 0, S: 0, N: 0 };
88
+ const result = series.map((s) => {
89
+ const periods = s.buckets.length;
90
+ const moved = s.buckets.filter((b) => b.quantity > 0).length;
91
+ const movementRatio = periods === 0 ? 0 : moved / periods;
92
+ const cls = moved === 0 ? "N" : movementRatio >= fastCutoff ? "F" : "S";
93
+ counts[cls]++;
94
+ return { itemId: s.itemId, class: cls, movementRatio };
95
+ });
96
+ return explain(result, {
97
+ method: "fsn-movement-frequency",
98
+ inputs: { items: series.length, fastCutoff },
99
+ reasoning: [
100
+ `N = no demand, F \u2265 ${fastCutoff} of periods with demand, S = the rest`,
101
+ `${counts.F} fast, ${counts.S} slow, ${counts.N} non-moving`
102
+ ]
103
+ });
104
+ }
105
+ var POLICY = {
106
+ AX: "high value, stable \u2014 tight control, low safety stock, automate reordering (JIT)",
107
+ AY: "high value, variable \u2014 tight control, moderate safety stock, forecast carefully",
108
+ AZ: "high value, erratic \u2014 manual review, buffer or make-to-order, avoid over-committing",
109
+ BX: "moderate value, stable \u2014 automate with standard safety stock",
110
+ BY: "moderate value, variable \u2014 standard control, moderate safety stock",
111
+ BZ: "moderate value, erratic \u2014 cautious stocking, periodic review",
112
+ CX: "low value, stable \u2014 simple rules, generous safety stock is cheap, bulk order",
113
+ CY: "low value, variable \u2014 simple rules, generous safety stock",
114
+ CZ: "low value, erratic \u2014 minimal stock or make-to-order; do not over-invest"
115
+ };
116
+ function abcXyzMatrix(abcResult, xyzResult) {
117
+ const xyzById = new Map(xyzResult.map((x) => [x.itemId, x.class]));
118
+ const warnings = [];
119
+ const cells = [];
120
+ for (const a of abcResult) {
121
+ const x = xyzById.get(a.itemId);
122
+ if (x === void 0) {
123
+ warnings.push(`item "${a.itemId}" has an ABC class but no XYZ class; skipped`);
124
+ continue;
125
+ }
126
+ const cls = `${a.class}${x}`;
127
+ cells.push({ itemId: a.itemId, abc: a.class, xyz: x, class: cls, policyHint: POLICY[cls] });
128
+ }
129
+ const abcIds = new Set(abcResult.map((a) => a.itemId));
130
+ for (const x of xyzResult) {
131
+ if (!abcIds.has(x.itemId)) {
132
+ warnings.push(`item "${x.itemId}" has an XYZ class but no ABC class; skipped`);
133
+ }
134
+ }
135
+ return explain(cells, {
136
+ method: "abc-xyz-matrix",
137
+ inputs: { abcItems: abcResult.length, xyzItems: xyzResult.length, matched: cells.length },
138
+ reasoning: [
139
+ "joined the value axis (ABC) with the variability axis (XYZ) by itemId",
140
+ "each cell carries a recommended inventory policy"
141
+ ],
142
+ ...warnings.length > 0 ? { warnings } : {}
143
+ });
144
+ }
145
+ var RECOMMENDED = {
146
+ smooth: ["SES", "Holt", "Holt-Winters"],
147
+ erratic: ["SES", "Holt"],
148
+ intermittent: ["Croston", "SBA"],
149
+ lumpy: ["SBA", "TSB"]
150
+ };
151
+ var DESCRIPTION = {
152
+ smooth: "frequent demand of stable size",
153
+ erratic: "frequent demand of highly variable size",
154
+ intermittent: "sporadic demand of stable size",
155
+ lumpy: "sporadic demand of highly variable size"
156
+ };
157
+ function classifyDemandPattern(series, options = {}) {
158
+ const { adiCutoff = 1.32, cv2Cutoff = 0.49 } = options;
159
+ const adi = averageDemandInterval(series);
160
+ const cv2 = squaredCvOfNonZero(series);
161
+ const warnings = [];
162
+ const nonZero = series.filter((v) => v !== 0).length;
163
+ if (nonZero === 0) warnings.push("series has no demand; classification is not meaningful");
164
+ else if (nonZero < 2) warnings.push("fewer than two demand occurrences; CV\xB2 is unreliable");
165
+ const effectiveAdi = Number.isNaN(adi) ? 1 : adi;
166
+ const effectiveCv2 = Number.isNaN(cv2) ? 0 : cv2;
167
+ const intermittent = effectiveAdi >= adiCutoff;
168
+ const variable = effectiveCv2 >= cv2Cutoff;
169
+ const pattern = intermittent ? variable ? "lumpy" : "intermittent" : variable ? "erratic" : "smooth";
170
+ const recommendedMethods = nonZero === 0 ? [] : [...RECOMMENDED[pattern]];
171
+ return explain(
172
+ { pattern, adi, cv2, recommendedMethods },
173
+ {
174
+ method: "syntetos-boylan-croston",
175
+ inputs: { adi: round(adi), cv2: round(cv2), adiCutoff, cv2Cutoff },
176
+ reasoning: [
177
+ `ADI ${fmt(adi)} ${intermittent ? "\u2265" : "<"} ${adiCutoff} \u2192 ${intermittent ? "sporadic" : "frequent"} demand`,
178
+ `CV\xB2 ${fmt(cv2)} ${variable ? "\u2265" : "<"} ${cv2Cutoff} \u2192 ${variable ? "variable" : "stable"} demand size`,
179
+ `classified as ${pattern}: ${DESCRIPTION[pattern]}`,
180
+ recommendedMethods.length > 0 ? `suited to ${recommendedMethods.join(", ")}` : "no demand to forecast"
181
+ ],
182
+ citations: ["Syntetos, Boylan & Croston (2005), JORS 56(5)"],
183
+ ...warnings.length > 0 ? { warnings } : {}
184
+ }
185
+ );
186
+ }
187
+ function round(x) {
188
+ return Number.isNaN(x) ? Number.NaN : Math.round(x * 1e3) / 1e3;
189
+ }
190
+ function fmt(x) {
191
+ return Number.isNaN(x) ? "n/a" : String(round(x));
192
+ }
193
+
194
+ export { abc, abcXyzMatrix, classifyDemandPattern, fsn, xyz };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@logistics-ts/classification",
3
+ "version": "0.0.1",
4
+ "description": "ABC, XYZ, FSN, ABC-XYZ matrix, and demand-pattern (SBC) classification for logistics-ts.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/adam-drag/logistics-ts.git",
10
+ "directory": "packages/classification"
11
+ },
12
+ "homepage": "https://github.com/adam-drag/logistics-ts/tree/main/packages/classification",
13
+ "sideEffects": false,
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@logistics-ts/core": "0.0.1"
31
+ },
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "typecheck": "tsc --noEmit"
35
+ }
36
+ }