A reviewer named a defect in my RAW developer, in one sentence, correctly. Pulling the Highlights slider on a blown sunrise, he said, lost the transition and kept the blown bit — which is the wrong way round. He was right, and the photographer's version of what was wrong and what shipped (including a few technical bits) is a separate post.

This is the other thing that happened. Going after that defect took some intense effort, and only a small part of that was the fix. Most of it was discovering that the numbers I had been tuning by hand for the last few months were not the numbers the app was applying, that a second control had been running backwards past half travel without anyone noticing, and that a good fraction of the instruments I used to establish any of this were themselves wrong.

None of them failed in a way you would notice. They passed, or said nothing, or returned a perfectly plausible number. That is the actual subject here, and the rule I've ended up with is one sentence: assume a gate is theatre until you have broken it on purpose, and when you compare two numbers, print both.


The constants never described the render

Core Image's CIToneCurve applies its curve in an sRGB-encoded domain. Photo Developer's working space is Display P3, which carries that same transfer. So every call to that filter wrapped my curve in an extra encode/decode round trip that nobody put there and nothing documents as a hazard.

It takes ten lines to demonstrate, and you can run the equivalent on any platform where a filter's domain and your working space disagree. Build a curve that reads y = x/2 at every single control point, so that interpolation cannot possibly be the explanation, and ask what it delivers at white. It delivers 0.2140. And srgbToLinear(linearToSrgb(x)/2) predicts the entire ramp to four decimal places.

Three things follow, and they're all mine. The control-point tables in the source — tuned by eye, commented, argued over — were not describing the transfer that was actually delivered. The calibration note explaining that this app's −100 lands near another developer's −200, which I had written up as a considered decision about strength, was compensating for a distorted domain. And a zone leak I had spent a week trying to fix by nudging control points was the domain, not the points.

Here is the part that closes the loop. The five control points sit at 0, 0.25, 0.5, 0.75 and 1.0 in the filter's domain. Run them through that round trip and the fourth knot doesn't land at 0.75 of what you see on screen. It lands at 0.522. Above that there is one free value left, the endpoint at white. There is no knot in the highlights to aim with, and there never had been — which is why no amount of fiddling with the points was ever going to confine the pull.

Nothing here was a bug. The filter does exactly what its documentation says. The working space was chosen for good reasons and I'd choose it again. If you are building on someone else's filter, the hazard isn't the filter, it's the seam: an operator specified in one encoding, composed into a working space carrying another, will do precisely what it promises and not at all what you meant, and it will never warn you, because nothing is wrong.

Tone EQ ran backwards past −51

The Tone EQ panel had a worse problem, and a different one. Past roughly −51 on any zone, the delivered transfer was not monotonic — a brighter input rendered darker. At −100 on the Highlights zone the map is a V, with its minimum near display 0.72.

in    0.4667  0.5000  0.5500  0.6000  0.6500  0.7000  0.7500  0.8000  0.9000  1.0000
out   0.4667  0.4905  0.4835  0.4499  0.4169  0.3989  0.4015  0.4248  0.4809  0.5371

A hundred and thirty-six code values of input arrive as thirty-five. White renders as mid-grey. A light and a highlight land on the same output, which means they stop being distinguishable in the file you export.

The cause is geometric, and it's the kind of thing you can check against your own panel in about five minutes. The zone weights are cosine bells of half-width W spaced W apart across an EV scale S, which makes them an exact partition of unity, which makes the delivered slope between two adjacent zone centres 1 + w′·(E_upper − E_lower)/S, with |w′| bounded by π/(2W). The transfer inverts precisely when

E_upper − E_lower  <  −2SW/π  =  −1.019 EV

which is about 51 slider points.

I hadn't seen a single consequence of that before writing it down. It was never a −100 phenomenon: half the negative travel on every zone was already folding. Any adjacent pair more than about 51 apart inverts, wherever on the scale it sits. And widening the bells to cure the fold at ±2 EV would need them over three EV wide, which makes the other standing complaint about that panel — that it touches the whole frame — strictly worse. No recalibration could be both confined and monotone, which is why the fix had to leave the bell family entirely for a hinge that is monotone by construction rather than by tuning. Nothing about that is specific to my panel: any zone-based tone control built from overlapping weights that sum to one has a version of that bound, set by how wide the zones are relative to the scale they sit on.

The algebra is the least interesting part of it, though. The defect had already been reported, by me, in the plainest possible language: this control "pulls the whole frame flat and milky, too strong." That sentence is the table above, read by eye, months before anyone wrote down the inequality. The arithmetic explained a complaint that already existed rather than discovering a new one — which is the useful order, because a complaint you can see is one you can check a fix against.

