ABC Classification — Test Sheet¶
SQL examples predate the v6 unique_id migration
The SQL snippets on this page use the legacy unique_id column, which was
dropped from every table in the v6 migration. All series are now keyed by
item_id + site_id — replace unique_id selection / filtering / joining
/ ORDER BY / GROUP BY with item_id, site_id (and the built-in
item_name / site_name columns where applicable). This page is retained
as a historical test artifact; do not copy its SQL verbatim.
Date: 2026-05-04 Pipeline tested: theBicycle, pipeline_id=4, scenario_id=4 Scope: Two active configurations — ABC (demand / cumulative_pct) and XYZ (hits / rank_pct)
TOC¶
- Configurations overview
- Test 1 — Configurations are active and complete
- Test 2 — Total series coverage
- Test 3 — Class distribution matches thresholds (ABC config)
- Test 4 — Boundary correctness A/B and B/C
- Test 5 — Cumulative % math is correct
- Test 6 — Ranking is strictly monotone (no ties skew)
- Test 7 — Top series are plausible (spot-check)
- Test 8 — XYZ config (hits / rank_pct)
- Test 9 — Cross-config consistency (same universe)
- Test 10 — No series classified in multiple classes
- Known issues / flags
- How to drive the test
Configurations overview¶
Two active configurations in scenario.config_abc_configuration:
| id | name | metric | method | lookback | thresholds | labels |
|---|---|---|---|---|---|---|
| 1 | Default (demand) | demand |
cumulative_pct |
12 months | [80, 95] | A, B, C |
| 23 | hits | hits |
rank_pct |
12 months | [80.0, 95.0] | X, Y, Z |
Config 1 (ABC): Sorts series by 12-month demand volume descending. Assigns A to series whose cumulative demand reaches 80% of the total, B to the next 15%, C to the remainder.
Config 23 (XYZ): Sorts series by demand frequency (number of weeks with qty > 0) over 12 months.
Uses rank_pct (rank relative to total series count) rather than cumulative volume.
Verify configurations:
-- PostgreSQL (thebicycle)
SELECT id, name, metric, method, lookback_months,
thresholds, class_labels, is_active, granularity
FROM scenario.config_abc_configuration
ORDER BY id;
Test 1 — Configurations are active and complete¶
Objective: Both configs are flagged is_active=TRUE and have non-empty thresholds.
-- PostgreSQL
SELECT id, name, is_active,
array_length(thresholds, 1) AS n_thresholds,
array_length(class_labels, 1) AS n_labels
FROM scenario.config_abc_configuration
ORDER BY id;
Pass criteria:
- is_active = TRUE for both rows
- n_thresholds = 2 (two cut points define three classes)
- n_labels = 3
- n_thresholds = n_labels - 1 always
Test 2 — Total series coverage¶
Objective: Both configs classify the same number of series. The only legitimate exclusion is a series with zero demand across the lookback window.
-- ClickHouse
SELECT config_id, count(DISTINCT unique_id) AS series_classified
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
GROUP BY config_id
ORDER BY config_id;
Observed 2026-05-04:
| config_id | series_classified |
|---|---|
| 1 | 575 |
| 23 | 575 |
Total series in PIPE_series_characteristics: 576
-- ClickHouse: which series are missing from config 1?
SELECT unique_id
FROM PIPE_series_characteristics
WHERE pipeline_id = 4
AND scenario_id = 4
AND unique_id NOT IN (
SELECT unique_id FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
);
Observed: 25_312 — this series has zero demand in master.demand_actuals (total qty = NULL, 0 rows).
Correct to exclude; a series with no demand has no rank in a demand-volume classification.
Pass criteria: - Both configs cover the same series count - Any missing series must have zero demand (verify with the query above)
Test 3 — Class distribution matches thresholds (ABC config)¶
Objective: With thresholds [80, 95], the cumulative percentage at the last A-series must be ≤ 80%, the first B-series ≥ 80%, the last B ≤ 95%, the first C ≥ 95%.
-- ClickHouse
SELECT class_label,
count() AS series_count,
round(min(cumulative_pct), 2) AS cum_pct_first,
round(max(cumulative_pct), 2) AS cum_pct_last
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
GROUP BY class_label
ORDER BY class_label;
Observed 2026-05-04:
| class | series_count | cum_pct_first | cum_pct_last |
|---|---|---|---|
| A | 322 | 1.18 | 79.94 |
| B | 180 | 80.03 | 94.94 |
| C | 73 | 95.01 | 100.00 |
Pass criteria:
- A.cum_pct_last ≤ 80 and B.cum_pct_first > 80
- B.cum_pct_last ≤ 95 and C.cum_pct_first > 95
- C.cum_pct_last = 100.00 (all demand accounted for)
- Series counts sum to total classified (322+180+73 = 575)
Test 4 — Boundary correctness A/B and B/C¶
Objective: The series immediately before and after each class boundary are correctly
assigned. A series with cumulative_pct=79.94 is A; the next (80.03) is B.
-- ClickHouse: A/B boundary (last 3 A + first 3 B)
SELECT unique_id, class_label,
round(metric_value, 1) AS demand_12m,
round(cumulative_pct, 2) AS cum_pct,
rank
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
AND cumulative_pct BETWEEN 79.5 AND 80.5
ORDER BY rank;
Expected: class flips from A → B exactly where cumulative_pct crosses 80.
-- ClickHouse: B/C boundary (last 3 B + first 3 C)
SELECT unique_id, class_label,
round(metric_value, 1) AS demand_12m,
round(cumulative_pct, 2) AS cum_pct,
rank
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
AND cumulative_pct BETWEEN 94.5 AND 95.5
ORDER BY rank;
Observed A/B boundary (2026-05-04):
| unique_id | class | demand_12m | cum_pct |
|---|---|---|---|
| 12_315 | A | 74.1 | 79.94 |
| 15_314 | B | 74.1 | 80.03 |
Both have identical demand_12m = 74.1 — a tie at the threshold. The assignment is
deterministic by rank (rank order breaks ties). No series is misclassified.
Observed B/C boundary:
| unique_id | class | demand_12m | cum_pct |
|---|---|---|---|
| 17_301 | B | 57.2 | 94.94 |
| 15_332 | C | 57.0 | 95.01 |
Clean drop in demand value across the boundary — consistent.
Test 5 — Cumulative % math is correct¶
Objective: Verify cumulative_pct at rank 1 equals metric_value_rank1 / total_demand * 100.
-- ClickHouse: verify rank-1 cumulative pct
SELECT
round(metric_value, 1) AS rank1_demand,
round(cumulative_pct, 4) AS stored_cum_pct,
round(
metric_value / (
SELECT sum(metric_value)
FROM PIPE_abc_results
WHERE pipeline_id = 4 AND scenario_id = 4 AND config_id = 1
) * 100
, 4) AS computed_cum_pct
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
AND rank = 1;
Observed 2026-05-04:
- rank1_demand = 937.0
- stored_cum_pct = 1.18
- computed_cum_pct = 1.18 (937 / 79072.2 × 100 = 1.185%)
Pass criteria: abs(stored_cum_pct - computed_cum_pct) < 0.05
Note — total vs demand_actuals mismatch (2026-05-04): ABC total (config 1) = 79,072 vs
demand_actualslast-12-month sum = 75,103 (~5% gap). This is expected: ABC uses a lookback of exactly 53 weeks (≈ 12 months), which includes slightly more data than a calendar-12-month window. Outlier corrections may also shift the ABC total if the pipeline applies corrected values instead of raw actuals.To confirm which demand source the ABC step reads:
Test 6 — Ranking is strictly monotone (no ties skew)¶
Objective: cumulative_pct must be non-decreasing across all ranks.
A violation means the ranking is broken (sort instability, duplicate insertion, etc.).
-- ClickHouse: monotone check — any row where cum_pct < previous row?
SELECT count() AS violations
FROM (
SELECT rank,
cumulative_pct,
lagInFrame(cumulative_pct) OVER (ORDER BY rank) AS prev_cum_pct
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1
)
WHERE cumulative_pct < prev_cum_pct;
Observed: 0 violations — ranking is clean.
Pass criteria: result = 0
-- ClickHouse: also check that rank values are contiguous (no gaps)
SELECT
max(rank) - min(rank) + 1 AS expected_rows,
count() AS actual_rows
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 1;
Pass criteria: expected_rows = actual_rows
Test 7 — Top series are plausible (spot-check)¶
Objective: Rank 1–5 should be recognisable high-volume SKU×site combinations. Chain Lube at major Netherlands cycling shops is a strong candidate for top-volume.
-- ClickHouse
SELECT r.unique_id,
r.class_label,
round(r.metric_value, 1) AS demand_12m,
round(r.cumulative_pct, 2) AS cum_pct,
r.rank
FROM PIPE_abc_results r
WHERE r.pipeline_id = 4
AND r.scenario_id = 4
AND r.config_id = 1
ORDER BY r.rank
LIMIT 10;
Observed 2026-05-04 (names resolved via master.item / master.location):
| rank | unique_id | class | demand_12m | cum_pct | item | site |
|---|---|---|---|---|---|---|
| 1 | 1_342 | A | 937.0 | 1.18% | Chain Lube Finish Line 500ml | Rotterdam Fietsen |
| 2 | 1_344 | A | 886.3 | 2.31% | Chain Lube Finish Line 500ml | Eindhoven Bikes |
| 3 | 1_301 | A | 774.0 | 3.28% | Chain Lube Finish Line 500ml | Cycles Paris |
| 4 | 1_313 | A | 773.2 | 4.26% | Chain Lube Finish Line 500ml | Hamburg Fahrrad |
| 5 | 1_341 | A | 746.8 | 5.21% | Chain Lube Finish Line 500ml | Amsterdam Fietsen |
Top 5 are all the same item (Chain Lube) at different sites — plausible for a consumable with high weekly demand. Combined, they account for 5.21% of total demand.
Flag:
item_nameandsite_namecolumns inPIPE_abc_resultsare stored empty. Names are resolved at query time viamaster.item/master.locationjoins. The ABC write step does not populate the denormalised name columns. See Known issues item 1.
Test 8 — XYZ config (hits / rank_pct)¶
Objective: XYZ uses demand frequency (weeks with qty > 0) as the metric. Thresholds [80, 95] split by rank percentile — the top 80% of series by hit count are X, next 15% are Y, bottom 5% are Z.
-- ClickHouse: hit count distribution
SELECT toInt32(metric_value) AS hit_weeks,
count() AS series_count
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 23
GROUP BY hit_weeks
ORDER BY hit_weeks DESC;
Observed 2026-05-04:
| hit_weeks | series_count |
|---|---|
| 53 | 571 |
| 52 | 4 |
Flag — degenerate distribution (2026-05-04): 571 of 575 series have identical hit count = 53 (100% demand weeks). Only 4 series have 52 hits. With
rank_pctmethod the tie-breaking is by internal sort order — 460 X, 87 Y, 28 Z classes result from rank position alone, not from meaningful metric differentiation.This configuration is not useful in its current state: nearly all series have essentially the same hit count, so XYZ is arbitrary. Consider switching to a metric that differentiates better (e.g. ADI, CV, zero_ratio) or extending the lookback to 24 months to expose more variance.
-- ClickHouse: XYZ class distribution
SELECT class_label,
count() AS series_count,
round(min(cumulative_pct), 2) AS cum_pct_start,
round(max(cumulative_pct), 2) AS cum_pct_end,
round(min(metric_value), 0) AS min_hits,
round(max(metric_value), 0) AS max_hits
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
AND config_id = 23
GROUP BY class_label
ORDER BY class_label;
| class | series_count | cum_pct [start–end] | hits range |
|---|---|---|---|
| X | 460 | 0.17% – 80.01% | 53–53 |
| Y | 87 | 80.18% – 95.14% | 53–53 |
| Z | 28 | 95.32% – 100.00% | 52–53 |
Test 9 — Cross-config consistency (same universe)¶
Objective: Both configs must classify the exact same set of series. A mismatch indicates one config was run against a different demand snapshot.
-- ClickHouse: series in config 1 but not config 23
SELECT unique_id
FROM PIPE_abc_results
WHERE pipeline_id = 4 AND scenario_id = 4 AND config_id = 1
AND unique_id NOT IN (
SELECT unique_id FROM PIPE_abc_results
WHERE pipeline_id = 4 AND scenario_id = 4 AND config_id = 23
);
-- ClickHouse: series in config 23 but not config 1
SELECT unique_id
FROM PIPE_abc_results
WHERE pipeline_id = 4 AND scenario_id = 4 AND config_id = 23
AND unique_id NOT IN (
SELECT unique_id FROM PIPE_abc_results
WHERE pipeline_id = 4 AND scenario_id = 4 AND config_id = 1
);
Observed: 0 rows in both queries — perfect alignment.
Pass criteria: both queries return 0 rows
Test 10 — No series classified in multiple classes¶
Objective: A (config_id, unique_id) pair must have exactly one class label.
Duplicate insertion (e.g., from re-running without clearing) would cause double counting.
-- ClickHouse: find duplicate class assignments
SELECT config_id, unique_id, count(DISTINCT class_label) AS n_classes
FROM PIPE_abc_results
WHERE pipeline_id = 4
AND scenario_id = 4
GROUP BY config_id, unique_id
HAVING n_classes > 1
ORDER BY n_classes DESC
LIMIT 20;
Pass criteria: 0 rows returned
Known issues / flags¶
| # | Issue | Severity | Status |
|---|---|---|---|
| 1 | item_name and site_name columns in PIPE_abc_results are empty — ABC write step does not populate denormalised name columns |
Low | Resolve via JOINs at query time; or fix the ABC writer to populate names same as other PIPE_ tables |
| 2 | segment_name is empty for all rows — series are not assigned to named segments |
Low | Investigate whether segment assignment runs before or after ABC; check PIPE_segment_membership join logic |
| 3 | XYZ config (id=23) is degenerate: 571/575 series have identical hit count = 53, so rank_pct assigns classes by tie-breaking order, not by meaningful frequency spread | Medium | Consider replacing hits metric with ADI or zero_ratio, or extending lookback to 24 months |
| 4 | ABC total demand (79,072) is ~5% higher than demand_actuals 12-month sum (75,103) — ABC may use 53-week lookback vs calendar-12-month, or uses outlier-corrected values |
Low | Verify which demand source and exact date range the ABC step reads |
| 5 | Series 25_312 is absent from both configs — it has zero demand. Correct behaviour, but it remains in PIPE_series_characteristics. Confirm it is intentionally orphaned or should be excluded from characterisation too |
Low | Review |
How to drive the test¶
- Verify configs — run Test 1 SQL against PostgreSQL
- Coverage check — run Test 2 to confirm 575/576 and identify the zero-demand exclusion
- Boundary check — run Test 4 to confirm class assignments at the 80% and 95% thresholds
- Math check — run Test 5 to verify cumulative % at rank 1
- Monotone check — run Test 6 (should be 0 violations after any re-run)
- Duplicate check — run Test 10 before trusting any downstream segmentation using ABC classes
- XYZ review — run Test 8 hit distribution; if still degenerate, consider reconfiguring config 23
Re-run ABC classification:
Check downstream usage of ABC classes — segments, parameter assignment, MEIO may use
class_label from PIPE_abc_results to route SKUs to parameter sets. Degenerate XYZ
classification (issue 3) propagates silently to those downstream steps.