Every point operation ran in camera RGB and the camera matrix came after
them all, against the order ARCH §5.2 draws. So `luminance()` applied
Rec.709 weights to a body's own primaries, a band in the colour mixer
was a different hue on every make of sensor, and the vibrance skin guard
tested channel order in a space where skin does not have one.
Operations now declare a `Stage`. White balance is the only camera-stage
node — its multipliers scale the sensor's channels, and after a matrix
that mixes them the same numbers are a different correction — and says
so with `stage: camera` in its YAML, a key both the build-time generator
and the load-time declared op read. The composer emits the camera nodes,
then the matrix, then the rest, each group in graph order; an empty
chain still gets the matrix.
Film simulation stops converting out of camera space itself, since it is
now handed working-space colour like every other scene node. The base
curve stays where it was, after the operations, and so now acts on
working-space colour; the next commit replaces it (D19).
`Attribute::ALL` has claimed since it was written to be roughly the order a
photographer works in, and 474dcf0 moved `Compose` to the front on exactly that
argument. Both ends went on contradicting it.
`Optics` sat fifth, so the column offered the lens corrections after the tones
they change. Removing a vignette brightens the frame; an exposure judged before
that correction has to be judged again after it, which is the definition of the
wrong order. `Detail` sat fourth, so sharpening and noise reduction — the only
work here that depends on everything above it, and the only work that cannot be
judged at fit view at all — were offered before the lens had even been put
right.
So `Optics, Compose, Tone, Colour, Effect, Detail`. `Optics` leads even
`Compose` because it is not a decision about the photograph at all: it is
undoing what the equipment did, a property of the capture rather than a choice.
`Effect` after `Colour` is a look laid over a settled picture, and is the one
slot that is genuinely arguable — a spectral film simulation declares `renders`
and replaces the base curve, which is a case for treating it as foundational
instead, and e235e99 filed film under `Effect` only days ago. The doc comment
records that tension rather than pretending to settle it; an array of six
cannot say "last, except when it is first".
`decl::Attr::ALL` moves with it. It is the second spelling of one vocabulary,
compiled by `build.rs` where `descriptor` is not visible, and the agreement
test in `declared/mod.rs` zips the two positionally — that test is what caught
the last reorder, and it would have caught this one.
Nothing persists a position in this list, which is what makes the reorder safe
rather than merely tidy. `Scope` packs one bit per attribute indexed by
`Attribute::ALL`, but `bits` is private, has no accessor and no `serde`; what
reaches a settings file is `develop.copy_attributes`, a list of names read back
through `Attribute::from_name`. A photographer's copy scope survives untouched
— only the order the names happen to be written in changes.
6a97fdf is why this is worth a commit now rather than a shrug: the
contradiction was harmless while the list only fed a row of chips nobody reads
in order, and stopped being harmless when the same list began driving a column
read top to bottom.
The matrix follows the two files' shifted line numbers.
An earlier commit claimed to move `film_sim` from `[tone, colour]` to
`[effect]` and did not. It edited `ops/film_sim.yaml`, where `attributes:` is
read, validated against the vocabulary, and then dropped: a `rust:` node
publishes its own descriptor, and the type still said tone and colour. The
stock went on appearing in the Light group beside exposure and again in Colour
beside white balance, exactly as before, and every test passed.
Nothing caught it because nothing could. The declaration parsed, the parity
tests compare ids rather than attributes, and an operation filed under the
wrong groups renders perfectly. It surfaced only on screen, as a missing
Effects tab — which is indistinguishable from a category that genuinely has
nothing in it, and is precisely how `Optics` looked for as long as it was
empty.
So three changes rather than one:
`FilmSim`'s descriptor declares `Attribute::Effect`, which is the move the
earlier commit described.
`attributes:` joins the keys a `rust:` node may not carry, beside `params`,
`uniforms`, `wgsl`, `helpers`, `define` and `label`. The rule was already
written — "its descriptor comes from the type" — and attributes were the one
field that slipped past it. A key that is silently ignored is worse than one
that is rejected, because it reads as though it worked; the eight hand-written
declarations lose a line that never did anything.
And a test asserts that every attribute the chain carries reaches the tab
strip. That is the property that was actually broken, and its failure mode is
invisible from every direction: the controls exist, they are in the shader,
and there is no way to filter to them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Attribute::ALL` claims to be "roughly the order a photographer works in" and
then listed framing fifth, behind tone, colour and detail. Framing is the
first decision made about a photograph and the one every later judgement is
made inside — there is no sense balancing tones across a frame about to lose a
third of its width.
The contradiction was harmless while the list only fed a row of chips nobody
reads in order. It stops being harmless now that the same list drives a column
read top to bottom.
`declared::Attr::ALL` moves with it. The two are separate spellings of one
vocabulary and a test asserts they agree, which is what caught this rather
than the order silently disagreeing between the YAML front end and the crate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves
from `[tone, colour]` to `[effect]`.
Two categories were doing the wrong job. "Geometry" describes what crop,
straighten and the quarter turns do to coordinates — but it describes lens
distortion correction exactly as well, and that is not a compositional
choice at all. Naming the attribute for the photographer's decision is what
separates it from `Optics`: one is what the lens did, the other is what
they chose. The maths the two have in common is not the thing worth
filing them under.
A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in
the Light group beside exposure and again in Colour beside white balance —
two places, neither of which is where anyone looks for it. It is neither:
`Effect` is defined in this same file as "applied rather than corrected — a
look, not a fix", which is what a stock is. That it moves tone and colour
is true of every look, and is not what the attribute is for.
`from_name` still accepts "geometry" on the way in. That string is
persisted in `develop.copy_attributes`, and an entry it fails to parse is
not an error — `presets::scope_for` logs it and drops it — so without the
alias an existing settings file would have quietly narrowed what a paste
carries. `name` writes the current spelling, so the file migrates itself
the first time it is saved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ops/*.yaml` plus `build.rs` has been the class-1 plugin format since the
declarative nodes landed — it was simply resolved at build time. Nothing about
a declaration requires the compiler: everything it produces is data plus a
WGSL string, and the composer already assembles WGSL at run time from whatever
operations are active. So this is not a new mechanism. It is the existing one,
loaded later (FR-PLG-2).
`DeclaredOp` implements `Operation` from an owned `Declaration` — one
interpreter over many declarations, where `build.rs` emits generated code per
node. The generated path stays, as FR-PLG-2 says it should: a generated
`match` is faster than an interpreted one, the built-ins' declared `tests:`
have to run under `cargo test`, and generated source is inspectable in a way
an interpreter's state is not.
**The reader is now one file, read by both.** `src/declared/decl.rs` and
`src/declared/expr.rs` are `#[path]`-included by `build.rs` as well as being
modules of the crate, and they produce a neutral `Declaration` that names no
Rust type. The build script's job is reduced to *rendering* that declaration
as Rust; `DeclaredOp` converts the same declaration into descriptors and
`Expr::eval` walks the same tree the renderer writes out. There is one
grammar, one set of validations and one set of error messages, so "a plugin is
the same kind of thing as a built-in" is structural rather than aspirational.
What remains genuinely written twice is the pair of backends — an arithmetic
node rendered as Rust here and evaluated there — and that is what the parity
test stands between. `tests/declared_parity.rs` parses every built-in
declaration at run time and asserts the composed WGSL is byte-for-byte what
the generated implementation produces, with the uniform block bit-for-bit
identical, at both ends of every parameter's range and at four interior
points; then again over the whole develop chain with the declared nodes
swapped in, which is what covers uniform slot ordering and helper
de-duplication between operations. A third test asserts the declared and
hand-written nodes partition `ops/` between them, so coverage cannot shrink
silently.
Bit-for-bit rather than within a tolerance, because a tolerance is where a
real divergence hides. The one thing that had to be got right for that to hold
is number literals: `expr::as_f32` rounds a decimal exactly once, through the
same shortest-round-trip text the compiler is handed, rather than rounding an
`f64` a second time.
Not in scope, and deliberately untagged: load-time WGSL validation
(FR-PLG-11), id namespacing, a plugin directory read at startup, and pass
nodes (FR-PLG-2a). Those are separate work, and tagging them from here would
be the overstatement the spec's own §7 warns about.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>