Files
DarkRoom/core/dr-pipeline/tests/declared_parity.rs
T
dtourolle 81b1ae8c42 Measure the haze from the picture, and divide it back out
Four files named dehaze as a member of the compositional detail family —
`detail.rs` twice, `dr-gpu`'s detail module, `ops/README.md` and
`capture_sharpen.rs` — and no such node existed. Every one of them was
describing the family by listing clarity, texture and a control the
photographer could not reach.

Haze is the one degradation the controls already in the chain cannot
remove, and the reason is spatial rather than tonal. Scattering
composites an airlight over the scene in proportion to distance, so the
lift is per-pixel: a black point that clears the mountains crushes the
foreground, and a contrast curve that clears the mountains does the
same. So the node has to estimate the transmission at every pixel, which
is the dark-channel prior — the local minimum over the channels and over
a patch is the airlight that has been added there — and then invert the
scattering model with it.

The airlight is taken as neutral and as unit, which removes the one part
of the published method this stage cannot perform. Estimating it
properly is a whole-frame reduction, and the detail chain has none: it
hands each pass the pass before it. It is also unnecessary, because
white balance is the first node in the chain and has already driven the
illuminant to grey, so only the magnitude is unknown — and an unknown
magnitude on the veil is a scale factor on the amount slider, which the
photographer is setting by eye regardless.

The patch is a fraction of the frame's shorter edge, through
`RenderScale::frame_fraction`, and never a count of pixels. It has to be
wide enough to contain something dark and narrow enough that what it
measures is still local, and both of those are statements about how much
of the composition it covers — so it must cover the same proportion of
the picture on a proxy as in the export, or the file is sharpened for a
patch three times narrower than the one that was tuned on screen.

Affording it needs an identity a Gaussian does not have. Erosions
compose by adding their structuring elements, so the minimum over a run
of d followed by the minimum over k points spaced d apart is the exact
minimum over the whole kd window. At the square root that is 16 taps
rather than 61 at 4K, and it is the same filter rather than an
approximation of one — which is the difference from the strided kernel
`local_contrast` refuses, where sampling an image that is not
band-limited aliases into the base and comes back as mottling.

It runs first among the compositional detail nodes, at order 125: after
noise reduction, because dividing by a transmission below one amplifies
the noise in the veiled distance by exactly the factor it recovers the
contrast by, and before clarity and texture, coarse before fine, so that
their base is computed on the picture the veil has left rather than on a
modelling about to be divided out.

What it cannot honour is the placement dehaze most wants. It shifts
colour — it subtracts a grey term and rescales, so saturation changes
wherever the veil is thick — and the colour work would ideally be
correcting the picture that leaves here. The detail stage runs as a
group after every point operation, because a neighbourhood pass is a
separate dispatch over a texture the fused pass has finished writing, so
an order placing this node ahead of `vibrance` would be a lie the chain
cannot tell. Interleaving would mean splitting the fused pass in half
around it, at the cost of a second full-frame dispatch and intermediate
for every edit in the catalogue whether it dehazes or not. The
declaration records that rather than leaving it to be rediscovered.

FR-DEV-18 is added to the requirements register alongside it. The tag
had nowhere to point, and an orphan tag fails the traceability gate
rather than quietly counting for nothing.
2026-09-06 19:01:47 +02:00

438 lines
16 KiB
Rust

