From 6e22d6a412fd333ea2040f5edf108321076cb29e Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 9 Sep 2026 11:13:41 +0000 Subject: [PATCH] Serve a declared name's census default on an absent read An absent read of a name the census declares now serves that name's own default rather than its value type's, which is the rule for a declared name. The census stages it exactly as it fills any absent field, as the declared default's bytes, so the read reuses the census's own mechanism instead of a second one; the compiler takes them when the name folds and `set_layout` installs them beside the offset. A name the census does not declare keeps the value type's default, which is that case's own rule. One name carries one value type, checked when the name folds, so the row's value type and width agree with the read's. Co-Authored-By: Claude Fable 5 --- node-graph/graph-craft/src/proto.rs | 1 + .../src/dynamic_executor.rs | 7 ++++ .../libraries/core-types/src/record/access.rs | 23 ++++++++++++ .../libraries/core-types/src/record/layout.rs | 27 +++++++++++++- .../libraries/core-types/src/record/mod.rs | 2 +- .../libraries/core-types/src/runtime.rs | 1 + node-graph/node-macro/src/codegen.rs | 37 ++++++++++++++++++- node-graph/nodes/gcore/src/record.rs | 3 ++ node-graph/nodes/graphic/src/record.rs | 2 + node-graph/nodes/gstd/src/render_node.rs | 1 + node-graph/nodes/math/src/lib.rs | 3 ++ 11 files changed, 104 insertions(+), 3 deletions(-) diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index e10e354763..d8622d6e08 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -392,6 +392,7 @@ impl ProtoNetwork { lane_invariant, named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), layout, }), ConstructionArgs::Nodes(inputs) => node.resolved.layout_meta.as_ref().and_then(|meta| { diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index e8f4e35280..09200278a2 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -841,6 +841,13 @@ mod test { assert_eq!(read_back(read_attribute_network("opacity", "opacity", TaggedValue::F64(0.5))), 0.5); } + #[test] + fn an_absent_census_named_read_serves_the_census_default() { + // `opacity` is declared `f64` defaulting to 1, so its absence reads as + // the census default rather than the value type's. + assert_eq!(read_back(read_attribute_network("novel:count", "opacity", TaggedValue::F64(2.5))), 1.); + } + #[test] fn a_named_read_disagreeing_with_its_write_is_refused() { // The name is written at a path upstream and read at `f64` here, which diff --git a/node-graph/libraries/core-types/src/record/access.rs b/node-graph/libraries/core-types/src/record/access.rs index c03f8565d1..f1872ef258 100644 --- a/node-graph/libraries/core-types/src/record/access.rs +++ b/node-graph/libraries/core-types/src/record/access.rs @@ -114,6 +114,29 @@ pub unsafe fn read_at<'e, A: attribute::Attribute>(rec: Rec<'_>, offset: Option< }) } +/// [`read_at`] for a name-generic read, whose marker carries the value type +/// but not the name and so cannot know the name's own default. `absent` is +/// that default's bytes, which the compiler takes from the census once the +/// name is folded; without one the value type's default applies, which is +/// exactly the rule for a name the census does not declare. +/// +/// # Safety +/// As [`read_at`]. `absent`, where present, must hold the object +/// representation of `A::Value`, which the compiler takes from the census row +/// for the folded name after checking that row's value type against `A`'s. A +/// reference-valued default addresses `'static` data, so the value it yields +/// outlives any evaluation. +pub unsafe fn read_at_defaulting<'e, A: attribute::Attribute>(rec: Rec<'_>, offset: Option, absent: Option<&[u8]>) -> attribute::Attr<'e, A> { + attribute::Attr(match (offset, absent) { + // SAFETY: the caller's contract. + (Some(offset), _) => unsafe { rec.read::>(offset) }, + // SAFETY: the caller's contract; the bytes are unaligned storage, so + // the value is read out rather than referenced in place. + (None, Some(bytes)) => unsafe { bytes.as_ptr().cast::>().read_unaligned() }, + (None, None) => A::default(), + }) +} + /// The read-less [`DerivedLazyInput`] glue: the token alone. /// /// # Safety diff --git a/node-graph/libraries/core-types/src/record/layout.rs b/node-graph/libraries/core-types/src/record/layout.rs index 1ddf6f3f02..d322398076 100644 --- a/node-graph/libraries/core-types/src/record/layout.rs +++ b/node-graph/libraries/core-types/src/record/layout.rs @@ -377,6 +377,11 @@ pub struct RecordLayout { /// name and the read input's finished layout sit together, so `set_layout` /// only copies the numbers into the read slots. pub named_reads: Vec>, + /// The census default's bytes for each absent name-from-input read whose + /// name the census declares, in the same order. A name the census does not + /// declare has `None` and reads as its value type's default, which is that + /// case's own rule; a read that resolved to an offset needs no default. + pub named_read_defaults: Vec>>, } /// A write whose name comes from the graph rather than from a marker: the @@ -552,12 +557,31 @@ impl LayoutMeta { // A named read resolves against the input it reads, whose layout is // finished by the time this node folds. An absent attribute stays // `None`, which the read serves as the name's forced default. - let named_reads = self + let named_reads: Vec> = self .named_reads .iter() .zip(&self.folded_read_names) .map(|(read, name)| inputs.get(read.input as usize).copied().flatten().and_then(|layout| layout.offset_of(name, read.template.level))) .collect(); + // An absent read serves the name's own default, which for a declared + // name is the census's rather than the value type's. The census stages + // it the same way it fills any absent field: as the declared default's + // bytes. One name carries one value type, checked when the name folds, + // so the size agreement below is a guard rather than a branch. + let named_read_defaults = self + .named_reads + .iter() + .zip(&self.folded_read_names) + .zip(&named_reads) + .map(|((read, name), offset)| { + let row = offset.is_none().then(|| attribute::info(name))??; + (row.value_type == read.template.type_id && row.size == read.template.size).then(|| { + let mut bytes = vec![0u8; row.size]; + (row.write_default_bytes)(&mut bytes); + bytes.into_boxed_slice() + }) + }) + .collect(); RecordLayout { layout, frame_bytes, @@ -565,6 +589,7 @@ impl LayoutMeta { lane_invariant: 0, named_writes: self.folded_names.clone(), named_reads, + named_read_defaults, } } diff --git a/node-graph/libraries/core-types/src/record/mod.rs b/node-graph/libraries/core-types/src/record/mod.rs index 9a5b5ef9fa..254237617a 100644 --- a/node-graph/libraries/core-types/src/record/mod.rs +++ b/node-graph/libraries/core-types/src/record/mod.rs @@ -21,7 +21,7 @@ mod serve; mod test_support; mod testkit; -pub use access::{Rec, RecordValue, apply_plan, borrow_element, erase_static, read_at, read_element, token_only, write_element, write_element_sized, write_field}; +pub use access::{Rec, RecordValue, apply_plan, borrow_element, erase_static, read_at, read_at_defaulting, read_element, token_only, write_element, write_element_sized, write_field}; pub use frames::{FrameArena, FrameScope, Frames}; pub use input::{DerivedLazyInput, DerivedRecordInput, ElementInput, ElementLazyInput, LevelStatus, RecordExtract, RecordInput, RecordLazyInput, fill_frames, materialize_batch, materialize_level}; pub use layout::{ diff --git a/node-graph/libraries/core-types/src/runtime.rs b/node-graph/libraries/core-types/src/runtime.rs index f4d4f70910..fe365d2850 100644 --- a/node-graph/libraries/core-types/src/runtime.rs +++ b/node-graph/libraries/core-types/src/runtime.rs @@ -280,6 +280,7 @@ mod tests { lane_invariant: u32::MAX, named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), }); RecordExtract::new(graph, &layout) } diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 3477b3be19..626d4ead38 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -240,6 +240,18 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let slot = format_ident!("__read_{index}"); quote!(pub(super) #slot: Option) })); + // A name-generic read carries the folded name's own default beside its + // offset, since its marker names the value type but not the name. + state.extend( + field_reads(&struct_regular_fields) + .iter() + .enumerate() + .filter(|(_, (_, read))| crate::parsing::named_marker(&read.marker).is_some()) + .map(|(index, _)| { + let slot = format_ident!("__read_default_{index}"); + quote!(pub(super) #slot: Option<::std::boxed::Box<[u8]>>) + }), + ); state.extend((0..node.output.shape.attrs.len()).map(|index| { let slot = format_ident!("__write_{index}"); quote!(pub(super) #slot: usize) @@ -1244,7 +1256,15 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let read_binding = |slot: usize, read: &AttributeRead, rec: TokenStream2| { let pat = &read.pat_ident; let marker = &read.marker; + let default_slot = format_ident!("__read_default_{slot}"); let slot = format_ident!("__read_{slot}"); + // A name-generic marker carries the value type but not the name, so it + // cannot know the name's own default; the compiler supplies it. + if crate::parsing::named_marker(marker).is_some() { + return quote! { + let #pat = unsafe { #core_types::record::read_at_defaulting::<#marker>(#rec, self.#slot, self.#default_slot.as_deref()) }; + }; + } quote! { let #pat = unsafe { #core_types::record::read_at::<#marker>(#rec, self.#slot) }; } @@ -2554,10 +2574,14 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn .enumerate() .filter(|(_, (_, read))| crate::parsing::named_marker(&read.marker).is_some()) .map(|(slot, _)| { + let default_slot = format_ident!("__read_default_{slot}"); let slot = format_ident!("__read_{slot}"); let position = folded_read; folded_read += 1; - quote!(self.#slot = __resolved.named_reads[#position];) + quote! { + self.#slot = __resolved.named_reads[#position]; + self.#default_slot = __resolved.named_read_defaults[#position].clone(); + } }) .collect(); let plan = (!skips_carrier || gather_carrier).then(|| quote!(self.__plan = __resolved.plan;)); @@ -2711,6 +2735,16 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn }); let plan_default = (!skips_carrier || gather_carrier).then(|| quote!(__plan: ::std::vec::Vec::new(),)).into_iter(); let read_names = (0..flat_reads.len()).map(|index| format_ident!("__read_{index}")).map(|slot| quote!(#slot,)); + // The folded name's default arrives with the layout, so the constructor + // leaves the slot empty and `set_layout` fills it. + let read_default_inits = flat_reads + .iter() + .enumerate() + .filter(|(_, (_, read))| crate::parsing::named_marker(&read.marker).is_some()) + .map(|(index, _)| { + let slot = format_ident!("__read_default_{index}"); + quote!(#slot: ::core::option::Option::None,) + }); let write_defaults = (0..write_markers.len()).map(|index| format_ident!("__write_{index}")).map(|slot| quote!(#slot: 0,)); let mat_cache_defaults = materialized_indices(®ular_fields, &node).into_iter().map(|index| { let slot = format_ident!("__mat_cache_{index}"); @@ -2757,6 +2791,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn __frame_bytes: 0, __lane_invariant: 0, #(#read_names)* + #(#read_default_inits)* #(#write_defaults)* #(#mat_cache_defaults)* #(#slot_default)* diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index a9e3565243..92d688be54 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -664,6 +664,7 @@ mod tests { let resolved = core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), lane_invariant: u32::MAX, ..meta.resolve(inputs) }; @@ -675,6 +676,7 @@ mod tests { let bundle = core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -1581,6 +1583,7 @@ mod tests { let resolved = core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), lane_invariant: 0, ..mirror_layout_meta().resolve(&[Some(&layout)]) }; diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index b7343547bc..869019228a 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -287,6 +287,7 @@ mod tests { let resolved = record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), lane_invariant: u32::MAX, ..meta.resolve(inputs) }; @@ -298,6 +299,7 @@ mod tests { let bundle = record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 1136da432f..326970ad51 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -274,6 +274,7 @@ mod tests { lane_invariant: u32::MAX, named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), }, ); let GPoll::Final(result) = core_types::record::serve_input(&graph, &ctx, &frames) else { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index f48f3dd1bc..11614d6494 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1055,6 +1055,7 @@ mod graphene_test { node.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -1118,6 +1119,7 @@ mod graphene_test { wired.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -1166,6 +1168,7 @@ mod graphene_test { wired.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), named_reads: Vec::new(), + named_read_defaults: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(),