Prove that zooming to 1:1 reads the source, and only then tag FR-DSP-5

FR-DSP-5 has been satisfied for some time and untagged. `Framing::view` shrinks
the sampled region while the render target keeps its size, so a zoom raises the
resolution the pipeline works at rather than magnifying pixels already drawn —
there is no second full-resolution path because the zoom is that path.

Tagging it on that basis alone is what §7 of the display spec warns against:
traceability counts a requirement as covered when a comment names it, and
checks nothing about the code under the tag. So the tag goes on tests instead,
and the tests are built so that removing the behaviour breaks them. Both
failure modes were checked by hand: deleting the view from `visible_rect`
leaves the 1:1 render flat, and dropping only its offset leaves the render
exactly inverted. The assertion message names both, since those are the two
ways this can go wrong and the numbers alone do not say which.

The fixture is one-pixel black-and-white stripes — the highest frequency an
image can hold, and precisely what a proxy discards. A 1024 px source in a
128 px viewport reads source column `8x + 4` for every output column `x`, all
the same parity, so the fit render comes out uniform; that is asserted first,
because a 1:1 render showing detail proves nothing unless the proxy is known to
carry none. What remains is an equality against the source bytes rather than a
claim that something looks sharper.

The third test takes the arbitrary zoom the requirement also names, and pins
`RenderScale` beside the pixels: a zoom that moved the pixels but not the scale
would sharpen at the wrong radius, which stays invisible until somebody
compares a preview against an export.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:05:05 +02:00
co-authored by Claude Opus 5
parent 13deaa2fbb
commit 772a69711d
+287
View File
@@ -0,0 +1,287 @@
//! TRACES: FR-DSP-5
//! Zooming to 1:1 samples the source, pixel for pixel.
//!
//! FR-DSP-5: *"Fit, 1:1, and arbitrary zoom levels. At 1:1 and above, the
//! pipeline operates on the visible crop at full source resolution."*
//!
//! `Framing::view` shrinks the sampled region while the render target keeps its
//! size, so zooming *raises* the resolution the pipeline works at rather than
//! magnifying pixels it has already drawn. There is no second full-resolution
//! code path — the zoom is the full-resolution path — which is why the
//! requirement has been satisfied for some time without anyone tagging it.
//!
//! `docs/display-and-extension.md` §7 is the reason this file exists rather
//! than a tag on `framing.rs`: a requirement counts as covered when a `TRACES`
//! comment names it, and nothing checks that the code under the tag does the
//! thing. `FR-DEV-8` is tagged against plumbing a future operation would use.
//! So the rule that document sets is that a requirement is closed by **a test
//! that would fail if the behaviour were removed**, and these are written to
//! fail in exactly that case: delete the view from `Framing::visible_rect` and
//! the 1:1 render collapses into the fit render, which
//! [`a_proxy_cannot_resolve_the_finest_detail_in_the_source`] establishes
//! carries none of the information the 1:1 render reproduces.
//!
//! # Why the fixture is alternating columns
//!
//! Because it makes "sampled at source resolution" a *pixel* assertion rather
//! than a "something got sharper" one.
//!
//! The source is one pixel black, one pixel white, all the way across. That is
//! the highest spatial frequency the image can hold, and it is exactly what a
//! proxy render throws away: a 1024-wide source in a 128-wide viewport maps
//! output column `x` to source column `8x + 4`, every one of which has the same
//! parity, so the whole proxy comes out flat. No amount of resampling that flat
//! image recovers the stripes. If the 1:1 render shows them — and shows them in
//! the right phase, from the right place in the source — then it read the
//! source and did not magnify the proxy. There is no third explanation.
//!
//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it
//! non-linear, so the fused shader decodes sRGB before the chain and re-encodes
//! after it. Bytes 0 and 255 are fixed points of that round trip, which is why
//! the pattern is black and white and why the comparison can be for equality
//! rather than within a tolerance.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::{CropRect, EditGraph};
/// A power of two, so every view rect below is exact in binary32 and the
/// mapping from output column to source column is exact arithmetic rather than
/// something that happens to round the right way.
const SOURCE: u32 = 1024;
/// The viewport. `SOURCE / RENDER` is 8, so a fit render steps eight source
/// columns per output column — four full periods of the pattern.
const RENDER: u32 = 128;
/// Where the 1:1 window sits in the source. **Odd on both axes on purpose**: a
/// view that honoured `width` but dropped `x` would land on the opposite phase
/// of the stripes and produce an exactly inverted image, which is the most
/// likely way for this to be subtly wrong and the one an assertion about
/// "contrast" or "variance" would sail straight past.
const WINDOW: (u32, u32) = (301, 157);
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip rather
// than fail, exactly as the rest of this crate's device tests do.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// Black and white alternating every column: the finest detail an image can
/// carry.
fn stripes(ctx: &GpuContext) -> DemosaicedImage {
let data: Vec<u8> = (0..SOURCE * SOURCE)
.flat_map(|i| {
let v = if (i % SOURCE).is_multiple_of(2) { 0u8 } else { 255 };
[v, v, v, 255]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, SOURCE, SOURCE).expect("upload")
}
/// What the source holds at `(x, y)` — the same rule [`stripes`] wrote.
fn source_byte(x: u32, _y: u32) -> u8 {
if x.is_multiple_of(2) {
0
} else {
255
}
}
/// Render a neutral edit through `view` and hand back the bytes and the size.
///
/// Neutral because this is a test about *which pixel* is read, and any active
/// operation would put a colour transform between the source byte and the
/// rendered one for no gain.
fn render(ctx: &GpuContext, source: &DemosaicedImage, view: CropRect) -> (Vec<u8>, u32, u32) {
let mut graph = EditGraph::default_chain();
graph.framing_mut().set_view(view);
let shader = graph.compose();
let mut adjust = AdjustPass::new(ctx);
adjust
.render(source, &shader, RENDER, RENDER)
.expect("render");
adjust.export_pixels().expect("readback")
}
/// The view rect that puts one render pixel on one source pixel, with its
/// top-left corner at `WINDOW`.
fn one_to_one() -> CropRect {
CropRect {
x: WINDOW.0 as f32 / SOURCE as f32,
y: WINDOW.1 as f32 / SOURCE as f32,
width: RENDER as f32 / SOURCE as f32,
height: RENDER as f32 / SOURCE as f32,
}
}
/// The red channel of one row of a rendered frame.
fn row(pixels: &[u8], width: u32, y: u32) -> Vec<u8> {
(0..width)
.map(|x| pixels[((y * width + x) * 4) as usize])
.collect()
}
/// TRACES: FR-DSP-5
/// The proxy render carries none of the source's finest detail.
///
/// Half of the argument, and the half that makes the other half mean something:
/// if the fit render already showed the stripes, a 1:1 render showing them would
/// prove nothing at all. It comes out uniform, so whatever the 1:1 render
/// contains cannot have come from resampling it.
#[test]
fn a_proxy_cannot_resolve_the_finest_detail_in_the_source() {
let Some(ctx) = ctx() else { return };
let source = stripes(&ctx);
let (pixels, w, h) = render(&ctx, &source, CropRect::default());
assert_eq!((w, h), (RENDER, RENDER));
let first = pixels[0];
let uniform = pixels
.chunks_exact(4)
.all(|px| px[0] == first && px[1] == first && px[2] == first);
assert!(
uniform,
"the fit render of a one-pixel stripe pattern should be flat — a 1024 px \
source in a {RENDER} px viewport steps 8 source columns per output \
column, so every sample lands on the same phase. It was not: row 0 is \
{:?}. Either the sampling changed or the fixture no longer says what it \
is meant to, and the 1:1 test below is worthless until this is true \
again.",
&row(&pixels, w, 0)[..16.min(w as usize)]
);
}
/// TRACES: FR-DSP-5
/// At 1:1 the render *is* the source region, byte for byte.
///
/// The requirement's actual content — "at 1:1 and above, the pipeline operates
/// on the visible crop at full source resolution" — stated as the strongest
/// thing that could be true of it: not that the result is sharper, but that
/// output pixel `(x, y)` is source pixel `(WINDOW.0 + x, WINDOW.1 + y)` and
/// nothing has been interpolated, averaged or magnified on the way.
#[test]
fn a_one_to_one_view_reproduces_the_source_pixel_for_pixel() {
let Some(ctx) = ctx() else { return };
let source = stripes(&ctx);
let (pixels, w, h) = render(&ctx, &source, one_to_one());
// The render target keeps its size while the sampled region shrinks. That
// is the whole mechanism, and a zoom that resized the target would be
// magnification rather than resolution.
assert_eq!(
(w, h),
(RENDER, RENDER),
"zooming must not change the size of the render target"
);
let mut mismatches = Vec::new();
for y in 0..h {
for x in 0..w {
let got = pixels[((y * w + x) * 4) as usize];
let want = source_byte(WINDOW.0 + x, WINDOW.1 + y);
if got != want && mismatches.len() < 8 {
mismatches.push((x, y, got, want));
}
}
}
assert!(
mismatches.is_empty(),
"a 1:1 view starting at {WINDOW:?} must reproduce the source exactly. \
First mismatches (x, y, got, want): {mismatches:?}\n\
rendered row 0: {:?}\n\
source row 0: {:?}\n\
An exactly inverted row means the view's *offset* was dropped while its \
width was honoured; a flat row means the view was ignored altogether \
and the pipeline is still rendering the proxy.",
&row(&pixels, w, 0)[..12],
(0..12)
.map(|x| source_byte(WINDOW.0 + x, WINDOW.1))
.collect::<Vec<_>>(),
);
}
/// TRACES: FR-DSP-5
/// An arbitrary zoom between fit and 1:1 samples at the ratio it asks for.
///
/// FR-DSP-5 says "fit, 1:1, **and arbitrary zoom levels**", and the two tests
/// above only pin the ends. This one takes the middle: a view a quarter of the
/// frame wide, which puts four source pixels behind each output pixel, and
/// checks that the pipeline reports and samples at that ratio rather than
/// snapping to one of the two cases anybody would have special-cased.
///
/// `render_scale` is asserted alongside the pixels because it is what the
/// neighbourhood stage converts kernel radii through: a zoom that moved the
/// pixels but not the scale would silently sharpen at the wrong radius, which
/// is invisible until somebody compares a preview against an export.
#[test]
fn an_arbitrary_zoom_samples_at_the_ratio_it_asks_for() {
let Some(ctx) = ctx() else { return };
let source = stripes(&ctx);
// A quarter of the frame: 256 source columns across 128 output columns.
let view = CropRect {
x: 0.25,
y: 0.25,
width: 0.25,
height: 0.25,
};
let mut graph = EditGraph::default_chain();
graph.framing_mut().set_view(view);
// The region on screen is 256 source pixels wide, rendered into 128, so the
// ratio is one render pixel per two source pixels.
let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER));
assert_eq!(scale.full_size(), (256, 256));
assert!(
(scale.ratio() - 0.5).abs() < 1e-6,
"ratio {}",
scale.ratio()
);
let (pixels, w, _) = render(&ctx, &source, view);
// Where output column `x` reads from, worked through rather than asserted
// from a previous run — a test that recomputed this with the shader's own
// expression would agree with a bug in it.
//
// uv = 0.25 + (x + 0.5) / 128 * 0.25 = (128 + x + 0.5) / 512
// col = floor(uv * 1024) = floor(256 + 2x + 1.0) = 257 + 2x
//
// Odd for every `x`, so this zoom lands flat — as the fit render does, and
// for the same reason. **The 1.0 is the interesting part.** At a two-to-one
// downsample an output pixel's centre falls exactly on the boundary between
// the two source pixels it covers, and truncation takes the right-hand one.
// That is nearest-neighbour behaving correctly and not an off-by-one; a
// reader checking this file by hand will get 256 on the first attempt, so
// it is written out.
const SAMPLED: u32 = 257;
let first = pixels[0];
assert!(
pixels.chunks_exact(4).all(|px| px[0] == first),
"a two-to-one zoom steps two source columns per output column, so every \
sample has the same parity and the frame should be flat. row 0: {:?}",
&row(&pixels, w, 0)[..12]
);
// And it is flat on the *right* phase. A pipeline that had ignored the view
// entirely would also be flat — but its `ratio` would not be 0.5, which the
// assertion above already rules out — and one that had snapped to 1:1 would
// show the stripes instead. Between them, only sampling the window the view
// actually asked for produces this.
assert_eq!(
first,
source_byte(SAMPLED, SAMPLED),
"a view starting a quarter of the way across a {SOURCE} px source should \
sample from source column {SAMPLED}"
);
}