The bound also got derived twice, independently and at the same time, once as a band-span argument and once as the adjacent-pair inequality above. Two routes to the same number beats one route checked twice, because this sort of algebra usually goes wrong at the premise rather than at a sign, and a bad premise rarely survives the second route.

And there was a picture of the problem sitting inside the app. The zone-preview overlay and the targeted-adjustment cursor both classify zones by display luminance, which is the geometry you'd expect and the geometry the interface promises. The render classified in EV space. The overlay was drawing one geometry over an engine delivering another, which is a thing you can look at directly once you know to.

Why some sliders felt weak

In one sitting I wrote down four impressions of four different controls. Negative Shadows behaves as expected but positive is very subtle. The reverse for Whites, where the negative direction hazes the frame. Negative Blacks is right and positive does almost nothing. And Darks and Shadows on the Tone EQ panel feel more controlled than their neighbours.

All four are the convexity of the sRGB transfer, showing up in two forms.

In its EV form, delivered code values per EV are 73.65·(v + 0.055): 9.7 at the shadow zone's centre against 77.7 at white. An eight-fold spread of authority from identical settings, plus a uniform 1.8× asymmetry between pushing up and pulling down. That is "Darks and Shadows feel more controlled," measured.

In its control-point form, the delivered gain per unit of curve-point movement runs 0.08, 0.40, 0.93, 1.56, 2.27 across the knots. A move at the midpoint knot delivers twenty-eight times what the same move delivers at black. The positive branches of those controls had never had their midpoint coefficients raised alongside their negative counterparts, which is exactly why they read as doing nothing. Mirroring a negative coefficient onto its positive branch does not mirror the behaviour, on any curve whose slope varies by a factor of twenty-eight across its own knots.

The annoying part is that the fix was already in the codebase. Back in the spring, one zone of one panel had been halved in the positive direction, with a comment naming the exact reason it needed to be. Four months later I was still explaining the other four to myself as if they were a mystery. The correction was sitting in the file, applied once, for a reason that held everywhere.

What happens at white

That question had four independent answers living inside one tonal pipeline. Two endpoint terms were dead, because min(1.0, 1.0 + positive) can only ever be 1.0 — in one case sitting directly under a comment claiming it was live. One pushed the white point above the ceiling and silently clipped roughly the top thirty-five code values. And a zone normalisation pinned the top weight at exactly 1, applying a constant lift right up to white and clipping about sixty-six.

Two of the four accidentally produced the behaviour that was wanted. One destroyed detail nobody had noticed losing. The fifth answer, the deliberate one, is now written at the constants: pulling white down is compression into headroom that exists, and pushing white up has no headroom above 1.0 to compress into. That's why the shipped control moves white by −40 going down and by exactly 0 going up. It is a ceiling rather than a tuning choice, and writing it down once replaced all four accidents.

Opinion rather than measurement, but: a pipeline that grows one control at a time seems to answer the same structural question separately every time it comes up, and the answers don't agree.

Where the measurements went wrong

All of the above was found with instruments — probes, metrics, gates, test suites — and a good fraction of those instruments were wrong.

  • A mask overlay drawn in green, adjudicating a green colour-range mask. It made a false claim look conclusive across two rounds of screenshots. Re-shot in white, the conclusion reversed.
  • A luma-only metric ranking a repair whose defect was chromatic. It scored the visibly bad variant best — because the artefact was invisible to it.
  • A band comparison that re-binned each variant by its own values, so it could not detect a shift between variants at all. It could only ever report that both variants resembled themselves.
  • A clamp sweep measured on the output, blind to every pixel already pinned at 255 — which is to say, blind to exactly the population the feature existed for. It reversed a design conclusion, not just a number.
  • A test suite that reported ** TEST SUCCEEDED ** in 1.3 seconds having executed nothing, because the filter didn't match the suite's identifier.

Two of them are worth more than a line each, because both are mistakes I expect to make again.

The first was an audit that carefully enumerated thirteen fields, checked all thirteen against a set of preset packs, and concluded the packs were unaffected by the retuning. That was a conclusion about the list, not about the packs. Tonal values also live as JSON nested inside a local-adjustment field, which wasn't on it — and an entire pack whose identity is local tonal bands re-renders. A schema you enumerate by hand describes your memory of the schema.

The second is my favourite thing that happened all month. In the test suite written specifically to avoid the blind-metric failure two bullets up, the fixture was built with CIImage(color:). That clamps at 1.0. The suite's entire subject was values above 1.0. It was evaluating f(1.0) three times under three labels, comparing identity against identity, and passing.

