diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx index d34a491e64c..3ef33750aeb 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/settings.tsx @@ -68,13 +68,12 @@ export const settingsSection: DocSectionDef = { - - Excludes values falling significantly outside the normal - distribution. This is done by computing the interquartile range - (IQR), i.e. the values falling between 25% and 75% of - observations, and then excluding any values which are greater than - 1.5x IQR above the third quartile or less than 1.5x IQR below the - first quartile (aka Tukey fences). + + Excludes outliers from every mean shown. Specifically, this + excludes values outside the Tukey fences (removing values which + fall greater than 1.5x the interquartile range below Q1 or greater + than 1.5x the interquartile range above Q3). Does not affect the + median, percentiles (P75, P95) or minimum and maximum. diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx index a4872a53c22..eeadd6c4260 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/site-overview.tsx @@ -137,7 +137,7 @@ export const siteOverviewSection: DocSectionDef = {

Dwell rows remain visible even when Exclude low samples is on, because a small number of high-value waits can still explain real - carrying cost. Low-sample dwell rows are labelled. + carrying cost. Low and limited-sample dwell rows are labelled.

), @@ -260,8 +260,8 @@ export const siteOverviewSection: DocSectionDef = {
  • Exclude low samples — hide Planning and Trend - rows with fewer than 10 observations, so rankings are not - dominated by noisy single-event steps. + rows with fewer than 5 observations. Rows with 5–9 + observations remain visible with a "limited" sample badge.
  • diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx index d6699c1ac79..ae1eea4b22a 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-detail.tsx @@ -82,11 +82,11 @@ export const stepDetailDoc: DocEntry = { Time range buttons (3m, 6m, 12) filter the panel.

  • - Outlier count appears in the header when the Exclude - outliers setting removes timing observations from the current step. - See{" "} + Outlier count appears in the header when values are + excluded from the mean. The raw observations and percentile statistics + remain unchanged. See{" "} - Exclude outliers + Exclude outliers from mean .
  • diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx index d824c7e4fbb..5ed2a08b2cf 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/docs/docs-modal/docs-content/step-qa.tsx @@ -8,19 +8,18 @@ export const qaDoc: DocEntry = { render: () => ( <> - QA hold measures the time a finished batch waits between production + QA hold measures the time a production campaign waits between production completion and QA release — the quality inspection and hold period.

    - What it measures: production receipt (the batch is complete - and received into inventory) to QA release (the batch passes inspection - and is cleared for use). One observation per finished-good batch. + What it measures: the time between a production campaign + finishing and the associated QA release. The full 'data' table also + shows the time between each batch's production finish and QA release.

    - Time filtering: observations are anchored to the - production-receipt date, so the selected window picks batches that - completed production inside that period. + Time filtering: observations are anchored to the campaign + end date.

    The wait that follows QA release — from release to dispatch diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx new file mode 100644 index 00000000000..832710ebf02 --- /dev/null +++ b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { OutlierContext } from "./cost"; +import { AnalysisSettingsPanel } from "./header-actions"; +import { MeasureContext } from "./measure-context"; +import { TimeRangeContext } from "./time-range-context"; + +vi.mock("@hashintel/ds-components", () => ({ + Icon: () => null, + NumberInput: () => , +})); + +describe("AnalysisSettingsPanel", () => { + afterEach(cleanup); + + it("allows the mean outlier policy to change under non-mean headline measures", () => { + const setExcludeOutliers = vi.fn(); + + render( + + + + + + + , + ); + + const checkbox = screen.getByRole("checkbox", { + name: "Exclude outliers from mean", + }); + expect(checkbox).toHaveProperty("checked", true); + expect(checkbox).toHaveProperty("disabled", false); + fireEvent.click(checkbox); + expect(setExcludeOutliers).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx index 886ad821cf8..c7172358853 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/header-actions.tsx @@ -293,7 +293,7 @@ export const AnalysisSettingsPanel = ({ checked={excludeOutliers} onChange={(event) => setExcludeOutliers(event.target.checked)} /> - Exclude outliers + Exclude outliers from mean {children} diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts index f77d859aad5..717134a1c37 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/node-badges.test.ts @@ -55,20 +55,20 @@ describe("node R:/C: badge recompute from shipped series", () => { expect(windowed.yield_summary?.median).toBe(88); }); - it("drops outliers from the yield series under the outlier rule", () => { + it("does not apply the timing mean rule to yield observations", () => { const withOutlier: NodeYieldSeries = { reference: 95, observations: [...yieldSeries.observations, obs("2026-05", 5)], }; const node = makeNode({ type: "production", yield_series: withOutlier }); const selected = applyOutlierSelectionToNode(node, true); - // The 5% point is far below the others' IQR fence and is dropped. + // Yield remains raw: the setting applies only to timing means. expect( selected.yield_series?.observations.some( (observation) => observation.value === 5, ), - ).toBe(false); + ).toBe(true); const windowed = windowGraphNodeToRange(selected, "12m"); - expect(windowed.yield_summary?.n).toBe(4); + expect(windowed.yield_summary?.n).toBe(5); }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts b/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts index a968a9cf1d8..4f46efb2c1e 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/normalize-contract.ts @@ -104,10 +104,8 @@ function buildMonthlyFromObservations(obs: Observation[]): MonthlyBucket[] { } /** - * Single-source records derive. When a step ships only the canonical - * `detail_rows` (+ `value_col`/`ref_date_col`) and no precomputed timing series, - * rehydrate observations/durations/monthly/stats from the rows. A strict no-op - * whenever `observations` are already present. + * Single-source records derive. Campaign rows are the canonical timing source + * when present; detail rows remain batch evidence and the v1.2 fallback. */ function ensureTimingSeriesStats< T extends { @@ -223,13 +221,15 @@ function fillMonthlyTiming( export function deriveTimingFromRecords(step: StepDetail): StepDetail; export function deriveTimingFromRecords(step: StepDetailWire): StepDetailWire; export function deriveTimingFromRecords(step: StepDetailWire): StepDetailWire { + const campaignRows = + step.timing_grain === "campaign" ? step.campaign_rows?.rows : undefined; const existingObservations = step.observations ?? []; - if (existingObservations.length > 0) { + if (!campaignRows?.length && existingObservations.length > 0) { return step; } const valueCol = step.value_col; const dateCol = step.ref_date_col; - const rows = step.detail_rows?.rows; + const rows = campaignRows?.length ? campaignRows : step.detail_rows?.rows; if (!valueCol || !dateCol || !rows || rows.length === 0) { return step; } diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts new file mode 100644 index 00000000000..2391eb1de30 --- /dev/null +++ b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { countNoun, countTooltip, shortCountLabel } from "./observation-labels"; + +describe("campaign observation labels", () => { + const qaCampaign = { + id: "prod_to_qa", + label: "Production to QA", + type: "qa_hold" as const, + timingGrain: "campaign" as const, + }; + + it("labels campaign-grain QA timing observations as campaigns", () => { + expect(countNoun(qaCampaign)).toBe("campaigns"); + expect(shortCountLabel(7, qaCampaign)).toBe("7 campaigns"); + expect( + countTooltip({ ...qaCampaign, count: 7, rangeLabel: "12m" }), + ).toContain("7 campaigns in the last 12 months"); + }); + + it("keeps yield and consumption terminology ahead of timing grain", () => { + expect(countNoun({ ...qaCampaign, dimension: "yield" })).toBe( + "production orders", + ); + expect( + countNoun({ + ...qaCampaign, + dimension: "consumption", + selectedComponent: true, + }), + ).toBe("component events"); + }); +}); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts index 8e10e990f10..3fa08ace2ca 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/observation-labels.ts @@ -8,12 +8,14 @@ interface CountContext { type: StepType; dimension?: CountDimension; selectedComponent?: boolean; + timingGrain?: "campaign" | null; } interface CountTooltipContext extends CountContext { count: number; rangeLabel?: string | null; nBatches?: number | null; + nCampaigns?: number | null; nMovements?: number | null; } @@ -34,6 +36,9 @@ export function countNoun(ctx: CountContext): string { if (ctx.dimension === "supplier") { return "schedule lines"; } + if (ctx.timingGrain === "campaign") { + return "campaigns"; + } return "events"; } @@ -80,7 +85,9 @@ export function dateAnchorLabel(ctx: CountContext): string { case "production": return "schedule start"; case "qa_hold": - return "production receipt date"; + return ctx.timingGrain === "campaign" + ? "campaign reference date" + : "production receipt date"; case "post_qa_ship": return "QA release date"; case "transit": @@ -114,7 +121,9 @@ function eventMethodology(ctx: CountContext): string { case "production": return "each production schedule campaign contributes one duration event."; case "qa_hold": - return "each finished-good batch contributes one QA-hold event."; + return ctx.timingGrain === "campaign" + ? "each campaign contributes one timing observation; batch rows are supporting evidence." + : "each finished-good batch contributes one QA-hold event."; case "post_qa_ship": return "each dispatch (customer delivery or hub transfer) contributes one post-QA dwell event."; case "transit": @@ -130,6 +139,9 @@ export function countTooltip(ctx: CountTooltipContext): string { const label = shortCountLabel(ctx.count, ctx); const period = rangeLabel(ctx.rangeLabel); const base = `${label} in ${period}. Filtered by ${dateAnchorLabel(ctx)}; ${eventMethodology(ctx)}`; + if (ctx.nCampaigns != null && ctx.nBatches != null) { + return `${base} All-time source coverage: ${ctx.nCampaigns} campaigns across ${ctx.nBatches} batches.`; + } if (ctx.nBatches != null && ctx.nMovements != null) { return `${base} All-time source coverage: ${ctx.nBatches} batches, ${ctx.nMovements} movements.`; } diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts index 4ec3447672c..0bda8047dff 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.test.ts @@ -10,10 +10,10 @@ import { applyOutlierSelectionToNode, applyOutlierSelectionToStep, } from "./outlier-selection"; +import { computeStats } from "./stats"; -// Characterization tests for the client-side Tukey 1.5x IQR outlier rule. -// Behaviour pinned here MUST survive the contract refactor (the selected-view -// semantics stay even as the duplicated outlier paths were collapsed). +// Regression tests for mean-only client-side Tukey 1.5x IQR filtering. +// Raw observations, counts, and percentile statistics remain unchanged. describe("applyOutlierSelectionToStep", () => { // One clear high outlier (100) far beyond the Tukey upper fence. @@ -30,10 +30,14 @@ describe("applyOutlierSelectionToStep", () => { obs("2026-04", 100), ]); - it("drops out-of-fence points and recomputes stats when excluding outliers", () => { + it("filters only the mean while retaining raw observations and percentiles", () => { const selected = applyOutlierSelectionToStep(withOutlier(), true); - expect(selected.durations).toEqual([10, 11, 12, 13, 14, 15, 16, 17]); - expect(selected.stats.n).toBe(8); + expect(selected.durations).toEqual([10, 11, 12, 13, 14, 15, 16, 17, 100]); + expect(selected.observations).toHaveLength(9); + expect(selected.stats.n).toBe(9); + expect(selected.stats.mean).toBe(13.5); + expect(selected.stats.p95).toBe(withOutlier().stats.p95); + expect(selected.stats.max).toBe(100); expect(selected.excluded_count).toBe(1); expect(selected.excluded_pct).toBeCloseTo(100 / 9, 1); }); @@ -82,12 +86,13 @@ describe("applyOutlierSelectionToStep", () => { // Headline untouched, secondary loses its outlier. expect(selected.stats.n).toBe(4); expect(selected.excluded_count).toBe(0); - expect(selected.complete_timing?.stats.n).toBe(8); + expect(selected.complete_timing?.stats.n).toBe(9); + expect(selected.complete_timing?.stats.max).toBe(200); expect( selected.complete_timing?.observations.map( (observation) => observation.value, ), - ).not.toContain(200); + ).toContain(200); }); it("leaves complete_timing untouched when including outliers", () => { @@ -102,9 +107,6 @@ describe("applyOutlierSelectionToStep", () => { }); describe("applyOutlierSelectionToNode", () => { - // Shipped v1 data carries a single base series (no raw_*/filtered_*), so today - // both toggle states return the base series untouched. The fixture uses a tight - // distribution so this invariant also holds once Tukey IQR filtering lands. it("returns the base series when including outliers", () => { const out = applyOutlierSelectionToNode(makeNode(), false); expect(out.observations?.map((observation) => observation.value)).toEqual([ @@ -118,4 +120,27 @@ describe("applyOutlierSelectionToNode", () => { 10, 12, 11, 13, ]); }); + + it("keeps raw P95 while filtering an outlier from the mean", () => { + const observations = [ + obs("2026-01", 10), + obs("2026-01", 11), + obs("2026-02", 12), + obs("2026-02", 13), + obs("2026-03", 14), + obs("2026-03", 15), + obs("2026-04", 16), + obs("2026-04", 17), + obs("2026-04", 100), + ]; + const node = makeNode({ + observations, + stats: computeStats(observations.map((observation) => observation.value)), + }); + const selected = applyOutlierSelectionToNode(node, true); + + expect(selected.stats.p95).toBe(node.stats.p95); + expect(selected.observations).toHaveLength(9); + expect(selected.mean_observations).toHaveLength(8); + }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts index 84f6d2f3e49..a2bdb6f54b1 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/outlier-selection.ts @@ -1,15 +1,11 @@ import { computeIqrFences, partitionByFences } from "./outlier-selection/iqr"; -import { computeStats, percentileOf, round } from "./stats"; +import { computeStats, round } from "./stats"; import type { GraphNode, StepDetail, Observation, MonthlyBucket, - StepStats, - YieldData, - ConsumptionData, - ComponentConsumption, TimingSeries, } from "./types"; @@ -17,17 +13,15 @@ import type { * Apply the client-side Tukey 1.5x IQR outlier rule to a graph node. * * When `excludeOutliers` is true, fences are computed from the shipped - * `observations`, out-of-bound points are dropped, and `stats`/`monthly` are - * recomputed from the kept series (cost columns on each monthly bucket are - * preserved -- inventory kg-days are independent of duration outliers). + * `observations`, and only the overall/monthly mean is recomputed from kept + * points. Raw observations, sample size and percentile statistics are retained. * `excluded_count`/`excluded_pct` describe the full-series exclusion. When * false (or with too few points), the base series is returned unchanged. */ /** - * Recompute per-month timing values (mean/median/n) from a kept observation set, - * preserving each bucket's non-timing columns (kg-days, qty, variance). Months - * with no kept observations keep their bucket with null timing and `n = 0`. + * Recompute only the per-month mean from kept observations. Median, sample size, + * percentile inputs and non-timing columns remain raw. */ function rebuildMonthlyTiming( original: MonthlyBucket[], @@ -49,7 +43,7 @@ function rebuildMonthlyTiming( return original.map((bucket) => { const vals = byMonth.get(bucket.month); if (!vals || vals.length === 0) { - return { ...bucket, mean: null, median: null, n: 0 }; + return { ...bucket, mean: null }; } const sorted = [...vals].sort((left, right) => left - right); return { @@ -57,24 +51,10 @@ function rebuildMonthlyTiming( mean: round( sorted.reduce((left, right) => left + right, 0) / sorted.length, ), - median: round(percentileOf(sorted, 50)), - n: sorted.length, }; }); } -/** Drop Tukey-IQR outliers from a bare observation array (over its own values). */ function outlierFilterObservations( - obs: Observation[], -): Observation[] { - if (obs.length === 0) { - return obs; - } - const { kept } = partitionByFences( - obs, - computeIqrFences(obs.map((observation) => observation.value)), - ); - return kept; -} /** * A series shaped like the per-family blocks shipped on a step * (`yield_data`, `consumption_data.aggregate`, each consumption component): @@ -98,8 +78,11 @@ function rebuildMonthlyTiming( ); if (excluded.length > 0) { timing = { - stats: computeStats(kept.map((observation) => observation.value)), - observations: kept, + mean_observations: kept, + stats: { + ...node.stats, + mean: computeStats(kept.map((observation) => observation.value)).mean, + }, monthly: node.monthly ? rebuildMonthlyTiming(node.monthly, kept) : node.monthly, @@ -110,22 +93,6 @@ function rebuildMonthlyTiming( return { ...node, ...timing, - yield_series: node.yield_series - ? { - ...node.yield_series, - observations: outlierFilterObservations( - node.yield_series.observations, - ), - } - : node.yield_series, - consumption_series: node.consumption_series - ? { - ...node.consumption_series, - observations: outlierFilterObservations( - node.consumption_series.observations, - ), - } - : node.consumption_series, excluded_count: excludedCount, excluded_pct: observations.length > 0 @@ -133,60 +100,10 @@ function rebuildMonthlyTiming( : 0, }; } -interface SeriesLike { - values: number[]; - observations: Observation[]; - monthly: MonthlyBucket[]; - stats: StepStats; -} -/** - * Drop Tukey-IQR outliers from one family series (computed over its own value - * distribution) and recompute `values`/`stats`/`monthly` from the kept points. - * Returns the series unchanged when there is nothing to exclude. Non-timing - * monthly columns (kg-days, qty, variance) are preserved via the spread in - * {@link rebuildMonthlyTiming}. - */ function applyOutlierToSeries(series: T): T { - const observations = series.observations; - if (observations.length === 0) { - return series; - } - const { kept, excluded } = partitionByFences( - observations, - computeIqrFences(observations.map((observation) => observation.value)), - ); - if (excluded.length === 0) { - return series; - } - const values = kept.map((observation) => observation.value); - return { - ...series, - values, - observations: kept, - monthly: rebuildMonthlyTiming(series.monthly, kept), - stats: computeStats(values), - }; -} -/** Outlier-filter the yield series (receipt-ratio %) in place of its raw points. */ function applyOutlierToYield( - yd: YieldData, -): YieldData { - return applyOutlierToSeries(yd); -} -/** - * Outlier-filter the consumption block: each component series and the aggregate - * series are partitioned independently. Downstream windowing recomputes the - * aggregate `weighted_variance_pct` from whatever observations remain, so - * dropping outlier points here makes that window-aware metric outlier-aware too. - */ function applyOutlierToConsumption(cd: ConsumptionData): ConsumptionData { - const components = cd.components.map((column) => - applyOutlierToSeries(column), - ); - const aggregate = applyOutlierToSeries(cd.aggregate); - return { ...cd, components, aggregate }; -} /** * Outlier-filter a secondary {@link TimingSeries} (e.g. procurement's * full-receipt lead time) over its own value distribution, recomputing - * `monthly`/`stats` from the kept points. Returned unchanged when there is + * only its mean from the kept points. Returned unchanged when there is * nothing to exclude. Independent of the headline series so the two can have * different fences. */ function applyOutlierToTimingSeries(ts: TimingSeries): TimingSeries { @@ -203,9 +120,12 @@ interface SeriesLike { } return { ...ts, - observations: kept, + mean_observations: kept, monthly: rebuildMonthlyTiming(ts.monthly, kept), - stats: computeStats(kept.map((observation) => observation.value)), + stats: { + ...ts.stats, + mean: computeStats(kept.map((observation) => observation.value)).mean, + }, }; } /** Step-level counterpart of {@link applyOutlierSelectionToNode}. */ export function applyOutlierSelectionToStep( @@ -228,10 +148,9 @@ interface SeriesLike { if (excluded.length > 0) { const values = kept.map((observation) => observation.value); timing = { - durations: values, - observations: kept, + mean_observations: kept, monthly: rebuildMonthlyTiming(step.monthly, kept), - stats: computeStats(values), + stats: { ...step.stats, mean: computeStats(values).mean }, }; excludedCount = excluded.length; } @@ -239,12 +158,6 @@ interface SeriesLike { return { ...step, ...timing, - yield_data: step.yield_data - ? applyOutlierToYield(step.yield_data) - : step.yield_data, - consumption_data: step.consumption_data - ? applyOutlierToConsumption(step.consumption_data) - : step.consumption_data, complete_timing: step.complete_timing ? applyOutlierToTimingSeries(step.complete_timing) : step.complete_timing, diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts index 2faf6e67c7e..39e48066630 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.test.ts @@ -204,6 +204,32 @@ describe("period helpers anchored to a fixed clock", () => { expect(threshold.pctChange).toBeCloseTo(-70, 6); }); + it("uses filtered mean values without reducing raw sample counts", () => { + const node = siteNode({ + observations: [ + obs("2025-07", 10), + obs("2025-08", 10), + obs("2025-09", 1000), + obs("2026-01", 20), + obs("2026-02", 20), + obs("2026-03", 2000), + ], + mean_observations: [ + obs("2025-07", 10), + obs("2025-08", 10), + obs("2026-01", 20), + obs("2026-02", 20), + ], + }); + + const trend = computeTimingTrend(node, "6m", "mean"); + + expect(trend.previousValue).toBe(10); + expect(trend.currentValue).toBe(20); + expect(trend.previousN).toBe(3); + expect(trend.currentN).toBe(3); + }); + it("computeCostTrend totals carrying cost across windows", () => { const node = siteNode({ cost: cost(100), @@ -251,7 +277,7 @@ describe("period helpers anchored to a fixed clock", () => { expect(deltas.previousRange).not.toBeNull(); }); - it("computePeriodDeltas can compare an outlier-filtered historical series", () => { + it("filters the mean delta while retaining raw percentile deltas", () => { const step = stepFrom([ fixtureObs("2025-07", 10), fixtureObs("2025-08", 10), @@ -265,11 +291,29 @@ describe("period helpers anchored to a fixed clock", () => { const selected = applyOutlierSelectionToStep(step, true); const rawDeltas = computePeriodDeltas(step.observations, "6m"); - const filteredDeltas = computePeriodDeltas(selected.observations, "6m"); + const filteredDeltas = computePeriodDeltas( + selected.observations, + "6m", + selected.mean_observations, + ); expect(rawDeltas.previousStats?.median).toBe(505); expect(rawDeltas.medianPctChange).toBeCloseTo(-96.03960396039604, 6); - expect(filteredDeltas.previousStats?.median).toBe(10); - expect(filteredDeltas.medianPctChange).toBeCloseTo(100, 6); + expect(filteredDeltas.previousStats?.median).toBe(505); + expect(filteredDeltas.medianPctChange).toBeCloseTo(-96.03960396039604, 6); + expect(filteredDeltas.previousStats?.mean).toBe(10); + expect(filteredDeltas.statDeltas.mean).toBeCloseTo(100, 6); + }); + + it("leaves a period mean and its delta empty when no kept values remain", () => { + const observations = [obs("2025-07", 10), obs("2026-01", 20)]; + const deltas = computePeriodDeltas( + observations, + "6m", + observations.slice(0, 1), + ); + + expect(deltas.previousStats?.mean).toBe(10); + expect(deltas.statDeltas.mean).toBeNull(); }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts index 22f02bd4627..9cb10d419ef 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/period-trends.ts @@ -127,13 +127,24 @@ export function computeTimingTrend( timeRange: TimeRange, measure: BaseMeasure = "median", ): TimingTrend { - const trend = computeTrend(node.observations ?? [], timeRange, measure); + const observations = node.observations ?? []; + const trend = computeTrend( + measure === "mean" + ? (node.mean_observations ?? observations) + : observations, + timeRange, + measure, + ); + const sampleTrend = + measure === "mean" && node.mean_observations != null + ? computeTrend(observations, timeRange, measure) + : trend; return { pctChange: trend.pctChange, currentValue: trend.currentValue, previousValue: trend.previousValue, - currentN: trend.currentN, - previousN: trend.previousN, + currentN: sampleTrend.currentN, + previousN: sampleTrend.previousN, }; } @@ -202,6 +213,7 @@ export interface PeriodComparison { export function computePeriodDeltas( observations: Observation[], range: TimeRange, + meanObservations: Observation[] = observations, ): PeriodComparison { const { currentFrom, previousFrom, previousTo } = periodCutoffs(range); @@ -219,12 +231,34 @@ export function computePeriodDeltas( const prevStats = computeStats( prevObs.map((observation) => observation.value), ); + const currentMeanValues = meanObservations + .filter((observation) => observation.date.slice(0, 7) >= currentFrom) + .map((observation) => observation.value); + const previousMeanValues = meanObservations + .filter((observation) => { + const month = observation.date.slice(0, 7); + return month >= previousFrom && month <= previousTo; + }) + .map((observation) => observation.value); + currentStats.mean = + currentMeanValues.length > 0 + ? currentMeanValues.reduce((sum, value) => sum + value, 0) / + currentMeanValues.length + : null; + prevStats.mean = + previousMeanValues.length > 0 + ? previousMeanValues.reduce((sum, value) => sum + value, 0) / + previousMeanValues.length + : null; - const pctDelta = (curr: number, prev: number) => { - if (prev === 0) { + const pctDelta = ( + currentValue: number | null, + previousValue: number | null, + ) => { + if (currentValue == null || previousValue == null || previousValue === 0) { return null; } - return ((curr - prev) / prev) * 100; + return ((currentValue - previousValue) / previousValue) * 100; }; const hasPrev = prevStats.n > 0; @@ -256,10 +290,7 @@ export function computePeriodDeltas( const statDeltas: Record = {}; for (const key of keys) { - statDeltas[key] = pctDelta( - currentStats[key] as number, - prevStats[key] as number, - ); + statDeltas[key] = pctDelta(currentStats[key], prevStats[key]); } return { diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts index de70be12f61..a5df8ae92ee 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.test.ts @@ -9,6 +9,8 @@ import { import { filterGraphNodeByDateRange, filterStepByDateRange, + windowGraphNodeToRange, + windowStepToRange, } from "./range-filter"; describe("filterStepByDateRange", () => { @@ -44,6 +46,25 @@ describe("filterStepByDateRange", () => { ]); }); + it("computes the mean fence over full history before windowing", () => { + const step = stepFrom([ + obs("2026-01", 2), + obs("2026-02", 2), + obs("2026-03", 3), + obs("2026-03", 4), + obs("2026-03", 5), + obs("2026-04", 1), + obs("2026-05", 1), + obs("2026-06", 1), + obs("2026-06", 4), + ]); + const out = filterStepByDateRange(step, "3m", true); + + expect(out.observations).toHaveLength(4); + expect(out.stats.mean).toBe(1.8); + expect(out.stats.p95).toBe(3.5); + }); + it("windows the secondary complete_timing series to the cutoff too", () => { const step = tightStep(); step.complete_timing = timingSeriesFrom([ @@ -60,6 +81,20 @@ describe("filterStepByDateRange", () => { expect(out.complete_timing?.stats.n).toBe(1); expect(out.complete_timing?.stats.median).toBe(26); }); + + it("leaves empty kept means null while retaining raw windowed points", () => { + const step = stepFrom([obs("2026-04", 13)]); + step.mean_observations = [obs("2026-01", 10)]; + step.complete_timing = timingSeriesFrom([obs("2026-04", 26)]); + step.complete_timing.mean_observations = [obs("2026-01", 20)]; + + const out = windowStepToRange(step, "3m"); + + expect(out.stats.n).toBe(1); + expect(out.stats.mean).toBeNull(); + expect(out.complete_timing?.stats.n).toBe(1); + expect(out.complete_timing?.stats.mean).toBeNull(); + }); }); describe("filterGraphNodeByDateRange", () => { @@ -86,4 +121,44 @@ describe("filterGraphNodeByDateRange", () => { expect(out.stats.n).toBe(1); expect(out.stats.median).toBe(13); }); + + it("computes the node mean fence over full history before windowing", () => { + const observations = [ + obs("2026-01", 2), + obs("2026-02", 2), + obs("2026-03", 3), + obs("2026-03", 4), + obs("2026-03", 5), + obs("2026-04", 1), + obs("2026-05", 1), + obs("2026-06", 1), + obs("2026-06", 4), + ]; + const out = filterGraphNodeByDateRange( + makeNode({ + observations, + stats: stepFrom(observations).stats, + }), + "3m", + true, + ); + + expect(out.observations).toHaveLength(4); + expect(out.mean_observations).toBeUndefined(); + expect(out.stats.mean).toBe(1.8); + expect(out.stats.p95).toBe(3.5); + }); + + it("leaves an empty kept node mean null while retaining raw points", () => { + const out = windowGraphNodeToRange( + makeNode({ + observations: [obs("2026-04", 13)], + mean_observations: [obs("2026-01", 10)], + }), + "3m", + ); + + expect(out.stats.n).toBe(1); + expect(out.stats.mean).toBeNull(); + }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts index c3578070d89..0b00bd2e6e6 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/range-filter.ts @@ -91,6 +91,14 @@ function filterObservationsByCutoff( }; } +function meanForObservations(observations: Observation[]): number | null { + if (observations.length === 0) { + return null; + } + return computeStats(observations.map((observation) => observation.value)) + .mean; +} + function filterYieldData(yd: YieldData, cutoff: string): YieldData { const row = filterObservationsByCutoff(yd.observations, yd.monthly, cutoff); return { @@ -308,12 +316,30 @@ export function applyProcurementBasisToStep( */ function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries { - const { observations, monthly, stats } = filterObservationsByCutoff( - ts.observations, - ts.monthly, - cutoff, + const { + observations, + monthly, + stats: rawStats, + } = filterObservationsByCutoff(ts.observations, ts.monthly, cutoff); + const meanObservations = ts.mean_observations?.filter( + (observation) => observation.date.slice(0, 7) >= cutoff, ); - return { ...ts, observations, monthly, stats }; + const stats = + meanObservations == null + ? rawStats + : { + ...rawStats, + mean: meanForObservations(meanObservations), + }; + return { + ...ts, + observations, + ...(meanObservations == null + ? {} + : { mean_observations: meanObservations }), + monthly, + stats, + }; } /** @@ -335,7 +361,17 @@ function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries { (observation: Observation) => observation.date.slice(0, 7) >= cutoff, ); const values = filtered.map((observation: Observation) => observation.value); - const stats = computeStats(values); + const meanObservations = selectedStep.mean_observations?.filter( + (observation: Observation) => observation.date.slice(0, 7) >= cutoff, + ); + const rawStats = computeStats(values); + const stats = + meanObservations == null + ? rawStats + : { + ...rawStats, + mean: meanForObservations(meanObservations), + }; const filteredMonthly: MonthlyBucket[] = selectedStep.monthly.filter( (month) => month.month >= cutoff, ); @@ -352,6 +388,9 @@ function filterTimingSeries(ts: TimingSeries, cutoff: string): TimingSeries { ...selectedStep, durations: values, observations: filtered, + ...(meanObservations == null + ? {} + : { mean_observations: meanObservations }), monthly: filteredMonthly, stats, cost: selectedStep.cost, @@ -470,7 +509,17 @@ export function windowGraphNodeToRange( (observation: Observation) => observation.date.slice(0, 7) >= cutoff, ); const values = filtered.map((observation: Observation) => observation.value); - const stats = computeStats(values); + const meanObservations = selectedNode.mean_observations?.filter( + (observation: Observation) => observation.date.slice(0, 7) >= cutoff, + ); + const rawStats = computeStats(values); + const stats = + meanObservations == null + ? rawStats + : { + ...rawStats, + mean: meanForObservations(meanObservations), + }; const filteredMonthly = (selectedNode.monthly ?? []).filter( (month) => month.month >= cutoff, ); @@ -487,6 +536,9 @@ export function windowGraphNodeToRange( ...selectedNode, stats, observations: filtered, + ...(meanObservations == null + ? {} + : { mean_observations: meanObservations }), monthly: filteredMonthly, cost: selectedNode.cost, pct_exceeding_plan: pctExceedingForObservations( diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts index b1467113fd9..0357f939dee 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/records-derive.test.ts @@ -73,6 +73,33 @@ function recordsOnlyWireStep( } describe("deriveTimingFromRecords", () => { + it("uses campaign_rows as canonical QA observations while retaining batch evidence", () => { + const step = recordsOnlyStep({ + type: "qa_hold", + timing_grain: "campaign", + n_campaigns: 2, + n_batches: 5, + campaign_rows: { + columns: detailRows.columns, + rows: [ + { consumption_date: "2026-01-12", dwell_days: 4 }, + { consumption_date: "2026-02-15", dwell_days: 8 }, + ], + }, + observations: [{ date: "2025-01-01", value: 999 }], + }); + + const out = ensureStepStats(step); + expect(out.observations).toEqual([ + { date: "2026-01-12", value: 4 }, + { date: "2026-02-15", value: 8 }, + ]); + expect(out.stats.n).toBe(2); + expect(out.detail_rows).toBe(detailRows); + expect(out.n_campaigns).toBe(2); + expect(out.n_batches).toBe(5); + }); + it("rehydrates observations/durations/monthly/stats from detail_rows", () => { const derived = deriveTimingFromRecords(recordsOnlyStep()); expect( @@ -203,6 +230,32 @@ describe("deriveTimingFromRecords", () => { }, ); + it("uses detail_rows as the explicit v1.2 fallback for campaign timing", () => { + const step = recordsOnlyStep({ + type: "qa_hold", + timing_grain: "campaign", + campaign_rows: null, + detail_rows: { + columns: [], + rows: [ + { campaign_date: "2026-04-01", qa_days: 4 }, + { campaign_date: "2026-05-01", qa_days: 8 }, + ], + }, + ref_date_col: "campaign_date", + value_col: "qa_days", + }); + + const out = ensureStepStats(step); + + expect(out.observations).toEqual([ + { date: "2026-04-01", value: 4 }, + { date: "2026-05-01", value: 8 }, + ]); + expect(out.durations).toEqual([4, 8]); + expect(out.stats.n).toBe(2); + }); + it("derives procurement timing as one first/full observation per PO", () => { const step = recordsOnlyStep({ type: "procurement", diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts new file mode 100644 index 00000000000..b6ed0419fa0 --- /dev/null +++ b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { + combinedSampleTier, + isExcludedLowSample, + sampleTier, +} from "./sample-confidence"; + +describe("sample confidence", () => { + it.each([ + [0, "none"], + [1, "low"], + [4, "low"], + [5, "limited"], + [9, "limited"], + [10, "good"], + ] as const)("classifies %i observations as %s", (count, expected) => { + expect(sampleTier(count)).toBe(expected); + }); + + it("uses the weakest populated period", () => { + expect(combinedSampleTier(8, 3)).toBe("low"); + expect(combinedSampleTier(12, 7)).toBe("limited"); + expect(combinedSampleTier(12, 0)).toBe("good"); + }); + + it("excludes only the low tier", () => { + expect(isExcludedLowSample(4)).toBe(true); + expect(isExcludedLowSample(5)).toBe(false); + expect(isExcludedLowSample(9)).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts new file mode 100644 index 00000000000..904fc7ac3c4 --- /dev/null +++ b/apps/hash-frontend/src/pages/supply-chain/shared/sample-confidence.ts @@ -0,0 +1,40 @@ +export const LOW_SAMPLE_MIN = 5; +export const GOOD_SAMPLE_MIN = 10; + +export type SampleTier = "none" | "low" | "limited" | "good"; + +export const sampleTier = (count: number): SampleTier => { + if (count <= 0) { + return "none"; + } + if (count < LOW_SAMPLE_MIN) { + return "low"; + } + if (count < GOOD_SAMPLE_MIN) { + return "limited"; + } + return "good"; +}; + +/** Return the weakest populated tier across current/previous periods. */ +export const combinedSampleTier = ( + ...counts: (number | null | undefined)[] +): SampleTier => { + const tiers = counts + .filter((count): count is number => count != null && count > 0) + .map(sampleTier); + if (tiers.includes("low")) { + return "low"; + } + if (tiers.includes("limited")) { + return "limited"; + } + if (tiers.includes("good")) { + return "good"; + } + return "none"; +}; + +/** The "exclude low samples" setting retains limited (5–9) samples. */ +export const isExcludedLowSample = (count: number): boolean => + count < LOW_SAMPLE_MIN; diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx index 95c21efc208..53ba797ddfd 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel.tsx @@ -603,8 +603,14 @@ export const StepDetailPanel = ({ return comparisonStep.observations; }, [comparisonStep, dimension, selectedComponent]); const periodComparison = useMemo(() => { - return computePeriodDeltas(comparisonObservations, timeRange); - }, [comparisonObservations, timeRange]); + return computePeriodDeltas( + comparisonObservations, + timeRange, + dimension === "timing" + ? (comparisonStep?.mean_observations ?? comparisonObservations) + : comparisonObservations, + ); + }, [comparisonObservations, comparisonStep, dimension, timeRange]); const selectedComponentReconciliationCount = useMemo(() => { if ( dimension !== "consumption" || @@ -839,7 +845,7 @@ export const StepDetailPanel = ({ {filteredStep.excluded_count} {" "} - outliers excluded + excluded from mean )} diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx index 86456e2409f..dacb1b7ecec 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/distribution-chart.tsx @@ -379,6 +379,7 @@ export const DistributionChart = ({ type: step.type, dimension, selectedComponent: selectedComponent != null, + timingGrain: step.timing_grain, })}`, "Count", ]} diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx index d68faac7886..fe32ae28e48 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/step-detail-primitives.tsx @@ -434,6 +434,7 @@ export const ObservationCount = ({ id: step.id, label: step.label, type: step.type, + timingGrain: step.timing_grain, dimension, selectedComponent, }); @@ -446,11 +447,13 @@ export const ObservationCount = ({ id: step.id, label: step.label, type: step.type, + timingGrain: step.timing_grain, dimension, selectedComponent, count, rangeLabel: timeRange, nBatches: step.n_batches, + nCampaigns: step.n_campaigns, nMovements: step.n_movements, })} > diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx index 90f8f566053..9e2a59c614b 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/time-series-chart.tsx @@ -181,6 +181,7 @@ export const TimeSeriesChart = ({ type: step.type, dimension, selectedComponent: selectedComponent != null, + timingGrain: step.timing_grain, }); const base = count != null ? `${_label}: ${count} ${noun}` : String(_label); diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx index 703ed6963f0..bbf5aabe079 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/shared/step-detail-panel/timing-metrics.tsx @@ -323,7 +323,13 @@ export const KeyMetricsRow = ({ ? `${formatNumber(pep, { maximumFractionDigits: 0 })}%` : "–"} - {pep != null && of batches} + {pep != null && ( + + {step.timing_grain === "campaign" + ? "of campaigns" + : "of batches"} + + )} diff --git a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts index 6ee00ac12a5..92936290cc8 100644 --- a/apps/hash-frontend/src/pages/supply-chain/shared/types.ts +++ b/apps/hash-frontend/src/pages/supply-chain/shared/types.ts @@ -201,6 +201,9 @@ export interface GraphNode { cost: CostData | null; material_value?: MaterialValueData | null; observations?: Observation[]; + /** Client-derived Tukey-kept timing points used only for mean trends. */ + mean_observations?: Observation[]; + timing_grain?: "campaign" | null; /** Client-side cache of combined procurement node observations from the wire. */ procurement_observations?: ProcurementNodeObservation[]; monthly?: MonthlyBucket[]; @@ -209,6 +212,7 @@ export interface GraphNode { /** Client-computed exclusion rate (%) under the current outlier setting. */ excluded_pct?: number; n_batches?: number; + n_campaigns?: number; n_movements?: number; /** Recomputed client-side from `yield_series` under window + outlier; not shipped by the generator. */ yield_summary?: YieldSummary | null; @@ -472,6 +476,8 @@ export interface ProcurementNodeObservation { export interface TimingSeries { label?: string; observations: Observation[]; + /** Client-derived Tukey-kept timing points used only for mean calculations. */ + mean_observations?: Observation[]; monthly: MonthlyBucket[]; stats: StepStats; } @@ -614,6 +620,8 @@ export interface StepDetail { type: StepType; durations: number[]; observations: Observation[]; + /** Client-derived Tukey-kept timing points used only for mean trends. */ + mean_observations?: Observation[]; monthly: MonthlyBucket[]; stats: StepStats; /** Client-computed by the Tukey IQR outlier selection (lib/utils); not shipped by the generator. */ @@ -626,12 +634,18 @@ export interface StepDetail { pct_exceeding_plan?: number | null; cost: CostData | null; material_value?: MaterialValueData | null; + /** Observation grain for timing. Campaign timing is one canonical QA observation per campaign. */ + timing_grain?: "campaign" | null; + /** + * Canonical campaign-level timing records. When present these take precedence + * over `detail_rows`, which remains the underlying batch evidence. + */ + campaign_rows?: DetailRows | null; detail_rows?: DetailRows | null; ref_date_col?: string | null; /** - * Canonical value column within `detail_rows.rows`. With `ref_date_col`, the - * timing series (observations/durations/monthly/stats) is fully derivable from - * `detail_rows` on load. + * Canonical value column within the selected timing rows. Campaign timing + * derives from `campaign_rows`; legacy timing derives from `detail_rows`. */ value_col?: string | null; /** @@ -646,6 +660,7 @@ export interface StepDetail { * the inactive first/last receipt basis after deriving both from detail rows. */ complete_timing?: TimingSeries | null; + n_campaigns?: number; n_batches?: number; n_movements?: number; yield_data?: YieldData | null; diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx index 917821497f4..49036c2ab77 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity.tsx @@ -17,6 +17,7 @@ import { buildCsvContent, downloadCsv } from "../shared/export-utils"; import { useSupplierPerformanceEnabled } from "../shared/feature-flags"; import { ErrorState, SupplyChainAppSkeleton } from "../shared/load-state"; import { ensureNodeStats } from "../shared/normalize-contract"; +import { countNoun } from "../shared/observation-labels"; import { applyOutlierSelectionToStep } from "../shared/outlier-selection"; import { computePeriodDeltas, @@ -602,13 +603,14 @@ function formatTrendSummary( previousN: number; }, range: TimeRange, + observationNoun: string, ): string { if ( trend.pctChange == null || trend.currentValue == null || trend.previousValue == null ) { - return `No comparison (${formatNumber(trend.currentN)} current / ${formatNumber(trend.previousN)} previous samples)`; + return `No comparison (${formatNumber(trend.currentN)} current / ${formatNumber(trend.previousN)} previous ${observationNoun})`; } return `${formatNumber(trend.currentValue, { maximumFractionDigits: 1 })}d vs ${formatNumber(trend.previousValue, { maximumFractionDigits: 1 })}d previous ${range} (${formatDeviation(trend.pctChange)})`; } @@ -859,8 +861,10 @@ const DwellExecutiveSummary = ({ const PlanningExecutiveSummary = ({ brief, + observationNoun, }: { brief: PlanningOpportunityBrief; + observationNoun: string; }) => { return (

    @@ -891,7 +895,7 @@ const PlanningExecutiveSummary = ({ /> 20 ? "bad" : "neutral"} /> @@ -1811,10 +1815,23 @@ export const OpportunityBrief = ({ siteNode?.products ?? products.filter((product) => product.id === productId); const isDwellBrief = brief.kind === "dwell"; + const observationNoun = countNoun({ + id: filteredStep.id, + label: filteredStep.label, + type: filteredStep.type, + dimension: "timing", + timingGrain: filteredStep.timing_grain, + }); + const observationLabel = + observationNoun.charAt(0).toUpperCase() + observationNoun.slice(1); const ranking = computeBriefRanking(rollups, stepId, isDwellBrief); // Headline period-over-period trends for median / P95 / cost (newest vs prior window). - const periodCmp = computePeriodDeltas(historicalStep.observations, timeRange); + const periodCmp = computePeriodDeltas( + historicalStep.observations, + timeRange, + historicalStep.mean_observations ?? historicalStep.observations, + ); const prevStats = periodCmp.previousStats; const costCmp = isDwellBrief ? computeCostComparison( @@ -1974,10 +1991,10 @@ export const OpportunityBrief = ({ label="Analysis assumptions" value={ isDwellBrief - ? `${formatNumber(waccRate * 100, { maximumFractionDigits: 0 })}% WACC, ${formatNumber(storageCost, { maximumFractionDigits: 3 })}${filteredStep.cost?.currency ? ` ${filteredStep.cost.currency}` : ""}/t/day storage, ${excludeOutliers ? "outliers excluded" : "outliers included"}` + ? `${formatNumber(waccRate * 100, { maximumFractionDigits: 0 })}% WACC, ${formatNumber(storageCost, { maximumFractionDigits: 3 })}${filteredStep.cost?.currency ? ` ${filteredStep.cost.currency}` : ""}/t/day storage, ${excludeOutliers ? "outliers excluded from mean" : "raw mean"}` : excludeOutliers - ? "outliers excluded" - : "outliers included" + ? "outliers excluded from mean" + : "raw mean" } />
    @@ -2016,7 +2033,7 @@ export const OpportunityBrief = ({ /> @@ -2038,7 +2055,10 @@ export const OpportunityBrief = ({ {isDwellBrief ? ( ) : ( - + )} @@ -2132,10 +2152,10 @@ export const OpportunityBrief = ({ { ); }); + it("keeps the raw P95 opportunity invariant under mean-only Tukey filtering", () => { + const raw = step({ + plan: 20, + durations: [10, 11, 12, 13, 14, 15, 16, 17, 100], + observations: [ + { date: "2026-01-01", value: 10 }, + { date: "2026-01-02", value: 11 }, + { date: "2026-01-03", value: 12 }, + { date: "2026-01-04", value: 13 }, + { date: "2026-01-05", value: 14 }, + { date: "2026-01-06", value: 15 }, + { date: "2026-01-07", value: 16 }, + { date: "2026-01-08", value: 17 }, + { date: "2026-01-09", value: 100 }, + ], + stats: { + ...baseStats, + n: 9, + mean: 22, + median: 14, + p95: 66.8, + max: 100, + }, + }); + const meanFiltered = applyOutlierSelectionToStep(raw, true); + + const brief = buildPlanningOpportunityBrief( + meanFiltered, + meanFiltered, + "12m", + ); + + expect(meanFiltered.stats.mean).toBe(13.5); + expect(brief.p95Days).toBe(raw.stats.p95); + expect(brief.p95DeviationPct).toBeCloseTo(234, 6); + }); + it("labels conservative planning candidates as tighten opportunities", () => { const brief = buildPlanningOpportunityBrief( step({ plan: 20, stats: { ...baseStats, median: 8, p95: 14 } }), diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity/opportunity-utils.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity/opportunity-utils.ts index 49e7975365b..c866e9b1fcf 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity/opportunity-utils.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/opportunity/opportunity-utils.ts @@ -7,6 +7,7 @@ import { } from "../../shared/cost"; import { computeTrend, computePeriodDeltas } from "../../shared/period-trends"; import { summarizeProcurementPlanning } from "../../shared/procurement-planning"; +import { sampleTier } from "../../shared/sample-confidence"; import { percentileOf } from "../../shared/stats"; import { recomputeSupplierBlock } from "../../shared/supplier-otif"; import { @@ -265,7 +266,6 @@ export interface PlanningOpportunityBrief { tailTrendNote: string | null; } -const LOW_SAMPLE_N = 10; const WARNING_SAMPLE_N = 30; const STALE_OBSERVATION_DAYS = 60; @@ -638,10 +638,11 @@ function buildEvidenceFlags( }, ): EvidenceFlag[] { const flags: EvidenceFlag[] = []; - if (step.stats.n > 0 && step.stats.n < LOW_SAMPLE_N) { + const currentTier = sampleTier(step.stats.n); + if (currentTier === "low" || currentTier === "limited") { flags.push({ - severity: "warning", - label: "Low sample", + severity: currentTier === "low" ? "warning" : "info", + label: currentTier === "low" ? "Low sample" : "Limited sample", detail: `Only ${formatNumber(step.stats.n)} observations are in the selected period.`, }); } @@ -660,10 +661,12 @@ function buildEvidenceFlags( "Potential cost impact cannot be calculated without a material unit cost.", }); } - if (options.trend.previousN > 0 && options.trend.previousN < LOW_SAMPLE_N) { + const previousTier = sampleTier(options.trend.previousN); + if (previousTier === "low" || previousTier === "limited") { flags.push({ severity: "info", - label: "Trend sample", + label: + previousTier === "low" ? "Low trend sample" : "Limited trend sample", detail: `Previous comparison period has ${formatNumber(options.trend.previousN)} observations.`, }); } @@ -758,7 +761,8 @@ function buildConfidence( const hasLowConfidenceWarning = flags.some( (flag) => flag.severity === "warning" && flag.label !== "Stale recent data", ); - if (step.stats.n < LOW_SAMPLE_N || hasLowConfidenceWarning) { + const tier = sampleTier(step.stats.n); + if (tier === "low" || tier === "none" || hasLowConfidenceWarning) { return { label: "Low", caveats, diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product.tsx index c95f05482a8..01907dab6f2 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product.tsx @@ -802,6 +802,7 @@ export const Overview = ({ setPipelineExpanded(false)} onStepDrill={onStepSelect} activeSegments={activeSegments} diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if.tsx index 52ba3d29c93..077fbb0c597 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/e2e-what-if.tsx @@ -3,12 +3,7 @@ import { useCallback, useMemo, useState } from "react"; import { Button } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; -import { - formatCost, - formatNumber, - useCostParams, - useOutlierSetting, -} from "../../shared/cost"; +import { formatCost, formatNumber, useCostParams } from "../../shared/cost"; import { BindingLever } from "./e2e-what-if/binding-lever"; import { KpiTile } from "./e2e-what-if/what-if-kpis"; import { PipelineHeader } from "./shared/pipeline-header"; @@ -26,6 +21,7 @@ import type { GraphData } from "../../shared/types"; interface E2EWhatIfProps { graph: GraphData; timeRange: TimeRange; + excludeOutliers: boolean; onCollapse: () => void; onStepDrill: (stepId: string) => void; /** Segments currently included in totals, KPIs and the lever list. */ @@ -115,6 +111,7 @@ const kpiGrid = css({ export const E2EWhatIf = ({ graph, timeRange, + excludeOutliers, onCollapse, onStepDrill, activeSegments, @@ -123,7 +120,6 @@ export const E2EWhatIf = ({ onActiveRouteChange, }: E2EWhatIfProps) => { const { waccRate, storageCost } = useCostParams(); - const { excludeOutliers } = useOutlierSetting(); const setActiveRoute = onActiveRouteChange; // Levers store CAP days: missing entries or caps at the step's max mean diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.test.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.test.ts index 0eed20fe387..4f5371ea258 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.test.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.test.ts @@ -68,4 +68,35 @@ describe("recomputeBatchTimelines", () => { 50, 50, 0, 0, ]); }); + + it("excludes outliers from means without changing other statistics", () => { + const durations = [10, 11, 12, 13, 14, 15, 16, 17, 100]; + const batches = durations.map((duration, index) => + batch({ + batch: `B${index + 1}`, + seg_proc_to_prodstart: duration, + total_days: duration, + }), + ); + + const { timelines, pipeline } = recomputeBatchTimelines( + batches, + batchTimelines(batches), + true, + ); + + expect(timelines.segments?.seg_proc_to_prodstart).toMatchObject({ + mean: 13.5, + median: 14, + p25: 12, + p75: 16, + n: 9, + }); + expect(pipeline.direct?.stages[0]).toMatchObject({ + mean: 13.5, + median: 14, + }); + expect(pipeline.direct?.total_mean).toBe(13.5); + expect(pipeline.direct?.total_median).toBe(14); + }); }); diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.ts b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.ts index d3cc57ee70a..87afc72e729 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.ts +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/recompute-batch-timelines.ts @@ -12,46 +12,48 @@ import type { /** * Summary statistics for one batch segment over a filtered batch set. * Uses nearest-rank percentiles (no interpolation/rounding) so the pipeline - * waterfall matches the backend's batch-timeline numbers. When `excludeOutliers` - * is set, the segment's per-batch values are first trimmed with the shared - * Tukey 1.5x IQR rule (same definition as the node/step series) so the - * waterfall + E2E totals honour the outlier toggle. + * waterfall matches the backend's batch-timeline numbers. When + * `excludeOutliers` is set, only the mean excludes values outside the shared + * Tukey 1.5x IQR fences; the sample size, median and percentiles continue to + * describe the full series. */ function segStats( batches: BatchRow[], key: BatchSegmentKey, excludeOutliers: boolean, ): BatchTimelineSegment | null { - let vals = batches + const values = batches .map((batch) => batch[key]) .filter( (value): value is number => value != null && value >= 0 && value <= 730, ); - if (vals.length === 0) { + if (values.length === 0) { return null; } + let meanValues = values; if (excludeOutliers) { - const fences = computeIqrFences(vals); + const fences = computeIqrFences(values); if (fences) { - vals = vals.filter( + meanValues = values.filter( (value) => value >= fences.lower && value <= fences.upper, ); } - if (vals.length === 0) { + if (meanValues.length === 0) { return null; } } - vals.sort((left, right) => left - right); - const mean = vals.reduce((sum, value) => sum + value, 0) / vals.length; - const midpoint = Math.floor(vals.length / 2); - const upper = vals[midpoint]; + values.sort((left, right) => left - right); + const mean = + meanValues.reduce((sum, value) => sum + value, 0) / meanValues.length; + const midpoint = Math.floor(values.length / 2); + const upper = values[midpoint]; if (upper === undefined) { throw new Error("Segment statistics were missing a midpoint value"); } const median = - vals.length % 2 === 0 + values.length % 2 === 0 ? (() => { - const lower = vals[midpoint - 1]; + const lower = values[midpoint - 1]; if (lower === undefined) { throw new Error( "Segment statistics were missing a lower midpoint value", @@ -60,12 +62,12 @@ function segStats( return (lower + upper) / 2; })() : upper; - const p25 = vals[Math.floor(vals.length * 0.25)]; - const p75 = vals[Math.floor(vals.length * 0.75)]; + const p25 = values[Math.floor(values.length * 0.25)]; + const p75 = values[Math.floor(values.length * 0.75)]; if (p25 === undefined || p75 === undefined) { throw new Error("Segment statistics percentile value missing"); } - return { label: "", mean, median, p25, p75, n: vals.length }; + return { label: "", mean, median, p25, p75, n: values.length }; } /** Pipeline stages in display order: segment key, label, and step type. */ diff --git a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/shared/step-card.tsx b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/shared/step-card.tsx index 83108ec5867..8c9ee973bd9 100644 --- a/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/shared/step-card.tsx +++ b/apps/hash-frontend/src/pages/supply-chain/supply-chain-data-shell/product/shared/step-card.tsx @@ -28,6 +28,7 @@ import { planSourceLabel, pctChangeTooltip, } from "../../../shared/planning-param"; +import { sampleTier } from "../../../shared/sample-confidence"; // Local portal tooltip retained for the box-plot popover only: it renders rich // content `bare` (no dark-pill chrome), which the ds `Tooltip` can't express. import { Tooltip as RichTooltip } from "../../../shared/tooltip"; @@ -515,7 +516,8 @@ export const StepCard = ({ node, onClick, timeRange }: StepCardProps) => { return (diff / plan) * 100; }, [plan, hasData, measureValue]); const eventCount = node.observations?.length ?? node.stats.n; - const isLowSample = node.stats.n > 0 && node.stats.n < 10; + const sampleLevel = sampleTier(node.stats.n); + const hasSampleWarning = sampleLevel === "low" || sampleLevel === "limited"; return (