Skip to content

refactor(core)!: replace the hybrid contrast solver with chroma-column tracing - #228

Merged
pbroom merged 5 commits into
mainfrom
refactor/core-chroma-contrast-solver
Oct 10, 2026
Merged

pbroom merged 5 commits into
mainfrom
refactor/core-chroma-contrast-solver

Conversation

@pbroom

@pbroom pbroom commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Contrast regions now have one solver, built from the chroma-column
prototype ("option C"). Pass/fail depends only on a sample's displayed
luminance, so each side of a region is one luminance level curve. The
solver traces it in columns of fixed chroma-to-lightness ratio (rays from
black), which cover the GAMUT_EPSILON slack near black where l < c: along a
ray every linear channel is r^3 times a constant, the displayed luminance
never decreases inside the gamut, and the ray's gamut edge is explicit.

  • Luminance is measured with the target-gamut clipping contrastRatio /
    contrastAPCA use, the gamut edge uses the shared linear bounds, and the
    near-black strict bound is gone. Side targets come from a bisection with
    the public metric's own arithmetic, kept a hair inside the passing side,
    and piece ends are verified with the public checks, so every returned
    point passes the check and is in gamut per inSrgbGamut / inP3Gamut.
  • Gamut events (where a piece starts or ends) are found on a fixed
    internal scan of the edge-visibility function, independent of the
    sampling options: 256 uniform angles plus every edge breakpoint (two
    channel bounds meeting, a channel constant changing sign, clipping
    starting, the maxChroma cap), all exact cubic roots, plus a search of
    every local extremum near zero. This replaces the prototype's probeFin /
    locateEdge, which missed fins and mixed up crossings in one bracket.
  • Next to every edge corner the scan also tests one-sided slopes for a
    turn toward zero, so a gamut notch narrower than a scan step (the edge
    switching from blue > 1 to red < 0 and back near h 264.206) splits the
    contour instead of being bridged.
  • Chords are checked exactly against the gamut (each channel is a cubic
    along a chord, tested at its turning points) and split while they leave
    it; sampling is clamped and spread over the hue's gamut chroma extent
    (and the angle a piece sweeps), not [0, maxChroma].
  • The reference is measured unmapped, as the public checks clip it. A
    threshold equal to the contrast against white returns white as a
    one-point path, and traces count dropped pieces (droppedPieceCount).

New sampling options: initialSamples (default 32), errorTolerance (default
0.0015), maxDepth (default 6), validated like the old steps and clamped
(512, 1e-6, 12). Traces record solver 'contrast-rays' with fidelity info.
ContrastRegionLayer maps quality and drag freeze onto them, with
interactive defaults 8 / 0.004 / 3.

BREAKING CHANGE: the hybrid solver is removed. lightnessSteps, chromaSteps,
hybridMaxDepth, hybridErrorTolerance, tolerance, and maxIterations are type
errors and throw a TypeError (in contrastRegionPaths, plane queries, and
ContrastRegionLayer props). degradedReason, ContrastHybridDegradedReason,
the hybrid-only trace stages and their types, and the cusp / rootFinding /
branching timing keys are removed. ContrastRegionLayerMetrics reports
initialSamples, errorTolerance, and maxDepth.

Verification

An independent reviewer, using its own brute-force harness built on the public contrast and gamut checks, ran three rounds. Its final verdict was safe to submit.

  • Sets: main grid 3,456 queries, sRGB fold band 1,360 plus a dense 12,928, stress, cusp/polarity/slack/out-of-gamut references, more than 30k fuzz queries, and 54 constructed gamut-notch cases.
  • Max gap to the true boundary: 0.00054 at core defaults and 0.0026 at React settings. There were no out-of-gamut segments and no topology errors.
  • Of about 1.5M returned vertices, none fails contrastRatio / contrastAPCA or the gamut check.
  • Speed: about 42× faster than the hybrid at core defaults (median 0.10 vs 4.2 ms) and 4.3× faster at the React layer setting.
  • React fill mismatch: 0.023% mean, with no cases over 5%.
  • Size: the plane bundle grows by about 1 kB (28.0 of 30 kB). The parse bundles are unchanged.

Known limitation (follow-up): the solver misses 7 tiny P3 APCA pieces against black, 6e-6 to 6e-4 long, with gaps up to 0.0022. That happens where two edge-luminance minima fall inside one internal scan step (region-solver.ts searches for a single extremum per scan interval).

Breaking: lightnessSteps/chromaSteps are replaced by initialSamples, hybridErrorTolerance by errorTolerance, and hybridMaxDepth by maxDepth. tolerance, maxIterations and degradedReason are removed, and removed options throw TypeError.

