Skip to content

feat(icc): built-in profile constructors and CICP → profile - #542

Open
justin13888 wants to merge 26 commits into
masterfrom
feat/424-icc-builtin-profiles
Open

justin13888 wants to merge 26 commits into
masterfrom
feat/424-icc-builtin-profiles

Conversation

@justin13888

@justin13888 justin13888 commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

gamut-icc gains built-in profile constructors and a CICP → profile path — pieces 1 and 2 of
issue #424.

  • IccProfile::builtin(BuiltinProfile) emits a spec-valid v4 three-component matrix/TRC display
    profile (ICC.1:2022 §8.4) for sRGB, linear sRGB, Display P3 and BT.2100 PQ.
  • IccProfile::gray_with_gamma(f64) emits the monochrome equivalent.
  • IccProfile::from_cicp(Cicp) builds one from the H.273 code-point triple AVIF, HEIC and JXL
    usually signal instead of embedding a profile, and records it in a cicpType tag.
  • IccProfile::from_source_profile(SourceProfile) builds one from gamut-color's bundle.

gamut-icc gains a normal dependency on gamut-color. The colorimetry is never restated in this
crate
: primaries and white point come from ColourPrimaries::chromaticities, the RGB→XYZ
construction and Bradford adaptation from gamut_color::matrix, and the ST 2084 curve from
gamut_color::transfer. That is what makes the buildable set exactly what gamut-color can express
on the two CICP axes — and why Adobe RGB and ProPhoto RGB are declined (None) rather than
approximated: both return None from colour_primaries() and transfer_characteristics(), and
their chromaticities are private to gamut-color. Filed as #537.

Tone curves follow the record: a parametricCurveType (§10.18) where H.273 gives the transfer a
closed form ICC also defines (linear → type 0, sRGB → type 3, grey gamma → type 0), a sampled
curveType (§10.6) of 1024 uInt16 points for PQ, which §10.18 has no form for. The per-space
choice and its deciding clause are tabulated in crates/gamut-icc/STATUS.md.

One defect was found and fixed by the oracle while building this: adapting colorants to
gamut_color::matrix::D50 (the CIE chromaticity) while writing XyzNumber::D50 (ICC's rounded
tristimulus, §7.2.16) as the mediaWhitePointTag left the colorants disagreeing with the white
point they are supposed to sum to by 2e-4 in Z, and put the disagreement with lcms2's own colorants
at 1.8e-4. Deriving the adaptation target from XyzNumber::D50 brings both inside four
s15Fixed16 quanta. See commit 2 and the new colorants_sum_to_the_declared_media_white_point.

Commit 4 is a no-behaviour follow-up: Trc::from_cicp carried an explicit
Bt709 | Hlg | Unspecified => None arm in front of the _ => None that #[non_exhaustive] makes
mandatory, so deleting it changed nothing and no test could kill the mutant. The arm is folded into
the wildcard's comment, which still names the three code points.

No human approved this plan. This is an unattended automated run; the record below is what a
human reads afterwards.

Validation

Every command below was run in this worktree against the head SHA 9b12bba1, and every one of them
completed in that run. Each was wrapped in the mandated memory-capped scope
(systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 -- env CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2 sh -c 'ulimit -v 12000000; exec <command>').

Command Outcome
mise run fmt-check pass (exit 0)
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits passconvco check: "no errors in 3 commits"
mise run check-release-deps pass — "release dependency graph has no dev-only workspace edges"
mise run check-ffi-features pass — "gamut-ffi features in sync with gamut"
mise run check-ffi-header passcbindgen reproduces the committed header unchanged
cargo test -p gamut-icc --all-features pass — 156 lib + 19 tests/oracle.rs + 7 tests/roundtrip.rs + 6 doctests, 0 failed (15 of the 156 lib tests are new; master has 141)
mise run lint pass (exit 0) — no clippy warning in workspace or tooling/ code
mise run test pass (exit 0) — 3787 passed, 0 failed across 202 test binaries and doctest runs
mise run mutants-diff pass (exit 0) — 61 mutants in the diff: 48 caught, 13 unviable, 0 missed