One more belongs here even though the instrument itself was fine. A kernel had a 9× discontinuity in its first derivative at display 0.807, which is a textbook Mach-band generator. Every fixture that had passed everything was stone, foliage and close detail. The defect needs a smooth bright ramp to exist on screen, and no textured photograph can provide one. There the measurements were fine, the metrics were fine, and the test photographs were the blind spot.

The time the measurement was pessimistic

Every story above is an instrument being too generous or blind, and a post that only tells that story is itself a biased instrument. So here is the one that went the other way.

Working on the next release's highlight reconstruction, a spread metric on the recovered core predicted a near-flat grey patch — a standard deviation of 0.61 code values, which is nothing, which means don't bother. The render showed a building facade, a window, blue shutters and balcony detail coming out of what had been a white void. The metric's population was drawn too narrowly and missed the band carrying most of the visible change. The measurement said don't bother; the eye said look at that.

Which is the same failure as all the others, pointing the opposite way, and worth more than the reassurance it costs. A number you were relieved to see is not audited by anyone.

What I do about it now

Listing bad measurements is the easy half, and on its own it's just confession. The later part of this work ran on something more useful, and it's reproducible from the commit order, which is what makes it a receipt rather than a claim.

Every calibration landed as two commits. First the predicted transfer table — what the change should deliver, row by row, including what those rows would read if the hypothesis were wrong. Then the measured rows. Predictions before runs, so that "close enough" can't be decided after seeing the answer.

One of those pre-registered rows was wrong, which is the thing that makes the ledger worth keeping. A transcription slip: one row computed with the inputs from the row above it, predicting +0.1 where the arithmetic says +3.1. The render agreed with the arithmetic and not with the prediction. The run caught me rather than the kernel, and the log scores it as a miss. A ledger that only ever records hits isn't worth the disk it sits on.

And the sabotage controls stopped being an afternoon's discipline and became test architecture. The monotonicity scan ships with a deliberately backward-dipping spline that it has to flag on every run. The chroma gate runs its positive control first and fails with "fixture is chroma-inert" if the per-channel arm can't be made to drift at all. Both of those exist because a green test that cannot go red is decoration.

A day of contaminated renders

Here is what that discipline is for. A prototype override belonging to the next release's work was left engaged in the app's sandbox container. For about a day, every render on the machine went through an unintended decode stage — including a day of eye passes, on a work item whose entire subject is how blown highlights render.

It was caught twice on the same afternoon, and the catch that mattered was an accident of housekeeping. An A/B export set had a control bucket: frames staged precisely because their sidecars contained none of the changed fields, so they had nothing to test and were expected back bit-identical. None of the thirty-eight read zero. The quietest control still moved 23% of its pixels by a code value or more, and the worst read a mean of 7.56 with a maximum of 182.

The frames with nothing to test are the ones that caught it. A regression set built only from photographs that exercise the changed code would have shown differences everywhere, and every one of those differences would have read as the fix working. Once the key was deleted and the set re-exported, thirty-seven of thirty-seven controls came back bit-identical. The thirty-eighth turned out to carry tonal edits nested inside a local adjustment — which is the field-list blindness from the section above, one level of nesting down, twice in one afternoon.

That control bucket was boring by design and I nearly didn't stage it. It was the only thing in the set capable of catching this.

The change I didn't make

Everything above is a measurement being blind or silent. This one went the other way, and it's the one I can't automate.

A design review proposed that two controls feeding the same operator should sum rather than compose. The argument was internally correct, carefully made, and built. The premise underneath it was wrong: the two controls have different jobs, and collapsing them had happened as a side effect of an aiming fix, without anyone deciding it.

What caught it was reading the proposal and thinking that sounds wrong. Nothing flagged. Then the sidecars settled it: roughly seven in ten of the affected frames drive both sliders, and a smaller group drives them in opposite directions, so a shared operator would have partially undone edits that had been made deliberately, on files already sitting on disk.

The analysis was right and the question was wrong, and what settled it was reading my own edits.

What it cost, and what it bought

The defect that started all of this was in my own notes in April, filed as an extreme-exposure curiosity and dismissed as marginal. It sat there for four months, through every green suite in this post.

I don't think the lesson is more tests. It's that a test which has never been broken on purpose is a claim about intent, not about behaviour, and the difference doesn't show up until something you shipped is being described back to you by a stranger who has no reason to be kind about it.

Everything was green the whole time.