`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the declarative nodes landed — it was simply resolved at build time. Nothing about a declaration requires the compiler: everything it produces is data plus a WGSL string, and the composer already assembles WGSL at run time from whatever operations are active. So this is not a new mechanism. It is the existing one, loaded later (FR-PLG-2). `DeclaredOp` implements `Operation` from an owned `Declaration` — one interpreter over many declarations, where `build.rs` emits generated code per node. The generated path stays, as FR-PLG-2 says it should: a generated `match` is faster than an interpreted one, the built-ins' declared `tests:` have to run under `cargo test`, and generated source is inspectable in a way an interpreter's state is not. **The reader is now one file, read by both.** `src/declared/decl.rs` and `src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being modules of the crate, and they produce a neutral `Declaration` that names no Rust type. The build script's job is reduced to *rendering* that declaration as Rust; `DeclaredOp` converts the same declaration into descriptors and `Expr::eval` walks the same tree the renderer writes out. There is one grammar, one set of validations and one set of error messages, so "a plugin is the same kind of thing as a built-in" is structural rather than aspirational. What remains genuinely written twice is the pair of backends — an arithmetic node rendered as Rust here and evaluated there — and that is what the parity test stands between. `tests/declared_parity.rs` 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 both ends of every parameter's range and at four interior points; then again over the whole develop chain with the declared nodes swapped in, which is what covers uniform slot ordering and helper de-duplication between operations. A third test asserts the declared and hand-written nodes partition `ops/` between them, so coverage cannot shrink silently. Bit-for-bit rather than within a tolerance, because a tolerance is where a real divergence hides. The one thing that had to be got right for that to hold is number literals: `expr::as_f32` rounds a decimal exactly once, through the same shortest-round-trip text the compiler is handed, rather than rounding an `f64` a second time. Not in scope, and deliberately untagged: load-time WGSL validation (FR-PLG-11), id namespacing, a plugin directory read at startup, and pass nodes (FR-PLG-2a). Those are separate work, and tagging them from here would be the overstatement the spec's own §7 warns about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
436 lines
16 KiB
Rust
436 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` — 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",
|
|
"film_sim",
|
|
"noise_reduction",
|
|
"texture",
|
|
"tone_curve",
|
|
],
|
|
"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);
|
|
}
|