The fmt/fmt-check tasks were invoked with a __CARGO_TEST_ROOT=$(git rev-parse --show-toplevel)
prefix. That is the documented nested-worktree artefact: in a nested tree cargo
otherwise walks past the worktree root to the primary checkout's Cargo.toml when loading the
tooling/* manifests, and the task exits 101 on an untouched tree. No manifest was changed to work
around it.

Acceptance against the oracle

Per the record, no assertion in this change compares against golden bytes this crate produced.
tooling/lcms2-oracle (vendored Little-CMS) is the authority, matching docs/testing.md's table
for gamut-icc (Little-CMS, differential):

  • oracle_colorants_match_lcms_for_the_same_primaries — lcms2 re-opens the built-in profiles for
    the three distinct primary sets (sRGB/BT.709, Display P3, BT.2020) and reports the same
    colorants it derives from the same chromaticities itself, within four s15Fixed16 quanta. Linear
    sRGB is not in that loop because colorants are a function of the primaries alone and it shares
    sRGB's; it is covered by the two whole-set tests below. The tolerance is the tag encoding, not the
    derivation: largest observed disagreement 2.4e-5, under two quanta.
  • oracle_srgb_tone_curve_matches_lcms — lcms2 evaluates our sRGB TRC to the same values as the
    sRGB profile it synthesizes itself. Two independent constructions of IEC 61966-2-1, read by one
    reference CMM.
  • oracle_transform_through_our_srgb_is_the_identity — a media-relative colorimetric transform from
    our sRGB into lcms2's own sRGB returns all 256 ramp values within one 8-bit code. End-to-end:
    colorants, white point, adaptation and TRC all have to be right together.
  • oracle_gray_gamma_matches_lcms — lcms2's estimate_gamma on kTRC returns the gamma asked for.
  • colorants_sum_to_the_declared_media_white_point — over all four spaces: the colorants sum to the
    mediaWhitePointTag the profile itself declares. This is the assertion the D50 defect above
    tripped.
  • every_constructor_satisfies_the_section_8_display_model — over all four spaces plus the grey
    constructor: gamut's own validate() accepts each profile against the §8 Display required-tag
    set.

The one self-referential assertion is constructors_are_byte_deterministic, which compares two
calls of the same constructor to each other. It deliberately proves nothing colorimetric; it
guards the "no timestamp, no profile ID, no entropy" property the module documents, which a later
DateTime::now() would silently break.

Tests are inline in src/builtin.rs, not tests/: a dev-dependency oracle is explicitly not a
reason to move up (docs/testing.md), several assertions read non-pub items (colorants_d50,
cicp_byte, Trc, BuiltinProfile::parts), and inline is the only placement from which a mutant
masked at the public boundary is killable.

Risks and rollout

  • New workspace dependency edge gamut-icc → gamut-color. No cycle: gamut-color does not and
    will not depend on gamut-icc. check-release-deps confirms release-plz can still order the
    graph. It widens gamut-icc's dependency footprint from gamut-core + md-5; gamut-color's
    own dependencies are gamut-core plus an optional serde this edge does not select, so no
    third-party dependency is added (the Cargo.lock change is a single line).
  • Additive only. No existing public item changed signature or behaviour; parsing, serialization
    and validation are untouched. BuiltinProfile is #[non_exhaustive], #[repr(u8)], with
    permanent append-only discriminants, so gamut-color/gamut-icc: expose Gamut chromaticities so Adobe RGB and ProPhoto get built-in profiles #537 can add variants without a breaking change.
  • BT.2100 PQ is display-referred to the 10 000 cd/m² peak. The normalization divisor is read
    back from pq_eotf(1.0) rather than restated. A caller wanting a different peak needs a curve
    this crate does not offer; SourceProfile::BT2020's encoder-exact transfer (PQ + Reinhard@203
    tone map to SDR) is deliberately not what the profile carries — that is a tone map, not a
    display TRC, and embedding it would misdescribe the signal.
  • cicpType in every RGB profile built here. Consistent with the matrix/TRC pipeline beside it
    by construction. Harmless to a CMM that ignores it; lcms2 transforms through these profiles
    correctly (test above).
  • Rollback is deleting crates/gamut-icc/src/builtin.rs, its lib.rs wiring and the gamut-color
    dependency line (plus the README/STATUS paragraphs that describe them).

Issue

Refs #424 — pieces 1 and 2 land here. The issue does not fully close:

Decisions taken

These entries are frozen. A later round appends a correction beside them rather than editing
one, so the record reads as it was written. Decision 28's stated authority is corrected in round 5
below (entry 32): the mutation set is evidence about what the gate can see, never the whole reason
a test exists or does not.

Issue 424 - gamut-icc/gamut-color: built-in profile constructors + CICP -> profile
Plan:     v1
Branch:   feat/424-icc-builtin-profiles
Base:     origin/master (6a75ec4)
Cause:    -
Touches:  gamut-icc Cargo.toml (+gamut-color), new src/builtin.rs, lib.rs, tests, STATUS/README; Cargo.lock
Will not: add colour spaces (the issue's piece 3 - ICtCp belongs to the HDR milestone and the rest are declined for want of a named consumer); add a transform-side "prefer CICP transfer" option (that is gamut-cmm's surface, not this crate's); touch gamut-cmm
Lane:     parallel (clean root; no open pull request touches gamut-icc or gamut-color)
Settled:  S1 no new EXTERNAL dependency - gamut-color is a workspace crate; S3 docs/testing.md

Decisions taken.
1. Deliverable boundary
   Taken:    the issue's pieces 1 and 2 - built-in `IccProfile` constructors for the spaces gamut-color already defines colorimetry for, and a CICP -> profile path - and NOT piece 3, which the issue itself says to take only with a named consumer
   Rejected: all three pieces; the issue orders them and says to stop when the justification runs out, and no consumer is named for the extra spaces
   Reverses: delete builtin.rs
   Filed:    -
2. The dependency edge
   Taken:    `gamut-icc` gains a normal dependency on `gamut-color`. The constructors need gamut-color's colorimetry and gamut-icc's serializer, and only one of the two can own the edge: gamut-color has fan-in 8 and no icc dependency, so pointing it the other way would invert the primitive layering and give a widely-depended-on crate a serializer dependency it does not need. Verify with `mise run check-release-deps` that the release graph still orders
   Rejected: gamut-color depending on gamut-icc; a third crate, which the crate-boundary rule says must be justified with fan-in evidence rather than asserted
   Reverses: move builtin.rs to a consumer crate
3. Which spaces
   Taken:    exactly those with a `SourceProfile` constant - sRGB, linear sRGB, Display P3, Adobe RGB, BT.2020, ProPhoto - plus a grey-with-gamma constructor; each emits a v4 matrix/TRC profile whose primaries, white point and TRC come from gamut-color rather than from constants retyped here
   Rejected: hardcoding primaries in gamut-icc - it would duplicate gamut-color's tables and let the two drift
   Reverses: drop a space's constructor
4. Transfer curve shape
   Taken:    a parametricCurveType where H.273 gives the transfer function a parametric form, and a sampled curve otherwise; the choice per space is recorded in STATUS with the clause that decides it
   Rejected: sampled curves throughout - larger profiles and a needless loss of exactness where the spec gives a closed form
5. Acceptance
   Taken:    for each constructed profile, lcms2 re-opens it and reports the same colorimetry (primaries, white point, and the TRC evaluated at a set of points inside a stated tolerance), and gamut's own `validate()` accepts it against the SS 8 required-tag set for its class; the CICP path is checked against `tooling/lcms2-oracle`'s own `cicp` synthesiser where their inputs coincide
   Rejected: asserting against bytes this crate itself produced, which would only prove the writer is deterministic

Appended by this lane

6. Adobe RGB and ProPhoto RGB are dropped, not approximated (reverses part of decision 3)
   Taken:    ship constructors for sRGB, linear sRGB, Display P3 and BT.2100 PQ, plus grey-with-gamma, and return `None` for `SourceProfile::ADOBE_RGB` / `PROPHOTO_RGB`. Decision 3 requires the colorimetry to come from gamut-color, and gamut-color cannot supply it for these two: both return `None` from `colour_primaries()` AND `transfer_characteristics()` (no H.273 code point), and `ADOBE_RGB_PRIMARIES` / `PROPHOTO_PRIMARIES` / `gamut_chromaticities` in `crates/gamut-color/src/matrix.rs` are private. So the boundary is gamut-color's own model, not convenience. Decision 3's own reversal clause is "drop a space's constructor", and the lane rule prefers reducing the deliverable over freezing
   Rejected: (a) retyping the two primary sets in gamut-icc - exactly what decision 3 rejects; (b) recovering the matrices by inverting `derive_m1`'s OKLab LMS transform, which is publicly reachable but routes RGB->XYZ through a space it has no business visiting and would not survive review; (c) making the accessor public in gamut-color - outside the manifest, which names that case as a revision request; (d) freezing the whole lane - it would deliver none of the four spaces or the CICP path that are unblocked
   Reverses: add the two variants once gamut-color exposes the accessor
   Filed:    #537 - gamut-color/gamut-icc: expose Gamut chromaticities so Adobe RGB and ProPhoto get built-in profiles
7. The Bradford adaptation targets ICC's PCS D50, not the CIE D50
   Taken:    colorants are adapted to the chromaticity derived from `XyzNumber::D50` - the exact tristimulus ICC.1:2022 SS 7.2.16 mandates for the PCS illuminant - rather than to `gamut_color::matrix::D50`. Found by the oracle: the two differ by 2e-4 in Z, and adapting to the CIE one while writing the ICC one as the `mediaWhitePointTag` left the colorants disagreeing with the white point they sum to, and disagreeing with lcms2 by 1.8e-4. The fix took both inside four `s15Fixed16` quanta. This does not weaken decision 3: the source white (D65) is still gamut-color's; the PCS illuminant is an ICC fact, so gamut-icc owns it
   Rejected: writing the CIE D50 as the `mediaWhitePointTag` instead - SS 7.2.16 mandates the encoding, and a profile must not restate the illuminant its own header fixes; loosening the oracle tolerance to 1e-3 to hide the disagreement, which would have made the test unable to see a wrong adaptation
   Reverses: revert commit 2
8. The named constructors are infallible; only open input is fallible
   Taken:    `builtin` and `gray_with_gamma` return `Self`; `from_cicp` and `from_source_profile` return `Option<Self>` for input that genuinely cannot be described. To keep the infallible path total with no unreachable fallback, `BuiltinProfile::parts` names its tone curve directly (agreement with the code-point mapping is pinned by a test), and `colorants_d50` / `cicp_byte` are total functions whose degenerate arm is tested at the only input that reaches it. The workspace precedent for propagating (`gamut-cmm`'s `CmmError::SingularMatrix`) applies to open input, which a closed enum is not
   Rejected: `new_srgb() -> Result<Self>` for an argument-free constructor; `unwrap_or_else` with a dead closure, which adds an uncovered region and an unkillable mutant, and which AGENTS.md's no-`expect` rule would otherwise push toward
   Reverses: make the two constructors fallible
9. Decision 2's "fan-in 8" is left as written, though the measured figure is higher
   Taken:    the decision record is reproduced verbatim, including its "fan-in 8" for gamut-color. Measured at the base commit, ten workspace crates take a normal dependency on gamut-color (nine excluding the `gamut` umbrella), so the record understates it. The figure is a magnitude supporting the layering argument, and understating it can only weaken that argument, never inflate it, so it is reported here rather than silently edited into a record a human is meant to read as written
   Rejected: editing the number inside the verbatim record; re-cutting `STATUS.md` to carry the measured figure, which would force a full gate re-run for a supporting adjective
   Reverses: correct the number in STATUS.md when that file is next touched
10. The one surviving diff mutant is removed structurally, not excluded
   Taken:    `mise run mutants-diff` reported one MISSED mutant - "delete match arm TransferCharacteristics::Bt709 | Hlg | Unspecified in Trc::from_cicp". It is equivalent, not uncovered: the arm sits in front of a `_ => None` wildcard that `#[non_exhaustive]` on gamut-color's enum makes mandatory, so deleting it cannot change any observable result and no test can kill it. Delete the redundant arm (commit 4) and keep the three code points named in the wildcard's comment. `unrepresentable_signalling_is_rejected` already pins BT.709 and HLG to `None` through the public entry point, so nothing loses coverage
   Rejected: an exclusion in `.cargo/mutants.toml` - AGENTS.md requires strictly strong justification for one, and "the code can be written so the mutant does not exist" is the opposite of that; a test asserting `Trc::from_cicp(Bt709) == None`, which the wildcard already satisfies and which would therefore pass against both the original and the mutant
   Reverses: restore the explicit arm

Unresolved review notes

None.


Round 2 — review repair (branch feat/424-icc-builtin-profiles, issue #424)

A review of this pull request found eight items on the same surface. All eight are repaired here, in
commits 6592e65 (fix(icc)!) and d250db5 (docs(icc)). Everything above this line is the
round-1 record and is reproduced unchanged.

What changed

The headline defect: transfer code point 14 was given the PQ curve. ITU-T H.273 (07/2024)
Table 3 defines code point 14 as the BT.709 curve — its own informative remark calls it
"functionally the same as the values 1, 6 and 15" — so from_cicp produced an rTRC reading
0.009224 at signal 0.5 where the spec gives 0.259719, a factor of 28, and Little-CMS read the
same wrong value back from the serialized bytes. The same match rejected code point 1 outright
while accepting 14.

Table 3's four members (1, 6, 14, 15) share one pair of constants — §8.2 states them exactly:
β = 0.018053968510807… and α = 1 + 5.5 β = 1.099296826809442… — and the inverse of that
opto-electronic function is parametricCurveType function type 3 (ICC.1:2022 §10.18) exactly:

g = 1 / 0.45      a = 1 / α      b = (α − 1) / α      c = 1 / 4.5      d = 4.5 β

so the parametric branch of the record's decision 1 is the one taken; nothing had to be declined
for want of a closed form. Quantizing (g, a, b, c, d) to s15Fixed16 costs at most 1.4e-6
across the whole domain, measured, which is what sets the test tolerance.

§10.3 conformance. "When the data colour space in the profile header is RGB or XYZ,
MatrixCoefficients shall be 0 (zero)."
from_cicp wrote the caller's value verbatim, so the
commonest real input there is — an AVIF or HEIC nclx box carrying 1, 5, 6 or 9 — produced a
spec-violating profile. It now writes 0, and normalizes VideoFullRangeFlag to 1 to match the
full-scale RGB the profile's own matrix and curves are defined over (§10.3's RGB examples note the
flag "is often 1"). This is conformance, not information loss: both fields describe a luma–chroma
encoding the caller de-matrixes before this profile applies, and both remain in the container
signalling a decoder actually reads them from.

Fallible where the input is open. gray_with_gamma accepted 0.0, -1.0, NaN, and
40000.0, emitting descriptions like "Grey gamma NaN"; it now declines any gamma a kTRC cannot
carry. colorants_d50 returned the identity matrix for primaries with no chromaticities — a
profile silently claiming the PCS axes as its colorants — and now returns the option, which
from_cicp consumes with ? in place of a guard that named Unspecified by hand.

constructors_are_byte_deterministic compared first.ok() == second.ok(), so two serialization
failures compared equal and the test passed vacuously. It now serializes twice and compares the
bytes, failing if either serialization fails.

Validation

Every command below completed in this run, in this worktree, at head d250db5. Each workspace-wide
one was wrapped in the mandated memory-capped scope (systemd-run --user --scope --slice=agents.slice -p MemoryMax=16G -p MemorySwapMax=0 -- env CARGO_BUILD_JOBS=2 CMAKE_BUILD_PARALLEL_LEVEL=2 TMPDIR=… sh -c 'ulimit -v 12000000; exec <command>').

Command Outcome
cargo test -p gamut-icc --all-features pass — 161 lib (was 156) + 19 tests/oracle.rs + 7 tests/roundtrip.rs + 6 doctests, 0 failed
cargo clippy -p gamut-icc --all-targets --all-features -- -D warnings pass (exit 0)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass (exit 0) — the prefix is the known nested-worktree artefact; no manifest was changed for it
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits passconvco check: "no errors in 6 commits"
mise run check-release-deps pass — "release dependency graph has no dev-only workspace edges"
mise run check-ffi-features pass — "gamut-ffi features in sync with gamut"
mise run lint pass (exit 0) — whole workspace, no clippy warning
mise run test pass (exit 0) — whole workspace, 202 test result: ok lines, 0 failed
mise run mutants-diff pass (exit 0) — 83 mutants: 69 caught, 14 unviable, 0 missed

A green mutation gate does not by itself pin an or-pattern's alternatives — cargo-mutants does not
mutate them individually — so every_bt709_family_code_point_builds_the_same_curve asserts each of
1, 6, 14 and 15 separately rather than sweeping them as a set.

New and repaired tests, each naming one thing:

  • bt709_curve_inverts_the_h273_transfer — the written curve is the inverse of Table 3's forward
    function, transcribed independently in the test with its own literals (sharing BT709_ALPHA
    would share any mistyped digit), swept over both segments and across the β knee.
  • every_bt709_family_code_point_builds_the_same_curve — the F1 regression pin, and the
    alternative-by-alternative pin of the or-pattern.
  • oracle_bt709_tone_curve_matches_the_h273_transfer — Little-CMS re-opens our serialized bytes
    and evaluates the rTRC back to the light Table 3's forward function started from. This is what
    says the function type, parameter order and s15Fixed16 encoding are right, not merely that our
    own evaluator agrees with itself.
  • from_cicp_normalizes_the_matrix_coefficients_and_range_flag — over matrix coefficients 1, 5, 6
    and 9 × both range flags.
  • an_unencodable_grey_gamma_is_declined — asserts the encoding limit from both sides, because
    only a value exactly at it separates >= from >.
  • every_builtin_space_is_buildable — what makes builtin's Option a type-level guard rather
    than a new burden.
  • unrepresentable_signalling_is_rejected — BT.709 leaves the rejection sweep (it now builds) and
    Unspecified (2) joins it, so the loop pins both code points the round-1 decision 10 claimed and
    the third one it had left unpinned.

Issues filed

Issue (corrected)

Refs #424. Piece 1 lands except for Adobe RGB and ProPhoto RGB (blocked on #537). Piece 2
lands only its constructor halffrom_cicp / from_source_profile; its transform-side
option is #555. Piece 3 is declined per the issue's own instruction. The issue does not close.

Decisions appended in round 2

11. The BT.709 family is encoded, not declined (F1; reverses decision 4's consequence and part of decision 10)
    Taken:    transfer code points 1, 6, 14 and 15 all build a `parametricCurveType` function type 3 whose (g, a, b, c, d) are the inverse of H.273 Table 3's opto-electronic function, transcribed from the vendored spec's own exact constants. This is the parametric branch the round-2 record's decision 1 asks for first; the fallback branch (decline all four) was not needed, because §10.18 type 3 represents the shape exactly - measured quantization error 1.4e-6 over the whole domain. Round-1 decision 10 said an existing test pinned "BT.709 and HLG" to None; that claim is now false for BT.709 by design, and the test pins Unspecified in its place
    Rejected: declining all four for consistency - it would have been the honest fallback only if the shape were unrepresentable, and it is not; keeping 14 on the PQ curve, which is the defect
    Reverses: map 1 | 6 | 14 | 15 back to None in `Trc::for_code_point`
12. The transfer axis is keyed on the raw H.273 code point, not on gamut-color's enum (a fork the record did not cover)
    Taken:    `Trc::for_code_point(u8)` replaces `Trc::from_cicp(TransferCharacteristics)`. Decision 1 requires all four of 1, 6, 14 and 15, and `gamut_color::cicp::TransferCharacteristics` models only 1 and 14 - `from_code_point(6)` and `from_code_point(15)` return None, so keying on it would silently deliver half the decision. The two sets are genuinely different sets: what an ICC tag can ENCODE is not what gamut-color can EVALUATE, and gamut-color supplies no EOTF for any of the four. The primaries axis still goes through gamut-color, because chromaticities are colorimetry this crate refuses to restate
    Rejected: (a) adding code points 6 and 15 to gamut-color's enum - `crates/gamut-color/` is outside this lane's scope and the record says to return a revision rather than widen; (b) delivering only 1 and 14 and filing the other two, which would leave decision 1 half-done for a reason that is an artefact of another crate's modelling choices rather than of the spec
    Reverses: take `TransferCharacteristics` again once gamut-color models all four
13. MatrixCoefficients and VideoFullRangeFlag are normalized, not carried (F2)
    Taken:    `from_cicp` writes 0 and 1. ICC.1:2022 §10.3: "when the data colour space in the profile header is RGB or XYZ, MatrixCoefficients shall be 0 (zero)". The range flag is normalized with it for self-consistency: the profile's matrix and tone curves are defined over full-scale RGB, and §10.3's own RGB examples note the flag "is often 1". Documented at the constructor, in the module docs and in STATUS.md as conformance rather than loss - both fields describe an encoding the caller de-matrixes before the profile applies, and both stay in the container signalling
    Rejected: rejecting a non-zero MatrixCoefficients outright, which would make the commonest real input (an AVIF/HEIC `nclx` box carrying 1, 5, 6 or 9) unbuildable; carrying it verbatim, which is the non-conformance; adding a §10.3 MatrixCoefficients rule to `IccProfile::validate` - the record does not ask for it, and it would change the PARSER's behaviour on profiles read from the wild, which is a separate decision on a separate surface
    Reverses: restore `..cicp` in place of `normalized_cicp(cicp)`
14. The named constructors become fallible (F3, F4; reverses decision 8)
    Taken:    all four constructors return `Option<Self>`. `colorants_d50` propagates the option instead of falling back to the identity, so primaries with no chromaticities are declined structurally and `from_cicp`'s hand-written `== Unspecified` guard is gone; that makes `rgb_matrix_trc` fallible and therefore `builtin` too. `gray_with_gamma` declines non-finite, non-positive and unencodable gamma. Decision 8 held that a closed enum is not open input - true, but it was discharged by an identity fallback that is finding F4 itself, and this crate cannot prove totality at the type level because gamut-color's chromaticity and matrix constructors are themselves fallible. `every_builtin_space_is_buildable` pins that no variant declines today, so a variant added without chromaticities fails in CI rather than shipping
    Rejected: keeping `builtin -> Self` with an unreachable `unwrap_or_else` fallback - it is the defect, and AGENTS.md's no-`expect` rule forbids the alternative spelling; restating the four spaces' chromaticities in this crate to make the path total, which decision 3 rejects
    Reverses: reinstate the identity fallback and the infallible signatures
15. `gray_with_gamma` also declines a gamma `s15Fixed16` cannot hold (widens F3)
    Taken:    reject `gamma >= 32768.0` alongside non-finite and non-positive. `S15Fixed16::from_f64` SATURATES rather than failing, so 40000.0 - one of the review's own example inputs - would have been written as 32767.99998: a gamma nobody asked for, in a profile that validates. The reviewer's rule ("an f64 gamma is open input") covers the encoding limit as much as it covers NaN
    Rejected: accepting it and documenting the saturation, which leaves a silently wrong profile; clamping to the limit, same objection
    Reverses: drop the third clause of the guard
16. STATUS.md's fan-in figure is corrected; the frozen record is not (F6; reverses decision 9)
    Taken:    STATUS.md and `Cargo.toml` now say fan-in 10 before this crate's edge and 11 with it, measured: gamut, av1, av2, avif, cmm, dng, heic, jpeg, vvc, webp. Decision 9 deferred this on the ground that a frozen record is reproduced verbatim. That ground is sound for the record and wrong for STATUS.md: the paragraph carrying the figure is ADDED by this diff, and writing a known-wrong number into new prose is not the same act as reproducing an old one. The round-1 record above still says "fan-in 8", verbatim, as it must
    Rejected: correcting the number inside the verbatim round-1 record
17. `AGENTS.md`'s architecture table gains the edge this pull request adds (F7; manifest widening)
    Taken:    `**gamut-icc**` becomes `**gamut-icc** ← color` in the metadata-crates bullet. This widens the round-1 manifest by one path outside `crates/gamut-icc/`; the widening is authorized by the round-2 record and is recorded here as the record's shape requires. No other line of AGENTS.md is touched
    Rejected: leaving the table stale, which would leave the workspace's own architecture description contradicting the dependency this branch introduces
    Reverses: revert the one line
18. The PQ normalization is stated as a known limit and filed, not changed
    Taken:    STATUS.md gains "Known limit: the BT.2100 PQ profile is peak-referred", with the measured consequence - diffuse white (BT.2408's ~203 cd/m²) is 203/10000 = ~0.02 media-relative, so a CMM renders such content near black - and #557 carries the decision. Correct for a peak-referred profile; plausibly not what a caller embedding it expects. Diffuse-white referral clips the top ~5.7 stops, so it trades one wrong answer for another and is a colour-appearance decision, not a repair
    Rejected: changing the normalization in this pull request; leaving the consequence only in a body that does not survive the merge
    Filed:    #557 - gamut-icc: the BT.2100 PQ built-in profile is peak-referred, so diffuse white renders near black
19. Issue #424's piece 2 is split; the declined half is filed (F5)
    Taken:    the body above is corrected to say piece 2 lands only its CONSTRUCTOR half. The transform-side "prefer CICP-signalled transfer" option is gamut-cmm's surface (gamut-icc explicitly does not evaluate profiles), was named as out of scope in the round-1 plan's "Will not" line, but had no issue behind it
    Rejected: implementing it here, which would put an evaluation policy in a crate whose STATUS.md defers evaluation entirely
    Filed:    #555 - gamut-cmm: option to prefer CICP-signalled transfer characteristics over a profile's TRC tags
20. The repair lands as two commits, not one per finding
    Taken:    one `fix(icc)!` commit carrying the four behavioural repairs and their tests, and one `docs(icc)` commit carrying the prose. The four repairs are interleaved line-by-line inside a single function-level rewrite of `src/builtin.rs` and share their tests, so splitting them further would produce commits that do not compile in isolation, which is worse than a commit message that enumerates. The breaking footer names every changed signature and behaviour
    Rejected: six commits, one per finding; a single commit including the documentation, which would hide the AGENTS.md edit inside a code change
    Reverses: -

Unresolved review notes (round 2)

None. All eight review findings are repaired; the two pieces of work this pull request declines are
filed as #555 and #557, and #537 (round 1) remains open and unchanged.

No human approved this repair. This remains an unattended automated run; the record above is
what a human reads afterwards.

Addendum — head f868da15

A third commit followed the validation table above: gray_with_gamma is public and
GAMMA_ENCODING_LIMIT is not, so cargo doc -p gamut-icc reported "public documentation for
gray_with_gamma links to private item GAMMA_ENCODING_LIMIT"
and rendered no link at all. The
magnitude is now stated inline. No repository gate covers rustdoc warnings, so this was found by
running cargo doc directly against the diff.

Every gate in the table was then re-run at f868da15 and passed again, unchanged in outcome:
check-commits ("no errors in 7 commits"), check-tests, check-release-deps,
check-ffi-features, mise run lint (exit 0), mise run test (exit 0, 202 test result: ok
lines), mise run mutants-diff (83 mutants — 69 caught, 14 unviable, 0 missed), plus
cargo doc -p gamut-icc --no-deps --all-features with 0 warnings and
cargo test -p gamut-icc --all-features (161 + 19 + 7 + 6, 0 failed).


Round 3 — repair of the round-2 review (branch feat/424-icc-builtin-profiles, issue #424)

Four commits at head 3d56d3d5, on top of f868da15. Scope: crates/gamut-icc/src/builtin.rs
and this body.

Corrections to entries above (the entries themselves are left verbatim)

  • Corrects decision 15 (round 2), "gray_with_gamma also declines a gamma s15Fixed16 cannot
    hold".
    That entry bounded the value at 32 768 and called it the encoding limit. It was wrong
    in both directions. S15Fixed16::from_f64 rounds before it clamps, so it also degenerates at the
    low end: any gamma below 0.5 / 65 536 = 7.62939453125e-6 is written as raw 0, and
    Y = X^0 maps every input — black included — to white. Executed against the shipped code, gamma
    1e-6 was accepted, serialized, and reported clean by validate(), with a kTRC of
    ParametricCurve { function_type: 0, params: [S15Fixed16(0)] } — precisely the profile that
    entry gives as the reason for refusing a literal 0.0. The top bound was also in the wrong
    place: saturation begins at (2^31 − 0.5) / 65 536 = 32 767.999992370605468750, half a quantum
    above the largest representable value, so gammas between that point and 32 768 were accepted and
    silently written as 32 767.99998474121.
  • Corrects decision 13 (round 2), "MatrixCoefficients and VideoFullRangeFlag are normalized, not
    carried".
    That entry documented both fields as ICC.1:2022 §10.3 conformance. §10.3 carries
    one shallMatrixCoefficients shall be 0 in an RGB or XYZ profile — and no requirement at
    all about VideoFullRangeFlag: it says only that the flag "is often 1" for RGB, and its own RGB
    examples list 1-1-0-0 and 9-16-0-0 with the flag at zero. Writing 1 unconditionally is
    this crate's normalisation, not conformance, and its consequence — the caller's flag is
    discarded, not preserved — was not stated.
  • Corrects the addendum at head f868da15. That addendum reported cargo doc -p gamut-icc --no-deps --all-features with 0 warnings. The rename it made in the same breath left a second
    stale intra-doc link to Trc::from_cicp on a private item. Both ends of that link are private, so
    rustdoc without --document-private-items does not resolve it and reports the crate clean —
    demonstrated: with the stale link restored, the private-items run fails with
    "error: unresolved link to Trc::from_cicp" while the default run emits zero diagnostics.

Decisions taken (round 3) — the record this lane was given, verbatim

1. F1: bound the encoding at both ends, not just the value. A profile the crate's own validator
   blesses that maps every input to white is the silent wrong answer this run refuses.
2. F2: pin the complement with an exhaustive test over the whole byte range minus the accepted and
   explicitly-rejected sets. Neither the gate nor any existing test can see a wrongly-admitted
   code point.
3. F3: fix the link, and run the documentation check with private items - otherwise it cannot see
   the crate's own internals.
4. F4: correct the constant and the claim to where saturation actually begins.
5. Design question 1, the largest - keep the literal reading, document the alternative, and file
   it. The specification's own note sanctions a second reading for display-class profiles, and the
   two differ by 21% at mid-grey (0.2597 against 0.1895; the crate's own sRGB code point gives
   0.2140). Your choice is spec-literal and matches the reference implementations, so changing it
   would make this crate disagree with every other tool - but the docs must say the alternative
   exists, quantify the divergence, and note that two of your own code points now differ by that
   much at mid-grey.
6. Design question 2: keep writing the full-range flag but stop calling it conformance. Only the
   matrix field is mandated; the flag is a deliberate normalisation. Document it as one, including
   that the caller's value is not carried.
7. Design questions 3 and 4: file both - validating parsed profiles against that clause changes
   parser behaviour on real-world files, and the two further code points with exact closed forms
   are an additive capability, not a defect.
8. File the separate question of whether the gamma should be bounded to a colorimetrically
   meaningful range rather than to encoding limits.

Decisions appended by this lane

21. The gamma guard is written as the encoding's own arithmetic, not as two constants (discharges
    round-3 decisions 1 and 4; reverses decision 15's `GAMMA_ENCODING_LIMIT`)
    Taken:    `gamma_is_encodable(gamma)` computes `(gamma * 65_536.0).round()` - the same
              expression `S15Fixed16::from_f64` performs - and requires it to land in
              `1 ..= i32::MAX`. Both failure modes then fall out of one statement: raw 0 is the
              identically-white curve, and anything above `i32::MAX` is what the clamp rewrites.
              Non-finite input satisfies neither comparison, so `is_finite` and `<= 0.0` are gone
              as separate clauses rather than being restated. There is no longer a magic constant
              that can be in the wrong place, which is what finding F4 was; the two numeric bounds
              survive only as prose in the doc comment and as independently written literals in
              the test
    Rejected: (a) keeping two named constants and correcting their values - it repairs this
              instance of F4 without removing the class, since a constant restating another
              function's arithmetic can drift from it again; (b) round-tripping through
              `S15Fixed16::from_f64` and comparing to within half a quantum - it cannot separate
              saturation from legitimate round-half-away-from-zero, because at the saturation
              point the error is *exactly* half a quantum; (c) rejecting `raw == i32::MAX`
              outright, which would also refuse the legitimate largest representable gamma
    Reverses: restore a value-bounded `if !gamma.is_finite() || gamma <= 0.0 || gamma >= LIMIT`
22. The saturation bound is written at its shortest exact decimal
    Taken:    the test's `SATURATING` constant is `32_767.999_992_370_605`, with the exact value
              `32 767.999992370605468750` stated in the doc comment beside it. `-D warnings` makes
              `clippy::excessive_precision` a build failure on the fully written literal, and the
              two parse to the same `f64` (verified). The exact decimal stays in prose, where it
              is the fact a reader needs
    Rejected: `#[allow(clippy::excessive_precision)]`, which suppresses a lint that is right about
              the literal in order to keep digits that change nothing
    Reverses: -
23. The four questions the record defers are filed, and the two the code cannot answer are linked
    from the code
    Taken:    #586 (BT.1886 reading), #587 (`validate` against §10.3 on parsed profiles), #588
              (H.273 transfer 4 and 5), #589 (colorimetric vs encoding bound on the grey gamma).
              #586 and #589 are linked from the module docs and from `gray_with_gamma`'s doc
              respectively, because in both cases the shipped behaviour is a choice a caller can
              observe and would otherwise have to infer
    Rejected: filing without linking, which leaves the reader of the API no path to the open
              question; documenting without filing, which leaves no owner
    Reverses: -

What each repair pins that nothing else did

  • F1. With the previous value-only guard restored, the new boundary test fails at
    HALF_QUANTUM.next_down() with the accepted profile's kTRC printed as S15Fixed16(0) — 161
    other tests pass. The test reads each accepted gamma back out of the tag it was written into, so
    a value that survives the guard but not the encoding cannot pass.
  • F2. With an eighth arm added to Trc::for_code_point (code point 4 → Trc::Gamma(2.2), a
    curve H.273 Table 3 defines differently), exactly one test fails —
    no_unlisted_transfer_code_point_is_encodable — and the other 161 pass. No mutation of a match
    expression produces an extra arm, so mutants-diff cannot generate that defect either.
  • F3. Demonstrated above: the private-items run catches the stale link, the default run does
    not.
  • F4. The bound is now derived rather than stated; the test pins it at
    32_767.999_992_370_605 (accepted at .next_down(), refused at the value itself).

Validation (round 3, at head 3d56d3d5)

Command Result
cargo test -p gamut-icc --all-features pass — 162 + 19 + 7 + 6, 0 failed (161 unit tests before this round)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits pass — "no errors in 12 commits"
mise run lint failed once, then pass. clippy::excessive_precision on the fully written saturation literal under -D warnings; repaired by decision 22 and re-run to exit 0
mise run test pass — no test result: FAILED, no error lines
mise run mutants-diff pass — 83 mutants: 69 caught, 14 unviable, 0 missed
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-icc --all-features --no-deps --document-private-items pass — 0 diagnostics

mise run lint, mise run test and mise run mutants-diff each ran inside a
MemoryMax=16G, MemorySwapMax=0 scope with CARGO_BUILD_JOBS=2. check-release-deps,
check-ffi-features and check-ffi-header were not re-run: no Cargo.toml and no public
C-surface type changed this round. Coverage was not re-run: no new module was added.

Issues filed (round 3)

Unresolved review notes (round 3)

None. All four round-2 findings are repaired and both design questions are answered in the
documentation; the four questions the record defers are filed as #586#589. #537 and #555 remain
open and unchanged.

No human approved this repair. This remains an unattended automated run; the record above is
what a human reads afterwards.

Round 4 — repair of the round-3 review (branch feat/424-icc-builtin-profiles, issue #424)

Three commits at head 1b45aa46, on top of 3d56d3d5. Scope: crates/gamut-icc/ in full
source, STATUS.md and README.md alike — and this body.

The lesson of this round, stated before the findings. Rounds 1–3 chased one class of defect:
a second statement of a fact, drifting from the arithmetic that owns it. Round 1 restated the
fact as a constant; round 2 removed the constant from one place and restated it in a doc comment;
round 3 deleted the constant and put the guard on the arithmetic, which genuinely eliminated the
class inside builtin.rs. Both superseded statements survived one file away, in STATUS.md,
because the scope line named the file the arithmetic is in. A repair of this class is complete
only when it covers every statement of the fact wherever it lives. Every restatement found in
this crate is tabulated below with what was done to it.

Corrections to entries above (the entries themselves are left verbatim)

  • Reverses decision 6 (round 3), "keep writing the full-range flag but stop calling it
    conformance", and decision 13 (round 2), "MatrixCoefficients and VideoFullRangeFlag are
    normalized, not carried".
    Both rested on the premise that the two fields describe a
    luma–chroma encoding the caller de-matrixes before the profile applies, so normalising the range
    flag loses metadata but not colour. ICC.1:2022 §10.3's own RGB examples refute the premise for
    the range flag: 1-1-0-0 is listed as "RGB narrow range representation specified in
    Recommendation ITU-R BT.709-6, Item 3.4"
    with MatrixCoefficients already zero — a narrow
    range on the RGB samples themselves, which no de-matrixing removes. A caller holding genuine
    narrow-range signalling was therefore handed a profile that renders its colour wrongly, not
    one that merely dropped metadata. from_cicp now declines any video_full_range_flag other
    than 1.
  • Corrects the round-2 statement of the grey-gamma domain in crates/gamut-icc/STATUS.md.
    Round 3 moved the guard onto the encoding but left STATUS.md stating the superseded value
    domain — "non-finite, non-positive, or ≥ 32 768", citing the fixed-point width as the deciding
    clause. Executed, that sentence was wrong in both directions: a value that is finite,
    positive and below 32 768 is refused (the whole interval under 0.5 / 65 536), and the entire
    low interval the guard refuses was admitted by it.
  • Corrects the mid-grey divergence table added in round 3. Its first row was labelled "what
    this module writes" but carried the value of the exact closed form, which is not what the
    tag holds — the parametricCurveType parameters are rounded to s15Fixed16 first.

Findings repaired, with evidence

# Severity Finding Repair
F-A Medium STATUS.md stated the superseded grey-gamma domain (value-bounded at 32 768, citing the fixed-point width). Wrong in both directions against the shipped guard. STATUS.md now states the encoding domain the arithmetic owns: accepted from 0.5 / 65 536 = 7.629 394 531 25e-6 up to but not including (2^31 − 0.5) / 65 536 = 32 767.999 992 370 605 468 75, with the reason each end is refused.
F-B Medium STATUS.md said of the two rewritten fields "Neither is a loss of information: both describe a luma–chroma encoding the caller de-matrixes before this profile applies", while the round-3 source said the flag is discarded, not preserved — two documents in one PR contradicting each other. The status claim is also false on §10.3's own terms. Behaviour reversed (decision 25): narrow range is declined, not normalised. The claim is removed from STATUS.md, the module docs, from_cicp's doc and README.md, and replaced by the §10.3 1-1-0-0 reading in each.
F-C Low builtin.rs:244 cited ICC.1:2022 §10.7 for the four-byte cicpType layout. §10.7 is dataType; cicpType is §10.3 — the very clause this round's subject was a misreading of. Citation corrected. Verified against the vendored references/icc/icc.1-2022-05.pdf table of contents (§10.3 cicpType, §10.7 dataType). Every other citation in the module checks out.
F-E Low The profileDescriptionTag was written from the requested gamma while the kTRC was written from the encoded one. At the smallest accepted gamma (0.5 / 65 536) the description read one number and the tag carried twice it (1 / 65 536). The guard returns the encoded S15Fixed16; gray_with_gamma shadows its argument with it, so the requested value is out of scope for the rest of the function and nothing below can be written from it. gray_with_gamma(2.2) is now described as Grey gamma 2.1999969482421875 — the parameter a reader inspecting the tag finds.
F-F Info One table row labelled "what this module writes" carried the exact-function value; and the saturating parameter was written truncated (32 767.999 984 741 21) where its neighbour was written exact. Both rows this module writes are quoted as the tag evaluates (0.259721 and 0.214045, against 0.259719 and 0.214041 before rounding); the BT.1886 row is labelled as the closed form, since no tag here holds it. The truncated bound is now exact: 32 767.999 984 741 210 937 5. The paragraph's divergence figures (1.371×, 0.0703, 0.0457, 21 %) are unchanged at their stated precision — recomputed from the rounded parameters.

Every place each fact was restated, and what was done with it

Fact Location Disposition
Full-range flag is normalised to 1 / no information lost src/builtin.rs module docs (# What a CICP triple contributes) Rewritten: the flag is a precondition, not a rewrite; the §10.3 1-1-0-0 reading is given
" src/builtin.rs normalized_cicp doc + body Function renamed rgb_conforming_cicp; it now carries only the §10.3 shall
" src/builtin.rs IccProfile::from_cicp doc + doctest Rewritten; the doctest asserts the narrow-range decline instead of asserting it is ignored
" src/builtin.rs test from_cicp_normalizes_the_matrix_coefficients_and_range_flag Renamed from_cicp_zeroes_the_matrix_coefficients; the range loop is gone, replaced by a_cicp_triple_that_is_not_full_range_is_declined
" crates/gamut-icc/STATUS.md "CICP fields the profile does not carry" Rewritten (F-B), heading included
" crates/gamut-icc/README.md example + prose Rewritten; the example now shows both the accepted and the declined triple
The accepted flag value 1 itself cicp_of (tag) and from_cicp (precondition) Stated once as const FULL_RANGE: u8 = 1, read by both, so the tag and the precondition cannot drift
Grey-gamma domain src/builtin.rs gamma_is_encodable doc Rewritten as encodable_gamma; bound written exact; the two ends named for what they are
" src/builtin.rs gray_with_gamma public doc Kept (it is the caller-facing statement) and reconciled: fidelity-only top bound added, description behaviour stated
" src/builtin.rs test a_grey_gamma_the_ktrc_cannot_carry_is_declined doc Reference updated to the renamed function; the deliberately-restated literals are left restated (decision 21 above)
" crates/gamut-icc/STATUS.md "Grey gamma domain" Rewritten (F-A)
"declines only what its colorimetry cannot resolve" crates/gamut-icc/STATUS.md "Built-in profiles" opening Amended: colorimetry is not the only reason to decline

Searched crate-wide (crates/gamut-icc/) plus crates/gamut/src, crates/gamut-cli/src, docs/
and the root README.md for 32 768/32 767/s15Fixed16/full[-_ ]range/gray_with_gamma/the
three table values/§10.7. Nothing outside crates/gamut-icc/ restates any of these facts;
crates/gamut-icc/src/lib.rs's one-line summary ("Each returns None for signalling no
matrix/TRC profile can describe") is more accurate after this round, not less, and was left
alone.

Behaviour change and how it is versioned

IccProfile::from_cicp now returns None for a Cicp whose video_full_range_flag is not 1,
where it previously built a full-range profile from it. That is a behaviour change a caller can
observe, and it is published as fix(icc)!: … with a BREAKING CHANGE: footer naming exactly
that, so release-plz treats it as a major-level change rather than a patch. Two facts a reader
should have alongside it: the released gamut-icc v1.0.0 contains none of these constructors
(builtin, gray_with_gamma, from_cicp, from_source_profile are all introduced by this pull
request), so the surface being narrowed is the one this branch itself adds; and the branch already
carries a ! commit for this same API (6592e655), so the bump this footer implies is not a new
one. It is written as breaking regardless, because under-signalling a narrowed domain is the
failure that costs a consumer, and over-signalling costs a version number.

Decisions taken (round 4) — the record this lane was given, verbatim

1. Fix F-A, F-B, F-C and F-F - and treat the repair as covering every statement of the fact
   wherever it lives, not only the file the arithmetic is in. That is the lesson of this round;
   say so in the body.
2. Design question 3: decline a narrow-range triple rather than normalising it. This reverses my
   own earlier decision, and I am saying so plainly. I had told you to keep writing the flag and
   stop calling it conformance. The reviewer falsified the premise that made that safe:
   de-matrixing does not remove a narrow range on the RGB samples themselves, so a caller holding
   genuine narrow-range input gets mis-rendered colour, not merely lost metadata. Your crate
   already declines what it cannot describe - unmodelled primaries, one transfer function - and
   this is the same case. Record the reversal and its reason.
3. Design question 4: write the description from the encoded value, so a profile's own description
   cannot contradict its own tag. That is this loop's class in miniature.
4. Design question 2: keep the top bound but state that it is fidelity-only. The rejected value
   and the one below it differ by less than four parts in a trillion and evaluate identically, so
   the symmetry is worth keeping and its nature is worth naming.
5. Design question 5: do not wire a documentation gate. It is a workspace-wide addition unrelated
   to this entry and it is already filed - two other lanes reached it independently. Reference the
   existing issue and record this round's defect as a concrete instance of it.
6. Design question 1: the status document is normative - your body points readers at it - but
   policing it repository-wide is already carried by two filed issues. Reference those rather than
   filing a fourth.

Decisions appended by this lane

24. The repair's unit is the fact, not the file
    Taken:    every statement of each repaired fact in `crates/gamut-icc/` was located by a
              crate-wide search on its terms and either rewritten, deleted, or replaced by a
              single named binding read from both ends. Where two places had to agree on a value,
              the value became one item - `const FULL_RANGE: u8 = 1` - rather than two literals
              that happen to match
    Rejected: repairing only the two `STATUS.md` paragraphs the review named, which is the round-3
              mistake at a different radius; a "check STATUS.md against the source" gate, which is
              decision 5's already-filed workspace concern, not this entry's
    Reverses: the round-3 scope line, which named `builtin.rs` alone
25. `from_cicp` declines a triple that does not signal full range
    Taken:    `video_full_range_flag != 1` returns `None`, joining the two declines already there
              (primaries with no chromaticities, transfer with no ICC tone curve). §10.3's
              `1-1-0-0` example is narrow range on the RGB samples with `MatrixCoefficients`
              already zero, so the range survives de-matrixing and a normalised profile
              mis-renders it. Full range is the only value the colorants, `chad` and tone curves
              written alongside it are defined over
    Rejected: (a) keeping the normalisation and documenting the loss - the loss is colour, not
              metadata; (b) building a narrow-range profile by folding a 16-235/219 scale into the
              tone curves - the flag would then be honest but the profile would no longer be the
              published colorimetry `gamut-color` supplies, which is this crate's whole premise;
              (c) accepting the flag and writing it through unchanged into `cicpType` - the tag
              would then contradict the colorants beside it
    Reverses: decision 6 (round 3) and decision 13 (round 2)
26. Accepting `1` only, not "0 or 1"
    Taken:    the guard is `!= FULL_RANGE`, so every one of the other 255 bytes is declined. H.273
              defines the field as a one-bit value; a byte outside `{0, 1}` is signalling this
              crate cannot interpret, and admitting it would write it verbatim into the tag
    Rejected: `== 0` (declining narrow range but admitting 2..=255), which passes undefined
              signalling through into a conformance-checked tag
    Reverses: -
27. The grey description is written from the encoded gamma, by shadowing
    Taken:    `let gamma = encodable_gamma(gamma)?.to_f64();` - the caller's value is out of scope
              from that line on, so the description and the tag are written from one binding and
              cannot disagree. The visible cost is a long decimal in the description
              (`Grey gamma 2.1999969482421875` for a requested 2.2), documented on the constructor
              and in `STATUS.md`
    Rejected: (a) formatting the description to fewer digits, which re-admits a description that
              disagrees with its tag at some input and hides which one; (b) keeping two bindings
              and adding a test that they agree - the repository's rule is that a test must name
              the one function whose mutation it kills, and no mutation of this function produces
              the two-binding defect. A defect a mutation cannot express is removed structurally,
              not watched
    Reverses: -
28. No new test for the description, one new test for the decline
    Taken:    `a_cicp_triple_that_is_not_full_range_is_declined` sweeps the whole complement of
              `FULL_RANGE` over `0..=u8::MAX`, because the guard is a comparison and an ordering
              mutation of it (`<`, `>`) leaves one side of `1` admitted; a sample at `0` alone
              would not kill both. It names one thing and fails for one reason: `from_cicp`'s
              range precondition. The description repair adds no test, per decision 27
    Rejected: adding the narrow-range case to `unrepresentable_signalling_is_rejected`, which
              would widen a test whose name and reach are about *colorimetry* it cannot resolve
    Reverses: -
29. The top of the grey-gamma domain is kept and named as fidelity-only
    Taken:    the first rejected gamma and the last accepted one are one `f64` ulp apart (2^-38,
              under four parts in a trillion) and evaluate identically, so the top bound saves no
              caller from a misrendering, while the bottom bound refuses a genuinely degenerate
              all-white profile. Both are kept - a domain closed at both ends is easier to reason
              about - and each end is now labelled with which kind it is, in
              `encodable_gamma`, on the public constructor, and in `STATUS.md`
    Rejected: dropping the top bound as cosmetic, which reopens the saturating write decision 4
              (round 3) closed; leaving both ends unlabelled, which implies the top also refuses
              harm
    Reverses: -
30. No documentation gate is wired, and no fourth issue is filed
    Taken:    the missing gate is #549 ("No gate compiles a README code block or fails on a broken
              rustdoc link"), and this round's F-A/F-B are recorded here as a concrete instance of
              it: a status document and a source file in one pull request stating opposite things,
              which no check in this repository can see. The repository-wide question of a status
              document's authority is carried by #561 ("Decide whether 'lib.rs, then Cargo.toml,
              then STATUS.md' is the repository-wide authority order for a documented claim") and
              #590 ("Crate doc summary lines that their own source contradicts")
    Rejected: filing a fourth issue duplicating #549/#561/#590; wiring a workspace-wide gate from
              a single-crate entry
    Reverses: -

Validation (round 4, at head 1b45aa46)

Command Result
cargo test -p gamut-icc --all-features pass — 163 + 19 + 7 + 6, 0 failed
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt then … mise run fmt-check pass
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits pass — "no errors in 16 commits"
mise run lint pass — cargo clippy --workspace --all-targets --all-features -- -D warnings, 0 diagnostics
mise run test pass — 202 test result: ok lines, no FAILED and no error lines
mise run mutants-diff pass — 82 mutants: 67 caught, 15 unviable, 0 missed
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-icc --all-features --no-deps --document-private-items pass — 0 diagnostics

mise run lint, mise run test and mise run mutants-diff each ran inside a
MemoryMax=16G, MemorySwapMax=0 scope with CARGO_BUILD_JOBS=2. check-release-deps,
check-ffi-features and check-ffi-header were not re-run: no Cargo.toml and no public
C-surface type changed this round. Coverage was not re-run: no new module was added.

Issues filed (round 4)

None. Decisions 5 and 6 of the record direct this round to reference existing issues rather than
file: #549 (no gate compiles a README block or fails on a broken rustdoc link — the gate that
would have caught F-A and F-B), #561 (repository-wide authority order for a documented claim)
and #590 (crate doc summary lines their own source contradicts). None of the three was edited,
commented on or labelled.

Unresolved review notes (round 4)

None. All five round-3 findings (F-A, F-B, F-C, F-E, F-F) are repaired, and all six decisions in
the record are carried out. One factual note for the reviewer, offered rather than acted on: the
record describes the narrow-range decline as "a behaviour change on a published crate". The
published gamut-icc v1.0.0 does not contain from_cicp — it is introduced by this pull request
— so the narrowed surface is this branch's own. The change is published as breaking anyway, for
the reason given under "Behaviour change and how it is versioned". #537 and #555 remain open and
unchanged.

No human approved this repair. This remains an unattended automated run; the record above is
what a human reads afterwards.

Round 5 — repair of the round-4 review (branch feat/424-icc-builtin-profiles, issue #424)

Ten commits on top of 1b45aa46. Scope: crates/gamut-icc/ (source, STATUS.md, README.md),
this body, and two new issues. Everything above this line is reproduced unchanged.

This round's deliverable is the enumeration, not the two corrections. Rounds 1–4 each closed the
class of "a fact restated somewhere that drifts from the thing that owns it" one step from where the
last round searched — round 3 closed it inside one file, round 4 across the crate's files, and the
round-4 review then found the drift one fact away, including inside the sentence round 4 wrote to
close it ("Every other citation in the module checks out" — itself an ungated second statement, and
false). So the unit of repair here is the fact, and the deliverable is the derived set of
restated facts, mechanically, in the two shapes that have produced defects: every §X.Y citation,
and every doc sentence asserting what a function outside this crate returns or does. Both sets are
below with the commands that derive them, every member resolved, and the members that resolved
false named.

Shape 1 — every §X.Y citation, resolved against a clause index extracted from the vendored PDF

pdftotext -layout references/icc/icc.1-2022-05.pdf icc2022.txt        # 7 812 lines
grep -nE '^\s*[0-9]+(\.[0-9]+)+\s+[A-Za-z]' icc2022.txt               # → 210-clause index
grep -rn '§' crates/gamut-icc --include='*.rs' --include='*.md'       # 216 lines
# join: 229 citation occurrences, 63 distinct clause numbers, 24 files

Resolution of all 63:

Class Count Resolution
Sub-clause of ICC.1:2022 56 Each resolves to a clause whose heading names the tag, type or field the citing sentence is about (§10.3 cicpType, §7.2.16 PCS illuminant field, §9.2.17 cicpTag, …).
Top-level ICC.1:2022 clause 5 §4 Basic number types, §7 Profile requirements, §8 Required tags, §9 Tag definitions, §10 Tag type definitions — each matches its citing file's subject.
Another edition 1 ICC.1:2001-04 §6.5.17 (src/mluc.rs), resolved against the vendored 2001 PDF: textDescriptionType. Correct, and the line names the edition.
Another standard 1 (6 occurrences) H.273 §8.2; every one of the six occurrences names H.273 on its own line, so none is ambiguous with ICC's §8.2 Common requirements.

Members that resolved false — two, both in src/builtin.rs, both corrected in 5a9cc0c5:

Cited Clause the vendored PDF gives that number Corrected to
§9.2.10 for rXYZ/gXYZ/bXYZ BToD1Tag §9.2.46 / §9.2.31 / §9.2.4 (red/green/blueMatrixColumnTag)
§9.2.35 for chad metadataTag §9.2.15 (chromaticAdaptationTag)

Neither number exists for those tags in any edition — the 2001 edition was checked too. Which
numbering:
the citations use the standard's heading numbering. ICC.1:2022 numbers two of the
same clauses differently where §8.4.3 cross-references them (9.2.44 for redMatrixColumnTag, 9.2.30
for greenMatrixColumnTag) — an erratum in the published document, not two editions. That is stated
at the citation site, so a reader who follows the cross-reference is not misled.

No gate exists for this, and none is wired here. pdftotext is not provisioned by mise.toml
(grep -n 'pdftotext\|poppler' mise.toml → no match), so a gate would add an unprovisioned system
dependency from a single-crate entry. Filed as #606 — No gate checks a §-citation against the
clause the vendored specification gives it
, referencing #549, and cited in builtin.rs beside the
derivation. Residual: Table N references (39 occurrences) are a sibling citation shape this
derivation does not cover; they are named in "Residual risk" below rather than silently included.

Shape 2 — every doc sentence asserting what a function outside this crate returns or does

grep -rnE '^\s*(//!|///)' crates/gamut-icc --include='*.rs' \
  | grep -E 'gamut_color|gamut-color|gamut_core|md-5|thiserror'          # 43 doc lines
grep -nE 'gamut_color|gamut-color|gamut_core|md-5|thiserror' \
  crates/gamut-icc/STATUS.md crates/gamut-icc/README.md                  # 24 lines

67 documentation lines name a crate outside this one. Twelve of them assert what an external item
returns or does — the shape that can be false — and each is resolved below. (The rest state
provenance or architecture: "the primaries come from ColourPrimaries::chromaticities", which the
compiler checks by the call itself.)

# Claim Resolves Now gated by
1 gamut-color supplies no EOTF for any of the four BT.709-family code points FALSEeotf_for(Bt2020_10) is Some(bt2020_pq_to_sdr) rewritten; the doctest on the module docs asserts what it does return (a5c88739)
2 …and that curve is 20.3 % above Table 3's at V = 0.5 true (20.298 %) the same doctest (a34093d1) — it was stated twice and gated nowhere
3 TransferCharacteristics models only two of the four (1 and 14) true the same doctest, alternative by alternative
4 gamut-color has no BT.709-family curve at all true — transfer.rs exposes linear/sRGB/Adobe/ProPhoto/PQ curves and no BT.709 one eotf_for(Bt709).is_none() in the doctest
5 gamut_color::transfer::srgb_eotf is a function, so its five parameters must be restated true srgb_parametric_curve_matches_gamut_color
6 SourceProfile::{ADOBE_RGB, PROPHOTO_RGB} return None from both CICP accessors true the constructor's own doctest and source_profiles_map_onto_the_builtin_spaces
7 their chromaticities are private to gamut-color true (gamut_chromaticities is private) not gated — a visibility fact a compiler error would report if it changed
8 gamut_color::matrix::D50 is the CIE-published chromaticity, Z = 0.825105 at Y = 1 FALSE as published in round 4 (0.82521) corrected (d0e32113) and now pinned by the_two_d50_tristimuli_the_doc_names_are_what_the_constants_hold (a34093d1)
9 …and it differs from ICC's XyzNumber::D50 by 2.0e-4 in Z true (1.992e-4) the same test
10 ColourPrimaries::Unspecified names no chromaticities true colorants_d50's degenerate-arm test
11 the sampled PQ curve reproduces gamut-color's pq_eotf true sampled_pq_curve_matches_gamut_color
12 this crate's dependency list is gamut-core, gamut-color, md-5 FALSEthiserror was omitted in both lib.rs and README.md corrected (4379973e); no gate — a completeness claim about a manifest

One further member resolved false in this round's own prose: STATUS.md claimed the doctest "pins
every clause of this paragraph that is a claim about gamut-color", while claim 2 was not in it. That
is the round-4 mistake exactly — an ungated completeness claim written while repairing ungated
completeness claims. It is replaced by the list of what the doctest actually asserts (a34093d1).

The round-4 review's findings, and what each got

# Finding Disposition
F1 builtin.rs and STATUS.md both say eotf_for supplies no EOTF for the four codes; it supplies one for code 14 Reproduced. Both statements corrected and demoted to a doctest, so the claim cannot drift from the crate that owns it. The underlying disagreement (H.273 Table 3 row 14 vs eotf_for) is pre-existing in gamut-color, out of this manifest, and filed as #605, cited from the corrected doc comment.
F2 Two wrong citations, and round 4's "every other citation checks out" is itself ungated Reproduced. Both corrected against the extracted clause index (not by hand); the completeness claim is deleted and replaced by the derivation above; the two numberings are named; gate filed as #606.
F3 from_source_profile(BT2020) diverges from the bundle it names by up to 0.729, and nothing in the crate says so Reproduced, and the crate's own figures re-derived here independently against the sampled tag rather than the closed form: max divergence 0.7353 at V ≈ 0.773, 52.3× at V = 0.1, against 4.2e-6 for the sRGB control (whose transfer is its code point, so only the s15Fixed16 rounding separates them). The review's 0.729 / 4.2e-9 are the same comparison taken against the unquantized curve. Kept building it, and the reasoning is now in from_source_profile's doc, in STATUS.md and beside the test that asserts the mapping, with the measured divergence.
F4 Decision 28's stated authority is a mutation cargo-mutants does not generate Reproduced. Corrected by appending (decision 32); the frozen entry stays as written.
F5 The round-4 validation entry says coverage was not re-run "because no new module was added"; builtin.rs is the new module Reproduced. Corrected below.
F6 STATUS.md says the colorimetry "is never restated here"; three published constants are Reproduced. The sentence is narrowed and each restatement is named with why it cannot be borrowed and which test pins it (bfc41a36).
F7 The 1024-point interpolation-error claim is stated twice and ungated; measured 0.986 against a tolerance of 2 Reproduced, and worse than reported: the guarding sweep's 101 points missed the peak (mid-interval at V ≈ 0.9956). The sweep now visits every interval at both ends and its midpoint, the tolerance is one quantum, and the measured 0.9854 (grid) / 0.9861 (200 001-point sweep) are published on the constant (ce65dd7c).

Two answers the record directs rather than leaves open: §9.2.17 is now cited for the rule that
decides the range flag — the CICP tag content "shall be equivalent to the data colour space encoding
represented by this ICC profile" makes the rejected option non-conforming outright rather than
inconsistent by argument — and the same clause is why gray_with_gamma writes no cicpType at all
(288b2a74); and every constructor's Perceptual rendering intent is disclosed, with a doctest
(9baf3997).

Correction to the round-4 validation entry (decision 6 of this round's record)

The round-4 entry read "Coverage was not re-run: no new module was added." That is wrong on its own
terms: crates/gamut-icc/src/builtin.rs is the module this pull request adds. The true statement
is that coverage had not been run locally for it in any round. It is run in this round and reported
in the table below.

The breaking markers: the cost, recorded rather than rewritten (decision 9)

Two commits on this branch carry ! and a BREAKING CHANGE: footer (6592e655, 5fb03d31). Both
describe churn internal to this unreleased branch: crates/gamut-icc/src/builtin.rs does not
exist on origin/master and no commit there touches it, so builtin, gray_with_gamma,
from_cicp and from_source_profile are all introduced here and the published gamut-icc 1.0.0
exposes none of them. The markers are therefore conservative rather than required, and their cost is
a major bump: gamut-icc 1.0.0 → 2.0.0, which widens the requirement in gamut, gamut-cmm and
gamut-metadata (the three workspace dependents) and for any external consumer pinned to 1. This
run does not rewrite published history under any authority, so the footers stand as written; whether
to collapse them at merge is a human's decision, stated here so it can be taken with the cost in
view.

Validation (round 5, at head a34093d1)

Command Result
cargo test -p gamut-icc --all-features pass — 164 lib + 19 tests/oracle.rs + 7 tests/roundtrip.rs + 8 doctests, 0 failed (163 lib before this round's last commit)
__CARGO_TEST_ROOT=$(git rev-parse --show-toplevel) mise run fmt-check pass (exit 0) — the prefix is the documented nested-worktree artefact; no manifest changed for it
mise run check-tests pass — "module docs, pinned proptest seeds and oracle filenames all conform"
mise run check-commits pass — convco check: "no errors in 26 commits"
mise run lint pass (exit 0) — whole workspace and tooling/, -D warnings
mise run test pass (exit 0) — whole workspace, 202 test result: ok lines, no FAILED and no error line
mise run mutants-diff pass (exit 0) — base origin/master, 82 mutants: 67 caught, 15 unviable, 0 missed
mise run coverage pass (exit 0) — workspace 97.62 % regions / 97.66 % lines against the 80 % floor; crates/gamut-icc/src/builtin.rs, the module this pull request adds, is 98.43 % regions / 98.39 % lines / 94.23 % functions. First local coverage run for this branch in any round
RUSTDOCFLAGS="-D warnings" cargo doc -p gamut-icc --all-features --no-deps --document-private-items pass — 0 diagnostics

lint, test, mutants-diff and coverage each ran inside a MemoryMax=16G, MemorySwapMax=0
scope with CARGO_BUILD_JOBS=2. The mutation base is origin/master: this branch is not stacked on
another unmerged head. check-release-deps, check-ffi-features and check-ffi-header were not
re-run this round — no Cargo.toml and no public C-surface type changed since round 4, where they
last passed.

Decisions appended by this lane (round 5)

31. The unit of repair is the fact, and the deliverable is the derived set
    Taken:    the two shapes that have produced defects in rounds 3 and 4 - a `§X.Y` citation, and
              a doc sentence asserting what a function outside this crate returns - are both
              greppable, so both are derived mechanically, published with their commands, and
              every member resolved. 63 distinct clauses over 229 citation occurrences; 67 doc
              lines naming an outside crate, 12 of them behavioural. Four members resolved false
              and are corrected; the rest are stated as resolved rather than left unexamined
    Rejected: (a) repairing the two instances the review named - that is the round-4 mistake at a
              new radius, and it is the fifth round of the same shape; (b) deriving the set but
              publishing only the failures, which leaves a reader unable to tell an examined
              member from an unexamined one; (c) writing a gate for either shape from a
              single-crate entry - `pdftotext` is not provisioned in `mise.toml`, so the citation
              gate is filed (#606) rather than wired
    Reverses: the round-4 scope, which was the crate's files rather than the crate's facts
32. Decision 28's authority is corrected by appending, not by editing (F4)
    Taken:    decision 28 justified sweeping the whole complement of `FULL_RANGE` by "an ordering
              mutation of it (`<`, `>`)". cargo-mutants generates no such mutant for `!=` - it
              emits only `replace != with ==`. The sweep is still right, for the reason the
              reviewer proved by hand: a single sample at `0` cannot distinguish `!= 1` from
              `< 1`, so a guard that admits 2..=255 passes such a test, and only the complement
              sees it. The frozen entry stays as written; this correction sits beside it. The
              principle that resolves the tension with decision 27 - which *rejects* a test
              because the mutation set contains nothing that would produce the defect - is that
              **the mutation set is evidence about what the gate can see, never the whole reason
              a test exists or does not.** Decision 27 is sound because the defect is removed
              structurally (one binding, so the two writes cannot disagree), not because
              cargo-mutants is silent about it
    Rejected: editing decision 28 in place, which rewrites a record a human is meant to read as
              written; dropping the sweep back to a single sample, which the hand proof refutes
    Reverses: nothing; it corrects the stated authority of decision 28 and leaves its behaviour
33. A figure a doc quotes from another crate is gated where the sentence lives
    Taken:    the two quantitative cross-crate claims the derivation found ungated - the 20.3 %
              by which `eotf_for`'s curve for code point 14 exceeds the one this module writes,
              and the two D50 tristimuli - are asserted where they are stated: the first joins
              the module doctest, the second is an inline drift guard (`XyzNumber::D50` and
              `gamut_color::matrix::D50` are both readable there and one of the two figures is
              private arithmetic). Each bound is half the last digit the prose states, so the
              gate is exactly as tight as the claim
    Rejected: (a) leaving them resolved-but-ungated, which is what round 4 did with the sentence
              that then drifted; (b) deleting the figures from the prose, which removes the fact
              a reader needs in order to see why the crate declines to borrow the curve;
              (c) a tolerance loose enough to survive a real change, which is a guard on nothing
    Reverses: delete the two assertions

Issues filed (round 5)

No existing issue was edited, commented on, labelled or closed. #537, #555, #557 and #586#589
remain open and unchanged.

Unresolved review notes (round 5)

Residual risk, stated rather than closed. Table N references (39 occurrences in this crate) are
a citation shape the shape-1 derivation does not resolve — the index built here is of clause
headings, not table captions. Members 7 and 12 of the shape-2 set are resolved but ungated: a
visibility fact and a manifest completeness claim, neither of which has anything in this crate to
assert against. Both are named so the next reader starts from the set rather than from the last
defect.

No human approved this repair. This remains an unattended automated run; the record above is what
a human reads afterwards.

Add `IccProfile::builtin`, `gray_with_gamma`, `from_cicp` and
`from_source_profile`: spec-valid v4 matrix/TRC display profiles for the
colour spaces gamut-color already carries colorimetry for, and for the H.273
code-point triple AVIF/HEIC/JXL usually signal instead of embedding a profile.

gamut-icc gains a normal dependency on gamut-color. The constructors need
gamut-color's colorimetry and gamut-icc's serializer, and only one of the two
can own that edge: gamut-color is the primitive (fan-in 8) with no need of a
serializer, so pointing it the other way would invert the layering.

Primaries, white point and transfer are read from gamut-color rather than
restated, so the buildable set is exactly what it can express on the two CICP
axes. Adobe RGB and ProPhoto RGB have no code point on either axis and their
chromaticities are private to gamut-color, so they are declined rather than
approximated.
Bradford-adapting to gamut-color's CIE D50 chromaticity while writing ICC's
mandated D50 encoding as the mediaWhitePointTag left the colorants disagreeing
with the white point they sum to by 2e-4 in Z, and put the disagreement with
lcms2's own colorants at 1.8e-4. Deriving the adaptation target from
`XyzNumber::D50` instead brings both inside four s15Fixed16 quanta.
…hoices

STATUS gains the dependency direction and the evidence for it, the buildable
set and why Adobe RGB / ProPhoto are declined, the parametric-vs-sampled
tone-curve choice per transfer with the clause that decides it, the PCS white
point, and the lcms2 acceptance criteria.
The explicit `Bt709 | Hlg | Unspecified => None` arm in `Trc::from_cicp` sat
in front of a `_ => None` wildcard that `#[non_exhaustive]` makes mandatory, so
deleting it changed no behaviour: an equivalent mutant no test can kill. Fold it
into the wildcard's comment, which still names the three code points and why
gamut-color supplies no curve for them.

`unrepresentable_signalling_is_rejected` already pins BT.709 and HLG to `None`
through the public entry point, so the behaviour stays covered.
`from_cicp` gave H.273 transfer code point 14 the PQ curve. Table 3 defines
14 as the BT.709 curve — its own remark calls it "functionally the same as
the values 1, 6 and 15" — so a BT.2020 10-bit signal produced an `rTRC`
reading 0.009224 at signal 0.5 where the spec gives 0.259719, a factor of
28. The same match rejected code point 1 outright while accepting 14.

Codes 1, 6, 14 and 15 are one curve, and its inverse is exactly
`parametricCurveType` function type 3 (ICC.1:2022 §10.18), so all four are
now encoded from Table 3's own α = 1 + 5.5β and β = 0.018053968510807. The
transfer axis is keyed on the raw code point rather than on
`gamut_color::cicp::TransferCharacteristics`, because what an ICC tag can
encode is not what gamut-color can evaluate: codes 6 and 15 have no variant
there and none of the four has an EOTF, yet all four are exactly encodable.

Three further conformance fixes on the same surface:

* §10.3 requires `MatrixCoefficients` to be 0 when the data colour space is
  RGB, so the caller's value — 1, 5, 6 or 9 in a typical AVIF or HEIC
  `nclx` box — is no longer written into the `cicpType` tag, and
  `VideoFullRangeFlag` is normalized to 1 to match the full-scale RGB the
  profile's matrix and curves are defined over. Nothing is lost: both
  describe an encoding the caller de-matrixes before the profile applies,
  and both stay in the container signalling a decoder reads them from.
* `gray_with_gamma` declines a gamma no `kTRC` can carry — non-finite,
  non-positive, or at or above the 32 768 `s15Fixed16` cannot hold, which
  `S15Fixed16::from_f64` would otherwise saturate silently.
* `colorants_d50` returns the option instead of falling back to the
  identity, so primaries with no chromaticities are declined structurally
  rather than by naming `Unspecified`, and no profile can claim the PCS
  axes as its colorants.

`constructors_are_byte_deterministic` compared `first.ok() == second.ok()`,
so two serialization failures compared equal and it passed vacuously.

BREAKING CHANGE: `IccProfile::builtin` and `IccProfile::gray_with_gamma`
now return `Option<IccProfile>`. `IccProfile::from_cicp` no longer records
the caller's `MatrixCoefficients` or `VideoFullRangeFlag`, and builds a
BT.709 tone curve — not a PQ one — for transfer code point 14.
…imit

STATUS.md's tone-curve table codified the defect the previous commit fixed,
listing a sampled PQ `curveType` as the encoding for transfer code points
"16, 14". It now names the BT.709 family separately, states which code
points are declined and why, and records that `from_cicp` builds from two
of the four H.273 fields because §10.3 fixes the other two.

The dependency paragraph claimed gamut-color has fan-in 8. It is 10 before
this crate's edge and 11 with it: gamut, av1, av2, avif, cmm, dng, heic,
jpeg, vvc, webp. The frozen record elsewhere stands as written — this is
new prose, and new prose must not restate a figure known to be wrong.

Also records, as a known limit, that the BT.2100 PQ profile is
peak-referred: its curve is normalized to ST 2084's own 10 000 cd/m² peak,
so a diffuse-white signal evaluates to roughly 0.02 media-relative and a
CMM renders such content through it near black. That is correct for a
peak-referred profile and may still surprise a caller embedding it, so the
alternative normalization is tracked rather than taken silently.

`AGENTS.md`'s architecture table listed gamut-icc with no dependency edge.
This branch adds `gamut-icc ← color`.
`gray_with_gamma` is public but `GAMMA_ENCODING_LIMIT` is not, so rustdoc
reported "public documentation for `gray_with_gamma` links to private item
GAMMA_ENCODING_LIMIT" and rendered no link at all. The magnitude is short
enough to state.
`gray_with_gamma` refused a gamma at or above 32 768 because
`S15Fixed16::from_f64` saturates there, but the same conversion
degenerates at the low end too: it rounds, so any gamma below
0.5 / 65 536 = 7.62939453125e-6 is written as raw 0. `Y = X^0` maps
every input, black included, to white — exactly the profile the doc
comment gives as the reason for refusing a literal 0.0 — and the
constructor accepted it, serialized it, and `validate` reported it
clean. Verified by re-opening the written bytes: gamma 1e-6 gave a
`kTRC` of raw 0 and a transfer that is identically white.

The guard now tests the encoding rather than the value, at both ends:
`(gamma * 65 536).round()` must land in `1 ..= i32::MAX`, which is the
same arithmetic `from_f64` performs, so the two cannot disagree about
where rounding stops carrying the caller's number.

That also corrects where the top bound sits. Saturation does not begin
at 32 768 but at (2^31 − 0.5) / 65 536 = 32 767.999992370605468750,
half a quantum above the largest representable value; gammas in between
were accepted and silently written as 32 767.99998474121.

The boundary test now reads each accepted gamma back out of the tag it
was written into, so a value that survives the guard but not the
encoding fails it.
`Trc::for_code_point` maps seven H.273 code points to a tone curve and
declines the rest, but every test of it started from a code point it
already believed in. That catches a member sent to the wrong curve; it
cannot catch a non-member let in. Adding an eighth arm — one H.273
Table 3 gives a different curve, with its own toe and its own constants
— left all 161 tests passing, and no mutation of a match expression
produces an extra arm, so the mutation gate is blind to it as well.

The new sweep asserts `None` for every byte in 0..=255 outside the
accepted set, with that set restated rather than read back from the
function under test.
ICC.1:2022 §10.3 carries one `shall` about a `cicpType` tag in an RGB
profile: `MatrixCoefficients` shall be 0. It says nothing of the kind
about `VideoFullRangeFlag` — only that it "is often 1" for RGB — and
its own RGB examples include 1-1-0-0 and 9-16-0-0 with the flag at
zero. This module wrote both fields and documented both as §10.3
requirements, which is true of one and false of the other.

Fixing the flag at 1 is a deliberate normalisation: the colorants, the
`chad` and the tone curves written alongside it are all defined over
full-scale RGB, so a profile of this shape signalling narrow range
would describe a scaling it does not perform. That is now documented as
this crate's choice, together with its consequence — the caller's flag
is discarded, not preserved, and a caller that needs the original value
must read it from the container signalling it came from.

Also repairs a doc link left behind by an earlier rename. Both items
are private, so `cargo doc` without `--document-private-items` never
resolved it and reported the crate clean.
H.273 Table 3 defines transfer code points 1, 6, 14 and 15 as an
opto-electronic function, and an ICC tone curve encodes signal to
light, so this module writes the exact inverse. That literal reading
stays: it is what the reference implementations write, and changing it
would make gamut-icc disagree with every other tool that reads the same
signalling.

It is not the only reading H.273 sanctions. §8.2 NOTE 1 points at
BT.1886-0 as the corresponding electro-optical function for flat-panel
displays, which at reference black zero is a pure gamma of 2.4 — and
every profile built here is a display-class profile, so that reading
has a claim. The two are far apart, and the docs now say so with
numbers: at mid-grey the literal reading gives Y = 0.259719 against
BT.1886's 0.189465, a factor of 1.371.

The same table records that two code points this module already
encodes, 1 and 13, differ by 0.0457 at mid-grey — 21 % of the sRGB
value — so a caller treating "BT.709 primaries" and "sRGB" as
interchangeable sees that much shift from the transfer alone.

Offering the BT.1886 reading as an option is filed separately.
`from_cicp` rewrote `VideoFullRangeFlag` to 1 and called the loss
harmless, on the reading that the flag — like `MatrixCoefficients` beside
it — describes a luma-chroma encoding the caller de-matrixes before an
RGB profile applies. ICC.1:2022 §10.3's own RGB examples refute that.
`1-1-0-0` is listed as "RGB narrow range representation specified in
Recommendation ITU-R BT.709-6, Item 3.4", and its `MatrixCoefficients`
is already zero: the narrow range is on the RGB samples themselves, and
no de-matrixing removes it.

So a caller holding genuine narrow-range signalling was not merely
losing a piece of metadata; it was handed a profile whose colorants,
`chad` and tone curves are all defined over full-scale RGB, and its
colour rendered wrongly. The flag is now a precondition rather than a
rewrite: anything but 1 is declined, which is what this module already
does for primaries it has no chromaticities for and for a transfer with
no ICC tone curve. Narrow-range callers scale to full range and pass 1.

`normalized_cicp` becomes `rgb_conforming_cicp` — it now carries only
the §10.3 `shall`, which is the one field that is still rewritten — and
the accepted flag is stated once as `FULL_RANGE`, read by both the tag
`cicp_of` writes and the precondition `from_cicp` enforces, so the two
cannot drift.

The complement is swept over all 256 flag bytes rather than sampled at
0: the guard is a comparison, and an ordering mutation of it leaves one
side of 1 admitted.

BREAKING CHANGE: `IccProfile::from_cicp` returns `None` for a `Cicp`
whose `video_full_range_flag` is not 1, where it previously built a
full-range profile from it.
`gray_with_gamma` wrote the `profileDescriptionTag` from the value the
caller passed and the `kTRC` from that value encoded as `s15Fixed16`, so
the two disagreed by up to half a quantum — and at the smallest accepted
gamma, `0.5 / 65 536`, the description read one number while the tag
carried exactly twice it. A profile contradicting its own tag is the
same class of defect this branch has been closing: one fact written down
in two places.

The guard now returns the encoded parameter instead of a bool, and
`gray_with_gamma` shadows its argument with it. The requested value is
out of scope from that line on, so nothing below can be written from it
— the description names what the tag holds because there is nothing else
left to name. `gray_with_gamma(2.2)` is therefore described as `Grey
gamma 2.1999969482421875`, which is the parameter a reader inspecting
the tag will find.

Two statements of the encoding bound are corrected while they are in
hand. The saturating parameter's value was written truncated
(`32 767.999 984 741 21`) beside a neighbour written exact, and is now
exact: `32 767.999 984 741 210 937 5`. And the two ends of the domain
are named for what they are — the bottom refuses a degenerate all-white
curve, while the top is fidelity only, since the first rejected gamma
and the last accepted one are one f64 ulp apart (2^-38) and evaluate
identically. It is kept for the symmetry of a closed domain, and now
says so instead of implying both ends refuse harm.

STATUS.md carried the superseded bound — "non-finite, non-positive, or
≥ 32 768" — which was wrong in both directions once the guard moved onto
the encoding: it refused values the constructor accepts and admitted the
whole low interval the constructor refuses.
…lues

Two documentation statements in this module were about something other
than what they named.

`cicp_byte` cited ICC.1:2022 §10.7 for the four-byte `cicpType` layout.
§10.7 is `dataType`; `cicpType` is §10.3 — the clause this branch spent
a round reading correctly for `MatrixCoefficients` and
`VideoFullRangeFlag`. Every other citation in the module checks out.

The mid-grey comparison table labelled a row "what this module writes"
but carried the value of the exact closed form, which is not what the
tag holds: the `parametricCurveType` parameters are rounded to
`s15Fixed16` first. Both rows this module actually writes are now quoted
as the tag evaluates — 0.259721 for the BT.709-family inverse OETF and
0.214045 for sRGB, against 0.259719 and 0.214041 before rounding — and
the BT.1886 row is labelled as the closed form, because no tag here
holds it. The divergence figures the paragraph draws from the table
(1.371x, 0.0703, 0.0457, 21 %) are unchanged at their stated precision.
`§9.2.10` is BToD1Tag and `§9.2.35` is metadataTag in the vendored
ICC.1:2022; the MatrixColumn tags are `§9.2.46`/`§9.2.31`/`§9.2.4` and
chromaticAdaptationTag is `§9.2.15`. Every `§`-citation in the crate was
resolved against a clause index extracted from the vendored PDF rather
than read by hand, and these two were the only members that resolved
false. Names the numbering used, since the standard's own §8.4.3
cross-references disagree with its headings for two of the four.
The module docs and STATUS.md both said `gamut_color::transfer::eotf_for`
supplies no EOTF for any of H.273's four BT.709-family code points. It
supplies one for code point 14: `bt2020_pq_to_sdr`, a PQ EOTF plus a tone
map, which at `V = 0.5` evaluates 20.3 % above the curve Table 3 gives
that code point. The sentence was the stated justification for keying
transfers on the raw code point, so a reader who believed it would key on
`TransferCharacteristics` and write a tone map into an ICC tag.

Both statements now say what the crate returns, and the claim itself is a
doctest beside them, so it cannot drift from the crate it describes. That
the two crates read code point 14 differently is gamut-color's question
and is filed as #605, not fixed here.

Refs #605
…oids

`pcs_d50_chromaticity` gave the CIE-published D50's Z as 0.82521. Derived
from `gamut_color::matrix::D50` — the chromaticity the sentence names —
it is 0.825105, against ICC's 0.824905. The gap the paragraph is about is
unchanged at 2.0e-4; only the number naming the other crate's constant was
wrong, and it is a third statement of an external value this crate
restated without checking.
`SAMPLED_TRC_POINTS` claims 1024 points hold the linear-interpolation
error below one `uInt16` quantum, and the guarding test admitted two — so
the claim was stated twice and gated nowhere. Its 101-point sweep also
missed the peak, which sits mid-interval at `V` about 0.9956: it measured
0.962 quanta where the true worst case is 0.986.

The sweep now visits every interval at both ends and at its midpoint, and
the tolerance is one quantum. Measured 0.9854 on that grid, 0.9861 over a
200 001-point sweep; both figures are published on the constant.
`from_source_profile(SourceProfile::BT2020)` builds a peak-referred ST
2084 profile, while the bundle's own `eotf` is that curve plus a Reinhard
tone map to SDR: measured up to 0.735 absolute apart over the signal
domain, 52x at V = 0.1, against 4.2e-6 for the sRGB bundle. The reasoning
for building it anyway was on the pull request and nowhere a consumer
reads, so it is now on the constructor, in STATUS.md and beside the test
that asserts the mapping, with the measured divergence and the
distinction that carries it: a sample range is a property of the samples,
a tone map is a rendering choice.
Both the module docs and STATUS.md said the colorimetry is never written
down twice. Three published constants are written here: H.273's beta, the
IEC 61966-2-1 parametric set, and the PCS D50. All three are correctly
gated, so the handling was right and only the blanket sentence was wrong
- but a sentence that overstates is what four rounds of review have been
about. Each restatement is now named with why it cannot be borrowed and
which test pins it.
The record justified declining a narrow-range triple by argument: the tag
would contradict the colorants beside it. ICC.1:2022 §9.2.17 settles it
outright - the CICP tag content "shall be equivalent to the data colour
space encoding represented by this ICC profile" - so carrying the flag
through is non-conforming, not merely inconsistent. The same clause is
why `gray_with_gamma` writes no `cicpType`: it is permitted only for RGB,
YCbCr and XYZ, which read as an omission until it is cited.
`Perceptual` is `ProfileHeader::new`'s default and a surprising one on a
colorimetrically exact matrix/TRC profile, where a caller most likely
means media-relative colorimetric. It costs one section to say so, and a
doctest to keep the statement true; the field is a preference a CMM may
override, so nothing about the colorimetry turns on it.
Both the crate docs and the README enumerated the dependency list and
both omitted `thiserror`, which `error.rs` derives its error type from.
Found by the same sweep that derived the cross-crate claims, and false
for the same reason: a list that says "only" is a completeness claim.
The derivation of every doc sentence asserting what a function outside
this crate returns left two members ungated: the 20.3 % by which
`gamut_color::transfer::eotf_for`'s curve for code point 14 exceeds the
one this module writes, and the tristimuli separating
`gamut_color::matrix::D50` from the PCS D50. Both resolve true — 20.298 %
and a 1.99e-4 gap — and both were stated twice with nothing to hold them.

The first joins the doctest that already pins the sentences beside it;
the second is a drift guard on the two constants, since the arithmetic it
describes is what decides the adaptation target. STATUS.md's claim that
the doctest pins "every clause" of that paragraph was itself an ungated
completeness claim, and is replaced by the list of what it asserts.
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