diff --git a/node-graph/rfcs/attribute-model.md b/node-graph/rfcs/attribute-model.md index 89c657338c..6de49dea41 100644 --- a/node-graph/rfcs/attribute-model.md +++ b/node-graph/rfcs/attribute-model.md @@ -7,9 +7,9 @@ declare their attribute reads and writes in their signatures, and the compiler resolves every access to a byte offset during wiring, so there is no name lookup at runtime. Storage and batch results are per-attribute columns. The contiguous record only exists as a per-lane view, assembled -into activation frames on a per-thread record stack. All of the machinery -that could corrupt a layout is generated code, so getting it wrong is a -type error or a graph compile error rather than undefined behavior. +into buffers the compiler assigns. All of the machinery that could +corrupt a layout is generated code, so getting it wrong is a type error or +a graph compile error rather than undefined behavior. # Motivation @@ -86,31 +86,83 @@ table at graph compile time, which is where every resolution happens anyway, and two user-supplied names colliding at different value types is a graph compile error naming both nodes. +A write can also be generic over both the name and the value type. The +attribute then arrives on its own wire, as an input whose element is +`()`: + +```rs +/// Attaches the attribute to the content. +#[node_macro::node(category("Attributes"))] +fn set_attribute( + _: impl Ctx, + element: T, + (_, attr): ((), Attr>), +) -> (T, Attr>) { + (element, attr) +} +``` + +A unit value component means the edge exists and carries only its +attributes (`_: ()` still means no edge at all). The name enters the +graph at a source node holding the constant text input, whose output +type is filled at graph compile time, where user-supplied names join +the name table anyway; the compiler pairs `A` and `Y` through the wire +types, so the write set is derived from types alone, and the +one-name-one-type check covers the binding, making a declared name +targeted at a different type a graph compile error. A generic read +resolves only when the input wire's type determines the binding +uniquely, and anything else is a validation error. The node is one +compiled instance: `A` and `Y` instantiate with tokens and the value +rides the copy plan as a byte move, parked in the arena when its type +has drop glue, so no implementations list exists. A kernel that +computes on the value uses a bound and monomorphizes per its +implementations list as usual. + ## Reading and writing attributes -A node declares its attribute io in its signature. The opacity node -becomes: +A node declares its attribute io in its signature. A parameter that +reads attributes destructures its input into the wired value and the +reads taken from that input's wire. The opacity node becomes: ```rs /// Modifies the opacity of the input by multiplying the existing value by this percentage. #[node_macro::node(category("Blending"))] fn opacity( _: impl Ctx, - element: T, + (element, opacity): (T, Attr), /// How visible the content should be, from 100% (fully opaque) to 0% (fully transparent). #[default(100.)] factor: Percentage, - opacity: Attr, ) -> (T, Attr) { (element, Attr(*opacity * factor / 100.)) } ``` -`Attr` as a parameter is a read (it yields the declared default -if nothing upstream wrote the attribute), in the return tuple it is a -write, and the same marker on both sides is a modify. The first -parameter after the context is the primary input, and an unbounded -generic `element: T` that is returned in the first tuple position means +An `Attr` inside a parameter tuple is a read from that parameter's +wire (it yields the declared default if nothing upstream wrote the +attribute), an `Attr` in the return tuple is a write, and the same +marker on both sides is a modify. A `RemoveAttr` in the return +tuple is a delete: the name leaves the output layout, downstream reads +yield the default again, and the column leaves the Data panel. A read +binds to the input it is destructured from, so which wire an attribute +comes from is always explicit in the signature, and secondary inputs +declare reads the same way: + +```rs + (factor, out_of_100): (Percentage, Attr), +``` + +There is no implicit attribute flow between inputs. The primary +input's attributes pass through to the output, overwritten where the +node writes; a secondary input contributes exactly the reads its tuple +names. An input without reads stays a plain parameter. Parameter +attributes (`#[default]`, `#[implementations]`, doc comments) apply to +the value component; attribute markers are concrete types and never +enter monomorphization. + +The first parameter after the context is the primary input, with or +without a read tuple, and an unbounded generic `element: T` in its +value position that is returned in the first tuple position means "I pass the element through unchanged". The compiler lowers this to a byte copy (often to nothing, see below), and a single compiled instance covers every element type, with no trait bounds and no implementations @@ -140,13 +192,12 @@ per-copy values: #[node_macro::node(category("Repeat"), level_extent = count)] fn repeat( ctx: impl Ctx + ExtractIndex, - element: T, + (element, transform): (T, Attr), #[default(1)] #[hard(1..)] count: u32, #[default(100., 100.)] direction: DVec2, - transform: Attr, ) -> List<(T, Attr)> { let offset = direction * ctx.innermost_index() as f64; emit(element, Attr(DAffine2::from_translation(offset) * *transform)) @@ -171,8 +222,10 @@ inputs', and an attribute missing on one side is filled with its declared default for that side's items, so the result is rectangular in every attribute. A scalar input contributes one item. When lists are combined, each input's own top-level attributes are pushed down onto that input's -items (composing by the attribute's declared rule where one exists, -otherwise the inner value wins), and the merged list starts with an empty +items (composing by the attribute's declared rule where one exists; +otherwise the pushed value fills the items that never wrote the name +and the inner value wins where they did, resolved from the write sets +at graph compile time), and the merged list starts with an empty top level. If the user wants to keep the groups as groups, they wrap explicitly instead. @@ -225,13 +278,15 @@ consumers are wired without adaptation, the wire keeps the element's type and colour, and the registry stays keyed on element types. A wire's layout is the set of all attributes written in its upstream -cone, in a canonical order (descending alignment, then size, then name -and level), computed at graph compile time. Some consequences: +cone and not removed since, in a canonical order (descending alignment, +then size, then name and level), computed at graph compile time. Some +consequences: - Layout identity is captured by stable node ids, because the write set is part of the hashed upstream cone. An instance that survives an incremental recompile cannot meet a changed layout. -- Reads resolve to `Option` at wiring. Present means a field +- Reads resolve to `Option` at wiring, each against the layout + of the input wire its tuple destructures. Present means a field access, and absent means the macro emits the default constant. Writes always resolve. The runtime does no name lookup, no hashing, and no downcasting. A resolved read costs the same as a native struct field @@ -242,6 +297,18 @@ and level), computed at graph compile time. Some consequences: - Layouts are derived data. The document stores only user-visible structure, no attribute data is serialized, and representation changes never require a document migration. +- Semantically a wire value has every attribute at all times: a read of + a name nobody wrote yields the declared default, so a written default + and an absent name are indistinguishable at runtime. Presence + (membership in the layout) is representation, consulted only by merge + push-down's fallback and the Data panel, which presents the layout: + column presence is a pure function of the graph, stable across frames + and across the branches a selector takes. +- Writes are unconditional: presence never depends on a value, so a + conditionally relevant attribute is written at its default, and a + runtime `Option` around a value buys nothing (`None` could only mean + the default). A name that wants a distinguished unset declares an + `Option` value type on its marker. Fields are `Copy`, and larger payloads go behind a pointer-sized field. Runtime-shaped data (CSV columns, arbitrary JSON) is a single dynamic @@ -252,7 +319,10 @@ A name's type is unique by construction. For declared markers the census admits one marker per name, checked when the registry is built. For user-supplied names the binding forms at graph compile time, carrying the marker's declared value type, and two names colliding at different types -is a graph compile error that names both nodes. We do not attempt +is a graph compile error that names both nodes. Generic-typed writes +join the same table, carrying the name and value type their bindings +resolve to, so the check runs over declared markers, user-supplied +names, and generic instantiations together. We do not attempt coercion. ## Levels and residency @@ -261,8 +331,9 @@ Levels are numbered from the innermost out. This keeps layout keys stable when a structure node pushes a level (nothing renumbers) and matches how indices are already numbered. The binding rules are: -- A read or write binds to the top level of the wire at the node's chain - position. +- A read binds to the top level of the input wire it is destructured + from at the node's chain position; a write binds to the top level of + the output wire. - A structure node pushes a level and then writes its per-copy attributes into the former top row, and the new top row starts empty. - A node that reads the element (concrete type or bound) is pinned to @@ -330,10 +401,12 @@ that decomposition is hoisted per run. | `_: ()` | no primary input | no carrier edge | | `element: T` (unbounded, returned first) | explicit passthrough | erased byte carry, where `T` is instantiated with a zero-sized token, so the routing is checked by the type system and costs nothing | | `element: Concrete` / bound | element read | field read at offset 0, monomorphized per implementations list, binds level 0 | -| `x: Attr` | attribute read | offset read, or the default constant | +| `(x, a): (X, Attr)` | input with attribute reads | the value as its ordinary lowering; each `Attr` an offset read into that input's record, or the default constant | +| `(_, a): ((), Attr)` | attribute-only input | wired record edge with unit element; the attribute is the payload | | `Attr` in the return tuple | attribute write | offset write into the output record | +| `RemoveAttr` in the return tuple | attribute delete | the name leaves the output layout; functionally a write of the default | | `keys: List` | whole-extent input | wired edge, evaluated over its extent into a view | -| plain parameters | wired value inputs | ordinary wired edges | +| plain parameters | wired value inputs | ordinary wired edges; attributes on their wires do not flow | | `impl Node, Output = Concrete>` | lazy value input | the value flows, attributes do not | | `impl Node, Output = T>` (unbounded) | source of an opaque record family | routing, see below | | `-> List` with `level_extent =` | per-lane level production | structural skeleton emitted by the macro | @@ -368,7 +441,8 @@ decomposition, and both come from one declaration: Each input's top row is pushed down one level onto that input's items via entries in the translation plan (a level remap computed at wiring; no values are needed at compile time), composing by the declared - combine rule with inner-wins as the fallback. The merged top row + combine rule; the fallback is inner wins iff the inner level wrote + the name, resolved from the write sets at wiring. The merged top row starts empty. An explicit Wrap node is how the user nests instead. An input with unbounded (Free) extent contributes exactly one item, so merge is an extent-forcing boundary, which is the scalar base case. @@ -484,9 +558,9 @@ make semantic mistakes (evaluating an input it did not need to) but cannot misalign an offset. Kernels see contexts only as an opaque `impl Ctx + ...` they cannot construct, and lifetimes keep them from stashing handles in node state. The one remaining discipline lives -inside generated code (a borrow of a released frame must not survive the -next claim), and debug assertions guard it at wiring boundaries, -following the existing precedent for TypeId checks. +inside generated code (a result buffer must not be borrowed across a +sibling evaluation it could alias), and debug assertions guard it at +wiring boundaries, following the existing precedent for TypeId checks. # Drawbacks @@ -515,8 +589,8 @@ following the existing precedent for TypeId checks. per value, and no compile-time name checking. Interning the keys improves the constant (about 1.9ns per access vs. 0.43 for a resolved offset) but keeps a per-access search and rules out the structural - optimizations that need static layouts: bypass, uniform columns, - stack-allocated activation frames. + optimizations that need static layouts: bypass, uniform columns, slot + coalescing. - Attributes as separate graph edges, one channel per attribute: bypass and per-channel caching become graph structure. We prototyped and measured this. Without caching at fan-outs, every channel re-evaluates @@ -565,10 +639,15 @@ from shading languages for residency. currently a hand-written reference lowering, and it has no domain logic of its own, so it is not clear what a kernel for it would even contain. -- The graph UX of the map/enter construct, and whether attribute-typed - parameters can ever be ordinary exposed inputs. +- The graph UX of the map/enter construct. - Naming: `Attribute` trait vs. `Attr` wrapper, and whether the authoring `List` sharing the wire type's name helps or confuses. +- Generic-typed writes: where the default for a generically written + name comes from (a `Default` bound on the value vs. an input on the + name source), what `A` instantiates to at the Rust level, whether + attribute-only wires carry exactly one attribute by construction or + uniqueness is checked per read, and the graph UX of the name source + node. - Whether evaluating at a lane outside the input's extent is clamped, wrapped, or a debug assertion. - `List>` outputs, i.e. one node pushing two levels.