Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,12 @@ export const settingsSection: DocSectionDef = {
</SettingRow>
</Anchor>
<Anchor id="outliers">
<SettingRow name="Exclude outliers">
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).
<SettingRow name="Exclude outliers from mean">
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.
</SettingRow>
</Anchor>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export const siteOverviewSection: DocSectionDef = {
<P>
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.
</P>
</>
),
Expand Down Expand Up @@ -260,8 +260,8 @@ export const siteOverviewSection: DocSectionDef = {
</LI>
<LI>
<Term>Exclude low samples</Term> &mdash; 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&ndash;9
observations remain visible with a "limited" sample badge.
</LI>
</UL>
<P>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ export const stepDetailDoc: DocEntry = {
<Term>Time range</Term> buttons (3m, 6m, 12) filter the panel.
</LI>
<LI>
<Term>Outlier count</Term> appears in the header when the Exclude
outliers setting removes timing observations from the current step.
See{" "}
<Term>Outlier count</Term> appears in the header when values are
excluded from the mean. The raw observations and percentile statistics
remain unchanged. See{" "}
<CrossRef to={{ section: "settings", sub: "outliers" }}>
Exclude outliers
Exclude outliers from mean
</CrossRef>
.
</LI>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,18 @@ export const qaDoc: DocEntry = {
render: () => (
<>
<Lead>
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 &mdash; the quality inspection and hold
period.
</Lead>
<P>
<Term>What it measures:</Term> 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.
<Term>What it measures:</Term> 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.
</P>
<P>
<Term>Time filtering:</Term> observations are anchored to the
production-receipt date, so the selected window picks batches that
completed production inside that period.
<Term>Time filtering:</Term> observations are anchored to the campaign
end date.
</P>
<P>
The wait that follows QA release &mdash; from release to dispatch
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <input readOnly />,
}));

describe("AnalysisSettingsPanel", () => {
afterEach(cleanup);

it("allows the mean outlier policy to change under non-mean headline measures", () => {
const setExcludeOutliers = vi.fn();

render(
<TimeRangeContext.Provider
value={{ timeRange: "12m", setTimeRange: vi.fn() }}
>
<MeasureContext.Provider
value={{ measure: "p95", setMeasure: vi.fn() }}
>
<OutlierContext.Provider
value={{ excludeOutliers: true, setExcludeOutliers }}
>
<AnalysisSettingsPanel />
</OutlierContext.Provider>
</MeasureContext.Provider>
</TimeRangeContext.Provider>,
);

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export const AnalysisSettingsPanel = ({
checked={excludeOutliers}
onChange={(event) => setExcludeOutliers(event.target.checked)}
/>
Exclude outliers
Exclude outliers from mean
</label>
{children}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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";
}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand All @@ -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.`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
});
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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([
Expand All @@ -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);
});
});
Loading
Loading