From c4ca4b001bc7f77ff5e188c9cda496d5b28347c3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 4 Apr 2026 22:25:45 +0200 Subject: [PATCH] test(image-pipeline): tighten parity quality gates --- .../parity/cpu-webgl-parity.test.ts | 68 +++++++ tests/image-pipeline/parity/fixtures.ts | 171 +++++++++++++++++- .../image-pipeline/webgl-backend-poc.test.ts | 3 + 3 files changed, 238 insertions(+), 4 deletions(-) diff --git a/tests/image-pipeline/parity/cpu-webgl-parity.test.ts b/tests/image-pipeline/parity/cpu-webgl-parity.test.ts index 601e3fb..c4fb4e7 100644 --- a/tests/image-pipeline/parity/cpu-webgl-parity.test.ts +++ b/tests/image-pipeline/parity/cpu-webgl-parity.test.ts @@ -11,6 +11,8 @@ import { } from "@/tests/image-pipeline/parity/fixtures"; describe("cpu vs webgl parity", () => { + // Contract-parity coverage in jsdom with a mocked WebGL context. + // This suite intentionally does not attempt driver-level GPU conformance. afterEach(() => { restoreParityWebglContextMock(); }); @@ -25,6 +27,7 @@ describe("cpu vs webgl parity", () => { expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( parityTolerances.curvesOnly.histogramSimilarity, ); + expect(metrics.spatialRmse).toBeLessThanOrEqual(parityTolerances.curvesOnly.spatialRmse); }); it("keeps color-adjust-only pipeline within parity tolerance", () => { @@ -39,6 +42,7 @@ describe("cpu vs webgl parity", () => { expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( parityTolerances.colorAdjustOnly.histogramSimilarity, ); + expect(metrics.spatialRmse).toBeLessThanOrEqual(parityTolerances.colorAdjustOnly.spatialRmse); }); it("keeps curves + color-adjust chain within parity tolerance", () => { @@ -53,5 +57,69 @@ describe("cpu vs webgl parity", () => { expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( parityTolerances.curvesPlusColorAdjust.histogramSimilarity, ); + expect(metrics.spatialRmse).toBeLessThanOrEqual( + parityTolerances.curvesPlusColorAdjust.spatialRmse, + ); + }); + + it("keeps channel-specific curves pressure case within parity tolerance", () => { + const pipelines = createParityPipelines(); + installParityWebglContextMock(); + + const metrics = evaluateCpuWebglParity(pipelines.curvesChannelPressure); + + expect(metrics.maxChannelDelta).toBeLessThanOrEqual( + parityTolerances.curvesChannelPressure.maxChannelDelta, + ); + expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( + parityTolerances.curvesChannelPressure.histogramSimilarity, + ); + expect(metrics.spatialRmse).toBeLessThanOrEqual( + parityTolerances.curvesChannelPressure.spatialRmse, + ); + }); + + it("keeps strong color-adjust pressure case within parity tolerance", () => { + const pipelines = createParityPipelines(); + installParityWebglContextMock(); + + const metrics = evaluateCpuWebglParity(pipelines.colorAdjustPressure); + + expect(metrics.maxChannelDelta).toBeLessThanOrEqual( + parityTolerances.colorAdjustPressure.maxChannelDelta, + ); + expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( + parityTolerances.colorAdjustPressure.histogramSimilarity, + ); + expect(metrics.spatialRmse).toBeLessThanOrEqual( + parityTolerances.colorAdjustPressure.spatialRmse, + ); + }); + + it("keeps curves + color-adjust pressure chain within parity tolerance", () => { + const pipelines = createParityPipelines(); + installParityWebglContextMock(); + + const metrics = evaluateCpuWebglParity(pipelines.curvesColorPressureChain); + + expect(metrics.maxChannelDelta).toBeLessThanOrEqual( + parityTolerances.curvesColorPressureChain.maxChannelDelta, + ); + expect(metrics.histogramSimilarity).toBeGreaterThanOrEqual( + parityTolerances.curvesColorPressureChain.histogramSimilarity, + ); + expect(metrics.spatialRmse).toBeLessThanOrEqual( + parityTolerances.curvesColorPressureChain.spatialRmse, + ); + }); + + it("is deterministic across repeated parity evaluations", () => { + const pipelines = createParityPipelines(); + installParityWebglContextMock(); + + const first = evaluateCpuWebglParity(pipelines.curvesColorPressureChain); + const second = evaluateCpuWebglParity(pipelines.curvesColorPressureChain); + + expect(second).toEqual(first); }); }); diff --git a/tests/image-pipeline/parity/fixtures.ts b/tests/image-pipeline/parity/fixtures.ts index 6e04a03..be1c4db 100644 --- a/tests/image-pipeline/parity/fixtures.ts +++ b/tests/image-pipeline/parity/fixtures.ts @@ -3,7 +3,13 @@ import type { PipelineStep } from "@/lib/image-pipeline/contracts"; import { applyPipelineStep } from "@/lib/image-pipeline/render-core"; import { vi } from "vitest"; -type ParityPipelineKey = "curvesOnly" | "colorAdjustOnly" | "curvesPlusColorAdjust"; +type ParityPipelineKey = + | "curvesOnly" + | "colorAdjustOnly" + | "curvesPlusColorAdjust" + | "curvesChannelPressure" + | "colorAdjustPressure" + | "curvesColorPressureChain"; type HistogramBundle = { red: number[]; @@ -15,6 +21,7 @@ type HistogramBundle = { export type ParityMetrics = { maxChannelDelta: number; histogramSimilarity: number; + spatialRmse: number; }; type ParityPipeline = { @@ -25,6 +32,7 @@ type ParityPipeline = { type ParityTolerance = { maxChannelDelta: number; histogramSimilarity: number; + spatialRmse: number; }; const FIXTURE_WIDTH = 8; @@ -33,17 +41,40 @@ const FIXTURE_HEIGHT = 8; let contextSpy: { mockRestore: () => void } | null = null; export const parityTolerances: Record = { + // Tightened against measured jsdom+mock outputs (2026-04-04 baseline): + // - maxChannelDelta / spatialRmse gates use ~+2 delta and ~+1.3 RMSE headroom. + // - histogramSimilarity gates use ~0.005-0.01 headroom below measured values. + // This intentionally validates backend-contract parity in deterministic jsdom mocks, + // not driver-level GPU conformance. curvesOnly: { - maxChannelDelta: 64, + maxChannelDelta: 33, histogramSimilarity: 0.16, + spatialRmse: 24.7, }, colorAdjustOnly: { - maxChannelDelta: 64, + maxChannelDelta: 42, histogramSimilarity: 0.15, + spatialRmse: 21.6, }, curvesPlusColorAdjust: { - maxChannelDelta: 72, + maxChannelDelta: 71, histogramSimilarity: 0.16, + spatialRmse: 43.5, + }, + curvesChannelPressure: { + maxChannelDelta: 99, + histogramSimilarity: 0.08, + spatialRmse: 52.5, + }, + colorAdjustPressure: { + maxChannelDelta: 203, + histogramSimilarity: 0.17, + spatialRmse: 75.8, + }, + curvesColorPressureChain: { + maxChannelDelta: 203, + histogramSimilarity: 0.18, + spatialRmse: 75.5, }, }; @@ -97,9 +128,106 @@ function createColorAdjustStep(): PipelineStep { }; } +function createCurvesChannelPressureStep(): PipelineStep { + return { + nodeId: "curves-channel-pressure-parity", + type: "curves", + params: { + channelMode: "red", + levels: { + blackPoint: 20, + whitePoint: 230, + gamma: 0.72, + }, + points: { + rgb: [ + { x: 0, y: 0 }, + { x: 80, y: 68 }, + { x: 190, y: 224 }, + { x: 255, y: 255 }, + ], + red: [ + { x: 0, y: 0 }, + { x: 60, y: 36 }, + { x: 180, y: 228 }, + { x: 255, y: 255 }, + ], + green: [ + { x: 0, y: 0 }, + { x: 124, y: 104 }, + { x: 255, y: 255 }, + ], + blue: [ + { x: 0, y: 0 }, + { x: 120, y: 146 }, + { x: 255, y: 255 }, + ], + }, + }, + }; +} + +function createColorAdjustPressureStep(): PipelineStep { + return { + nodeId: "color-adjust-pressure-parity", + type: "color-adjust", + params: { + hsl: { + hue: 48, + saturation: 64, + luminance: 18, + }, + temperature: 24, + tint: -28, + vibrance: 52, + }, + }; +} + +function createCurvesColorPressureChainSteps(): PipelineStep[] { + return [ + createCurvesChannelPressureStep(), + { + nodeId: "curves-master-pressure-parity", + type: "curves", + params: { + channelMode: "master", + levels: { + blackPoint: 10, + whitePoint: 246, + gamma: 1.36, + }, + points: { + rgb: [ + { x: 0, y: 0 }, + { x: 62, y: 40 }, + { x: 172, y: 214 }, + { x: 255, y: 255 }, + ], + red: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + green: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + blue: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + }, + }, + }, + createColorAdjustPressureStep(), + ]; +} + export function createParityPipelines(): Record { const curvesStep = createCurvesStep(); const colorAdjustStep = createColorAdjustStep(); + const curvesChannelPressureStep = createCurvesChannelPressureStep(); + const colorAdjustPressureStep = createColorAdjustPressureStep(); return { curvesOnly: { @@ -114,6 +242,18 @@ export function createParityPipelines(): Record tolerance.spatialRmse) { + throw new Error( + `${pipeline.key} spatial RMSE ${metrics.spatialRmse.toFixed(4)} exceeded tolerance ${tolerance.spatialRmse.toFixed(4)}`, + ); + } } } finally { restoreParityWebglContextMock(); diff --git a/tests/image-pipeline/webgl-backend-poc.test.ts b/tests/image-pipeline/webgl-backend-poc.test.ts index 59533e6..d5ffaaa 100644 --- a/tests/image-pipeline/webgl-backend-poc.test.ts +++ b/tests/image-pipeline/webgl-backend-poc.test.ts @@ -174,6 +174,8 @@ describe("webgl backend poc", () => { } it("selects webgl for preview when webgl is available and enabled", async () => { + // Parity gate in jsdom with mocked WebGL verifies backend contract behavior, + // not GPU-driver conformance. enforceCpuWebglParityGates(); const webglPreview = vi.fn(); @@ -227,6 +229,7 @@ describe("webgl backend poc", () => { }); it("uses cpu for every step in a mixed pipeline request", async () => { + // Keep backend-contract parity gate explicit for mocked jsdom runs. enforceCpuWebglParityGates(); vi.doMock("@/lib/image-pipeline/backend/feature-flags", async () => {