Hand out descriptors a declaration could produce

`Operation::descriptor()` returned `&'static OpDescriptor`, and that lifetime
is the whole reason a build-time node is free and a run-time node is
impossible: only a compile-time literal can satisfy it, so no amount of
reading `ops/*.yaml` at startup could ever produce a descriptor the rest of
the application would accept. 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 — and a lifetime outsiders cannot meet is exactly the second,
weaker format that requirement forbids.

So a descriptor is now owned and handed out as `Arc<OpDescriptor>`, with `Vec`
where it held `&'static` slices. `Arc` rather than a `&self`-borrowed
reference because the callers want to *keep* it: the develop panel collects
descriptors and then mutates the graph, and a borrow would tie the
descriptor's lifetime to a borrow of the operation it came from, which is the
one thing `&'static` was doing right.

The identifier newtypes deliberately did not follow. `ParamId` is `Copy`, is
compared in `match` arms against generated constants, is a map key in the
sidecar and history, and reaches Slint model rows; an `Arc<str>` there would
cost a refcount on every one of those and would take `match id { EXPOSURE =>
.. }` away from the generated code. They gain an interner instead, which is
honest about its lifetime rather than pretending to one — the set of ids is
bounded by deduplication and is process-lifetime by construction, because the
sidecar on disk names its parameters and an id has to stay resolvable for as
long as any edit naming it can be opened.

No behaviour changes. Every descriptor that was a `static` is a `LazyLock`
initialiser now, `Operation::helpers` borrows from `self` instead of being
`'static` so a future run-time node can own its list, and `Warp` and `Framing`
follow `Operation` so there is one shape rather than two.

The one place a descriptor is read per frame is `compose_full`, which takes
`descriptor().id` to prefix each active operation's uniforms, and `dr-ui`
composes on every frame it draws. That is a dozen atomic increments beside a
composition that is already building several kilobytes of WGSL on the same
call; it is noted at the trait method rather than left for a profiler to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 19:02:25 +02:00
co-authored by Claude Opus 5
parent 725f7bf77f
commit 0c3b8cb1c4
23 changed files with 772 additions and 490 deletions
+142 -29
View File
@@ -8,12 +8,75 @@
//! Labels are keys, not strings: resolving them needs a localiser, and
//! `core/` must not depend on one (NFR-A11Y-1).
use std::collections::HashSet;
use std::fmt;
use std::sync::{LazyLock, Mutex};
/// TRACES: FR-PLG-2
/// Give a string read at run time the `'static` lifetime the identifier types
/// carry.
///
/// # Why the identifiers stayed `&'static str` when the descriptors did not
///
/// [`OpDescriptor`] became owned so a declaration read at *load* time can
/// produce one (FR-PLG-2). The three identifier newtypes below deliberately
/// did not follow it.
///
/// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in
/// `match` arms against the constants `build.rs` generates, is a map key in
/// the sidecar and in history, and is threaded through `dr-ui` into Slint
/// model rows. An `Arc<str>` there would put a refcount on every one of those
/// and would take `match id { EXPOSURE => .. }` away from the generated code —
/// which is precisely the inspectability of the built-in chain that keeping
/// the generated path was for.
///
/// So ids are interned instead, and interning is honest about its lifetime
/// rather than pretending to one. The set of interned ids is:
///
/// - **Bounded.** One entry per *distinct* string, deduplicated on the way in.
/// Parsing the same declaration a thousand times adds nothing after the
/// first.
/// - **Process-lifetime by construction.** A loaded declaration's vocabulary
/// is never withdrawn. Nothing unloads a plugin, and nothing could: the
/// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to
/// stay resolvable for as long as any edit naming it can be opened.
///
/// A leak whose bound is "the distinct ids this process has ever seen" is a
/// different thing from one that grows with use, and this is the first.
pub fn intern(s: &str) -> &'static str {
static POOL: LazyLock<Mutex<HashSet<&'static str>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
// A poisoned pool is still a correct pool: every entry in it is a
// `&'static str` that was interned successfully, and a panic elsewhere
// while the lock was held cannot have made one invalid. Refusing to
// intern here would turn an unrelated panic into an application that can
// no longer read a declaration.
let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner());
if let Some(found) = pool.get(s) {
return found;
}
let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str());
pool.insert(leaked);
leaked
}
/// Identifies a parameter within an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ParamId(pub &'static str);
impl ParamId {
/// The same id, from a name read out of a declaration at load time.
///
/// Equal to `ParamId("exposure")` when the name is `"exposure"`: the
/// derived `PartialEq` compares the `str` contents, not the pointer, which
/// is what lets an interned id match a generated `match` arm. See
/// [`intern`].
pub fn interned(name: &str) -> Self {
Self(intern(name))
}
}
impl fmt::Display for ParamId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
@@ -24,6 +87,13 @@ impl fmt::Display for ParamId {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OpId(pub &'static str);
impl OpId {
/// The same id, from a declaration read at load time. See [`intern`].
pub fn interned(name: &str) -> Self {
Self(intern(name))
}
}
impl fmt::Display for OpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
@@ -34,6 +104,13 @@ impl fmt::Display for OpId {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedKey(pub &'static str);
impl LocalizedKey {
/// The same key, from a declaration read at load time. See [`intern`].
pub fn interned(key: &str) -> Self {
Self(intern(key))
}
}
/// What a slider's travel means.
///
/// Photographic controls are rarely linear in their underlying quantity:
@@ -170,7 +247,11 @@ pub enum ParamKind {
Enum {
/// In index order. The label is a localisation key, resolved by the
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
variants: &'static [LocalizedKey],
///
/// Owned rather than `&'static`, for the reason [`OpDescriptor`]
/// gives: a declaration parsed at load time has nowhere to put a
/// `'static` slice.
variants: Vec<LocalizedKey>,
},
}
@@ -204,7 +285,7 @@ pub struct Presentation {
/// pair of sliders, and an operation that can say so gets a good control
/// on a workstation and a usable one on a phone without the core knowing
/// which it is talking to.
pub widgets: &'static [WidgetKind],
pub widgets: Vec<WidgetKind>,
/// What the preferred widget needs in order to be worth drawing.
///
/// Applies to the list as a whole rather than per entry: a frontend that
@@ -215,7 +296,7 @@ pub struct Presentation {
///
/// Parameters absent from this list are presented normally, so an
/// operation can pair a curve with an ordinary strength slider.
pub params: &'static [ParamId],
pub params: Vec<ParamId>,
}
impl Presentation {
@@ -328,11 +409,13 @@ impl ParamDescriptor {
/// holds the way it does for every other kind: index 0 is the neutral
/// choice, and an operation whose default is not its first variant has
/// listed them in the wrong order.
pub const fn choice(
id: &'static str,
label: &'static str,
variants: &'static [LocalizedKey],
) -> Self {
//
// Not `const`, unlike its four siblings, and the reason is the `Vec` in
// [`ParamKind::Enum`]: a heap allocation cannot happen in a const context.
// Nothing is lost — every descriptor now lives inside a `LazyLock`
// initialiser rather than a `static`, because `descriptor()` hands out an
// `Arc` and an `Arc` is not const-constructible either.
pub fn choice(id: &'static str, label: &'static str, variants: Vec<LocalizedKey>) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
@@ -366,13 +449,15 @@ impl ParamDescriptor {
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
// to be `const` so descriptors can be `static`, which rules out the
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
// *is* const-callable — `faceted` below is one — but eight of them would
// be eight methods to say what one call already says.) The two common
// shapes have their own constructors above; this is the escape hatch for
// the rest.
// Eight arguments, and a builder would be the usual answer — but eight
// `&mut self` steps would be eight methods to say what one call already
// says, and each one would be a place for a caller to forget a field. The
// two common shapes have their own constructors above; this is the escape
// hatch for the rest.
//
// Still `const` although no descriptor is a `static` any more: it costs
// nothing, and it keeps the five constructors uniform where only `choice`
// genuinely cannot be.
#[allow(clippy::too_many_arguments)]
pub const fn scalar(
id: &'static str,
@@ -404,10 +489,13 @@ impl ParamDescriptor {
/// A method rather than a sixth constructor, because a facet is orthogonal
/// to the shape of the value: a faceted parameter is still an amount, or
/// still a scalar in stops, and pairing every constructor with a faceted
/// twin would double the list above to say one thing. Taking `self` by
/// value is what keeps it usable in the `static` descriptors — a `&mut
/// self` builder is what cannot be `const`.
pub const fn faceted(mut self, facet: Facet) -> Self {
/// twin would double the list above to say one thing.
//
// No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's
// variants), which gives the type drop glue, and assigning over a field of
// such a type is not something a const function may do. Nothing is lost —
// every descriptor is built inside a `LazyLock` initialiser now.
pub fn faceted(mut self, facet: Facet) -> Self {
self.facet = Some(facet);
self
}
@@ -418,10 +506,10 @@ impl ParamDescriptor {
/// bounds, or a sidecar written by a newer version with a wider range,
/// must not produce out-of-range uniforms.
pub fn clamp(&self, value: f32) -> f32 {
match self.kind {
match &self.kind {
ParamKind::Scalar { min, max, .. } => {
if value.is_finite() {
value.clamp(min, max)
value.clamp(*min, *max)
} else {
// A NaN from a corrupt sidecar would otherwise poison the
// uniform block and blank the image.
@@ -544,20 +632,45 @@ impl Attribute {
}
}
/// The static description of an operation.
/// TRACES: FR-PLG-2
/// The description of an operation.
///
/// # Owned, not `&'static`
///
/// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and
/// [`crate::Operation::descriptor`] used to hand out a reference to it. That
/// shape made a build-time node free and a run-time node **impossible**: a
/// declaration parsed at startup has nothing to borrow from, so no amount of
/// interpreting `ops/*.yaml` at load time could ever produce a descriptor the
/// rest of the application would accept. 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 — and a lifetime that only a compile-time literal can
/// satisfy is exactly a second, weaker format for outsiders.
///
/// So the descriptor owns its contents and is handed out as an
/// `Arc<OpDescriptor>`. The `Arc` rather than a `&self`-borrowed reference
/// because the callers want to *keep* it: the develop panel collects
/// descriptors and then mutates the graph, and a borrow would tie the
/// descriptor's lifetime to a borrow of the operation it came from — which is
/// the one thing `&'static` was doing right.
///
/// The cost is a refcount per read, on a path that reads descriptors when a
/// panel is built rather than per pixel. See `Operation::descriptor` for the
/// one place that is read per composition and why it does not matter.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
pub params: Vec<ParamDescriptor>,
/// What this operation is about (ARCH §4.3a).
///
/// **Never empty**, and `build.rs` refuses to generate an operation that
/// declares none. An operation with no attribute would be invisible to a
/// frontend that filters by them, and a control that silently does not
/// exist is a worse failure than a build that stops — particularly when
/// the cause would be a missing line in a YAML file nobody looked at.
pub attributes: &'static [Attribute],
/// **Never empty**, and both the build-time and the load-time reader
/// refuse an operation that declares none. An operation with no attribute
/// would be invisible to a frontend that filters by them, and a control
/// that silently does not exist is a worse failure than a build that stops
/// — particularly when the cause would be a missing line in a YAML file
/// nobody looked at.
pub attributes: Vec<Attribute>,
}
impl OpDescriptor {