Part of the color-kit assessment stack (#204 → #228).

🤖 Generated with Claude Code

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-07T13:20:43.177696Z 8aa4075 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

This was referenced Oct 7, 2026

pbroom commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

pbroom commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner Author

Merge activity

  • Oct 10, 6:47 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Oct 10, 7:46 PM UTC: Graphite rebased this pull request as part of a merge.
  • Oct 10, 7:47 PM UTC: @pbroom merged this pull request with Graphite.

@pbroom
pbroom changed the base branch from perf/core-contrast-imports to graphite-base/228 October 10, 2026 19:43
@pbroom
pbroom changed the base branch from graphite-base/228 to main October 10, 2026 19:44
pbroom and others added 5 commits October 10, 2026 19:45
…n tracing

Contrast regions now have one solver, built from the chroma-column
prototype ("option C"). Pass/fail depends only on a sample's displayed
luminance, so each side of a region is one luminance level curve. The
solver traces it in columns of fixed chroma-to-lightness ratio (rays from
black), which cover the GAMUT_EPSILON slack near black where l < c: along a
ray every linear channel is r^3 times a constant, the displayed luminance
never decreases inside the gamut, and the ray's gamut edge is explicit.

- Luminance is measured with the target-gamut clipping contrastRatio /
  contrastAPCA use, the gamut edge uses the shared linear bounds, and the
  near-black strict bound is gone. Side targets come from a bisection with
  the public metric's own arithmetic, kept a hair inside the passing side,
  and piece ends are verified with the public checks, so every returned
  point passes the check and is in gamut per inSrgbGamut / inP3Gamut.
- Gamut events (where a piece starts or ends) are found on a fixed
  internal scan of the edge-visibility function, independent of the
  sampling options: 256 uniform angles plus every edge breakpoint (two
  channel bounds meeting, a channel constant changing sign, clipping
  starting, the maxChroma cap), all exact cubic roots, plus a search of
  every local extremum near zero. This replaces the prototype's probeFin /
  locateEdge, which missed fins and mixed up crossings in one bracket.
- Next to every edge corner the scan also tests one-sided slopes for a
  turn toward zero, so a gamut notch narrower than a scan step (the edge
  switching from blue > 1 to red < 0 and back near h 264.206) splits the
  contour instead of being bridged.
- Chords are checked exactly against the gamut (each channel is a cubic
  along a chord, tested at its turning points) and split while they leave
  it; sampling is clamped and spread over the hue's gamut chroma extent
  (and the angle a piece sweeps), not [0, maxChroma].
- The reference is measured unmapped, as the public checks clip it. A
  threshold equal to the contrast against white returns white as a
  one-point path, and traces count dropped pieces (droppedPieceCount).

New sampling options: initialSamples (default 32), errorTolerance (default
0.0015), maxDepth (default 6), validated like the old steps and clamped
(512, 1e-6, 12). Traces record solver 'contrast-rays' with fidelity info.
ContrastRegionLayer maps quality and drag freeze onto them, with
interactive defaults 8 / 0.004 / 3.

BREAKING CHANGE: the hybrid solver is removed. lightnessSteps, chromaSteps,
hybridMaxDepth, hybridErrorTolerance, tolerance, and maxIterations are type
errors and throw a TypeError (in contrastRegionPaths, plane queries, and
ContrastRegionLayer props). degradedReason, ContrastHybridDegradedReason,
the hybrid-only trace stages and their types, and the cusp / rootFinding /
branching timing keys are removed. ContrastRegionLayerMetrics reports
initialSamples, errorTolerance, and maxDepth.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
simplifyPolyline ran after the exact chord gamut checks and could replace
checked segments with unchecked ones. Simplification now runs inside the
solver and only collapses a span when the replacing chord passes the same
exact cubic gamut check (allowing only the rounding its own end points
carry). Adds simplifyTolerance coverage on the fold-band, fin, and notch
cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ContrastRegionLayer requested and reported raw sampling props, while the
solver clamps them (initialSamples to 512, maxDepth to 12, errorTolerance
to at least 1e-6). Core now exports clampContrastRegionSampling, which
resolveContrastSampling also uses, and the layer resolves its sampling
once with it for both the request and the metrics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
getPlaneContrastRegion and getPlaneContrastBoundary returned empty
geometry on non-L x C planes before the options reached the solver, so
invalid sampling, criterion, or removed options silently succeeded there.
They now validate the options exactly as contrastRegionPaths does before
the early return.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The docs said an out-of-gamut reference is gamut-mapped; the solver and
the public metrics clip its channels as displayed. Fix the wording in the
contrast API page and the gamut option's JSDoc, document
clampContrastRegionSampling and gamut-safe simplification, and update the
changeset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@pbroom
pbroom force-pushed the refactor/core-chroma-contrast-solver branch from 319683f to fa57305 Compare October 10, 2026 19:45
@pbroom
pbroom merged commit 4a0f631 into main Oct 10, 2026
6 of 7 checks passed
@pbroom
pbroom deleted the refactor/core-chroma-contrast-solver branch October 10, 2026 19:47
pbroom added a commit that referenced this pull request Oct 10, 2026
IB-006 still described the legacy marching-squares and hybrid contrast
engines as current. Mark them as replaced with PR references (#223 retired
the legacy engine, #228 replaced the hybrid solver with the ray-based
solver in contrast/region-solver.ts), record the new options and the
removed ones, and list the two open follow-ups: 7 tiny P3 APCA pieces
missed by the single-extremum scan, and ContrastRegionLayer still
gamut-mapping its reference.

The repo-wide sweep for region-legacy, region-hybrid, hybridMaxDepth,
samplingMode and "legacy engine" found no other docs describing the removed
engines as current; remaining hits are removal notices, changelog entries or
the still-supported gamut/chroma-band samplingMode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
pbroom added a commit that referenced this pull request Oct 10, 2026
…nt (#232)

IB-006 still described the legacy marching-squares and hybrid contrast
engines as current. Mark them as replaced with PR references (#223 retired
the legacy engine, #228 replaced the hybrid solver with the ray-based
solver in contrast/region-solver.ts), record the new options and the
removed ones, and list the two open follow-ups: 7 tiny P3 APCA pieces
missed by the single-extremum scan, and ContrastRegionLayer still
gamut-mapping its reference.

The repo-wide sweep for region-legacy, region-hybrid, hybridMaxDepth,
samplingMode and "legacy engine" found no other docs describing the removed
engines as current; remaining hits are removal notices, changelog entries or
the still-supported gamut/chroma-band samplingMode.



lint, build, test, format:check, typecheck, size, check:artifacts, check:preprod and agents:check:strict all pass on this branch.

Part of the color-kit assessment stack (#204 → #232).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

This branch was successfully deployed

1 active deployment
Preview — fa573058 Deployed Oct 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant