From 0cd3ef1b3f6469d2fba1ac59e2996475e7e05d2b Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 27 Aug 2026 19:08:34 +0200 Subject: [PATCH] Run a node declaration without compiling it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- core/dr-pipeline/Cargo.toml | 6 + core/dr-pipeline/build.rs | 1472 +++------------------ core/dr-pipeline/ops/README.md | 16 +- core/dr-pipeline/src/declared/decl.rs | 1175 ++++++++++++++++ core/dr-pipeline/src/declared/expr.rs | 447 +++++++ core/dr-pipeline/src/declared/mod.rs | 487 +++++++ core/dr-pipeline/src/lib.rs | 2 + core/dr-pipeline/tests/declared_parity.rs | 435 ++++++ docs/traceability.md | 12 +- 9 files changed, 2766 insertions(+), 1286 deletions(-) create mode 100644 core/dr-pipeline/src/declared/decl.rs create mode 100644 core/dr-pipeline/src/declared/expr.rs create mode 100644 core/dr-pipeline/src/declared/mod.rs create mode 100644 core/dr-pipeline/tests/declared_parity.rs diff --git a/core/dr-pipeline/Cargo.toml b/core/dr-pipeline/Cargo.toml index 3ac1714..a411e98 100644 --- a/core/dr-pipeline/Cargo.toml +++ b/core/dr-pipeline/Cargo.toml @@ -12,6 +12,12 @@ license.workspace = true dr-types.workspace = true log.workspace = true +# A dependency of the library, not only of the build script, since FR-PLG-2: +# `src/declared/` reads the same declaration format at *load* time, so that an +# operation found in a file at startup is the same kind of thing as one found +# at compile time. The reader itself is one file shared by both. +serde_norway.workspace = true + # Nodes are declared in `ops/*.yaml` and compiled to Rust by `build.rs` # (ARCH §5.7). The same reasoning as `ui/dr-ui`'s style.yaml: the declaration # is the source of truth, the Rust is generated into OUT_DIR where it cannot diff --git a/core/dr-pipeline/build.rs b/core/dr-pipeline/build.rs index 5ca58ab..263ea46 100644 --- a/core/dr-pipeline/build.rs +++ b/core/dr-pipeline/build.rs @@ -23,6 +23,21 @@ //! declared node from a hand-written one, which is what allows the two to sit //! side by side in one chain. //! +//! # And it is no longer the only reader +//! +//! The parsing half of this script lives in `src/declared/`, included here by +//! `#[path]` and compiled into the crate as well (FR-PLG-2). This script's own +//! job is now purely *rendering*: it takes the [`decl::Declaration`] the shared +//! reader produced and writes Rust from it, while at run time +//! [`crate::declared::DeclaredOp`](../src/declared/mod.rs) takes the same +//! declaration and interprets it. +//! +//! That is what makes "a plugin is the same kind of thing as a built-in" +//! checkable rather than merely intended: there is one grammar, one set of +//! error messages, and one place where a node's meaning is decided. +//! `tests/declared_parity.rs` then asserts the two backends agree byte for +//! byte on every node in `ops/`. +//! //! A node that needs more than the four facts stays in Rust and declares //! itself with `rust:` instead (see `ops/tone_curve.yaml`). The escape hatch //! is deliberate: a schema stretched to cover the tone curve's interpolator @@ -40,18 +55,44 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use std::path::{Path, PathBuf}; -use serde_norway::{Mapping, Value}; +// The shared reader, compiled into this build script as well as into the +// crate. See its own module documentation, and `#[path]` rather than a copy +// because a copy is precisely what FR-PLG-2 forbids: a plugin and a built-in +// have to be read by the same code or they are not the same kind of thing. +// +// Nothing in either file may name `crate::` items — they are compiled here as +// modules of a build script, where those paths do not exist. +// +// `dead_code` because the run-time half of the shared reader — `Expr::eval`, +// the descriptor conversions' inputs — has no caller here. Silencing it at the +// module rather than at each item keeps the shared files free of attributes +// that only mean something in one of their two compilations. +#[allow(dead_code)] +#[path = "src/declared/decl.rs"] +mod decl; +#[allow(dead_code)] +#[path = "src/declared/expr.rs"] +mod expr; + +use decl::{Declaration, Node, ParamDef, SharedHelpers, TestDef}; +use expr::Expr; /// The directory holding node declarations, relative to the manifest. const OPS_DIR: &str = "ops"; /// Declarations whose name begins with this are not nodes. const NON_NODE_PREFIX: &str = "_"; -const HELPERS_FILE: &str = "_helpers.yaml"; +const HELPERS_FILE: &str = decl::HELPERS_FILE; const GENERATED: &str = "nodes.rs"; fn main() { println!("cargo:rerun-if-changed={OPS_DIR}"); println!("cargo:rerun-if-changed=build.rs"); + // The reader is an input to this build script now, so a change to it has + // to regenerate `nodes.rs` — otherwise a fix to the grammar would take + // effect at load time and not at build time, which is the one divergence + // this whole arrangement exists to prevent. + println!("cargo:rerun-if-changed=src/declared/decl.rs"); + println!("cargo:rerun-if-changed=src/declared/expr.rs"); let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("manifest dir")); let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("OUT_DIR")); @@ -78,179 +119,21 @@ fn fail(message: String) -> ! { std::process::exit(1); } -// --------------------------------------------------------------------------- -// The declaration model -// --------------------------------------------------------------------------- - -/// A WGSL helper function, either shared or declared by one node. -struct HelperDef { - name: String, - doc: Option, - wgsl: String, -} - -/// One parameter of a declared node. -struct ParamDef { - id: String, - doc: Option, - /// The `ParamDescriptor` constructor call, already rendered. - ctor: String, - /// Needed to generate `is_active` and to range-check test values. - default: f64, - min: f64, - max: f64, -} - -/// A uniform the node's fragment reads, and the expression computing it. -struct UniformDef { - name: String, - doc: Option, - /// The Rust expression, compiled from the declared one. - rust: String, -} - -/// A node's `presentation:` block, ready to render as a `Presentation`. -struct PresentationDef { - /// Rendered `WidgetKind` paths, most preferred first. - widgets: Vec, - /// The owned parameters' generated `ParamId` constants, in widget order. - params: Vec, - two_dimensional: bool, - precise_pointing: bool, -} - -/// One generated `#[test]`. -struct TestDef { - name: String, - why: Option, - set: Vec<(String, f64)>, - expect: Vec<(String, f64)>, - expect_range: Vec<(String, f64, f64)>, - expect_active: Option, - expect_wgsl: Vec, - expect_helper_wgsl: Vec<(String, Vec)>, -} - -/// The attributes an operation declares, validated against the vocabulary. -/// -/// **Required, and non-empty.** An operation with no attribute is invisible to -/// a frontend that filters by them, and a control that silently does not exist -/// is a far worse failure than a build that stops — especially when the cause -/// is one missing line in a YAML file nobody had reason to open. Failing here -/// costs whoever adds an operation ten seconds; failing at runtime costs a -/// photographer a control they cannot find and cannot know is missing. -/// -/// The vocabulary is closed on purpose. A typo would otherwise invent a -/// category containing exactly one operation, which is indistinguishable from -/// a deliberate new one until somebody notices the tab with a single control -/// in it. -fn read_attributes(root: &Mapping) -> Result, String> { - const KNOWN: [&str; 6] = ["tone", "colour", "detail", "optics", "geometry", "effect"]; - - let value = root.get("attributes").ok_or_else(|| { - format!( - "missing `attributes:`; every operation must say what it is about, \ - one or more of {KNOWN:?}. It is what lets the panel group \ - operations without naming any of them (ARCH §4.3a)." - ) - })?; - - let list = value - .as_sequence() - .ok_or("`attributes:` must be a list, even with one entry")?; - - let mut out = Vec::new(); - for entry in list { - let name = as_str(entry, "attributes")?; - if !KNOWN.contains(&name) { - return Err(format!( - "unknown attribute {name:?}; expected one of {KNOWN:?}" - )); - } - if out.contains(&name.to_string()) { - return Err(format!("attribute {name:?} is listed twice")); - } - out.push(name.to_string()); - } - - if out.is_empty() { - return Err("`attributes:` is empty; an operation with no attribute \ - would not appear in a panel that groups by them" - .into()); - } - Ok(out) -} - -/// A node: either declared in full, or a pointer to a hand-written type. -enum Node { - Declared { - id: String, - label: String, - order: i64, - /// What the operation is about (ARCH §4.3a). Never empty — see - /// `read_attributes`. - attributes: Vec, - doc: Option, - placement: Option, - params: Vec, - uniforms: Vec, - /// Names of shared helpers, in declaration order. - shared_helpers: Vec, - /// Helpers this node defines for itself. - local_helpers: Vec, - wgsl: String, - /// `None` means the default rule: active when any parameter has moved. - active: Option, - tests: Vec, - /// `None` — the usual case — means one control per parameter. - /// - /// Boxed so the rare node that declares one does not widen every - /// `Node` value by the size of a presentation it does not have. - presentation: Option>, - }, - Rust { - id: String, - order: i64, - /// The type in `crate::ops` implementing `Operation`. - ty: String, - why_rust: Option, - placement: Option, - }, -} - -impl Node { - fn id(&self) -> &str { - match self { - Node::Declared { id, .. } | Node::Rust { id, .. } => id, - } - } - - fn order(&self) -> i64 { - match self { - Node::Declared { order, .. } | Node::Rust { order, .. } => *order, - } - } - - fn placement(&self) -> Option<&str> { - match self { - Node::Declared { placement, .. } | Node::Rust { placement, .. } => placement.as_deref(), - } - } -} - // --------------------------------------------------------------------------- // Driver // --------------------------------------------------------------------------- fn generate(ops_dir: &Path, src_ops: &Path) -> Result { - let shared = read_helpers(&ops_dir.join(HELPERS_FILE))?; - let shared_names: BTreeSet<&str> = shared.helpers.iter().map(|h| h.name.as_str()).collect(); + let helpers_path = ops_dir.join(HELPERS_FILE); + let shared = decl::read_helpers(&read_file(&helpers_path)?)?; + let shared_names = shared.names(); let mut nodes = Vec::new(); for path in node_files(ops_dir)? { let name = file_stem(&path); - let node = read_node(&path, &shared_names) - .map_err(|e| format!("{}/{}.yaml: {e}", OPS_DIR, name))?; + let ctx = format!("{OPS_DIR}/{name}.yaml"); + let node = decl::read_node(&read_file(&path)?, &ctx, &shared_names) + .map_err(|e| format!("{ctx}: {e}"))?; // The filename and the id must agree. They are two names for one // thing, and a node found by one and referred to by the other is a @@ -300,6 +183,10 @@ fn node_files(dir: &Path) -> Result, String> { Ok(files) } +fn read_file(path: &Path) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display())) +} + fn file_stem(path: &Path) -> String { path.file_stem() .map(|s| s.to_string_lossy().into_owned()) @@ -337,9 +224,10 @@ fn check_unique(nodes: &[Node]) -> Result<(), String> { /// file to delete. fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> { for node in nodes { - let Node::Declared { id, .. } = node else { + let Node::Declared(declared) = node else { continue; }; + let id = &declared.id; let shadowed = src_ops.join(format!("{id}.rs")); if shadowed.exists() { return Err(format!( @@ -354,776 +242,59 @@ fn check_no_shadowing(nodes: &[Node], src_ops: &Path) -> Result<(), String> { } // --------------------------------------------------------------------------- -// Reading: shared helpers -// --------------------------------------------------------------------------- - -struct SharedHelpers { - doc: Option, - helpers: Vec, -} - -fn read_helpers(path: &Path) -> Result { - let doc = read_yaml(path)?; - let root = as_mapping(&doc, HELPERS_FILE)?; - - let module_doc = opt_prose(root, "doc", HELPERS_FILE)?; - let helpers = root - .get("helpers") - .ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?; - let helpers = as_mapping(helpers, "helpers")?; - - let mut out = Vec::new(); - for (name, spec) in helpers { - let name = as_str(name, "a helper name")?.to_string(); - let ctx = format!("helpers.{name}"); - let spec = as_mapping(spec, &ctx)?; - let wgsl = spec - .get("wgsl") - .ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?; - let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string(); - - // The composer deduplicates by name, so a helper whose declared name - // is not the function it defines would be emitted under one name and - // called under another. - if !wgsl.contains(&format!("fn {name}(")) { - return Err(format!( - "{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \ - is the name the composer deduplicates on, so it has to be the \ - function actually declared." - )); - } - out.push(HelperDef { - doc: opt_prose(spec, "doc", &ctx)?, - name, - wgsl, - }); - } - - if out.is_empty() { - return Err(format!("{HELPERS_FILE}: `helpers:` is empty")); - } - Ok(SharedHelpers { - doc: module_doc, - helpers: out, - }) -} - -// --------------------------------------------------------------------------- -// Reading: a node -// --------------------------------------------------------------------------- - -fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result { - let doc = read_yaml(path)?; - let root = as_mapping(&doc, "the document")?; - - let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string(); - check_ident(&id, "id")?; - - let order = root - .get("order") - .ok_or("missing `order:`; it is what places this node in the chain")? - .as_i64() - .ok_or("`order` must be a whole number")?; - - let placement = opt_prose(root, "placement", "placement")?; - - // A `rust:` node describes where a hand-written type sits, and nothing - // else — its descriptor comes from the type. Mixing the two forms would - // mean two sources for one node's parameters. - if let Some(ty) = root.get("rust") { - let ty = as_str(ty, "rust")?.to_string(); - check_type_name(&ty)?; - for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] { - if root.contains_key(key) { - return Err(format!( - "`{key}` is meaningless on a `rust:` node — {ty} publishes \ - its own descriptor. Remove one or the other." - )); - } - } - return Ok(Node::Rust { - id, - order, - ty, - why_rust: opt_prose(root, "why_rust", "why_rust")?, - placement, - }); - } - - let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string(); - let attributes = read_attributes(root)?; - - let params = read_params(root)?; - let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect(); - - let local_helpers = read_local_helpers(root)?; - let shared_helpers = read_shared_refs(root, shared, &local_helpers)?; - - let uniforms = read_uniforms(root, ¶m_names)?; - - let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")? - .trim_end() - .to_string(); - if wgsl.trim().is_empty() { - return Err("`wgsl:` is empty; a node that changes nothing is not a node".into()); - } - - let active = match root.get("active") { - None => None, - Some(v) => { - let expr = as_str(v, "active")?; - Some(compile_expr(expr, ¶m_names).map_err(|e| format!("`active`: {e}"))?) - } - }; - - let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect(); - let helper_names: BTreeSet<&str> = shared_helpers - .iter() - .map(String::as_str) - .chain(local_helpers.iter().map(|h| h.name.as_str())) - .collect(); - let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?; - let presentation = read_presentation(root, ¶m_names)?; - - Ok(Node::Declared { - id, - label, - order, - doc: opt_prose(root, "doc", "doc")?, - attributes, - placement, - params, - uniforms, - shared_helpers, - local_helpers, - wgsl, - active, - tests, - presentation, - }) -} - -/// The widgets a node would like, in descending order of preference. -/// -/// Optional, and absent on nearly every node — one control per parameter is -/// the right answer for a list of unrelated sliders, which is what most -/// operations are. Declaring this says that several parameters form *one* -/// conceptual control. -/// -/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of -/// the named widgets renders the parameters as ordinary sliders and the edit -/// still works, which is why the list can safely name widgets that do not -/// exist yet. -fn read_presentation( - root: &Mapping, - params: &BTreeSet<&str>, -) -> Result>, String> { - let Some(value) = root.get("presentation") else { - return Ok(None); - }; - let m = as_mapping(value, "presentation")?; - - let widgets = m - .get("widgets") - .and_then(Value::as_sequence) - .ok_or("`presentation` needs a `widgets:` list, most preferred first")?; - if widgets.is_empty() { - return Err("`presentation.widgets` is empty; omit `presentation:` instead".into()); - } - let widgets = widgets - .iter() - .enumerate() - .map(|(i, w)| { - let name = as_str(w, &format!("presentation.widgets[{i}]"))?; - // Spelled in the YAML the way the enum spells it, so a node - // declaration and `descriptor.rs` cannot drift into two - // vocabularies for one idea. - match name { - "tone_curve" => Ok("WidgetKind::ToneCurve"), - "colour_wheel" => Ok("WidgetKind::ColourWheel"), - "crop_overlay" => Ok("WidgetKind::CropOverlay"), - "gradient_handle" => Ok("WidgetKind::GradientHandle"), - "brush_mask" => Ok("WidgetKind::BrushMask"), - "white_point" => Ok("WidgetKind::WhitePoint"), - other => Err(format!( - "`presentation.widgets[{i}]` is `{other}`; expected tone_curve, \ - colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point" - )), - } - }) - .collect::, _>>()?; - - // The parameters the widget owns, in the order it expects them. Checked - // against the node's own list, because a typo here would silently leave a - // parameter out of the widget *and* out of the panel — the widget claims - // it, and the generic path skips what the widget claimed. - let owned = m - .get("params") - .and_then(Value::as_sequence) - .ok_or("`presentation` needs a `params:` list naming what the widget owns")?; - let owned = owned - .iter() - .enumerate() - .map(|(i, p)| { - let name = as_str(p, &format!("presentation.params[{i}]"))?; - if !params.contains(name) { - return Err(format!( - "`presentation.params[{i}]` is `{name}`, which this node does not declare" - )); - } - Ok(name.to_uppercase()) - }) - .collect::, _>>()?; - - let demand = match m.get("demand") { - None => (false, false), - Some(d) => { - let d = as_mapping(d, "presentation.demand")?; - let flag = |key: &str| -> Result { - match d.get(key) { - None => Ok(false), - Some(v) => v.as_bool().ok_or_else(|| { - format!("`presentation.demand.{key}` must be true or false") - }), - } - }; - // Deliberately only these two. ARCH §4.3a forbids a demand - // carrying pixels, breakpoints or a platform name — those are the - // frontend's to decide — so there is no key here to write one in. - (flag("two_dimensional")?, flag("precise_pointing")?) - } - }; - - Ok(Some(Box::new(PresentationDef { - widgets: widgets.into_iter().map(str::to_string).collect(), - params: owned, - two_dimensional: demand.0, - precise_pointing: demand.1, - }))) -} - -fn read_params(root: &Mapping) -> Result, String> { - let params = root - .get("params") - .ok_or("missing `params:`; an operation with no parameters has nothing to control")?; - let params = as_mapping(params, "params")?; - - let mut out = Vec::new(); - for (id, spec) in params { - let id = as_str(id, "a parameter name")?.to_string(); - check_ident(&id, &format!("params.{id}"))?; - let ctx = format!("params.{id}"); - let spec = as_mapping(spec, &ctx)?; - - let label = as_str( - spec.get("label") - .ok_or_else(|| format!("`{ctx}` has no `label:`"))?, - &format!("{ctx}.label"), - )? - .to_string(); - - let kind = as_str( - spec.get("kind") - .ok_or_else(|| format!("`{ctx}` has no `kind:`"))?, - &format!("{ctx}.kind"), - )?; - - let (ctor, default, min, max) = param_ctor(&id, &label, kind, spec, &ctx)?; - out.push(ParamDef { - id, - doc: opt_prose(spec, "doc", &ctx)?, - ctor, - default, - min, - max, - }); - } - - if out.is_empty() { - return Err("`params:` is empty".into()); - } - Ok(out) -} - -/// Render the `ParamDescriptor` constructor for a declared parameter kind. -/// -/// The kinds are the constructors `descriptor.rs` already offers, named rather -/// than spelled out: `amount` is the −100…+100 shape nearly every photographic -/// control takes, and writing its range in every node would invite one of them -/// to drift. -fn param_ctor( - id: &str, - label: &str, - kind: &str, - spec: &Mapping, - ctx: &str, -) -> Result<(String, f64, f64, f64), String> { - let need = |key: &str| -> Result { - spec.get(key) - .and_then(Value::as_f64) - .ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`")) - }; - let opt = |key: &str, fallback: f64| -> f64 { - spec.get(key).and_then(Value::as_f64).unwrap_or(fallback) - }; - - Ok(match kind { - "stops" => { - let (min, max) = (need("min")?, need("max")?); - ( - format!( - "ParamDescriptor::stops({:?}, {:?}, {}, {})", - id, - label, - rust_f32(min), - rust_f32(max) - ), - 0.0, - min, - max, - ) - } - "amount" => ( - format!("ParamDescriptor::amount({id:?}, {label:?})"), - 0.0, - -100.0, - 100.0, - ), - "switch" => ( - format!("ParamDescriptor::switch({id:?}, {label:?})"), - 0.0, - 0.0, - 1.0, - ), - "fraction" => { - let default = opt("default", 0.0); - ( - format!( - "ParamDescriptor::fraction({:?}, {:?}, {})", - id, - label, - rust_f32(default) - ), - default, - 0.0, - 1.0, - ) - } - "scalar" => { - let (min, max) = (need("min")?, need("max")?); - let default = opt("default", 0.0); - let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") { - "none" => "Unit::None", - "stops" => "Unit::Stops", - "kelvin" => "Unit::Kelvin", - "percent" => "Unit::Percent", - other => { - return Err(format!( - "`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent" - )) - } - }; - let scale = match spec - .get("scale") - .and_then(Value::as_str) - .unwrap_or("linear") - { - "linear" => "Scale::Linear", - "perceptual" => "Scale::Perceptual", - other => { - return Err(format!( - "`{ctx}.scale` is `{other}`; expected linear or perceptual" - )) - } - }; - let precision = spec - .get("precision") - .and_then(Value::as_u64) - .ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?; - ( - format!( - "ParamDescriptor::scalar({:?}, {:?}, {}, {}, {}, {}, {}, {})", - id, - label, - rust_f32(min), - rust_f32(max), - rust_f32(default), - unit, - scale, - precision - ), - default, - min, - max, - ) - } - // A fixed list of named alternatives. The value is the chosen index, - // so the range is the list's own bounds and a node declaring one needs - // no `min:`/`max:` of its own. - // - // Variants are localisation keys, like every other label in a node — - // the core never holds a display string (NFR-A11Y-1). The default is - // always the first, so `reset` means the same thing here as everywhere - // else; a node whose neutral choice is not first has listed them in - // the wrong order. - "enum" => { - let variants = spec - .get("variants") - .and_then(Value::as_sequence) - .ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?; - if variants.len() < 2 { - return Err(format!( - "`{ctx}.variants` lists {} choice(s); a control the user \ - cannot change is not a control", - variants.len() - )); - } - let keys = variants - .iter() - .enumerate() - .map(|(i, v)| { - as_str(v, &format!("{ctx}.variants[{i}]")) - .map(|k| format!("LocalizedKey({k:?})")) - }) - .collect::, _>>()?; - ( - format!( - "ParamDescriptor::choice({:?}, {:?}, vec![{}])", - id, - label, - keys.join(", ") - ), - 0.0, - 0.0, - (variants.len() - 1) as f64, - ) - } - other => { - return Err(format!( - "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \ - fraction, scalar or enum" - )) - } - }) - .and_then(|(ctor, default, min, max): (String, f64, f64, f64)| { - // Caught at build time rather than surfacing as a control that opens - // where it cannot be dragged back to. - if min >= max { - return Err(format!( - "`{ctx}` has an empty range: min {min} >= max {max}" - )); - } - if default < min || default > max { - return Err(format!( - "`{ctx}` has default {default} outside its range {min}..{max}" - )); - } - Ok((ctor, default, min, max)) - }) -} - -fn read_local_helpers(root: &Mapping) -> Result, String> { - let Some(define) = root.get("define") else { - return Ok(Vec::new()); - }; - let define = as_mapping(define, "define")?; - - let mut out = Vec::new(); - for (name, spec) in define { - let name = as_str(name, "a helper name under `define`")?.to_string(); - let ctx = format!("define.{name}"); - // Shorthand: the value may be the WGSL directly, or a mapping with - // prose beside it. Most node-local helpers carry their explanation in - // the WGSL itself, so the shorthand is the common case. - let (doc, wgsl) = match spec { - Value::String(s) => (None, s.trim_end().to_string()), - other => { - let m = as_mapping(other, &ctx)?; - let wgsl = as_str( - m.get("wgsl") - .ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?, - &format!("{ctx}.wgsl"), - )? - .trim_end() - .to_string(); - (opt_prose(m, "doc", &ctx)?, wgsl) - } - }; - if !wgsl.contains(&format!("fn {name}(")) { - return Err(format!("`{ctx}` does not define `fn {name}(`")); - } - out.push(HelperDef { name, doc, wgsl }); - } - Ok(out) -} - -fn read_shared_refs( - root: &Mapping, - shared: &BTreeSet<&str>, - local: &[HelperDef], -) -> Result, String> { - let Some(list) = root.get("helpers") else { - return Ok(Vec::new()); - }; - let list = list - .as_sequence() - .ok_or("`helpers` must be a list of helper names")?; - - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - for item in list { - let name = as_str(item, "a helper name")?.to_string(); - if !shared.contains(name.as_str()) { - let known: Vec<&str> = shared.iter().copied().collect(); - return Err(format!( - "`helpers` names `{name}`, which {HELPERS_FILE} does not \ - define. Known helpers: {}", - known.join(", ") - )); - } - if local.iter().any(|h| h.name == name) { - return Err(format!( - "`{name}` is both requested from {HELPERS_FILE} and redefined \ - under `define`. The composer deduplicates by name, so one of \ - the two definitions would silently win." - )); - } - if !seen.insert(name.clone()) { - return Err(format!("`helpers` lists `{name}` twice")); - } - out.push(name); - } - Ok(out) -} - -fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result, String> { - let uniforms = root - .get("uniforms") - .ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?; - let uniforms = as_mapping(uniforms, "uniforms")?; - - let mut out = Vec::new(); - for (name, spec) in uniforms { - let name = as_str(name, "a uniform name")?.to_string(); - check_ident(&name, &format!("uniforms.{name}"))?; - let ctx = format!("uniforms.{name}"); - - // Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose. - let (doc, expr) = match spec { - Value::String(s) => (None, s.clone()), - other => { - let m = as_mapping(other, &ctx)?; - let value = m - .get("value") - .ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?; - ( - opt_prose(m, "doc", &ctx)?, - as_str(value, &format!("{ctx}.value"))?.to_string(), - ) - } - }; - - let rust = compile_expr(&expr, params).map_err(|e| format!("`{ctx}`: {e}"))?; - out.push(UniformDef { name, doc, rust }); - } - - if out.is_empty() { - return Err("`uniforms:` is empty".into()); - } - Ok(out) -} - -fn read_tests( - root: &Mapping, - params: &[ParamDef], - uniforms: &BTreeSet<&str>, - helpers: &BTreeSet<&str>, -) -> Result, String> { - let Some(list) = root.get("tests") else { - return Ok(Vec::new()); - }; - let list = list.as_sequence().ok_or("`tests` must be a list")?; - - let mut out = Vec::new(); - let mut seen = BTreeSet::new(); - for item in list { - let m = as_mapping(item, "a test")?; - let name = as_str( - m.get("name").ok_or("a test has no `name:`")?, - "tests[].name", - )? - .to_string(); - check_ident(&name, &format!("tests.{name}.name"))?; - if !seen.insert(name.clone()) { - return Err(format!("two tests are both called `{name}`")); - } - let ctx = format!("tests.{name}"); - - let mut set = Vec::new(); - if let Some(v) = m.get("set") { - for (k, val) in as_mapping(v, &format!("{ctx}.set"))? { - let key = as_str(k, &format!("{ctx}.set key"))?.to_string(); - let Some(param) = params.iter().find(|p| p.id == key) else { - return Err(format!( - "`{ctx}.set` names `{key}`, which is not a parameter" - )); - }; - let value = val - .as_f64() - .ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?; - // Values reach `set_param` already clamped by the graph, so a - // test setting an out-of-range value would be asserting - // against something that cannot happen. - if value < param.min || value > param.max { - return Err(format!( - "`{ctx}.set.{key}` is {value}, outside the parameter's \ - range {}..{}. The graph clamps before an operation \ - sees a value, so this test could never run as written.", - param.min, param.max - )); - } - set.push((key, value)); - } - } - - let mut expect = Vec::new(); - if let Some(v) = m.get("expect") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? { - let key = as_str(k, &format!("{ctx}.expect key"))?.to_string(); - if !uniforms.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect` names `{key}`, which is not a uniform of this node" - )); - } - let value = val - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?; - expect.push((key, value)); - } - } - - let mut expect_range = Vec::new(); - if let Some(v) = m.get("expect_range") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? { - let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string(); - if !uniforms.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect_range` names `{key}`, which is not a uniform" - )); - } - let pair = val - .as_sequence() - .filter(|s| s.len() == 2) - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?; - let lo = pair[0] - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?; - let hi = pair[1] - .as_f64() - .ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?; - expect_range.push((key, lo, hi)); - } - } - - let mut expect_wgsl = Vec::new(); - if let Some(v) = m.get("expect_wgsl") { - for item in v - .as_sequence() - .ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))? - { - expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string()); - } - } - - let mut expect_helper_wgsl = Vec::new(); - if let Some(v) = m.get("expect_helper_wgsl") { - for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? { - let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string(); - if !helpers.contains(key.as_str()) { - return Err(format!( - "`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use" - )); - } - let mut needles = Vec::new(); - for item in val - .as_sequence() - .ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))? - { - needles.push( - as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(), - ); - } - expect_helper_wgsl.push((key, needles)); - } - } - - let expect_active = match m.get("expect_active") { - None => None, - Some(v) => Some( - v.as_bool() - .ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?, - ), - }; - - if expect.is_empty() - && expect_range.is_empty() - && expect_wgsl.is_empty() - && expect_helper_wgsl.is_empty() - && expect_active.is_none() - { - return Err(format!("`{ctx}` asserts nothing")); - } - - out.push(TestDef { - name, - why: opt_prose(m, "why", &ctx)?, - set, - expect, - expect_range, - expect_active, - expect_wgsl, - expect_helper_wgsl, - }); - } - Ok(out) -} - -// --------------------------------------------------------------------------- -// The expression language +// The expression language, rendered as Rust // --------------------------------------------------------------------------- // -// Uniforms are derived from parameters — `exp2(exposure)`, `blacks / 100 * -// 0.02` — and that derivation is the one piece of a node that is genuinely -// computation rather than description. It is kept to arithmetic over the -// node's own parameters and a fixed set of maths functions: enough for every -// operation in the chain, and small enough that a reader of the YAML can see -// exactly what will happen. +// The grammar and the validation live in `src/declared/expr.rs`, shared with +// the crate. What is left here is the half that is specific to generating +// code: turning a validated [`Expr`] into a Rust `f32` expression, so that a +// built-in node's arithmetic costs nothing at run time. // -// Compiled to Rust rather than interpreted, so an unknown name or a wrong -// arity is a build error naming the file, and the arithmetic itself costs -// nothing at runtime. +// `Expr::eval` is the other half, and the two must agree bit for bit. Every +// arm below has a counterpart there, written to produce the same operations in +// the same order — including `mix`, which is spelled out in both because +// `a + (b - a) * t` and `a * (1 - t) + b * t` are different numbers in `f32`. -#[derive(Debug)] -enum Expr { - Num(f64), - Param(String), - Neg(Box), - Bin(char, Box, Box), - Call(String, Vec), +/// Compile a validated expression to a Rust `f32` expression. +fn compile_expr(expr: &Expr) -> String { + unwrap_parens(&render(expr)) } -/// Compile a declared expression to a Rust `f32` expression. -fn compile_expr(src: &str, params: &BTreeSet<&str>) -> Result { - let tokens = tokenise(src)?; - let mut parser = Parser { tokens, at: 0 }; - let expr = parser.expr()?; - if parser.at < parser.tokens.len() { - return Err(format!( - "unexpected `{}` after the end of the expression", - parser.tokens[parser.at] - )); +/// Render an expression as Rust source. +/// +/// Infallible: `expr::parse` has already established that every name resolves +/// and every call has the right arity, which is why the validation lives in +/// the shared reader rather than here — an unknown function had to be rejected +/// identically whether the declaration was read by this script or at load +/// time. +fn render(expr: &Expr) -> String { + match expr { + Expr::Num(n) => rust_f32(*n), + Expr::Param(name) => format!("self.{name}"), + // One pair of parentheses, never two: the inner expression brings its + // own, and `-((a * b))` is a clippy warning in code nobody can edit. + // The pair that remains is load-bearing — `-(a + b)` and `-a + b` are + // different numbers. + Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner))), + Expr::Bin(op, l, r) => format!("({} {op} {})", render(l), render(r)), + Expr::Call(name, args) => { + let a: Vec = args.iter().map(|x| unwrap_parens(&render(x))).collect(); + match name.as_str() { + "exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => { + format!("f32::{name}({})", a[0]) + } + "pow" => format!("f32::powf({}, {})", a[0], a[1]), + "min" | "max" => format!("f32::{name}({}, {})", a[0], a[1]), + "clamp" => format!("f32::clamp({}, {}, {})", a[0], a[1], a[2]), + // Spelled out rather than called: Rust has no `mix`, and the + // linear form is what WGSL's `mix` means. + "mix" => format!("({x} + ({y} - {x}) * {t})", x = a[0], y = a[1], t = a[2]), + // `expr::FUNCTIONS` is the closed list and `expr::parse` + // enforces it, so reaching here means the two fell out of step. + other => unreachable!("`{other}` passed validation but has no Rust rendering"), + } + } } - render(&expr, params).map(|s| unwrap_parens(&s)) } /// Drop one redundant pair of enclosing parentheses. @@ -1159,240 +330,6 @@ fn unwrap_parens(s: &str) -> String { s.to_string() } } - -#[derive(Debug, Clone, PartialEq)] -enum Tok { - Num(f64), - Ident(String), - Sym(char), -} - -impl std::fmt::Display for Tok { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Tok::Num(n) => write!(f, "{n}"), - Tok::Ident(s) => write!(f, "{s}"), - Tok::Sym(c) => write!(f, "{c}"), - } - } -} - -fn tokenise(src: &str) -> Result, String> { - let bytes: Vec = src.chars().collect(); - let mut out = Vec::new(); - let mut i = 0; - while i < bytes.len() { - let c = bytes[i]; - if c.is_whitespace() { - i += 1; - } else if c.is_ascii_digit() - || (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit)) - { - let start = i; - while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { - i += 1; - } - let text: String = bytes[start..i].iter().collect(); - let n = text - .parse::() - .map_err(|_| format!("`{text}` is not a number"))?; - out.push(Tok::Num(n)); - } else if c.is_ascii_alphabetic() || c == '_' { - let start = i; - while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') { - i += 1; - } - out.push(Tok::Ident(bytes[start..i].iter().collect())); - } else if "+-*/(),".contains(c) { - out.push(Tok::Sym(c)); - i += 1; - } else { - return Err(format!( - "`{c}` is not valid in an expression; the language is \ - arithmetic (+ - * /), parentheses, numbers, this node's \ - parameters, and the maths functions" - )); - } - } - if out.is_empty() { - return Err("the expression is empty".into()); - } - Ok(out) -} - -struct Parser { - tokens: Vec, - at: usize, -} - -impl Parser { - fn peek(&self) -> Option<&Tok> { - self.tokens.get(self.at) - } - - fn eat(&mut self, sym: char) -> bool { - if self.peek() == Some(&Tok::Sym(sym)) { - self.at += 1; - return true; - } - false - } - - fn expr(&mut self) -> Result { - let mut left = self.term()?; - loop { - if self.eat('+') { - left = Expr::Bin('+', Box::new(left), Box::new(self.term()?)); - } else if self.eat('-') { - left = Expr::Bin('-', Box::new(left), Box::new(self.term()?)); - } else { - return Ok(left); - } - } - } - - fn term(&mut self) -> Result { - let mut left = self.unary()?; - loop { - if self.eat('*') { - left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?)); - } else if self.eat('/') { - left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?)); - } else { - return Ok(left); - } - } - } - - fn unary(&mut self) -> Result { - if self.eat('-') { - return Ok(Expr::Neg(Box::new(self.unary()?))); - } - self.primary() - } - - fn primary(&mut self) -> Result { - match self.peek().cloned() { - Some(Tok::Num(n)) => { - self.at += 1; - Ok(Expr::Num(n)) - } - Some(Tok::Ident(name)) => { - self.at += 1; - if !self.eat('(') { - return Ok(Expr::Param(name)); - } - let mut args = Vec::new(); - if !self.eat(')') { - loop { - args.push(self.expr()?); - if self.eat(',') { - continue; - } - if self.eat(')') { - break; - } - return Err(format!("expected `,` or `)` in the call to `{name}`")); - } - } - Ok(Expr::Call(name, args)) - } - Some(Tok::Sym('(')) => { - self.at += 1; - let inner = self.expr()?; - if !self.eat(')') { - return Err("unclosed `(`".into()); - } - Ok(inner) - } - Some(t) => Err(format!("unexpected `{t}`")), - None => Err("the expression ends early".into()), - } - } -} - -/// The maths functions a node may call, and the Rust each becomes. -/// -/// A closed list rather than a passthrough to `f32`: a node is a description, -/// and letting it name arbitrary Rust would make the YAML a second, worse -/// place to write code. -fn render(expr: &Expr, params: &BTreeSet<&str>) -> Result { - Ok(match expr { - Expr::Num(n) => rust_f32(*n), - Expr::Param(name) => { - if !params.contains(name.as_str()) { - let known: Vec<&str> = params.iter().copied().collect(); - return Err(format!( - "`{name}` is not a parameter of this node. Its parameters \ - are: {}", - known.join(", ") - )); - } - format!("self.{name}") - } - // One pair of parentheses, never two: the inner expression brings its - // own, and `-((a * b))` is a clippy warning in code nobody can edit. - // The pair that remains is load-bearing — `-(a + b)` and `-a + b` are - // different numbers. - Expr::Neg(inner) => format!("-({})", unwrap_parens(&render(inner, params)?)), - Expr::Bin(op, l, r) => format!("({} {op} {})", render(l, params)?, render(r, params)?), - Expr::Call(name, args) => { - let rendered: Vec = args - .iter() - .map(|a| render(a, params).map(|s| unwrap_parens(&s))) - .collect::>()?; - let arity = |n: usize| -> Result<(), String> { - if rendered.len() != n { - return Err(format!( - "`{name}` takes {n} argument(s), given {}", - rendered.len() - )); - } - Ok(()) - }; - match name.as_str() { - "exp2" | "log2" | "exp" | "sqrt" | "abs" | "floor" | "ceil" | "round" => { - arity(1)?; - format!("f32::{name}({})", rendered[0]) - } - "pow" => { - arity(2)?; - format!("f32::powf({}, {})", rendered[0], rendered[1]) - } - "min" | "max" => { - arity(2)?; - format!("f32::{name}({}, {})", rendered[0], rendered[1]) - } - "clamp" => { - arity(3)?; - format!( - "f32::clamp({}, {}, {})", - rendered[0], rendered[1], rendered[2] - ) - } - "mix" => { - arity(3)?; - // Spelled out rather than called: Rust has no `mix`, and - // the linear form is what WGSL's `mix` means. - format!( - "({a} + ({b} - {a}) * {t})", - a = rendered[0], - b = rendered[1], - t = rendered[2] - ) - } - other => { - return Err(format!( - "`{other}` is not one of the maths functions a node may \ - call. Available: exp2, log2, exp, sqrt, abs, floor, \ - ceil, round, pow, min, max, clamp, mix" - )) - } - } - } - }) -} - // --------------------------------------------------------------------------- // Emission // --------------------------------------------------------------------------- @@ -1409,8 +346,8 @@ fn emit(shared: &SharedHelpers, nodes: &[Node]) -> Result { emit_helpers(&mut out, shared); for node in nodes { - if let Node::Declared { .. } = node { - emit_node(&mut out, node)?; + if let Node::Declared(declared) = node { + emit_node(&mut out, declared); } } emit_chain(&mut out, nodes); @@ -1436,7 +373,7 @@ fn emit_helpers(out: &mut String, shared: &SharedHelpers) { " pub const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};", h.name.to_uppercase(), h.name, - wgsl_literal(&h.wgsl, h.doc.as_deref()) + raw_string(&h.source()) ); } @@ -1455,8 +392,8 @@ fn emit_helpers(out: &mut String, shared: &SharedHelpers) { ); } -fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { - let Node::Declared { +fn emit_node(out: &mut String, node: &Declaration) { + let Declaration { id, label, attributes, @@ -1465,15 +402,11 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { uniforms, shared_helpers, local_helpers, - wgsl, active, tests, presentation, .. - } = node - else { - return Ok(()); - }; + } = node; let ty = pascal_case(id); @@ -1529,7 +462,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { if let Some(doc) = &p.doc { out.push_str(&comment(doc, "//", 16)); } - let _ = writeln!(out, " {},", p.ctor); + let _ = writeln!(out, " {},", param_ctor(p)); } out.push_str(" ],\n"); @@ -1538,7 +471,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let attrs: Vec = attributes .iter() .map(|a| { - let mut c = a.chars(); + let mut c = a.name().chars(); let head = c .next() .expect("attribute names are non-empty") @@ -1556,7 +489,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { "\n const {}: Helper = Helper {{\n name: {:?},\n source: {},\n }};", local_helper_const(&h.name), h.name, - wgsl_literal(&h.wgsl, h.doc.as_deref()) + raw_string(&h.source()) ); } @@ -1624,7 +557,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { // default rule is the honest one: the operation is doing something exactly // when a parameter has moved off its default. let active_expr = match active { - Some(expr) => format!("({expr}) != 0.0"), + Some(expr) => format!("({}) != 0.0", compile_expr(expr)), None => params .iter() .map(|p| format!("self.{} != {}", p.id, rust_f32(p.default))) @@ -1639,7 +572,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let _ = writeln!( out, " fn wgsl_body(&self) -> String {{\n {}.into()\n }}\n", - wgsl_literal(wgsl, None) + raw_string(&node.wgsl_body()) ); out.push_str(" fn uniforms(&self) -> Vec {\n vec![\n"); @@ -1650,7 +583,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { let _ = writeln!( out, " Uniform {{ name: {:?}, value: {} }},", - u.name, u.rust + u.name, + compile_expr(&u.expr) ); } out.push_str(" ]\n }\n"); @@ -1676,10 +610,19 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { \x20 params: vec![{}],\n\ \x20 }})\n\ \x20 }}", - p.widgets.join(", "), + p.widgets + .iter() + .map(|w| w.rust()) + .collect::>() + .join(", "), p.two_dimensional, p.precise_pointing, - p.params.join(", ") + // The generated `ParamId` constants, which are the ids uppercased. + p.params + .iter() + .map(|name| name.to_uppercase()) + .collect::>() + .join(", ") ); } @@ -1687,7 +630,6 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> { emit_tests(out, &ty, params, tests); out.push_str("}\n\n"); - Ok(()) } fn emit_tests(out: &mut String, ty: &str, params: &[ParamDef], tests: &[TestDef]) { @@ -1831,7 +773,8 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { out.push_str(&comment(placement, "//", 8)); } match node { - Node::Declared { id, .. } => { + Node::Declared(declared) => { + let id = &declared.id; let _ = writeln!(out, " Box::new({id}::{}::new()),", pascal_case(id)); } Node::Rust { ty, why_rust, .. } => { @@ -1862,6 +805,66 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { // Rendering helpers // --------------------------------------------------------------------------- +/// The `ParamDescriptor` constructor call for a declared parameter. +/// +/// The kinds are the constructors `descriptor.rs` already offers, named rather +/// than spelled out: `amount` is the −100…+100 shape nearly every photographic +/// control takes, and writing its range in every node would invite one of them +/// to drift. +/// +/// The load-time reader calls the *same* constructors from the same +/// [`decl::Kind`] — see `declared/mod.rs`'s `param_descriptor` — so the two +/// produce equal descriptors by construction rather than by coincidence. +fn param_ctor(p: &ParamDef) -> String { + let (id, label) = (&p.id, &p.label); + match &p.kind { + decl::Kind::Stops { min, max } => format!( + "ParamDescriptor::stops({id:?}, {label:?}, {}, {})", + rust_f32(*min), + rust_f32(*max) + ), + decl::Kind::Amount => format!("ParamDescriptor::amount({id:?}, {label:?})"), + decl::Kind::Switch => format!("ParamDescriptor::switch({id:?}, {label:?})"), + decl::Kind::Fraction { default } => format!( + "ParamDescriptor::fraction({id:?}, {label:?}, {})", + rust_f32(*default) + ), + decl::Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + } => format!( + "ParamDescriptor::scalar({id:?}, {label:?}, {}, {}, {}, {}, {}, {precision})", + rust_f32(*min), + rust_f32(*max), + rust_f32(*default), + match unit { + decl::Unit::None => "Unit::None", + decl::Unit::Stops => "Unit::Stops", + decl::Unit::Kelvin => "Unit::Kelvin", + decl::Unit::Percent => "Unit::Percent", + }, + match scale { + decl::Scale::Linear => "Scale::Linear", + decl::Scale::Perceptual => "Scale::Perceptual", + } + ), + decl::Kind::Enum { variants } => { + let keys: Vec = variants + .iter() + .map(|v| format!("LocalizedKey({v:?})")) + .collect(); + format!( + "ParamDescriptor::choice({id:?}, {label:?}, vec![{}])", + keys.join(", ") + ) + } + } +} + /// A WGSL block as a Rust raw string literal. /// /// Raw so the WGSL reads as itself in the generated file — escaped quotes and @@ -1872,22 +875,12 @@ fn emit_chain(out: &mut String, nodes: &[Node]) { /// a fragment as it places it into the generated shader, so a literal indented /// here to look tidy in `nodes.rs` would arrive in the WGSL indented twice. /// The hand-written operations had the same shape for the same reason. -fn wgsl_literal(wgsl: &str, doc: Option<&str>) -> String { - let mut body = String::new(); - // Prose from the declaration becomes a WGSL comment above the function, - // which is where it is useful — the generated shader is what gets read - // when a compile fails. - if let Some(doc) = doc { - for line in doc.trim_end().lines() { - if line.trim().is_empty() { - body.push_str("//\n"); - } else { - let _ = writeln!(body, "// {line}"); - } - } - } - body.push_str(wgsl.trim_matches('\n').trim_end()); - +/// +/// The *content* it wraps comes from the shared reader — `Declaration:: +/// wgsl_body` and `HelperDef::source` — because that content is what reaches +/// the composed shader, and a run-time node has to produce the same +/// characters. All this adds is the quoting. +fn raw_string(body: &str) -> String { let mut hashes = String::new(); while body.contains(&format!("\"{hashes}")) { hashes.push('#'); @@ -1939,82 +932,3 @@ fn comment(text: &str, marker: &str, indent: usize) -> String { } out } - -// --------------------------------------------------------------------------- -// YAML access -// --------------------------------------------------------------------------- - -fn read_yaml(path: &Path) -> Result { - let text = std::fs::read_to_string(path) - .map_err(|e| format!("cannot read {}: {e}", path.display()))?; - serde_norway::from_str(&text).map_err(|e| format!("{}: not valid YAML: {e}", path.display())) -} - -fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> { - value - .as_mapping() - .ok_or_else(|| format!("`{ctx}` must be a mapping")) -} - -fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> { - value - .as_str() - .ok_or_else(|| format!("`{ctx}` must be a string")) -} - -fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result, String> { - match map.get(key) { - None => Ok(None), - Some(v) => v - .as_str() - .map(|s| Some(s.trim_end().to_string())) - .ok_or_else(|| format!("`{ctx}.{key}` must be a string")), - } -} - -/// Names reaching generated Rust have to be identifiers, and must not be -/// keywords — `ParamId("type")` would generate a struct field called `type`. -fn check_ident(name: &str, ctx: &str) -> Result<(), String> { - if name.is_empty() { - return Err(format!("`{ctx}` is empty")); - } - let head_ok = name - .chars() - .next() - .is_some_and(|c| c.is_ascii_lowercase() || c == '_'); - let rest_ok = name - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); - if !head_ok || !rest_ok { - return Err(format!( - "`{ctx}` is `{name}`; names must be lower_snake_case so they can \ - become Rust identifiers" - )); - } - const KEYWORDS: &[&str] = &[ - "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", - "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", - "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", - "use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv", - "try", "typeof", "unsized", "virtual", "yield", - ]; - if KEYWORDS.contains(&name) { - return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword")); - } - Ok(()) -} - -/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an -/// id. Checked only for shape — whether the type exists, and whether it -/// implements `Operation`, is for the compiler to say. -fn check_type_name(name: &str) -> Result<(), String> { - let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) - && name.chars().all(|c| c.is_ascii_alphanumeric()); - if !ok { - return Err(format!( - "`rust: {name}` must be the PascalCase name of a type in \ - `crate::ops`, such as `ToneCurve`" - )); - } - Ok(()) -} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index b2e7ad7..ba1d5d2 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -6,9 +6,23 @@ directory — there is no list to extend, no shader to edit, and no UI change. `../build.rs` compiles each declaration into Rust implementing [`Operation`](../src/operation.rs), generated into `OUT_DIR`. The result is indistinguishable downstream from a hand-written operation: the same -`&'static OpDescriptor`, the same fused-shader composition, the same sidecar +`Arc`, the same fused-shader composition, the same sidecar round-trip. +**The same declaration also runs without being compiled** (FR-PLG-2). +[`DeclaredOp`](../src/declared/mod.rs) reads this format at *load* time and +implements `Operation` from it directly — one interpreter over many +declarations, where `build.rs` emits generated code per node. Both paths exist +on purpose: the built-ins stay compiled, because a generated `match` is faster +than an interpreted one and because the `tests:` blocks below have to run under +`cargo test`. + +The reading half is one file, [`src/declared/decl.rs`](../src/declared/decl.rs), +shared by both — so everything documented here means exactly one thing, and +`tests/declared_parity.rs` asserts the two backends compose byte-identical WGSL +for every node in this directory. Nothing in this document is specific to the +build-time path. + ## `attributes:` — what the operation is about Required, one or more of `tone`, `colour`, `detail`, `optics`, `geometry`, diff --git a/core/dr-pipeline/src/declared/decl.rs b/core/dr-pipeline/src/declared/decl.rs new file mode 100644 index 0000000..adb1039 --- /dev/null +++ b/core/dr-pipeline/src/declared/decl.rs @@ -0,0 +1,1175 @@ +//! TRACES: FR-PLG-2 | FR-PLG-2d +//! Reading a node declaration — the one reader, used at build time and at load +//! time. +//! +//! # Why this is not in `build.rs` +//! +//! It was, and everything below is that code. FR-PLG-2 requires the schema +//! documented in `ops/README.md` to be "readable at **load time** as well as +//! build time … with no change to what a declaration means", and the sharpest +//! way to guarantee "no change" is for there to be nothing that could change: +//! one grammar, one set of validations, one set of error messages. +//! +//! So this module produces a **neutral** [`Declaration`] — owned data that +//! names no Rust type and no crate item — and the two backends work from it: +//! +//! - `build.rs` `#[path]`-includes this file and renders a `Declaration` as +//! Rust source, so the operations that ship with the application stay +//! compiled and inspectable. +//! - [`super::DeclaredOp`] converts a `Declaration` into descriptors and +//! evaluates it, so an operation found in a file at startup is the same kind +//! of thing as one found at compile time. +//! +//! `tests/declared_parity.rs` then asserts the two agree byte for byte on +//! every node in `ops/`. +//! +//! **Nothing here may refer to the rest of the crate.** `build.rs` compiles it +//! as a standalone module of a different crate, so `crate::` paths would not +//! resolve. The mapping from these neutral types onto `crate::descriptor`'s is +//! in `declared/mod.rs`, which is compiled only as part of the library. + +use std::collections::BTreeSet; + +use serde_norway::{Mapping, Value}; + +use super::expr::{self, Expr}; + +/// The file shared WGSL helpers are declared in. +pub const HELPERS_FILE: &str = "_helpers.yaml"; + +// --------------------------------------------------------------------------- +// The declaration model +// --------------------------------------------------------------------------- + +/// A WGSL helper function, either shared or declared by one node. +pub struct HelperDef { + pub name: String, + pub doc: Option, + pub wgsl: String, +} + +impl HelperDef { + /// The helper's source exactly as it reaches the composed shader. + /// + /// Prose from the declaration becomes a WGSL comment above the function, + /// which is where it is useful — the generated shader is what gets read + /// when a compile fails. + /// + /// Shared between the backends because it is *content*, not rendering: + /// `build.rs` wraps this in a Rust raw-string literal and a declared + /// operation interns it, and the two have to produce the same characters + /// or the composed shader differs. + pub fn source(&self) -> String { + literal_body(&self.wgsl, self.doc.as_deref()) + } +} + +/// TRACES: FR-PLG-2d +/// What a parameter *is*, from the closed list of kinds a declaration may name. +/// +/// Closed on purpose, and the reason `ops/README.md` gives strengthens here: a +/// typo that invented a kind would produce a control nobody asked for, and a +/// control that silently does not exist is worse than a build that stops. +pub enum Kind { + Stops { + min: f64, + max: f64, + }, + Amount, + Switch, + Fraction { + default: f64, + }, + Scalar { + min: f64, + max: f64, + default: f64, + unit: Unit, + scale: Scale, + precision: u64, + }, + Enum { + variants: Vec, + }, +} + +/// The unit a value carries, mirroring `crate::descriptor::Unit`. +#[derive(Clone, Copy)] +pub enum Unit { + None, + Stops, + Kelvin, + Percent, +} + +/// What a slider's travel means, mirroring `crate::descriptor::Scale`. +#[derive(Clone, Copy)] +pub enum Scale { + Linear, + Perceptual, +} + +/// TRACES: FR-PLG-2d +/// What an operation is about, mirroring `crate::descriptor::Attribute`. +/// +/// A closed vocabulary. `declared/mod.rs` asserts that this list and the +/// crate's own agree, so the two spellings of one idea cannot drift. +#[derive(Clone, Copy, PartialEq)] +pub enum Attr { + Tone, + Colour, + Detail, + Optics, + Geometry, + Effect, +} + +impl Attr { + /// The name a declaration spells this with. + pub const fn name(self) -> &'static str { + match self { + Attr::Tone => "tone", + Attr::Colour => "colour", + Attr::Detail => "detail", + Attr::Optics => "optics", + Attr::Geometry => "geometry", + Attr::Effect => "effect", + } + } + + /// Every attribute, in the order `crate::descriptor::Attribute` declares + /// them. + pub const ALL: [Attr; 6] = [ + Attr::Tone, + Attr::Colour, + Attr::Detail, + Attr::Optics, + Attr::Geometry, + Attr::Effect, + ]; +} + +/// TRACES: FR-PLG-2d +/// A widget a node may ask for, mirroring `crate::descriptor::WidgetKind`. +/// +/// Spelled in the YAML the way the enum spells it, so a node declaration and +/// `descriptor.rs` cannot drift into two vocabularies for one idea. +#[derive(Clone, Copy, PartialEq)] +pub enum Widget { + ToneCurve, + ColourWheel, + CropOverlay, + GradientHandle, + BrushMask, + WhitePoint, +} + +impl Widget { + pub const fn name(self) -> &'static str { + match self { + Widget::ToneCurve => "tone_curve", + Widget::ColourWheel => "colour_wheel", + Widget::CropOverlay => "crop_overlay", + Widget::GradientHandle => "gradient_handle", + Widget::BrushMask => "brush_mask", + Widget::WhitePoint => "white_point", + } + } + + /// The `WidgetKind` variant this is, as Rust source. For `build.rs`. + pub const fn rust(self) -> &'static str { + match self { + Widget::ToneCurve => "WidgetKind::ToneCurve", + Widget::ColourWheel => "WidgetKind::ColourWheel", + Widget::CropOverlay => "WidgetKind::CropOverlay", + Widget::GradientHandle => "WidgetKind::GradientHandle", + Widget::BrushMask => "WidgetKind::BrushMask", + Widget::WhitePoint => "WidgetKind::WhitePoint", + } + } + + pub const ALL: [Widget; 6] = [ + Widget::ToneCurve, + Widget::ColourWheel, + Widget::CropOverlay, + Widget::GradientHandle, + Widget::BrushMask, + Widget::WhitePoint, + ]; +} + +/// One parameter of a declared node. +pub struct ParamDef { + pub id: String, + pub label: String, + pub doc: Option, + pub kind: Kind, + /// Needed to generate `is_active` and to range-check test values. + pub default: f64, + pub min: f64, + pub max: f64, +} + +/// A uniform the node's fragment reads, and the expression computing it. +pub struct UniformDef { + pub name: String, + pub doc: Option, + pub expr: Expr, +} + +/// A node's `presentation:` block. +pub struct PresentationDef { + /// Most preferred first. + pub widgets: Vec, + /// The owned parameters' ids, in widget order. + pub params: Vec, + pub two_dimensional: bool, + pub precise_pointing: bool, +} + +/// One declared test. +/// +/// Read and validated here so that a nonsense assertion is rejected the same +/// way whoever wrote it, though only `build.rs` emits anything from it: a +/// declared node's tests run under `cargo test` against the *generated* +/// implementation, which is where FR-PLG-2 says they belong. +pub struct TestDef { + pub name: String, + pub why: Option, + pub set: Vec<(String, f64)>, + pub expect: Vec<(String, f64)>, + pub expect_range: Vec<(String, f64, f64)>, + pub expect_active: Option, + pub expect_wgsl: Vec, + pub expect_helper_wgsl: Vec<(String, Vec)>, +} + +/// A node declared in full. +pub struct Declaration { + pub id: String, + pub label: String, + pub order: i64, + /// What the operation is about (ARCH §4.3a). Never empty — see + /// [`read_attributes`]. + pub attributes: Vec, + pub doc: Option, + pub placement: Option, + pub params: Vec, + pub uniforms: Vec, + /// Names of shared helpers, in declaration order. + pub shared_helpers: Vec, + /// Helpers this node defines for itself. + pub local_helpers: Vec, + pub wgsl: String, + /// `None` means the default rule: active when any parameter has moved. + pub active: Option, + pub tests: Vec, + /// `None` — the usual case — means one control per parameter. + /// + /// Boxed so the rare node that declares one does not widen every + /// declaration by the size of a presentation it does not have. + pub presentation: Option>, +} + +impl Declaration { + /// The fragment body exactly as it reaches [`crate::Operation::wgsl_body`]. + pub fn wgsl_body(&self) -> String { + literal_body(&self.wgsl, None) + } +} + +/// A node: either declared in full, or a pointer to a hand-written type. +/// +/// The declared arm is boxed because it is an order of magnitude larger than +/// the other: a `Declaration` carries every parameter, uniform, helper and +/// test a node has, while a `rust:` node is four strings. Both readers move +/// these by value out of the parser, and an unboxed enum would copy the larger +/// shape every time it moved either. +pub enum Node { + Declared(Box), + Rust { + id: String, + order: i64, + /// The type in `crate::ops` implementing `Operation`. + ty: String, + why_rust: Option, + placement: Option, + }, +} + +impl Node { + pub fn id(&self) -> &str { + match self { + Node::Declared(d) => &d.id, + Node::Rust { id, .. } => id, + } + } + + pub fn order(&self) -> i64 { + match self { + Node::Declared(d) => d.order, + Node::Rust { order, .. } => *order, + } + } + + pub fn placement(&self) -> Option<&str> { + match self { + Node::Declared(d) => d.placement.as_deref(), + Node::Rust { placement, .. } => placement.as_deref(), + } + } +} + +/// The WGSL a declaration contributes, with its prose folded in as comments. +/// +/// The single definition of "what text does this declaration produce", so that +/// the generated raw-string literal and the interpreted `String` cannot come +/// to hold different characters. +/// +/// The trailing `trim` pair is load-bearing rather than tidiness: it is what +/// `build.rs` used to do inline when emitting the literal, and the composed +/// shader would differ by a newline without it. +fn literal_body(wgsl: &str, doc: Option<&str>) -> String { + let mut body = String::new(); + if let Some(doc) = doc { + for line in doc.trim_end().lines() { + if line.trim().is_empty() { + body.push_str("//\n"); + } else { + body.push_str("// "); + body.push_str(line); + body.push('\n'); + } + } + } + body.push_str(wgsl.trim_matches('\n').trim_end()); + body +} + +// --------------------------------------------------------------------------- +// Reading: shared helpers +// --------------------------------------------------------------------------- + +pub struct SharedHelpers { + pub doc: Option, + pub helpers: Vec, +} + +impl SharedHelpers { + /// The names, for validating a node's `helpers:` list against. + pub fn names(&self) -> BTreeSet<&str> { + self.helpers.iter().map(|h| h.name.as_str()).collect() + } + + pub fn get(&self, name: &str) -> Option<&HelperDef> { + self.helpers.iter().find(|h| h.name == name) + } +} + +/// Parse `_helpers.yaml`. +pub fn read_helpers(text: &str) -> Result { + let doc = parse_yaml(text, HELPERS_FILE)?; + let root = as_mapping(&doc, HELPERS_FILE)?; + + let module_doc = opt_prose(root, "doc", HELPERS_FILE)?; + let helpers = root + .get("helpers") + .ok_or_else(|| format!("{HELPERS_FILE}: missing `helpers:` map"))?; + let helpers = as_mapping(helpers, "helpers")?; + + let mut out = Vec::new(); + for (name, spec) in helpers { + let name = as_str(name, "a helper name")?.to_string(); + let ctx = format!("helpers.{name}"); + let spec = as_mapping(spec, &ctx)?; + let wgsl = spec + .get("wgsl") + .ok_or_else(|| format!("{HELPERS_FILE}: `{ctx}` has no `wgsl:`"))?; + let wgsl = as_str(wgsl, &format!("{ctx}.wgsl"))?.trim_end().to_string(); + + // The composer deduplicates by name, so a helper whose declared name + // is not the function it defines would be emitted under one name and + // called under another. + if !wgsl.contains(&format!("fn {name}(")) { + return Err(format!( + "{HELPERS_FILE}: `{ctx}` does not define `fn {name}(`. The key \ + is the name the composer deduplicates on, so it has to be the \ + function actually declared." + )); + } + out.push(HelperDef { + doc: opt_prose(spec, "doc", &ctx)?, + name, + wgsl, + }); + } + + if out.is_empty() { + return Err(format!("{HELPERS_FILE}: `helpers:` is empty")); + } + Ok(SharedHelpers { + doc: module_doc, + helpers: out, + }) +} + +// --------------------------------------------------------------------------- +// Reading: a node +// --------------------------------------------------------------------------- + +/// TRACES: FR-PLG-2d +/// The attributes an operation declares, validated against the vocabulary. +/// +/// **Required, and non-empty.** An operation with no attribute is invisible to +/// a frontend that filters by them, and a control that silently does not exist +/// is a far worse failure than a build that stops — especially when the cause +/// is one missing line in a YAML file nobody had reason to open. Failing here +/// costs whoever adds an operation ten seconds; failing at runtime costs a +/// photographer a control they cannot find and cannot know is missing. +/// +/// The vocabulary is closed on purpose. A typo would otherwise invent a +/// category containing exactly one operation, which is indistinguishable from +/// a deliberate new one until somebody notices the tab with a single control +/// in it. +fn read_attributes(root: &Mapping) -> Result, String> { + let known: Vec<&str> = Attr::ALL.iter().map(|a| a.name()).collect(); + + let value = root.get("attributes").ok_or_else(|| { + format!( + "missing `attributes:`; every operation must say what it is about, \ + one or more of {known:?}. It is what lets the panel group \ + operations without naming any of them (ARCH §4.3a)." + ) + })?; + + let list = value + .as_sequence() + .ok_or("`attributes:` must be a list, even with one entry")?; + + let mut out: Vec = Vec::new(); + for entry in list { + let name = as_str(entry, "attributes")?; + let Some(attr) = Attr::ALL.iter().copied().find(|a| a.name() == name) else { + return Err(format!( + "unknown attribute {name:?}; expected one of {known:?}" + )); + }; + if out.contains(&attr) { + return Err(format!("attribute {name:?} is listed twice")); + } + out.push(attr); + } + + if out.is_empty() { + return Err("`attributes:` is empty; an operation with no attribute \ + would not appear in a panel that groups by them" + .into()); + } + Ok(out) +} + +/// Parse one `ops/.yaml`. +/// +/// `shared` is the set of helper names `_helpers.yaml` defines, so that a node +/// naming one that does not exist is rejected where it is written rather than +/// producing a shader that fails to compile. +pub fn read_node(text: &str, ctx: &str, shared: &BTreeSet<&str>) -> Result { + let doc = parse_yaml(text, ctx)?; + let root = as_mapping(&doc, "the document")?; + + let id = as_str(root.get("id").ok_or("missing `id:`")?, "id")?.to_string(); + check_ident(&id, "id")?; + + let order = root + .get("order") + .ok_or("missing `order:`; it is what places this node in the chain")? + .as_i64() + .ok_or("`order` must be a whole number")?; + + let placement = opt_prose(root, "placement", "placement")?; + + // A `rust:` node describes where a hand-written type sits, and nothing + // else — its descriptor comes from the type. Mixing the two forms would + // mean two sources for one node's parameters. + if let Some(ty) = root.get("rust") { + let ty = as_str(ty, "rust")?.to_string(); + check_type_name(&ty)?; + for key in ["params", "uniforms", "wgsl", "helpers", "define", "label"] { + if root.contains_key(key) { + return Err(format!( + "`{key}` is meaningless on a `rust:` node — {ty} publishes \ + its own descriptor. Remove one or the other." + )); + } + } + return Ok(Node::Rust { + id, + order, + ty, + why_rust: opt_prose(root, "why_rust", "why_rust")?, + placement, + }); + } + + let label = as_str(root.get("label").ok_or("missing `label:`")?, "label")?.to_string(); + let attributes = read_attributes(root)?; + + let params = read_params(root)?; + let param_names: BTreeSet<&str> = params.iter().map(|p| p.id.as_str()).collect(); + + let local_helpers = read_local_helpers(root)?; + let shared_helpers = read_shared_refs(root, shared, &local_helpers)?; + + let wgsl = as_str(root.get("wgsl").ok_or("missing `wgsl:`")?, "wgsl")? + .trim_end() + .to_string(); + if wgsl.trim().is_empty() { + return Err("`wgsl:` is empty; a node that changes nothing is not a node".into()); + } + + let uniforms = read_uniforms(root, ¶m_names)?; + + let active = match root.get("active") { + None => None, + Some(v) => { + let src = as_str(v, "active")?; + Some(expr::parse(src, ¶m_names).map_err(|e| format!("`active`: {e}"))?) + } + }; + + let uniform_names: BTreeSet<&str> = uniforms.iter().map(|u| u.name.as_str()).collect(); + let helper_names: BTreeSet<&str> = shared_helpers + .iter() + .map(String::as_str) + .chain(local_helpers.iter().map(|h| h.name.as_str())) + .collect(); + let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?; + let presentation = read_presentation(root, ¶m_names)?; + + Ok(Node::Declared(Box::new(Declaration { + id, + label, + order, + doc: opt_prose(root, "doc", "doc")?, + attributes, + placement, + params, + uniforms, + shared_helpers, + local_helpers, + wgsl, + active, + tests, + presentation, + }))) +} + +/// The widgets a node would like, in descending order of preference. +/// +/// Optional, and absent on nearly every node — one control per parameter is +/// the right answer for a list of unrelated sliders, which is what most +/// operations are. Declaring this says that several parameters form *one* +/// conceptual control. +/// +/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of +/// the named widgets renders the parameters as ordinary sliders and the edit +/// still works, which is why the list can safely name widgets that do not +/// exist yet. +fn read_presentation( + root: &Mapping, + params: &BTreeSet<&str>, +) -> Result>, String> { + let Some(value) = root.get("presentation") else { + return Ok(None); + }; + let m = as_mapping(value, "presentation")?; + + let widgets = m + .get("widgets") + .and_then(Value::as_sequence) + .ok_or("`presentation` needs a `widgets:` list, most preferred first")?; + if widgets.is_empty() { + return Err("`presentation.widgets` is empty; omit `presentation:` instead".into()); + } + let widgets = widgets + .iter() + .enumerate() + .map(|(i, w)| { + let name = as_str(w, &format!("presentation.widgets[{i}]"))?; + Widget::ALL + .iter() + .copied() + .find(|k| k.name() == name) + .ok_or_else(|| { + format!( + "`presentation.widgets[{i}]` is `{name}`; expected tone_curve, \ + colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point" + ) + }) + }) + .collect::, _>>()?; + + // The parameters the widget owns, in the order it expects them. Checked + // against the node's own list, because a typo here would silently leave a + // parameter out of the widget *and* out of the panel — the widget claims + // it, and the generic path skips what the widget claimed. + let owned = m + .get("params") + .and_then(Value::as_sequence) + .ok_or("`presentation` needs a `params:` list naming what the widget owns")?; + let owned = owned + .iter() + .enumerate() + .map(|(i, p)| { + let name = as_str(p, &format!("presentation.params[{i}]"))?; + if !params.contains(name) { + return Err(format!( + "`presentation.params[{i}]` is `{name}`, which this node does not declare" + )); + } + Ok(name.to_string()) + }) + .collect::, _>>()?; + + let demand = match m.get("demand") { + None => (false, false), + Some(d) => { + let d = as_mapping(d, "presentation.demand")?; + let flag = |key: &str| -> Result { + match d.get(key) { + None => Ok(false), + Some(v) => v.as_bool().ok_or_else(|| { + format!("`presentation.demand.{key}` must be true or false") + }), + } + }; + // Deliberately only these two. ARCH §4.3a forbids a demand + // carrying pixels, breakpoints or a platform name — those are the + // frontend's to decide — so there is no key here to write one in. + (flag("two_dimensional")?, flag("precise_pointing")?) + } + }; + + Ok(Some(Box::new(PresentationDef { + widgets, + params: owned, + two_dimensional: demand.0, + precise_pointing: demand.1, + }))) +} + +fn read_params(root: &Mapping) -> Result, String> { + let params = root + .get("params") + .ok_or("missing `params:`; an operation with no parameters has nothing to control")?; + let params = as_mapping(params, "params")?; + + let mut out = Vec::new(); + for (id, spec) in params { + let id = as_str(id, "a parameter name")?.to_string(); + check_ident(&id, &format!("params.{id}"))?; + let ctx = format!("params.{id}"); + let spec = as_mapping(spec, &ctx)?; + + let label = as_str( + spec.get("label") + .ok_or_else(|| format!("`{ctx}` has no `label:`"))?, + &format!("{ctx}.label"), + )? + .to_string(); + + let kind_name = as_str( + spec.get("kind") + .ok_or_else(|| format!("`{ctx}` has no `kind:`"))?, + &format!("{ctx}.kind"), + )?; + + let (kind, default, min, max) = read_kind(kind_name, spec, &ctx)?; + out.push(ParamDef { + id, + label, + doc: opt_prose(spec, "doc", &ctx)?, + kind, + default, + min, + max, + }); + } + + if out.is_empty() { + return Err("`params:` is empty".into()); + } + Ok(out) +} + +/// Read a declared parameter kind, with its default and its bounds. +/// +/// The kinds are the constructors `descriptor.rs` already offers, named rather +/// than spelled out: `amount` is the −100…+100 shape nearly every photographic +/// control takes, and writing its range in every node would invite one of them +/// to drift. +fn read_kind(kind: &str, spec: &Mapping, ctx: &str) -> Result<(Kind, f64, f64, f64), String> { + let need = |key: &str| -> Result { + spec.get(key) + .and_then(Value::as_f64) + .ok_or_else(|| format!("`{ctx}` is `kind: {kind}` and needs a numeric `{key}:`")) + }; + let opt = |key: &str, fallback: f64| -> f64 { + spec.get(key).and_then(Value::as_f64).unwrap_or(fallback) + }; + + let (kind, default, min, max) = match kind { + "stops" => { + let (min, max) = (need("min")?, need("max")?); + (Kind::Stops { min, max }, 0.0, min, max) + } + "amount" => (Kind::Amount, 0.0, -100.0, 100.0), + "switch" => (Kind::Switch, 0.0, 0.0, 1.0), + "fraction" => { + let default = opt("default", 0.0); + (Kind::Fraction { default }, default, 0.0, 1.0) + } + "scalar" => { + let (min, max) = (need("min")?, need("max")?); + let default = opt("default", 0.0); + let unit = match spec.get("unit").and_then(Value::as_str).unwrap_or("none") { + "none" => Unit::None, + "stops" => Unit::Stops, + "kelvin" => Unit::Kelvin, + "percent" => Unit::Percent, + other => { + return Err(format!( + "`{ctx}.unit` is `{other}`; expected none, stops, kelvin or percent" + )) + } + }; + let scale = match spec + .get("scale") + .and_then(Value::as_str) + .unwrap_or("linear") + { + "linear" => Scale::Linear, + "perceptual" => Scale::Perceptual, + other => { + return Err(format!( + "`{ctx}.scale` is `{other}`; expected linear or perceptual" + )) + } + }; + let precision = spec + .get("precision") + .and_then(Value::as_u64) + .ok_or_else(|| format!("`{ctx}` is `kind: scalar` and needs `precision:`"))?; + ( + Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + }, + default, + min, + max, + ) + } + // A fixed list of named alternatives. The value is the chosen index, + // so the range is the list's own bounds and a node declaring one needs + // no `min:`/`max:` of its own. + // + // Variants are localisation keys, like every other label in a node — + // the core never holds a display string (NFR-A11Y-1). The default is + // always the first, so `reset` means the same thing here as everywhere + // else; a node whose neutral choice is not first has listed them in + // the wrong order. + "enum" => { + let variants = spec + .get("variants") + .and_then(Value::as_sequence) + .ok_or_else(|| format!("`{ctx}` is `kind: enum` and needs a `variants:` list"))?; + if variants.len() < 2 { + return Err(format!( + "`{ctx}.variants` lists {} choice(s); a control the user \ + cannot change is not a control", + variants.len() + )); + } + let keys = variants + .iter() + .enumerate() + .map(|(i, v)| as_str(v, &format!("{ctx}.variants[{i}]")).map(str::to_string)) + .collect::, _>>()?; + let last = (keys.len() - 1) as f64; + (Kind::Enum { variants: keys }, 0.0, 0.0, last) + } + other => { + return Err(format!( + "`{ctx}.kind` is `{other}`; expected stops, amount, switch, \ + fraction, scalar or enum" + )) + } + }; + + // Caught at build time rather than surfacing as a control that opens where + // it cannot be dragged back to. + if min >= max { + return Err(format!( + "`{ctx}` has an empty range: min {min} >= max {max}" + )); + } + if default < min || default > max { + return Err(format!( + "`{ctx}` has default {default} outside its range {min}..{max}" + )); + } + Ok((kind, default, min, max)) +} + +fn read_local_helpers(root: &Mapping) -> Result, String> { + let Some(define) = root.get("define") else { + return Ok(Vec::new()); + }; + let define = as_mapping(define, "define")?; + + let mut out = Vec::new(); + for (name, spec) in define { + let name = as_str(name, "a helper name under `define`")?.to_string(); + let ctx = format!("define.{name}"); + // Shorthand: the value may be the WGSL directly, or a mapping with + // prose beside it. Most node-local helpers carry their explanation in + // the WGSL itself, so the shorthand is the common case. + let (doc, wgsl) = match spec { + Value::String(s) => (None, s.trim_end().to_string()), + other => { + let m = as_mapping(other, &ctx)?; + let wgsl = as_str( + m.get("wgsl") + .ok_or_else(|| format!("`{ctx}` has no `wgsl:`"))?, + &format!("{ctx}.wgsl"), + )? + .trim_end() + .to_string(); + (opt_prose(m, "doc", &ctx)?, wgsl) + } + }; + if !wgsl.contains(&format!("fn {name}(")) { + return Err(format!("`{ctx}` does not define `fn {name}(`")); + } + out.push(HelperDef { name, doc, wgsl }); + } + Ok(out) +} + +fn read_shared_refs( + root: &Mapping, + shared: &BTreeSet<&str>, + local: &[HelperDef], +) -> Result, String> { + let Some(list) = root.get("helpers") else { + return Ok(Vec::new()); + }; + let list = list + .as_sequence() + .ok_or("`helpers` must be a list of helper names")?; + + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for item in list { + let name = as_str(item, "a helper name")?.to_string(); + if !shared.contains(name.as_str()) { + let known: Vec<&str> = shared.iter().copied().collect(); + return Err(format!( + "`helpers` names `{name}`, which {HELPERS_FILE} does not \ + define. Known helpers: {}", + known.join(", ") + )); + } + if local.iter().any(|h| h.name == name) { + return Err(format!( + "`{name}` is both requested from {HELPERS_FILE} and redefined \ + under `define`. The composer deduplicates by name, so one of \ + the two definitions would silently win." + )); + } + if !seen.insert(name.clone()) { + return Err(format!("`helpers` lists `{name}` twice")); + } + out.push(name); + } + Ok(out) +} + +fn read_uniforms(root: &Mapping, params: &BTreeSet<&str>) -> Result, String> { + let uniforms = root + .get("uniforms") + .ok_or("missing `uniforms:`; the fragment has nothing to read otherwise")?; + let uniforms = as_mapping(uniforms, "uniforms")?; + + let mut out = Vec::new(); + for (name, spec) in uniforms { + let name = as_str(name, "a uniform name")?.to_string(); + check_ident(&name, &format!("uniforms.{name}"))?; + let ctx = format!("uniforms.{name}"); + + // Shorthand: `gain: exp2(exposure)`, or a mapping carrying prose. + let (doc, src) = match spec { + Value::String(s) => (None, s.clone()), + other => { + let m = as_mapping(other, &ctx)?; + let value = m + .get("value") + .ok_or_else(|| format!("`{ctx}` has neither a bare expression nor `value:`"))?; + ( + opt_prose(m, "doc", &ctx)?, + as_str(value, &format!("{ctx}.value"))?.to_string(), + ) + } + }; + + let expr = expr::parse(&src, params).map_err(|e| format!("`{ctx}`: {e}"))?; + out.push(UniformDef { name, doc, expr }); + } + + if out.is_empty() { + return Err("`uniforms:` is empty".into()); + } + Ok(out) +} + +fn read_tests( + root: &Mapping, + params: &[ParamDef], + uniforms: &BTreeSet<&str>, + helpers: &BTreeSet<&str>, +) -> Result, String> { + let Some(list) = root.get("tests") else { + return Ok(Vec::new()); + }; + let list = list.as_sequence().ok_or("`tests` must be a list")?; + + let mut out = Vec::new(); + let mut seen = BTreeSet::new(); + for item in list { + let m = as_mapping(item, "a test")?; + let name = as_str( + m.get("name").ok_or("a test has no `name:`")?, + "tests[].name", + )? + .to_string(); + check_ident(&name, &format!("tests.{name}.name"))?; + if !seen.insert(name.clone()) { + return Err(format!("two tests are both called `{name}`")); + } + let ctx = format!("tests.{name}"); + + let mut set = Vec::new(); + if let Some(v) = m.get("set") { + for (k, val) in as_mapping(v, &format!("{ctx}.set"))? { + let key = as_str(k, &format!("{ctx}.set key"))?.to_string(); + let Some(param) = params.iter().find(|p| p.id == key) else { + return Err(format!( + "`{ctx}.set` names `{key}`, which is not a parameter" + )); + }; + let value = val + .as_f64() + .ok_or_else(|| format!("`{ctx}.set.{key}` must be a number"))?; + // Values reach `set_param` already clamped by the graph, so a + // test setting an out-of-range value would be asserting + // against something that cannot happen. + if value < param.min || value > param.max { + return Err(format!( + "`{ctx}.set.{key}` is {value}, outside the parameter's \ + range {}..{}. The graph clamps before an operation \ + sees a value, so this test could never run as written.", + param.min, param.max + )); + } + set.push((key, value)); + } + } + + let mut expect = Vec::new(); + if let Some(v) = m.get("expect") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect"))? { + let key = as_str(k, &format!("{ctx}.expect key"))?.to_string(); + if !uniforms.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect` names `{key}`, which is not a uniform of this node" + )); + } + let value = val + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect.{key}` must be a number"))?; + expect.push((key, value)); + } + } + + let mut expect_range = Vec::new(); + if let Some(v) = m.get("expect_range") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect_range"))? { + let key = as_str(k, &format!("{ctx}.expect_range key"))?.to_string(); + if !uniforms.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect_range` names `{key}`, which is not a uniform" + )); + } + let pair = val + .as_sequence() + .filter(|s| s.len() == 2) + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` must be [low, high]"))?; + let lo = pair[0] + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` low must be a number"))?; + let hi = pair[1] + .as_f64() + .ok_or_else(|| format!("`{ctx}.expect_range.{key}` high must be a number"))?; + expect_range.push((key, lo, hi)); + } + } + + let mut expect_wgsl = Vec::new(); + if let Some(v) = m.get("expect_wgsl") { + for item in v + .as_sequence() + .ok_or_else(|| format!("`{ctx}.expect_wgsl` must be a list of strings"))? + { + expect_wgsl.push(as_str(item, &format!("{ctx}.expect_wgsl[]"))?.to_string()); + } + } + + let mut expect_helper_wgsl = Vec::new(); + if let Some(v) = m.get("expect_helper_wgsl") { + for (k, val) in as_mapping(v, &format!("{ctx}.expect_helper_wgsl"))? { + let key = as_str(k, &format!("{ctx}.expect_helper_wgsl key"))?.to_string(); + if !helpers.contains(key.as_str()) { + return Err(format!( + "`{ctx}.expect_helper_wgsl` names `{key}`, which this node does not use" + )); + } + let mut needles = Vec::new(); + for item in val + .as_sequence() + .ok_or_else(|| format!("`{ctx}.expect_helper_wgsl.{key}` must be a list"))? + { + needles.push( + as_str(item, &format!("{ctx}.expect_helper_wgsl.{key}[]"))?.to_string(), + ); + } + expect_helper_wgsl.push((key, needles)); + } + } + + let expect_active = match m.get("expect_active") { + None => None, + Some(v) => Some( + v.as_bool() + .ok_or_else(|| format!("`{ctx}.expect_active` must be true or false"))?, + ), + }; + + if expect.is_empty() + && expect_range.is_empty() + && expect_wgsl.is_empty() + && expect_helper_wgsl.is_empty() + && expect_active.is_none() + { + return Err(format!("`{ctx}` asserts nothing")); + } + + out.push(TestDef { + name, + why: opt_prose(m, "why", &ctx)?, + set, + expect, + expect_range, + expect_active, + expect_wgsl, + expect_helper_wgsl, + }); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// YAML access +// --------------------------------------------------------------------------- + +fn parse_yaml(text: &str, ctx: &str) -> Result { + serde_norway::from_str(text).map_err(|e| format!("{ctx}: not valid YAML: {e}")) +} + +fn as_mapping<'a>(value: &'a Value, ctx: &str) -> Result<&'a Mapping, String> { + value + .as_mapping() + .ok_or_else(|| format!("`{ctx}` must be a mapping")) +} + +fn as_str<'a>(value: &'a Value, ctx: &str) -> Result<&'a str, String> { + value + .as_str() + .ok_or_else(|| format!("`{ctx}` must be a string")) +} + +fn opt_prose(map: &Mapping, key: &str, ctx: &str) -> Result, String> { + match map.get(key) { + None => Ok(None), + Some(v) => v + .as_str() + .map(|s| Some(s.trim_end().to_string())) + .ok_or_else(|| format!("`{ctx}.{key}` must be a string")), + } +} + +/// Names reaching generated Rust have to be identifiers, and must not be +/// keywords — `ParamId("type")` would generate a struct field called `type`. +/// +/// Enforced at load time as well as build time even though a declaration read +/// at run time never becomes Rust: the two paths must agree on what a valid +/// declaration *is*, or a plugin could be accepted by one and rejected by the +/// other, and the built-ins would stop being a fair test of the format. +pub fn check_ident(name: &str, ctx: &str) -> Result<(), String> { + if name.is_empty() { + return Err(format!("`{ctx}` is empty")); + } + let head_ok = name + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase() || c == '_'); + let rest_ok = name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'); + if !head_ok || !rest_ok { + return Err(format!( + "`{ctx}` is `{name}`; names must be lower_snake_case so they can \ + become Rust identifiers" + )); + } + const KEYWORDS: &[&str] = &[ + "as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false", + "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", + "ref", "return", "self", "static", "struct", "super", "trait", "true", "type", "unsafe", + "use", "where", "while", "async", "await", "box", "final", "macro", "override", "priv", + "try", "typeof", "unsized", "virtual", "yield", + ]; + if KEYWORDS.contains(&name) { + return Err(format!("`{ctx}` is `{name}`, which is a Rust keyword")); + } + Ok(()) +} + +/// `rust:` names a type in `crate::ops`, so it is PascalCase rather than an +/// id. Checked only for shape — whether the type exists, and whether it +/// implements `Operation`, is for the compiler to say. +fn check_type_name(name: &str) -> Result<(), String> { + let ok = name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) + && name.chars().all(|c| c.is_ascii_alphanumeric()); + if !ok { + return Err(format!( + "`rust: {name}` must be the PascalCase name of a type in \ + `crate::ops`, such as `ToneCurve`" + )); + } + Ok(()) +} diff --git a/core/dr-pipeline/src/declared/expr.rs b/core/dr-pipeline/src/declared/expr.rs new file mode 100644 index 0000000..dd25268 --- /dev/null +++ b/core/dr-pipeline/src/declared/expr.rs @@ -0,0 +1,447 @@ +//! TRACES: FR-PLG-2 +//! The little expression language a node declaration derives its uniforms in. +//! +//! Uniforms are functions of parameters — `exp2(exposure)`, `blacks / 100 * +//! 0.02` — and that derivation is the one piece of a node that is genuinely +//! computation rather than description. The language is arithmetic over the +//! node's own parameters plus a fixed set of maths functions: enough for every +//! operation in the chain, and small enough that a reader of the YAML can see +//! exactly what will happen. +//! +//! # Why this file is compiled twice +//! +//! It is `#[path]`-included by `build.rs` as well as being a module of the +//! crate, and it deliberately depends on nothing but `std` so that it can be. +//! +//! There are two backends over one grammar. `build.rs` renders an [`Expr`] to +//! Rust source, so a built-in node's arithmetic costs nothing at run time and +//! an unknown name is a build error naming the file. [`Expr::eval`] evaluates +//! the same tree directly, which is what lets a declaration loaded at run time +//! produce uniforms without a compiler. +//! +//! **The two must agree bit for bit**, or a plugin is not the same kind of +//! thing as a built-in and `tests/declared_parity.rs` says so. Sharing the +//! tokeniser and the parser removes the larger half of the ways they could +//! drift; the remaining half is the pair of backends, and each entry in +//! [`FUNCTIONS`] below is written twice on purpose, once here and once in +//! `build.rs`, with the parity test standing between them. + +use std::collections::BTreeSet; + +/// A number in a declaration, as the `f32` the arithmetic will actually use. +/// +/// **The route through the decimal string is load-bearing, not clumsiness.** +/// `build.rs` renders a number as a Rust literal — `0.02f32` — and `rustc` +/// rounds that decimal text to the nearest `f32` exactly once. Writing +/// `n as f32` here would instead round the `f64` YAML parsed to the nearest +/// `f32`, which is a *second* rounding on top of the one that produced the +/// `f64`, and double rounding does not always land where single rounding does. +/// +/// So this reproduces what the compiler sees: `{:?}` is the shortest decimal +/// that round-trips the `f64`, which is exactly the literal `build.rs` emits, +/// and parsing it as `f32` is exactly what `rustc` does with it. +pub fn as_f32(n: f64) -> f32 { + // Infallible in practice: `{:?}` on a finite `f64` is always a parseable + // decimal, and the infinities and NaN it can also print all parse back. + // The fallback is the direct cast rather than a panic, because a bad + // number in a declaration is the reader's error to report, not this + // function's to crash on. + format!("{n:?}").parse::().unwrap_or(n as f32) +} + +/// The maths functions a declaration may call, and how many arguments each +/// takes. +/// +/// A closed list rather than a passthrough to `f32`: a node is a description, +/// and letting it name arbitrary Rust would make the YAML a second, worse +/// place to write code. Closed for the same reason `WidgetKind` and +/// `attributes` are closed (FR-PLG-2d) — a typo must be an error rather than a +/// silent new category of one. +/// +/// Shared by both backends so that the *set* of callable functions cannot +/// drift even though the two translations of each must be written separately. +pub const FUNCTIONS: &[(&str, usize)] = &[ + ("exp2", 1), + ("log2", 1), + ("exp", 1), + ("sqrt", 1), + ("abs", 1), + ("floor", 1), + ("ceil", 1), + ("round", 1), + ("pow", 2), + ("min", 2), + ("max", 2), + ("clamp", 3), + ("mix", 3), +]; + +/// A parsed expression over a node's parameters. +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + Num(f64), + Param(String), + Neg(Box), + Bin(char, Box, Box), + Call(String, Vec), +} + +/// Parse and validate an expression against the parameters a node declares. +/// +/// Validation happens here rather than in either backend, so that "this names +/// a parameter that does not exist" is one error message in one place and +/// cannot be reported at build time but missed at load time. +pub fn parse(src: &str, params: &BTreeSet<&str>) -> Result { + let tokens = tokenise(src)?; + let mut parser = Parser { tokens, at: 0 }; + let expr = parser.expr()?; + if parser.at < parser.tokens.len() { + return Err(format!( + "unexpected `{}` after the end of the expression", + parser.tokens[parser.at] + )); + } + check(&expr, params)?; + Ok(expr) +} + +/// Every name and arity in the tree resolves. +fn check(expr: &Expr, params: &BTreeSet<&str>) -> Result<(), String> { + match expr { + Expr::Num(_) => Ok(()), + Expr::Param(name) => { + if params.contains(name.as_str()) { + return Ok(()); + } + let known: Vec<&str> = params.iter().copied().collect(); + Err(format!( + "`{name}` is not a parameter of this node. Its parameters \ + are: {}", + known.join(", ") + )) + } + Expr::Neg(inner) => check(inner, params), + Expr::Bin(_, l, r) => { + check(l, params)?; + check(r, params) + } + Expr::Call(name, args) => { + let Some((_, arity)) = FUNCTIONS.iter().find(|(f, _)| *f == name) else { + let known: Vec<&str> = FUNCTIONS.iter().map(|(f, _)| *f).collect(); + return Err(format!( + "`{name}` is not one of the maths functions a node may \ + call. Available: {}", + known.join(", ") + )); + }; + if args.len() != *arity { + return Err(format!( + "`{name}` takes {arity} argument(s), given {}", + args.len() + )); + } + for a in args { + check(a, params)?; + } + Ok(()) + } + } +} + +impl Expr { + /// TRACES: FR-PLG-2 + /// Evaluate this expression for a set of parameter values. + /// + /// **Every step is an `f32` operation in the same order `build.rs` renders + /// it**, which is what makes the interpreted result bit-identical to the + /// compiled one rather than merely close. Rust's `f32` arithmetic is IEEE + /// 754 with no excess precision, so `(a * b) + c` here and `(a * b) + c` + /// in generated source are the same number down to the last bit — and the + /// parity test asserts exactly that rather than an epsilon, because a + /// tolerance is how a real divergence gets to hide. + /// + /// `param` is asked for a parameter's current value. It is a closure + /// rather than a map so the caller can serve the values out of whatever it + /// already has, which for [`super::DeclaredOp`] is a plain `Vec` + /// indexed in declaration order. + /// + /// Infallible: [`parse`] has already established that every name resolves + /// and every call has the right arity. An unknown parameter reaching here + /// would be a reader that let one through, so `param` decides what to do + /// about it rather than this returning a `Result` every caller would + /// unwrap. + pub fn eval(&self, param: &dyn Fn(&str) -> f32) -> f32 { + match self { + Expr::Num(n) => as_f32(*n), + Expr::Param(name) => param(name), + Expr::Neg(inner) => -inner.eval(param), + Expr::Bin(op, l, r) => { + let (l, r) = (l.eval(param), r.eval(param)); + match op { + '+' => l + r, + '-' => l - r, + '*' => l * r, + '/' => l / r, + // `tokenise` only ever produces these four as binary + // operators, and `Parser` only ever builds `Bin` from what + // `tokenise` produced. + _ => unreachable!("`{op}` is not a binary operator"), + } + } + Expr::Call(name, args) => { + let a = |i: usize| args[i].eval(param); + match name.as_str() { + "exp2" => f32::exp2(a(0)), + "log2" => f32::log2(a(0)), + "exp" => f32::exp(a(0)), + "sqrt" => f32::sqrt(a(0)), + "abs" => f32::abs(a(0)), + "floor" => f32::floor(a(0)), + "ceil" => f32::ceil(a(0)), + "round" => f32::round(a(0)), + "pow" => f32::powf(a(0), a(1)), + "min" => f32::min(a(0), a(1)), + "max" => f32::max(a(0), a(1)), + "clamp" => f32::clamp(a(0), a(1), a(2)), + // Spelled out rather than called, matching what `build.rs` + // renders: Rust has no `mix`, and this linear form is what + // WGSL's `mix` means. The association matters — `a + (b - + // a) * t` and `a * (1 - t) + b * t` are the same value in + // real arithmetic and different ones in `f32`. + "mix" => { + let (x, y, t) = (a(0), a(1), a(2)); + x + (y - x) * t + } + // `parse` rejects anything not in `FUNCTIONS`. + _ => unreachable!("`{name}` is not a declared maths function"), + } + } + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Tok { + Num(f64), + Ident(String), + Sym(char), +} + +impl std::fmt::Display for Tok { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Tok::Num(n) => write!(f, "{n}"), + Tok::Ident(s) => write!(f, "{s}"), + Tok::Sym(c) => write!(f, "{c}"), + } + } +} + +fn tokenise(src: &str) -> Result, String> { + let bytes: Vec = src.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i]; + if c.is_whitespace() { + i += 1; + } else if c.is_ascii_digit() + || (c == '.' && bytes.get(i + 1).is_some_and(char::is_ascii_digit)) + { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == '.') { + i += 1; + } + let text: String = bytes[start..i].iter().collect(); + let n = text + .parse::() + .map_err(|_| format!("`{text}` is not a number"))?; + out.push(Tok::Num(n)); + } else if c.is_ascii_alphabetic() || c == '_' { + let start = i; + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') { + i += 1; + } + out.push(Tok::Ident(bytes[start..i].iter().collect())); + } else if "+-*/(),".contains(c) { + out.push(Tok::Sym(c)); + i += 1; + } else { + return Err(format!( + "`{c}` is not valid in an expression; the language is \ + arithmetic (+ - * /), parentheses, numbers, this node's \ + parameters, and the maths functions" + )); + } + } + if out.is_empty() { + return Err("the expression is empty".into()); + } + Ok(out) +} + +struct Parser { + tokens: Vec, + at: usize, +} + +impl Parser { + fn peek(&self) -> Option<&Tok> { + self.tokens.get(self.at) + } + + fn eat(&mut self, sym: char) -> bool { + if self.peek() == Some(&Tok::Sym(sym)) { + self.at += 1; + return true; + } + false + } + + fn expr(&mut self) -> Result { + let mut left = self.term()?; + loop { + if self.eat('+') { + left = Expr::Bin('+', Box::new(left), Box::new(self.term()?)); + } else if self.eat('-') { + left = Expr::Bin('-', Box::new(left), Box::new(self.term()?)); + } else { + return Ok(left); + } + } + } + + fn term(&mut self) -> Result { + let mut left = self.unary()?; + loop { + if self.eat('*') { + left = Expr::Bin('*', Box::new(left), Box::new(self.unary()?)); + } else if self.eat('/') { + left = Expr::Bin('/', Box::new(left), Box::new(self.unary()?)); + } else { + return Ok(left); + } + } + } + + fn unary(&mut self) -> Result { + if self.eat('-') { + return Ok(Expr::Neg(Box::new(self.unary()?))); + } + self.primary() + } + + fn primary(&mut self) -> Result { + match self.peek().cloned() { + Some(Tok::Num(n)) => { + self.at += 1; + Ok(Expr::Num(n)) + } + Some(Tok::Ident(name)) => { + self.at += 1; + if !self.eat('(') { + return Ok(Expr::Param(name)); + } + let mut args = Vec::new(); + if !self.eat(')') { + loop { + args.push(self.expr()?); + if self.eat(',') { + continue; + } + if self.eat(')') { + break; + } + return Err(format!("expected `,` or `)` in the call to `{name}`")); + } + } + Ok(Expr::Call(name, args)) + } + Some(Tok::Sym('(')) => { + self.at += 1; + let inner = self.expr()?; + if !self.eat(')') { + return Err("unclosed `(`".into()); + } + Ok(inner) + } + Some(t) => Err(format!("unexpected `{t}`")), + None => Err("the expression ends early".into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params() -> BTreeSet<&'static str> { + ["a", "b"].into_iter().collect() + } + + fn eval(src: &str, a: f32, b: f32) -> f32 { + parse(src, ¶ms()) + .expect("parses") + .eval(&|name| match name { + "a" => a, + "b" => b, + other => panic!("no parameter {other}"), + }) + } + + #[test] + fn arithmetic_follows_the_usual_precedence() { + assert_eq!(eval("a + b * 2", 1.0, 3.0), 7.0); + assert_eq!(eval("(a + b) * 2", 1.0, 3.0), 8.0); + } + + #[test] + fn unary_minus_binds_tighter_than_addition() { + // `-(a + b)` and `-a + b` are different numbers, and the parser has to + // agree with the renderer about which one `-a + b` is. + assert_eq!(eval("-a + b", 1.0, 3.0), 2.0); + assert_eq!(eval("-(a + b)", 1.0, 3.0), -4.0); + } + + #[test] + fn a_number_is_rounded_once_the_way_the_compiler_rounds_a_literal() { + // The whole reason `as_f32` goes through the decimal string. If this + // ever regresses to `n as f32`, the values a declared node produces + // drift from the generated one in the last bit, and every parity + // assertion has to become a tolerance to keep passing — which is + // exactly the silent disagreement the test exists to catch. + assert_eq!(as_f32(0.02), 0.02f32); + assert_eq!(as_f32(0.1), 0.1f32); + // A third: eight significant digits, which is past what an `f32` + // resolves, so this is the case where a second rounding could land + // somewhere the compiler's single one does not. + assert_eq!(as_f32(1.0 / 3.0), 0.333_333_34_f32); + } + + #[test] + fn mix_is_the_linear_form_wgsl_means() { + assert_eq!(eval("mix(a, b, 0.25)", 0.0, 4.0), 1.0); + } + + #[test] + fn an_unknown_parameter_names_the_ones_that_exist() { + // The error is the whole value of validating in the parser: whoever + // wrote the typo needs the list, and needs it identically whether the + // declaration was read by the build script or at load time. + let err = parse("c * 2", ¶ms()).unwrap_err(); + assert!(err.contains("`c` is not a parameter"), "{err}"); + assert!(err.contains("a, b"), "{err}"); + } + + #[test] + fn an_unknown_function_is_rejected_rather_than_passed_through() { + let err = parse("tan(a)", ¶ms()).unwrap_err(); + assert!(err.contains("not one of the maths functions"), "{err}"); + } + + #[test] + fn a_wrong_arity_is_caught_where_it_is_written() { + let err = parse("pow(a)", ¶ms()).unwrap_err(); + assert!(err.contains("takes 2 argument(s), given 1"), "{err}"); + } +} diff --git a/core/dr-pipeline/src/declared/mod.rs b/core/dr-pipeline/src/declared/mod.rs new file mode 100644 index 0000000..f243b92 --- /dev/null +++ b/core/dr-pipeline/src/declared/mod.rs @@ -0,0 +1,487 @@ +//! TRACES: FR-PLG-2 | FR-PLG-2d +//! Running a node declaration without compiling it. +//! +//! # The format already existed +//! +//! `ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the +//! declarative nodes landed — it was simply resolved at build time: +//! +//! ```text +//! ops/exposure.yaml ──build.rs──▶ generated impl Operation ──▶ fused shader +//! ``` +//! +//! Nothing about that requires the declaration to be present when the compiler +//! runs. Everything a declaration produces is *data plus a WGSL string*, and +//! the composer already assembles WGSL at run time from whichever operations +//! are active. So this module is not a new mechanism; it is the existing one, +//! loaded later. +//! +//! [`DeclaredOp`] is **one interpreter over many declarations**, where +//! `build.rs` emits generated code per node. It implements [`Operation`] from +//! an owned [`Declaration`], which is only possible because descriptors became +//! owned — see [`crate::descriptor::OpDescriptor`] for why a `&'static` +//! descriptor made a run-time node impossible. +//! +//! # Both paths stay +//! +//! The generated path is not removed and should not be. FR-PLG-2 says so, and +//! the reasons are good ones: 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 +//! internal state is not. +//! +//! What matters is that the two are **indistinguishable downstream**, and that +//! is a test rather than an intention: `tests/declared_parity.rs` parses every +//! built-in `ops/*.yaml` at run time and asserts the composed WGSL is +//! byte-for-byte identical to what the generated implementation produces, for +//! the same parameter values. If the two ever disagree, a plugin is not the +//! same kind of thing as a built-in and the premise of the whole plugin plan +//! has failed quietly. +//! +//! # What this is not, yet +//! +//! Not load-time WGSL validation (FR-PLG-11), not id namespacing (FR-PLG-2's +//! `author.name`), and not a plugin directory read at startup. Those are +//! separate work and are deliberately absent — a declaration reaching +//! [`DeclaredOp`] here is one that ships in this repository, so its WGSL has +//! already been compiled by the build and its id has already been checked for +//! collisions. + +pub mod decl; +pub mod expr; + +use std::sync::{Arc, LazyLock}; + +pub use decl::{Declaration, Node, SharedHelpers}; + +use crate::descriptor::{ + intern, Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, + Scale, Unit, WidgetDemand, WidgetKind, +}; +use crate::operation::{Helper, Operation, Uniform}; +use expr::{as_f32, Expr}; + +/// The shared WGSL helper library, as the built-in nodes see it. +/// +/// The very same `_helpers.yaml` `build.rs` reads, embedded rather than read +/// from disk: there is no file path an installed application could look it up +/// at, and embedding is what makes it impossible for the compiled helpers and +/// the interpreted ones to be two different files. +/// +/// Panics if the bundled file does not parse, which is a build-time fact about +/// this repository rather than anything a user can cause — `build.rs` reads the +/// same text on the same build and fails first. +pub fn builtin_helpers() -> &'static SharedHelpers { + static LIBRARY: LazyLock = LazyLock::new(|| { + decl::read_helpers(include_str!("../../ops/_helpers.yaml")) + .unwrap_or_else(|e| panic!("the bundled {}: {e}", decl::HELPERS_FILE)) + }); + &LIBRARY +} + +/// TRACES: FR-PLG-2 +/// An operation built from a declaration at run time. +/// +/// Holds everything the trait has to answer with, resolved once when the +/// declaration is read: the descriptor, the uniform expressions, the helper +/// list and the fragment text. Nothing is re-parsed per call, so the per-frame +/// cost of an interpreted node is the arithmetic in [`Expr::eval`] and nothing +/// else — the same arithmetic the generated node does, simply walked rather +/// than inlined. +#[derive(Debug, Clone)] +pub struct DeclaredOp { + descriptor: Arc, + /// Parameter ids in declaration order, parallel to `defaults` and + /// `values`. + /// + /// Three parallel `Vec`s rather than one of triples because the hot read + /// is `values` alone, and because `set_param` writes exactly one of them. + /// They are built together and never resized. + params: Vec, + defaults: Vec, + values: Vec, + uniforms: Vec, + /// The declared `active:` rule, or `None` for the default one. + active: Option, + wgsl: String, + helpers: Vec, + presentation: Option, + order: i64, +} + +/// One uniform: the name the fragment reads it by, and how to compute it. +#[derive(Debug, Clone)] +struct DeclaredUniform { + /// Interned when the declaration was read, not on every `uniforms()` call. + /// See [`crate::operation::Uniform::name`]. + name: &'static str, + expr: Expr, +} + +impl DeclaredOp { + /// Read one `ops/.yaml` and build the operation it declares. + /// + /// `ctx` names the source in error messages — a path, usually. + /// + /// A `rust:` node is an error here rather than a silent `None`: it names a + /// hand-written type in this crate, which is by definition not something a + /// declaration can produce, and a caller that got one back as "nothing to + /// do" would drop an operation out of the chain without saying so. + pub fn from_yaml(text: &str, ctx: &str, library: &SharedHelpers) -> Result { + let names = library.names(); + match decl::read_node(text, ctx, &names)? { + Node::Declared(d) => Self::new(&d, library), + Node::Rust { id, ty, .. } => Err(format!( + "`{id}` declares `rust: {ty}`, which names a hand-written type \ + rather than describing an operation. Only a full declaration \ + can be read at run time." + )), + } + } + + /// Build the operation a parsed declaration describes. + pub fn new(declaration: &Declaration, library: &SharedHelpers) -> Result { + let params: Vec = declaration + .params + .iter() + .map(|p| ParamId::interned(&p.id)) + .collect(); + let defaults: Vec = declaration + .params + .iter() + .map(|p| as_f32(p.default)) + .collect(); + + // Helpers in the order the composer will see them: the shared ones the + // node asked for, in the order it asked, then its own definitions. + // Order decides emission order in the generated shader, so it is part + // of the output rather than an implementation detail. + let mut helpers = + Vec::with_capacity(declaration.shared_helpers.len() + declaration.local_helpers.len()); + for name in &declaration.shared_helpers { + // `decl::read_node` has already rejected a name the library does + // not define, so this is a library that changed underneath a + // declaration rather than a declaration with a typo in it. + let helper = library.get(name).ok_or_else(|| { + format!( + "`{}` asks for the shared helper `{name}`, which this \ + helper library does not define", + declaration.id + ) + })?; + helpers.push(Helper { + name: intern(&helper.name), + source: intern(&helper.source()), + }); + } + for helper in &declaration.local_helpers { + helpers.push(Helper { + name: intern(&helper.name), + source: intern(&helper.source()), + }); + } + + Ok(Self { + descriptor: Arc::new(OpDescriptor { + id: OpId::interned(&declaration.id), + label: LocalizedKey::interned(&declaration.label), + params: declaration.params.iter().map(param_descriptor).collect(), + attributes: declaration + .attributes + .iter() + .copied() + .map(attribute) + .collect(), + }), + values: defaults.clone(), + params, + defaults, + uniforms: declaration + .uniforms + .iter() + .map(|u| DeclaredUniform { + name: intern(&u.name), + expr: u.expr.clone(), + }) + .collect(), + active: declaration.active.clone(), + wgsl: declaration.wgsl_body(), + helpers, + presentation: declaration.presentation.as_deref().map(presentation), + order: declaration.order, + }) + } + + /// Where this node sits in the chain, from its `order:`. + /// + /// Not part of [`Operation`] — the graph holds operations in a `Vec` and + /// order *is* that position (ARCH §3.4). Exposed so whoever assembles a + /// chain out of declarations can sort them, which is what `build.rs` does + /// at the other end. + pub fn order(&self) -> i64 { + self.order + } + + fn index_of(&self, id: ParamId) -> Option { + self.params.iter().position(|p| *p == id) + } + + /// One parameter's current value, by the name an expression calls it. + /// + /// Returns 0.0 for a name that is not a parameter, matching what the + /// generated `param()` does with an unknown id. It cannot happen — + /// [`expr::parse`] rejects a name that is not declared — but a silent zero + /// is a better failure here than a panic inside a render. + fn value_named(&self, name: &str) -> f32 { + self.params + .iter() + .position(|p| p.0 == name) + .map_or(0.0, |i| self.values[i]) + } +} + +impl Operation for DeclaredOp { + fn descriptor(&self) -> Arc { + self.descriptor.clone() + } + + fn set_param(&mut self, id: ParamId, value: f32) { + match self.index_of(id) { + Some(i) => self.values[i] = value, + // The same complaint the generated `set_param` makes, for the same + // reason: a parameter that does not exist is a sidecar or a UI + // naming something this build does not have, and dropping it + // silently is how an edit comes to be half-applied. + None => log::warn!("{}: unknown parameter {id}", self.descriptor.id), + } + } + + fn param(&self, id: ParamId) -> f32 { + self.index_of(id).map_or(0.0, |i| self.values[i]) + } + + fn is_active(&self) -> bool { + match &self.active { + Some(expr) => expr.eval(&|name| self.value_named(name)) != 0.0, + // The default rule, and the honest one: the operation is doing + // something exactly when a parameter has moved off its default. + // Short-circuiting in declaration order, which is what the + // generated `a != d || b != d` does. + None => self.values.iter().zip(&self.defaults).any(|(v, d)| v != d), + } + } + + fn wgsl_body(&self) -> String { + self.wgsl.clone() + } + + fn uniforms(&self) -> Vec { + self.uniforms + .iter() + .map(|u| Uniform { + name: u.name, + value: u.expr.eval(&|name| self.value_named(name)), + }) + .collect() + } + + fn helpers(&self) -> &[Helper] { + &self.helpers + } + + fn presentation(&self) -> Option { + self.presentation.clone() + } +} + +/// A declared parameter as the descriptor the panel reads. +/// +/// Every arm calls the constructor `build.rs` renders a call to, so the two +/// produce the same `ParamDescriptor` by construction rather than by +/// coincidence. +fn param_descriptor(p: &decl::ParamDef) -> ParamDescriptor { + let id = intern(&p.id); + let label = intern(&p.label); + match &p.kind { + decl::Kind::Stops { min, max } => { + ParamDescriptor::stops(id, label, as_f32(*min), as_f32(*max)) + } + decl::Kind::Amount => ParamDescriptor::amount(id, label), + decl::Kind::Switch => ParamDescriptor::switch(id, label), + decl::Kind::Fraction { default } => ParamDescriptor::fraction(id, label, as_f32(*default)), + decl::Kind::Scalar { + min, + max, + default, + unit, + scale, + precision, + } => ParamDescriptor::scalar( + id, + label, + as_f32(*min), + as_f32(*max), + as_f32(*default), + match unit { + decl::Unit::None => Unit::None, + decl::Unit::Stops => Unit::Stops, + decl::Unit::Kelvin => Unit::Kelvin, + decl::Unit::Percent => Unit::Percent, + }, + match scale { + decl::Scale::Linear => Scale::Linear, + decl::Scale::Perceptual => Scale::Perceptual, + }, + // `decl::read_kind` refuses a precision that does not fit, so this + // cast cannot lose anything. + *precision as u8, + ), + decl::Kind::Enum { variants } => ParamDescriptor::choice( + id, + label, + variants.iter().map(|v| LocalizedKey::interned(v)).collect(), + ), + } +} + +fn attribute(a: decl::Attr) -> Attribute { + match a { + decl::Attr::Tone => Attribute::Tone, + decl::Attr::Colour => Attribute::Colour, + decl::Attr::Detail => Attribute::Detail, + decl::Attr::Optics => Attribute::Optics, + decl::Attr::Geometry => Attribute::Geometry, + decl::Attr::Effect => Attribute::Effect, + } +} + +fn widget(w: decl::Widget) -> WidgetKind { + match w { + decl::Widget::ToneCurve => WidgetKind::ToneCurve, + decl::Widget::ColourWheel => WidgetKind::ColourWheel, + decl::Widget::CropOverlay => WidgetKind::CropOverlay, + decl::Widget::GradientHandle => WidgetKind::GradientHandle, + decl::Widget::BrushMask => WidgetKind::BrushMask, + decl::Widget::WhitePoint => WidgetKind::WhitePoint, + } +} + +fn presentation(p: &decl::PresentationDef) -> Presentation { + Presentation { + widgets: p.widgets.iter().copied().map(widget).collect(), + demand: WidgetDemand { + two_dimensional: p.two_dimensional, + precise_pointing: p.precise_pointing, + }, + params: p.params.iter().map(|n| ParamId::interned(n)).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// TRACES: FR-PLG-2d + /// The two spellings of the attribute vocabulary are one vocabulary. + /// + /// `decl::Attr` exists because the reader is compiled by `build.rs`, which + /// cannot see `crate::descriptor`. That is a duplicated closed list, and a + /// duplicated closed list is exactly the thing FR-PLG-2d warns about: an + /// attribute added to one and not the other would put an operation in a + /// category the panel does not know it has. + #[test] + fn the_attribute_vocabulary_is_the_same_on_both_sides() { + assert_eq!(decl::Attr::ALL.len(), Attribute::ALL.len()); + for (a, b) in decl::Attr::ALL.iter().zip(Attribute::ALL) { + // Same order, so an index into one indexes the other. + assert_eq!(attribute(*a), b); + // And the name a declaration writes resolves to the same variant. + assert_eq!(Attribute::from_name(a.name()), Some(b), "{}", a.name()); + } + } + + /// TRACES: FR-PLG-2d + /// Every `WidgetKind` is nameable from a declaration. + /// + /// A widget the core can ask for but a declaration cannot name is a widget + /// only a hand-written operation may have, which would make the two kinds + /// of node unequal in exactly the way FR-PLG-2 forbids. + #[test] + fn every_widget_kind_can_be_declared() { + assert_eq!(decl::Widget::ALL.len(), 6); + let named: Vec = decl::Widget::ALL.iter().copied().map(widget).collect(); + for kind in [ + WidgetKind::ToneCurve, + WidgetKind::ColourWheel, + WidgetKind::CropOverlay, + WidgetKind::GradientHandle, + WidgetKind::BrushMask, + WidgetKind::WhitePoint, + ] { + assert!(named.contains(&kind), "{kind:?} cannot be declared"); + } + } + + #[test] + fn the_bundled_helper_library_parses() { + // It is `include_str!`'d, so a syntax error in it is a panic at first + // use rather than a build failure. This is the first use. + assert!(!builtin_helpers().helpers.is_empty()); + } + + fn exposure() -> DeclaredOp { + DeclaredOp::from_yaml( + include_str!("../../ops/exposure.yaml"), + "ops/exposure.yaml", + builtin_helpers(), + ) + .expect("exposure declares an operation") + } + + #[test] + fn a_declaration_becomes_an_operation_with_its_declared_descriptor() { + let op = exposure(); + let d = op.descriptor(); + assert_eq!(d.id, OpId("exposure")); + assert_eq!(d.label, LocalizedKey("op.exposure")); + assert_eq!(d.params.len(), 1); + assert_eq!(d.params[0].id, ParamId("exposure")); + assert_eq!(d.attributes, vec![Attribute::Tone]); + } + + #[test] + fn a_declared_operation_is_neutral_until_a_parameter_moves() { + let mut op = exposure(); + assert!(!op.is_active()); + assert_eq!(op.uniforms()[0].value, 1.0); + + op.set_param(ParamId("exposure"), 1.0); + assert!(op.is_active()); + // A stop is a doubling — the same assertion `exposure.yaml`'s own + // declared test makes against the generated implementation. + assert_eq!(op.uniforms()[0].value, 2.0); + } + + #[test] + fn an_interned_id_matches_a_literal_one() { + // The property that lets a declared operation be addressed by the same + // `ParamId` constants the generated code matches on. If interning ever + // stopped deduplicating, this would still pass — `ParamId` compares + // string contents — but the point is that the two are interchangeable + // at every call site. + let mut op = exposure(); + op.set_param(ParamId::interned("exposure"), 2.0); + assert_eq!(op.param(ParamId("exposure")), 2.0); + } + + #[test] + fn a_rust_node_is_refused_rather_than_silently_dropped() { + let err = DeclaredOp::from_yaml( + include_str!("../../ops/tone_curve.yaml"), + "ops/tone_curve.yaml", + builtin_helpers(), + ) + .expect_err("a `rust:` node is not a declaration"); + assert!(err.contains("hand-written type"), "{err}"); + } +} diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index e59af4f..dbb86b6 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -31,6 +31,7 @@ //! single multiply and white balance a per-channel scale; on gamma-encoded //! data neither would be physically meaningful (ARCH §5.2). +pub mod declared; pub mod descriptor; pub mod detail; pub mod framing; @@ -45,6 +46,7 @@ pub mod sidecar; pub mod spot; pub mod state; +pub use declared::{Declaration, DeclaredOp}; pub use descriptor::{ Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Scale, Unit, WidgetDemand, WidgetKind, diff --git a/core/dr-pipeline/tests/declared_parity.rs b/core/dr-pipeline/tests/declared_parity.rs new file mode 100644 index 0000000..3048e00 --- /dev/null +++ b/core/dr-pipeline/tests/declared_parity.rs @@ -0,0 +1,435 @@ +//! 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 `.yaml` in `ops/`, keyed by id, excluding `_helpers.yaml`. +fn declaration_files() -> BTreeMap { + 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 { + 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 { + 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) -> (ComposedShader, Box) { + 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); +} diff --git a/docs/traceability.md b/docs/traceability.md index 64cf961..20e1158 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 257 | -| TRACES tags found | 746 | +| Source files scanned | 258 | +| TRACES tags found | 751 | | Requirements defined | 177 | | Requirements covered | 100 | | **Coverage** | **56.5%** (100/177) | @@ -58,9 +58,9 @@ _None._ | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:384`](../core/dr-catalog/src/schema.rs#L384), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389) | | FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:2165`](../core/dr-gpu/src/adjust.rs#L2165), [`core/dr-gpu/src/adjust.rs:651`](../core/dr-gpu/src/adjust.rs#L651), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:387`](../core/dr-pipeline/src/detail.rs#L387), [`core/dr-pipeline/src/detail.rs:465`](../core/dr-pipeline/src/detail.rs#L465), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/framing.rs:365`](../core/dr-pipeline/src/framing.rs#L365), [`core/dr-pipeline/src/framing.rs:620`](../core/dr-pipeline/src/framing.rs#L620), [`core/dr-pipeline/src/graph.rs:169`](../core/dr-pipeline/src/graph.rs#L169), [`core/dr-pipeline/src/graph.rs:577`](../core/dr-pipeline/src/graph.rs#L577), [`core/dr-pipeline/src/mask.rs:121`](../core/dr-pipeline/src/mask.rs#L121), [`core/dr-pipeline/src/operation.rs:330`](../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:516`](../core/dr-pipeline/src/operation.rs#L516), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:210`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L210), [`core/dr-pipeline/src/ops/curve.rs:100`](../core/dr-pipeline/src/ops/curve.rs#L100), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:219`](../core/dr-pipeline/src/ops/curve.rs#L219), [`core/dr-pipeline/src/ops/curve.rs:635`](../core/dr-pipeline/src/ops/curve.rs#L635), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:273`](../core/dr-pipeline/src/ops/noise_reduction.rs#L273), [`core/dr-pipeline/src/sidecar.rs:156`](../core/dr-pipeline/src/sidecar.rs#L156), [`core/dr-pipeline/src/sidecar.rs:1636`](../core/dr-pipeline/src/sidecar.rs#L1636), [`core/dr-pipeline/src/sidecar.rs:1696`](../core/dr-pipeline/src/sidecar.rs#L1696), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1280`](../ui/dr-ui/src/develop.rs#L1280), [`ui/dr-ui/src/develop.rs:163`](../ui/dr-ui/src/develop.rs#L163), [`ui/dr-ui/src/develop.rs:1758`](../ui/dr-ui/src/develop.rs#L1758), [`ui/dr-ui/src/develop.rs:1776`](../ui/dr-ui/src/develop.rs#L1776), [`ui/dr-ui/src/develop.rs:1790`](../ui/dr-ui/src/develop.rs#L1790), [`ui/dr-ui/src/develop.rs:1812`](../ui/dr-ui/src/develop.rs#L1812), [`ui/dr-ui/src/develop.rs:1958`](../ui/dr-ui/src/develop.rs#L1958), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:326`](../ui/dr-ui/src/develop.rs#L326), [`ui/dr-ui/src/develop.rs:3287`](../ui/dr-ui/src/develop.rs#L3287), [`ui/dr-ui/src/develop.rs:363`](../ui/dr-ui/src/develop.rs#L363), [`ui/dr-ui/src/develop.rs:3851`](../ui/dr-ui/src/develop.rs#L3851), [`ui/dr-ui/src/develop.rs:3905`](../ui/dr-ui/src/develop.rs#L3905), [`ui/dr-ui/src/develop.rs:3949`](../ui/dr-ui/src/develop.rs#L3949), [`ui/dr-ui/src/develop.rs:3999`](../ui/dr-ui/src/develop.rs#L3999), [`ui/dr-ui/src/develop.rs:628`](../ui/dr-ui/src/develop.rs#L628), [`ui/dr-ui/src/develop.rs:675`](../ui/dr-ui/src/develop.rs#L675), [`ui/dr-ui/src/lib.rs:1422`](../ui/dr-ui/src/lib.rs#L1422), [`ui/dr-ui/src/lib.rs:2124`](../ui/dr-ui/src/lib.rs#L2124), [`ui/dr-ui/src/lib.rs:301`](../ui/dr-ui/src/lib.rs#L301), [`ui/dr-ui/src/library.rs:411`](../ui/dr-ui/src/library.rs#L411), [`ui/dr-ui/src/masks_ui.rs:218`](../ui/dr-ui/src/masks_ui.rs#L218), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:816`](../ui/dr-ui/src/masks_ui.rs#L816), [`ui/dr-ui/src/masks_ui.rs:930`](../ui/dr-ui/src/masks_ui.rs#L930), [`ui/dr-ui/src/segmentation.rs:219`](../ui/dr-ui/src/segmentation.rs#L219), [`ui/dr-ui/src/segmentation.rs:322`](../ui/dr-ui/src/segmentation.rs#L322), [`ui/dr-ui/src/segmentation.rs:350`](../ui/dr-ui/src/segmentation.rs#L350), [`ui/dr-ui/ui/app.slint:2100`](../ui/dr-ui/ui/app.slint#L2100), [`ui/dr-ui/ui/app.slint:981`](../ui/dr-ui/ui/app.slint#L981) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:194`](../core/dr-pipeline/src/descriptor.rs#L194), [`core/dr-pipeline/src/descriptor.rs:234`](../core/dr-pipeline/src/descriptor.rs#L234), [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/descriptor.rs:313`](../core/dr-pipeline/src/descriptor.rs#L313), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:23`](../core/dr-pipeline/src/graph.rs#L23), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365), [`core/dr-pipeline/src/ops/curve.rs:319`](../core/dr-pipeline/src/ops/curve.rs#L319), [`ui/dr-ui/src/develop.rs:1177`](../ui/dr-ui/src/develop.rs#L1177), [`ui/dr-ui/src/lib.rs:595`](../ui/dr-ui/src/lib.rs#L595) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:258`](../core/dr-pipeline/src/descriptor.rs#L258), [`core/dr-pipeline/src/framing.rs:262`](../core/dr-pipeline/src/framing.rs#L262), [`core/dr-pipeline/src/graph.rs:58`](../core/dr-pipeline/src/graph.rs#L58), [`core/dr-pipeline/src/operation.rs:365`](../core/dr-pipeline/src/operation.rs#L365) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1814`](../core/dr-pipeline/build.rs#L1814), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:756`](../core/dr-pipeline/build.rs#L756), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:250`](../core/dr-pipeline/src/graph.rs#L250), [`core/dr-pipeline/src/graph.rs:45`](../core/dr-pipeline/src/graph.rs#L45), [`core/dr-pipeline/src/mask.rs:955`](../core/dr-pipeline/src/mask.rs#L955), [`ui/dr-ui/src/develop.rs:4578`](../ui/dr-ui/src/develop.rs#L4578) | | FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:1041`](../core/dr-gpu/src/adjust.rs#L1041), [`core/dr-gpu/src/adjust.rs:104`](../core/dr-gpu/src/adjust.rs#L104), [`core/dr-gpu/src/adjust.rs:770`](../core/dr-gpu/src/adjust.rs#L770), [`core/dr-gpu/src/adjust.rs:84`](../core/dr-gpu/src/adjust.rs#L84), [`core/dr-gpu/src/adjust.rs:986`](../core/dr-gpu/src/adjust.rs#L986), [`core/dr-gpu/tests/capture_sharpen.rs:434`](../core/dr-gpu/tests/capture_sharpen.rs#L434), [`core/dr-gpu/tests/detail_stage.rs:242`](../core/dr-gpu/tests/detail_stage.rs#L242), [`core/dr-gpu/tests/local_contrast.rs:476`](../core/dr-gpu/tests/local_contrast.rs#L476), [`core/dr-gpu/tests/noise_reduction.rs:556`](../core/dr-gpu/tests/noise_reduction.rs#L556), [`core/dr-pipeline/src/framing.rs:191`](../core/dr-pipeline/src/framing.rs#L191), [`core/dr-pipeline/src/graph.rs:616`](../core/dr-pipeline/src/graph.rs#L616), [`core/dr-pipeline/src/operation.rs:32`](../core/dr-pipeline/src/operation.rs#L32), [`core/dr-pipeline/src/operation.rs:389`](../core/dr-pipeline/src/operation.rs#L389), [`core/dr-pipeline/src/operation.rs:53`](../core/dr-pipeline/src/operation.rs#L53), [`core/dr-pipeline/src/operation.rs:71`](../core/dr-pipeline/src/operation.rs#L71) | | FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:708`](../core/dr-decode/src/lib.rs#L708), [`core/dr-decode/src/lib.rs:748`](../core/dr-decode/src/lib.rs#L748), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:967`](../core/dr-gpu/src/adjust.rs#L967), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:1576`](../core/dr-pipeline/src/operation.rs#L1576), [`core/dr-pipeline/src/operation.rs:1601`](../core/dr-pipeline/src/operation.rs#L1601), [`core/dr-pipeline/src/operation.rs:1616`](../core/dr-pipeline/src/operation.rs#L1616), [`core/dr-pipeline/src/operation.rs:1640`](../core/dr-pipeline/src/operation.rs#L1640), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/operation.rs:440`](../core/dr-pipeline/src/operation.rs#L440), [`core/dr-pipeline/src/operation.rs:450`](../core/dr-pipeline/src/operation.rs#L450), [`core/dr-pipeline/src/operation.rs:600`](../core/dr-pipeline/src/operation.rs#L600) | | FR-DEV-3f | [`core/dr-film/src/bake.rs:271`](../core/dr-film/src/bake.rs#L271), [`core/dr-film/src/bake.rs:62`](../core/dr-film/src/bake.rs#L62), [`core/dr-film/src/boolean_grain.rs:1`](../core/dr-film/src/boolean_grain.rs#L1), [`core/dr-film/src/boolean_grain.rs:78`](../core/dr-film/src/boolean_grain.rs#L78), [`core/dr-film/src/grain.rs:140`](../core/dr-film/src/grain.rs#L140), [`core/dr-film/src/grain.rs:1`](../core/dr-film/src/grain.rs#L1), [`core/dr-film/src/grain.rs:302`](../core/dr-film/src/grain.rs#L302), [`core/dr-film/src/grain.rs:79`](../core/dr-film/src/grain.rs#L79), [`core/dr-film/src/lib.rs:160`](../core/dr-film/src/lib.rs#L160), [`core/dr-film/src/lib.rs:1`](../core/dr-film/src/lib.rs#L1), [`core/dr-film/src/profile.rs:100`](../core/dr-film/src/profile.rs#L100), [`core/dr-film/src/profile.rs:142`](../core/dr-film/src/profile.rs#L142), [`core/dr-film/src/profile.rs:182`](../core/dr-film/src/profile.rs#L182), [`core/dr-film/src/profile.rs:259`](../core/dr-film/src/profile.rs#L259), [`core/dr-film/src/profile.rs:502`](../core/dr-film/src/profile.rs#L502), [`core/dr-film/src/profile.rs:73`](../core/dr-film/src/profile.rs#L73), [`core/dr-gpu/src/adjust.rs:139`](../core/dr-gpu/src/adjust.rs#L139), [`core/dr-gpu/src/adjust.rs:196`](../core/dr-gpu/src/adjust.rs#L196), [`core/dr-gpu/src/adjust.rs:357`](../core/dr-gpu/src/adjust.rs#L357), [`core/dr-gpu/src/adjust.rs:483`](../core/dr-gpu/src/adjust.rs#L483), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/film_sim.rs:191`](../core/dr-gpu/tests/film_sim.rs#L191), [`core/dr-gpu/tests/film_sim.rs:1`](../core/dr-gpu/tests/film_sim.rs#L1), [`core/dr-pipeline/src/graph.rs:101`](../core/dr-pipeline/src/graph.rs#L101), [`core/dr-pipeline/src/graph.rs:124`](../core/dr-pipeline/src/graph.rs#L124), [`core/dr-pipeline/src/graph.rs:324`](../core/dr-pipeline/src/graph.rs#L324), [`core/dr-pipeline/src/operation.rs:1065`](../core/dr-pipeline/src/operation.rs#L1065), [`core/dr-pipeline/src/operation.rs:1094`](../core/dr-pipeline/src/operation.rs#L1094), [`core/dr-pipeline/src/operation.rs:1495`](../core/dr-pipeline/src/operation.rs#L1495), [`core/dr-pipeline/src/operation.rs:296`](../core/dr-pipeline/src/operation.rs#L296), [`core/dr-pipeline/src/operation.rs:310`](../core/dr-pipeline/src/operation.rs#L310), [`core/dr-pipeline/src/ops/film_sim.rs:129`](../core/dr-pipeline/src/ops/film_sim.rs#L129), [`core/dr-pipeline/src/ops/film_sim.rs:153`](../core/dr-pipeline/src/ops/film_sim.rs#L153), [`core/dr-pipeline/src/ops/film_sim.rs:1`](../core/dr-pipeline/src/ops/film_sim.rs#L1), [`core/dr-pipeline/src/ops/film_sim.rs:331`](../core/dr-pipeline/src/ops/film_sim.rs#L331), [`core/dr-pipeline/src/ops/film_sim.rs:43`](../core/dr-pipeline/src/ops/film_sim.rs#L43), [`core/dr-pipeline/src/ops/film_sim.rs:87`](../core/dr-pipeline/src/ops/film_sim.rs#L87), [`core/dr-pipeline/src/ops/film_sim.rs:92`](../core/dr-pipeline/src/ops/film_sim.rs#L92), [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:167`](../core/dr-pipeline/src/sidecar.rs#L167), [`core/dr-pipeline/src/sidecar.rs:1957`](../core/dr-pipeline/src/sidecar.rs#L1957), [`core/dr-pipeline/src/sidecar.rs:2033`](../core/dr-pipeline/src/sidecar.rs#L2033), [`core/dr-pipeline/src/sidecar.rs:533`](../core/dr-pipeline/src/sidecar.rs#L533), [`core/dr-pipeline/src/sidecar.rs:660`](../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:792`](../core/dr-pipeline/src/sidecar.rs#L792), [`core/dr-pipeline/src/state.rs:100`](../core/dr-pipeline/src/state.rs#L100), [`core/dr-pipeline/src/state.rs:115`](../core/dr-pipeline/src/state.rs#L115), [`core/dr-pipeline/src/state.rs:60`](../core/dr-pipeline/src/state.rs#L60), [`ui/dr-ui/src/develop.rs:2827`](../ui/dr-ui/src/develop.rs#L2827), [`ui/dr-ui/src/develop.rs:2844`](../ui/dr-ui/src/develop.rs#L2844), [`ui/dr-ui/src/develop.rs:2856`](../ui/dr-ui/src/develop.rs#L2856), [`ui/dr-ui/src/develop.rs:2894`](../ui/dr-ui/src/develop.rs#L2894), [`ui/dr-ui/src/develop.rs:2903`](../ui/dr-ui/src/develop.rs#L2903), [`ui/dr-ui/src/develop.rs:3006`](../ui/dr-ui/src/develop.rs#L3006), [`ui/dr-ui/src/develop.rs:3324`](../ui/dr-ui/src/develop.rs#L3324), [`ui/dr-ui/src/develop.rs:3339`](../ui/dr-ui/src/develop.rs#L3339), [`ui/dr-ui/src/lib.rs:2098`](../ui/dr-ui/src/lib.rs#L2098), [`ui/dr-ui/src/lib.rs:528`](../ui/dr-ui/src/lib.rs#L528), [`ui/dr-ui/src/lib.rs:586`](../ui/dr-ui/src/lib.rs#L586), [`ui/dr-ui/src/library.rs:402`](../ui/dr-ui/src/library.rs#L402), [`ui/dr-ui/src/library.rs:651`](../ui/dr-ui/src/library.rs#L651), [`ui/dr-ui/src/presets.rs:275`](../ui/dr-ui/src/presets.rs#L275), [`ui/dr-ui/ui/adjust.slint:889`](../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:959`](../ui/dr-ui/ui/adjust.slint#L959), [`ui/dr-ui/ui/app.slint:2712`](../ui/dr-ui/ui/app.slint#L2712), [`ui/dr-ui/ui/app.slint:775`](../ui/dr-ui/ui/app.slint#L775) | @@ -101,8 +101,8 @@ _None._ | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:53`](../core/dr-types/src/lib.rs#L53) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:845`](../ui/dr-ui/src/lib.rs#L845), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232) | -| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:414`](../core/dr-pipeline/src/declared/decl.rs#L414), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | +| FR-PLG-2 | [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/expr.rs:152`](../core/dr-pipeline/src/declared/expr.rs#L152), [`core/dr-pipeline/src/declared/expr.rs:1`](../core/dr-pipeline/src/declared/expr.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:82`](../core/dr-pipeline/src/declared/mod.rs#L82), [`core/dr-pipeline/src/descriptor.rs:15`](../core/dr-pipeline/src/descriptor.rs#L15), [`core/dr-pipeline/src/descriptor.rs:635`](../core/dr-pipeline/src/descriptor.rs#L635), [`core/dr-pipeline/src/operation.rs:232`](../core/dr-pipeline/src/operation.rs#L232), [`core/dr-pipeline/tests/declared_parity.rs:1`](../core/dr-pipeline/tests/declared_parity.rs#L1), [`core/dr-pipeline/tests/declared_parity.rs:240`](../core/dr-pipeline/tests/declared_parity.rs#L240), [`core/dr-pipeline/tests/declared_parity.rs:305`](../core/dr-pipeline/tests/declared_parity.rs#L305), [`core/dr-pipeline/tests/declared_parity.rs:358`](../core/dr-pipeline/tests/declared_parity.rs#L358), [`core/dr-pipeline/tests/declared_parity.rs:416`](../core/dr-pipeline/tests/declared_parity.rs#L416) | +| FR-PLG-2d | [`core/dr-pipeline/src/declared/decl.rs:112`](../core/dr-pipeline/src/declared/decl.rs#L112), [`core/dr-pipeline/src/declared/decl.rs:152`](../core/dr-pipeline/src/declared/decl.rs#L152), [`core/dr-pipeline/src/declared/decl.rs:1`](../core/dr-pipeline/src/declared/decl.rs#L1), [`core/dr-pipeline/src/declared/decl.rs:420`](../core/dr-pipeline/src/declared/decl.rs#L420), [`core/dr-pipeline/src/declared/decl.rs:67`](../core/dr-pipeline/src/declared/decl.rs#L67), [`core/dr-pipeline/src/declared/mod.rs:1`](../core/dr-pipeline/src/declared/mod.rs#L1), [`core/dr-pipeline/src/declared/mod.rs:384`](../core/dr-pipeline/src/declared/mod.rs#L384), [`core/dr-pipeline/src/declared/mod.rs:403`](../core/dr-pipeline/src/declared/mod.rs#L403) | | FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:129`](../core/dr-types/src/lib.rs#L129), [`core/dr-types/src/lib.rs:200`](../core/dr-types/src/lib.rs#L200) | | FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:506`](../core/dr-decode/src/lib.rs#L506), [`core/dr-decode/src/locate.rs:1366`](../core/dr-decode/src/locate.rs#L1366) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:186`](../ui/dr-ui/src/lib.rs#L186) |