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) |