//! TRACES: FR-PLG-2
//! The generated path and the interpreted path produce the same operation.
//!
//! # Why this test is the point
//!
//! FR-PLG-2 says a bundled operation and a third-party plugin are the same
//! kind of thing, differing only in where the file was found. Two
//! implementations sit behind that claim: `build.rs` compiles `ops/*.yaml`
//! into Rust, and [`DeclaredOp`] interprets the same declaration at run time.
//!
//! **If the two ever disagree, the claim fails quietly.** A plugin would be a
//! second-class kind of node — one whose colours come out fractionally
//! different, or whose fragment lands in the shader with a different comment,
//! or whose uniform arrives in a different slot — and nothing would say so.
//! The photographer would see a look they could not reproduce with a built-in
//! and would have no way to find out why.
//!
//! So this parses every built-in declaration at run time and asserts the
//! composed WGSL is **byte for byte** what the generated implementation
//! produces, with the uniform block bit for bit identical, at a spread of
//! parameter values.
//!
//! # Byte-for-byte, and bit-for-bit, on purpose
//!
//! Not "equivalent", not "within an epsilon". A tolerance is where a real
//! divergence hides: the arithmetic in a declaration is `f32` at both ends and
//! there is no reason for a single bit to differ, so any difference at all is
//! a bug in one of the two backends and should read as one. The one place
//! this bites is number literals, which is why `expr::as_f32` rounds a decimal
//! exactly once — see its own documentation.
//!
//! # What is not covered, and why that is honest
//!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
//! names a hand-written type and has no declaration to interpret. It is not skipped
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly
//! drop out of this file's coverage.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use dr_pipeline::declared::{builtin_helpers, decl, DeclaredOp, Node};
use dr_pipeline::descriptor::ParamKind;
use dr_pipeline::operation::{compose, ComposedShader, Operation};
use dr_pipeline::ops;
use dr_pipeline::ParamId;
/// The declarations this crate ships, read from disk rather than embedded.
///
/// From disk deliberately: `build.rs` reads these very files, so reading the
/// same bytes is what makes the comparison a comparison of the two *readers*
/// rather than of two snapshots that were taken at different times.
fn ops_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("ops")
}
/// Every `<id>.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`.
fn declaration_files() -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for entry in std::fs::read_dir(ops_dir()).expect("ops/ is readable") {
let path = entry.expect("a directory entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.expect("a file name")
.to_string();
if stem.starts_with('_') {
continue;
}
out.insert(stem, std::fs::read_to_string(&path).expect("readable"));
}
assert!(!out.is_empty(), "ops/ declares no nodes");
out
}
/// Parse one declaration the way both backends do.
fn read(id: &str, text: &str) -> Node {
let library = builtin_helpers();
decl::read_node(text, &format!("ops/{id}.yaml"), &library.names())
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"))
}
/// The generated implementation of one node, taken out of the default chain.
///
/// Out of `chain()` rather than constructed by name, because that is how the
/// application gets one: if the two ever differed, the chain's version is the
/// one a photograph would be developed with.
fn generated(id: &str) -> Box<dyn Operation> {
let chain = ops::chain();
let index = chain
.iter()
.position(|o| o.descriptor().id.0 == id)
.unwrap_or_else(|| panic!("`{id}` is not in the default chain"));
let mut chain = chain;
chain.remove(index)
}
/// Values worth setting a parameter to, spanning its declared range.
///
/// Both ends, because that is where a rounding difference in a `min:` or a
/// `max:` would show; the default, because that is the neutral every
/// `is_active` is written against; and two interior points that are not
/// round numbers, because a value like 37.5 exercises the arithmetic in a way
/// 0 and 100 do not.
fn probe_values(kind: &ParamKind, default: f32) -> Vec<f32> {
match kind {
ParamKind::Scalar { min, max, .. } => {
let span = max - min;
vec![
default,
*min,
*max,
min + span * 0.375,
min + span * 0.8125,
// A value that is not representable as a short decimal, to
// catch a backend that round-trips a uniform through text.
min + span / 3.0,
]
}
ParamKind::Bool => vec![0.0, 1.0],
ParamKind::Enum { variants } => (0..variants.len()).map(|i| i as f32).collect(),
}
}
/// Put every parameter back where it started.
fn reset(op: &mut dyn Operation) {
let descriptor = op.descriptor();
for p in &descriptor.params {
op.set_param(p.id, p.default);
}
}
/// Assert the two operations are indistinguishable at their current settings.
fn assert_same(id: &str, setting: &str, generated: &dyn Operation, declared: &dyn Operation) {
let g = generated.descriptor();
let d = declared.descriptor();
assert_eq!(*g, *d, "{id} [{setting}]: the descriptors differ");
assert_eq!(
generated.is_active(),
declared.is_active(),
"{id} [{setting}]: the two disagree about whether the operation is doing anything"
);
assert_eq!(
generated.wgsl_body(),
declared.wgsl_body(),
"{id} [{setting}]: the fragment bodies differ"
);
assert_eq!(
generated.helpers(),
declared.helpers(),
"{id} [{setting}]: the helper sets differ"
);
let (gu, du) = (generated.uniforms(), declared.uniforms());
assert_eq!(
gu.len(),
du.len(),
"{id} [{setting}]: different numbers of uniforms"
);
for (a, b) in gu.iter().zip(&du) {
assert_eq!(
a.name, b.name,
"{id} [{setting}]: uniforms in a different order"
);
// Bits, not values: `assert_eq!` on `f32` would call two NaNs unequal
// and would call `0.0` and `-0.0` equal, and both of those are
// differences worth failing on.
assert_eq!(
a.value.to_bits(),
b.value.to_bits(),
"{id} [{setting}]: uniform `{}` is {} generated and {} declared",
a.name,
a.value,
b.value
);
}
}
/// Assert two compositions are the same shader.
fn assert_same_shader(what: &str, a: &ComposedShader, b: &ComposedShader) {
// The source first, and compared as whole strings: a diff in the middle of
// several kilobytes of WGSL is unreadable as an assertion message, so the
// failure below points at the first differing line instead.
if a.source != b.source {
let line = a
.source
.lines()
.zip(b.source.lines())
.position(|(x, y)| x != y);
match line {
Some(n) => panic!(
"{what}: the composed WGSL differs at line {}:\n generated: {:?}\n declared: {:?}",
n + 1,
a.source.lines().nth(n).unwrap_or(""),
b.source.lines().nth(n).unwrap_or(""),
),
None => panic!(
"{what}: the composed WGSL differs in length: {} generated, {} declared",
a.source.len(),
b.source.len()
),
}
}
assert_eq!(
a.uniforms.len(),
b.uniforms.len(),
"{what}: different uniform block sizes"
);
for (i, (x, y)) in a.uniforms.iter().zip(&b.uniforms).enumerate() {
assert_eq!(
x.to_bits(),
y.to_bits(),
"{what}: uniform slot {i} is {x} generated and {y} declared"
);
}
// The hash is taken over the source, so it follows — but it is what the
// pipeline cache keys on, and asserting it says that a declared node and
// its generated twin would share a compiled pipeline rather than quietly
// splitting the cache in two.
assert_eq!(a.structure_hash, b.structure_hash, "{what}: structure hash");
assert_eq!(a.output_mode, b.output_mode, "{what}: output mode");
}
/// Compose a single operation, the way the display path composes a chain.
fn compose_one(op: Box<dyn Operation>) -> (ComposedShader, Box<dyn Operation>) {
let shader = compose(std::slice::from_ref(&op));
(shader, op)
}
/// TRACES: FR-PLG-2
/// Every declared built-in composes to the same shader either way.
#[test]
fn a_declaration_read_at_run_time_composes_byte_for_byte_as_the_generated_one() {
let library = builtin_helpers();
let mut checked = 0;
for (id, text) in declaration_files() {
let Node::Declared(declaration) = read(&id, &text) else {
continue;
};
let mut declared =
DeclaredOp::new(&declaration, library).unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}"));
let mut generated = generated(&id);
// The neutral state first: it is the state every image starts in, and
// an operation that is inactive in one path and active in the other
// would put a whole fragment into one shader and not the other.
assert_same(&id, "neutral", generated.as_ref(), &declared);
let descriptor = declared.descriptor();
for p in &descriptor.params {
for value in probe_values(&p.kind, p.default) {
reset(generated.as_mut());
reset(&mut declared);
generated.set_param(p.id, value);
declared.set_param(p.id, value);
let setting = format!("{} = {value}", p.id);
assert_same(&id, &setting, generated.as_ref(), &declared);
let (gs, back) = compose_one(generated);
generated = back;
let (ds, _) = compose_one(Box::new(declared.clone()));
assert_same_shader(&format!("{id} [{setting}]"), &gs, &ds);
checked += 1;
}
}
// And every parameter moved at once, which is the only case that
// exercises the *order* uniforms are emitted in.
reset(generated.as_mut());
reset(&mut declared);
for p in &descriptor.params {
let value =
probe_values(&p.kind, p.default)[3.min(probe_values(&p.kind, p.default).len() - 1)];
generated.set_param(p.id, value);
declared.set_param(p.id, value);
}
assert_same(&id, "all parameters moved", generated.as_ref(), &declared);
let (gs, _) = compose_one(generated);
let (ds, _) = compose_one(Box::new(declared));
assert_same_shader(&format!("{id} [all parameters moved]"), &gs, &ds);
checked += 1;
}
// A test that silently checked nothing would pass forever. There are eight
// declared nodes and several settings each, so this is a floor rather than
// a count anybody has to maintain.
assert!(
checked > 20,
"only {checked} comparisons ran; the declarations were not found"
);
}
/// TRACES: FR-PLG-2
/// The whole chain composes identically with the declared nodes swapped in.
///
/// The single-operation test above is the sharper one — it isolates each node
/// — but it cannot see an interaction. This composes the *default develop
/// chain*, with every declared node replaced by its interpreted twin and the
/// `rust:` nodes left alone, so it covers uniform slot ordering across
/// operations, helper de-duplication between them, and the order the fragments
/// land in the shader.
#[test]
fn the_whole_chain_composes_identically_with_interpreted_nodes() {
let library = builtin_helpers();
let files = declaration_files();
let mut generated_chain = ops::chain();
let mut declared_chain = ops::chain();
let mut swapped = 0;
for i in 0..declared_chain.len() {
let id = declared_chain[i].descriptor().id.0.to_string();
let text = files
.get(&id)
.unwrap_or_else(|| panic!("`{id}` is in the chain but has no ops/{id}.yaml"));
if let Node::Declared(declaration) = read(&id, text) {
declared_chain[i] = Box::new(
DeclaredOp::new(&declaration, library)
.unwrap_or_else(|e| panic!("ops/{id}.yaml: {e}")),
);
swapped += 1;
}
// Move every operation off neutral, declared or not, so the chain is
// not a list of fragments that were all omitted. A neutral chain
// composes to a shader with no operation blocks in it at all, which
// would make this test pass while asserting nothing.
let descriptor = generated_chain[i].descriptor();
for p in &descriptor.params {
let value = probe_values(&p.kind, p.default)[1];
generated_chain[i].set_param(p.id, value);
declared_chain[i].set_param(p.id, value);
}
}
assert!(
swapped >= 8,
"only {swapped} nodes were swapped for declared ones"
);
assert_same_shader(
"the default chain",
&compose(&generated_chain),
&compose(&declared_chain),
);
}
/// TRACES: FR-PLG-2
/// Nothing in `ops/` escapes this file unnoticed.
///
/// The coverage guard. A declaration that stopped parsing, or a node that
/// quietly became `rust:`, would otherwise reduce what the parity test covers
/// without anything failing — which is exactly the silent divergence the whole
/// file exists to prevent.
#[test]
fn every_declared_node_is_checked() {
let files = declaration_files();
let mut declared = Vec::new();
let mut hand_written = Vec::new();
for (id, text) in &files {
match read(id, text) {
Node::Declared(_) => declared.push(id.clone()),
Node::Rust { ty, .. } => hand_written.push((id.clone(), ty)),
}
}
// Every file is one or the other, and the chain holds exactly them.
assert_eq!(declared.len() + hand_written.len(), files.len());
assert_eq!(
ops::DECLARED_IDS.len(),
files.len(),
"the chain and ops/ hold different numbers of nodes"
);
for id in ops::DECLARED_IDS {
assert!(
files.contains_key(*id),
"`{id}` is in the chain but not in ops/"
);
}
// Named rather than counted, so that a node changing sides is a failure
// somebody reads rather than a number they update.
let hand: Vec<&str> = hand_written.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(
hand,
[
"capture_sharpen",
"clarity",
"colour_mixer",
"dehaze",
"film_sim",
"noise_reduction",
"texture",
"tone_curve",
"vignetting",
],
"the set of hand-written nodes changed; if that is deliberate, update \
this list and the module documentation above"
);
assert!(
declared.len() >= 8,
"only {} declared nodes: {declared:?}",
declared.len()
);
}
/// TRACES: FR-PLG-2
/// A declared operation is addressed by the ids the generated one uses.
///
/// The practical form of "indistinguishable downstream": the sidecar stores
/// parameters by `(op_id, param_id)` text, so a declared node whose interned
/// ids did not compare equal to the generated constants would load an edit
/// that silently did nothing.
#[test]
fn an_interpreted_node_answers_to_the_generated_parameter_ids() {
let mut declared = DeclaredOp::from_yaml(
&std::fs::read_to_string(ops_dir().join("exposure.yaml")).expect("readable"),
"ops/exposure.yaml",
builtin_helpers(),
)
.expect("exposure is a declaration");
declared.set_param(ops::exposure::EXPOSURE, 1.5);
assert_eq!(declared.param(ParamId("exposure")), 1.5);
assert_eq!(declared.descriptor().id, ops::exposure::ID);
}