diff --git a/node-graph/graph-craft/src/graphene_compiler.rs b/node-graph/graph-craft/src/graphene_compiler.rs index 04b9df01ef..b84f8645eb 100644 --- a/node-graph/graph-craft/src/graphene_compiler.rs +++ b/node-graph/graph-craft/src/graphene_compiler.rs @@ -20,7 +20,9 @@ impl Compiler { proto_networks.map(move |mut proto_network| { proto_network.insert_context_nullification_nodes()?; let _ = proto_network.resolve_types(registry); - proto_network.compute_layouts().map_err(|errors| errors.iter().map(|error| format!("{:?}", error.error)).collect::>().join("\n"))?; + proto_network + .compute_layouts() + .map_err(|errors| errors.iter().map(|error| format!("{:?}", error.error)).collect::>().join("\n"))?; proto_network.generate_stable_node_ids(); Ok(proto_network) }) diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 2106d1b327..0cff96760a 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -380,6 +380,7 @@ impl ProtoNetwork { pub fn compute_layouts(&mut self) -> Result<(), GraphErrors> { self.fold_attribute_names()?; + let mut errors = GraphErrors::new(); for index in 0..self.nodes.len() { let lane_invariant = self.nodes[index].1.lane_invariant_inputs; let layout = { @@ -390,6 +391,7 @@ impl ProtoNetwork { plan: Vec::new(), lane_invariant, named_writes: Vec::new(), + named_reads: Vec::new(), layout, }), ConstructionArgs::Nodes(inputs) => node.resolved.layout_meta.as_ref().and_then(|meta| { @@ -397,6 +399,10 @@ impl ProtoNetwork { .iter() .map(|input| self.nodes[input.0 as usize].1.resolved.layout.as_ref().map(|resolved| &resolved.layout)) .collect(); + // A read meets the name's value type where the name was + // written, so a disagreement upstream is caught here + // rather than read as the wrong type at run time. + errors.extend(read_type_conflicts(node, meta, &input_layouts)); meta.sources.iter().all(|&source| input_layouts[source as usize].is_some()).then(|| core_types::record::RecordLayout { lane_invariant, ..meta.resolve(&input_layouts) @@ -414,7 +420,7 @@ impl ProtoNetwork { self.nodes[index].1.resolved.layout = layout; } self.stack_need = self.fold_stack_peak(); - Ok(()) + errors.is_empty().then_some(()).ok_or(errors) } /// Resolves every name-from-input write against the constant its name @@ -426,55 +432,64 @@ impl ProtoNetwork { let mut errors = GraphErrors::new(); for index in 0..self.nodes.len() { let Some(meta) = &self.nodes[index].1.resolved.layout_meta else { continue }; - if meta.named_writes.is_empty() { + if meta.named_writes.is_empty() && meta.named_reads.is_empty() { continue; } let ConstructionArgs::Nodes(inputs) = &self.nodes[index].1.construction_args else { continue; }; - let names: Vec> = meta + // Writes first, then reads: both draw their names the same way, and + // the one-name-one-type check runs over them together. + let sources: Vec<(u8, std::any::TypeId)> = meta .named_writes .iter() - .map(|named| match inputs.get(named.name_input as usize).map(|input| &self.nodes[input.0 as usize].1.construction_args) { - Some(ConstructionArgs::Value(value)) => match &**value { - value::TaggedValue::String(name) => Ok(core_types::attribute::intern_name(name)), - other => Err(GraphErrorType::AttributeName(format!( - "an attribute name must be text, but input {} is {}", - named.name_input + 1, - other.ty() + .map(|named| (named.name_input, named.template.type_id)) + .chain(meta.named_reads.iter().map(|named| (named.name_input, named.template.type_id))) + .collect(); + let names: Vec> = sources + .iter() + .map( + |&(name_input, _)| match inputs.get(name_input as usize).map(|input| &self.nodes[input.0 as usize].1.construction_args) { + Some(ConstructionArgs::Value(value)) => match &**value { + value::TaggedValue::String(name) => Ok(core_types::attribute::intern_name(name)), + other => Err(GraphErrorType::AttributeName(format!("an attribute name must be text, but input {} is {}", name_input + 1, other.ty()))), + }, + _ => Err(GraphErrorType::AttributeName(format!( + "input {} must be a constant, since attribute names resolve when the graph compiles rather than when it runs", + name_input + 1 ))), }, - _ => Err(GraphErrorType::AttributeName(format!( - "input {} must be a constant, since attribute names resolve when the graph compiles rather than when it runs", - named.name_input + 1 - ))), - }) + ) .collect(); let node = &self.nodes[index].1; let mut folded: Vec<(&'static str, std::any::TypeId)> = Vec::new(); let mut failed = false; - for (position, name) in names.iter().enumerate() { + for (name, &(_, type_id)) in names.iter().zip(&sources) { match name { Err(error) => { errors.push(GraphError::new(node, error.clone())); failed = true; } Ok(name) => { - let template = node.resolved.layout_meta.as_ref().expect("checked above").named_writes[position].template; - if let Some(conflict) = one_name_one_type(name, template.type_id, &folded) { + if let Some(conflict) = one_name_one_type(name, type_id, &folded) { errors.push(GraphError::new(node, conflict)); failed = true; } - folded.push((name, template.type_id)); + folded.push((name, type_id)); } } } if failed { continue; } + let writes = self.nodes[index].1.resolved.layout_meta.as_ref().expect("checked above").named_writes.len(); let meta = self.nodes[index].1.resolved.layout_meta.as_mut().expect("checked above"); for (position, name) in names.into_iter().enumerate() { - meta.fold_name(position, name.expect("every name resolved")); + let name = name.expect("every name resolved"); + match position < writes { + true => meta.fold_name(position, name), + false => meta.fold_read_name(name), + } } } errors.is_empty().then_some(()).ok_or(errors) @@ -803,6 +818,26 @@ impl ProtoNetwork { Ok(()) } } +/// Reports each name-from-input read whose value type disagrees with the field +/// the name already names on the input it reads. An absent field is no +/// conflict: the read serves the name's forced default. +fn read_type_conflicts(node: &ProtoNode, meta: &core_types::record::LayoutMeta, inputs: &[Option<&core_types::record::Layout>]) -> GraphErrors { + meta.named_reads + .iter() + .zip(&meta.folded_read_names) + .filter_map(|(read, name)| { + let layout = inputs.get(read.input as usize).copied().flatten()?; + let field = layout.fields.iter().find(|field| field.name == *name && field.level == read.template.level)?; + (field.type_id != read.template.type_id).then(|| { + GraphError::new( + node, + GraphErrorType::AttributeName(format!("attribute `{name}` is read at one value type but written at another, and one name carries one value type")), + ) + }) + }) + .collect() +} + /// Reports a folded name that disagrees with a type the same name already /// carries, over the census and the names this node folded together. One name /// means one value type everywhere, so the layouts a graph folds can never diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 61189d8dfa..e8f4e35280 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -193,9 +193,7 @@ impl DynamicExecutor { pub fn introspect(&self, node_path: &[NodeId]) -> Result, IntrospectError> { let result = self.tree.introspect(node_path)?; if result.downcast_ref::().is_some() { - return self - .introspect_with(node_path, graphic_types::boundary::batch_to_legacy) - .map(Arc::from); + return self.introspect_with(node_path, graphic_types::boundary::batch_to_legacy).map(Arc::from); } Ok(result) } @@ -791,6 +789,71 @@ mod test { assert_eq!(unsafe { layout.rec(&value).read::<&[NodeId]>(offset) }, path.as_slice()); } + /// Reads `read_name` off a record that a write of `write_name` produced. + fn read_attribute_network(write_name: &str, read_name: &str, value: TaggedValue) -> ProtoNetwork { + ProtoNetwork { + stack_need: 0, + inputs: vec![], + output: NodeId(5), + nodes: vec![ + (NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])), + (NodeId(1), string_value(write_name)), + (NodeId(2), ProtoNode::value(ConstructionArgs::Value(value.into()), vec![])), + (NodeId(3), proto_node("graphic_nodes::graphic::WriteAttributeNode", vec![NodeId(0), NodeId(1), NodeId(2)])), + (NodeId(4), string_value(read_name)), + (NodeId(5), proto_node("graphic_nodes::graphic::ReadAttributeNode", vec![NodeId(3), NodeId(4)])), + ], + } + } + + fn read_back(network: ProtoNetwork) -> f64 { + let executor = build_executor(network); + let arena = Arena::new(1 << 12).unwrap(); + let generations = []; + let scope = EvalScope::new(None, None, None, &generations, &arena); + let ctx = ContextImpl::root(&scope); + let handle = executor.tree().get(NodeId(5)).unwrap(); + let layout = handle.layout().clone(); + let edge = handle.duplicate().downcast_record::().unwrap(); + let frames = core_types::record::test_frames(executor.tree().stack_need()); + let GPoll::Final(value) = core_types::record::serve_input(&edge, &ctx, &frames) else { + panic!("expected a final record"); + }; + unsafe { core_types::record::read_element::(layout.rec(&value)) } + } + + #[test] + fn a_named_read_serves_the_written_value() { + assert_eq!(read_back(read_attribute_network("novel:count", "novel:count", TaggedValue::F64(2.5))), 2.5); + } + + #[test] + fn an_absent_named_read_serves_the_forced_default() { + // Nothing upstream writes `novel:absent`, so the read collapses to the + // value type's default rather than reporting absence. + assert_eq!(read_back(read_attribute_network("novel:count", "novel:absent", TaggedValue::F64(2.5))), 0.); + } + + #[test] + fn a_census_named_read_round_trips_its_written_value() { + // A declared name folds onto its census field, so the read resolves + // against the same offset the write installed. + assert_eq!(read_back(read_attribute_network("opacity", "opacity", TaggedValue::F64(0.5))), 0.5); + } + + #[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 + // is the one-name-one-type rule spanning a write and a read. + let mut network = read_attribute_network("novel:count", "novel:count", TaggedValue::NodeIdPath(vec![NodeId(1)])); + network.resolve_types(&node_registry::NODE_REGISTRY).unwrap(); + let errors = network.compute_layouts().expect_err("a name at two value types must be refused"); + assert!( + errors.iter().any(|error| format!("{:?}", error.error).contains("one name carries one value type")), + "the refusal names the one-name-one-type rule, got {errors:?}" + ); + } + #[test] fn a_runtime_attribute_name_is_refused_when_the_graph_compiles() { // The outer node's name comes off another node rather than sitting on diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 00cc7c535c..9c7e374e74 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -7,7 +7,7 @@ use graphene_std::raster::GPU; #[cfg(feature = "gpu")] use graphene_std::SourceId; use graphene_std::raster::{CPU, Raster}; -use graphene_std::registry::{ConstructionError, SourceHandle, NodeIOTypes, RegistryEntry}; +use graphene_std::registry::{ConstructionError, NodeIOTypes, RegistryEntry, SourceHandle}; #[cfg(feature = "gpu")] use graphene_std::runtime::RuntimeHandle; @@ -241,7 +241,9 @@ mod node_registry_macros { let handle = inputs.next().unwrap(); let layout = handle.layout().clone(); let node = graphene_std::ops::IntoNode::<$to, _, $from>::new(handle.downcast_record::<$from>()?, &layout); - Ok(SourceHandle::new_record::<$to>(std::sync::Arc::new(node) as std::sync::Arc)) + Ok(SourceHandle::new_record::<$to>( + std::sync::Arc::new(node) as std::sync::Arc + )) }, }, ) diff --git a/node-graph/libraries/core-types/src/arena.rs b/node-graph/libraries/core-types/src/arena.rs index 5fbe3ddc9e..42ed7c4914 100644 --- a/node-graph/libraries/core-types/src/arena.rs +++ b/node-graph/libraries/core-types/src/arena.rs @@ -251,7 +251,12 @@ impl Arena { // SAFETY: the caller's contract. unsafe { p.cast::().drop_in_place() } } - self.drops.lock().unwrap().push(DropEntry { offset, type_of, drop_fn: glue::, retained }); + self.drops.lock().unwrap().push(DropEntry { + offset, + type_of, + drop_fn: glue::, + retained, + }); self.retained_heap.fetch_add(retained, Ordering::Relaxed); } // SAFETY: initialized above; insert-only, so no `&mut` to it can exist. @@ -772,7 +777,10 @@ mod tests { let (parked, _) = transient.alloc_sized_keyed(Owner(String::from("a keyed park")), 0).unwrap(); let src = std::ptr::from_ref(parked).cast::(); - assert!(unsafe { transient.move_park::(src, &persistent, 0) }.is_none(), "a park of another type of the same size is refused"); + assert!( + unsafe { transient.move_park::(src, &persistent, 0) }.is_none(), + "a park of another type of the same size is refused" + ); unsafe { transient.move_park::(src, &persistent, 0) }.unwrap(); assert!(unsafe { transient.move_park::(src, &persistent, 0) }.is_none(), "the forwarding refuses the same mistype"); diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 4abd42b23a..583dc50446 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -439,7 +439,6 @@ impl AttributeDyn { pub fn is_empty(&self) -> bool { self.0.len() == 0 } - } impl Clone for AttributeDyn { diff --git a/node-graph/libraries/core-types/src/node.rs b/node-graph/libraries/core-types/src/node.rs index 2c2f7ed326..6c4c8bdd19 100644 --- a/node-graph/libraries/core-types/src/node.rs +++ b/node-graph/libraries/core-types/src/node.rs @@ -136,7 +136,6 @@ impl<'a> RecordBatchMut<'a> { // SAFETY: the constructor's contract; the exclusive borrow is consumed. unsafe { RecordBatch::new(self.scratch.as_ptr().cast(), self.len, self.layout) } } - } /// One lane's record: its pointer paired with the batch's layout. diff --git a/node-graph/libraries/core-types/src/record/layout.rs b/node-graph/libraries/core-types/src/record/layout.rs index 10c7763f61..1ddf6f3f02 100644 --- a/node-graph/libraries/core-types/src/record/layout.rs +++ b/node-graph/libraries/core-types/src/record/layout.rs @@ -371,6 +371,12 @@ pub struct RecordLayout { /// leaves it empty; `set_layout` resolves its offsets through these /// instead of through a marker's `NAME`. pub named_writes: Vec<&'static str>, + /// The offsets the fold resolved for this node's name-from-input reads, in + /// placeholder order. `None` is an absent attribute, which the read serves + /// as the name's forced default. Resolved in the compiler, where the folded + /// 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>, } /// A write whose name comes from the graph rather than from a marker: the @@ -399,6 +405,34 @@ impl NamedWrite { } } +/// A read whose name comes from the graph rather than from a marker. The +/// compiler resolves it to an offset in the read input's own layout, where +/// the folded name and that finished layout already sit together. +#[derive(Clone, Copy, Debug)] +pub struct NamedRead { + /// The proto input the attribute is read from. + pub input: u8, + /// The proto input holding the name's constant text. + pub name_input: u8, + /// The read's field form, every facet but the name minted from the + /// concrete value type, as for a write. + pub template: FieldWrite, +} + +impl NamedRead { + /// The template for a name-generic marker's read at `level`. + pub fn of(input: u8, name_input: u8, level: u8) -> Self + where + V::Value<'static>: graphene_hash::CacheHash + PartialEq + 'static, + { + Self { + input, + name_input, + template: FieldWrite::of::>(level), + } + } +} + /// Declarative record-io metadata for a node type, emitted by the macro into /// its registry entry so the compiler can fold each input's layout without /// running the node's constructor. [`fold`](LayoutMeta::fold) reproduces the @@ -426,6 +460,11 @@ pub struct LayoutMeta { /// The names the fold gave [`named_writes`](Self::named_writes), in /// placeholder order. Empty until the fold runs. pub folded_names: Vec<&'static str>, + /// The attributes the node reads under a name taken from the graph. + pub named_reads: Vec, + /// The names the fold gave [`named_reads`](Self::named_reads), in + /// placeholder order. Empty until the fold runs. + pub folded_read_names: Vec<&'static str>, /// The attributes removed from the base layout, as `(name, level)`. pub removes: Vec<(&'static str, u8)>, /// The depth change the node applies: `0` for elementwise and flip nodes, @@ -464,6 +503,8 @@ impl LayoutMeta { writes: Vec::new(), named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), removes: Vec::new(), level_delta: 0, folded: None, @@ -508,12 +549,22 @@ impl LayoutMeta { // A reducer collapses its carrier's levels, so it writes a fresh record rather than copying fields down. _ => Vec::new(), }; + // 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 + .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(); RecordLayout { layout, frame_bytes, plan, lane_invariant: 0, named_writes: self.folded_names.clone(), + named_reads, } } @@ -531,6 +582,13 @@ impl LayoutMeta { self.writes.push(write); self.folded_names.push(name); } + + /// Records the name of the name-from-input read at `index`. The offset + /// itself waits for [`resolve`](Self::resolve), which is where the read + /// input's finished layout arrives. + pub fn fold_read_name(&mut self, name: &'static str) { + self.folded_read_names.push(name); + } } /// Field-by-field carry from `from`'s layout into `to`'s, computed at diff --git a/node-graph/libraries/core-types/src/record/mod.rs b/node-graph/libraries/core-types/src/record/mod.rs index 8c6a505c67..4dddfc6861 100644 --- a/node-graph/libraries/core-types/src/record/mod.rs +++ b/node-graph/libraries/core-types/src/record/mod.rs @@ -25,8 +25,8 @@ pub use access::{Rec, RecordValue, apply_plan, borrow_element, erase_static, rea 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::{ - ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, NamedWrite, RecordLayout, copy_plan, - element_dims, element_parked, element_write, element_write_hashed, empty_layout, + ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, NamedRead, NamedWrite, + RecordLayout, copy_plan, element_dims, element_parked, element_write, element_write_hashed, empty_layout, }; pub use owned::{OwnedRecord, deepen_field_value, has_deep_element_glue, register_deep_element_clone, register_deep_field_value, replay_field_value}; pub use promote::{Promotion, assert_promoted, register_element_promote, register_field_promote, register_retained_heap}; diff --git a/node-graph/libraries/core-types/src/record/owned.rs b/node-graph/libraries/core-types/src/record/owned.rs index 9077a9e883..fc47a8f5a1 100644 --- a/node-graph/libraries/core-types/src/record/owned.rs +++ b/node-graph/libraries/core-types/src/record/owned.rs @@ -57,10 +57,7 @@ static DEEP_FIELD_VALUES: std::sync::LazyLock( - copy_out: fn(&dyn crate::list::AnyAttributeValue) -> Option>, - replay: crate::list::FieldReplayFn, -) { +pub fn register_deep_field_value(copy_out: fn(&dyn crate::list::AnyAttributeValue) -> Option>, replay: crate::list::FieldReplayFn) { DEEP_FIELD_VALUES.lock().unwrap().insert(std::any::TypeId::of::(), DeepFieldGlue { copy_out, replay }); } diff --git a/node-graph/libraries/core-types/src/runtime.rs b/node-graph/libraries/core-types/src/runtime.rs index 7b3fab8085..f4d4f70910 100644 --- a/node-graph/libraries/core-types/src/runtime.rs +++ b/node-graph/libraries/core-types/src/runtime.rs @@ -279,6 +279,7 @@ mod tests { layout: layout.clone(), lane_invariant: u32::MAX, named_writes: Vec::new(), + named_reads: Vec::new(), }); RecordExtract::new(graph, &layout) } diff --git a/node-graph/libraries/graphic-types/src/graphic/glue.rs b/node-graph/libraries/graphic-types/src/graphic/glue.rs index 57601d01a6..e3018434d5 100644 --- a/node-graph/libraries/graphic-types/src/graphic/glue.rs +++ b/node-graph/libraries/graphic-types/src/graphic/glue.rs @@ -505,12 +505,7 @@ mod run_tests { } /// The promoted paint of one lane, at the layout the promote published. - fn promoted_paint<'p>( - span: &core_types::record::MaterializedSpan, - layout: &core_types::record::Layout, - lane: usize, - persistent: &'p core_types::arena::Arena, - ) -> &'p List> { + fn promoted_paint<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p List> { let offset = layout.offset_of(Fill::NAME, 0).unwrap(); let batch = span.batch(persistent, layout).expect("the span resolves in its own region"); // SAFETY: the promote wrote a record of `layout` into every lane. @@ -527,7 +522,9 @@ mod run_tests { // list the evaluation parked. let published = native_group_paint(&inner_vector, &persistent); let interior = { - let Some(Graphic::Group(group)) = published.element(0) else { panic!("the paint carries a native group") }; + let Some(Graphic::Group(group)) = published.element(0) else { + panic!("the paint carries a native group") + }; group.content.lanes().get(0).rec().ptr() }; // SAFETY: the list serves only while `persistent` is live, and the @@ -538,7 +535,9 @@ mod run_tests { let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent); let served = promoted_paint(&span, &layout, 0, &persistent); - let Some(Graphic::Group(group)) = served.element(0) else { panic!("the promote keeps the group form") }; + let Some(Graphic::Group(group)) = served.element(0) else { + panic!("the promote keeps the group form") + }; assert_eq!(group.content.lanes().get(0).rec().ptr(), interior, "a persistent interior is shared pointer for pointer"); assert!( persistent.occupancy() - occupied <= layout.frame_bytes() + size_of::>() + align_of::>(), @@ -560,8 +559,14 @@ mod run_tests { let (layout, span, _frames) = promote_paint_field(Some(paint), 2, &transient, &persistent); let served = promoted_paint(&span, &layout, 0, &persistent); - let Some(Graphic::Vector(vector)) = served.element(0) else { panic!("the promote keeps the vector") }; - assert_eq!(vector.point_domain.positions().as_ptr(), heap, "the promote moved the header, so the served paint names the pre-promote heap"); + let Some(Graphic::Vector(vector)) = served.element(0) else { + panic!("the promote keeps the vector") + }; + assert_eq!( + vector.point_domain.positions().as_ptr(), + heap, + "the promote moved the header, so the served paint names the pre-promote heap" + ); assert!(std::ptr::eq(served, promoted_paint(&span, &layout, 1, &persistent)), "a paint two lanes share moves once"); transient.reset(); @@ -593,7 +598,11 @@ mod run_tests { let served = promoted_paint(&span, &layout, 0, &persistent); let held = served.attribute::>>(Stroke::NAME, 0).expect("the stroke attribute rides the promoted list"); let held = held.as_ref().expect("the stroke is present"); - assert_eq!(map_groups_to_legacy(held.element(0).unwrap()), expected, "the attribute-held group serves from persistent storage after the reset"); + assert_eq!( + map_groups_to_legacy(held.element(0).unwrap()), + expected, + "the attribute-held group serves from persistent storage after the reset" + ); } #[test] @@ -615,8 +624,14 @@ mod run_tests { let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent); let served = promoted_paint(&span, &layout, 0, &persistent); - let Some(Graphic::Vector(vector)) = served.element(0) else { panic!("the promote keeps the vector") }; - assert_eq!(vector.point_domain.positions().as_ptr(), heap, "the promote moved the header, so the served paint names the pre-promote heap"); + let Some(Graphic::Vector(vector)) = served.element(0) else { + panic!("the promote keeps the vector") + }; + assert_eq!( + vector.point_domain.positions().as_ptr(), + heap, + "the promote moved the header, so the served paint names the pre-promote heap" + ); transient.reset(); let served = promoted_paint(&span, &layout, 0, &persistent); diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 395257ff19..a2ba32db08 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -999,8 +999,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // more: the name is spent resolving the layout when the graph compiles, so // it reaches neither the kernel's parameters nor its call. The wire input // stays, since the fold reads the constant off it. - let kernel_omits = - |field: &ParsedField| injected_name(&field.pat_ident.ident) || matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { name_source: Some(_), .. })); + let kernel_omits = |field: &ParsedField| injected_name(&field.pat_ident.ident) || matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { name_source: Some(_), .. })); let where_predicates: Vec = parsed.where_clause.iter().flat_map(|clause| clause.predicates.iter()).map(|predicate| quote!(#predicate)).collect(); let NodeFields { @@ -1743,7 +1742,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // kernel gets a fresh one; reference-valued writes name their real // lifetime explicitly and pass through untouched. An async source's value // outlives the evaluation, so its writes are `'static` instead. - let attr_injected = record_io.then(|| inject_attr_lifetimes(&parsed.output_type, if async_source { "'static" } else { "'__attr" })).flatten(); + let attr_injected = record_io + .then(|| inject_attr_lifetimes(&parsed.output_type, if async_source { "'static" } else { "'__attr" })) + .flatten(); let attr_lifetime = (attr_injected.is_some() && !async_source).then(|| quote!('__attr,)); let lane_injected = gather_carrier .then(|| crate::codegen::classify::inject_lane_lifetime(attr_injected.as_ref().unwrap_or(&parsed.output_type))) @@ -2531,9 +2532,24 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn } }) .collect(); + // A name-generic read's offset was resolved against the input it reads, + // which only the compiler sees, so installing it is a copy. + let mut folded_read = 0usize; + let read_installs: Vec = flat_reads + .iter() + .enumerate() + .filter(|(_, (_, read))| crate::parsing::named_marker(&read.marker).is_some()) + .map(|(slot, _)| { + let slot = format_ident!("__read_{slot}"); + let position = folded_read; + folded_read += 1; + quote!(self.#slot = __resolved.named_reads[#position];) + }) + .collect(); let plan = (!skips_carrier || gather_carrier).then(|| quote!(self.__plan = __resolved.plan;)); Some(quote! { #(#write_installs)* + #(#read_installs)* self.__frame_bytes = __resolved.frame_bytes; self.__lane_invariant = __resolved.lane_invariant; #plan @@ -2657,6 +2673,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn let read_inits = flat_reads.iter().enumerate().map(|(slot, (owner, read))| { let marker = &read.marker; let slot = format_ident!("__read_{slot}"); + // A name-generic read has no marker name to look up here; the + // compiler resolved its offset against the input's own layout, so + // `set_layout` installs the number. + if crate::parsing::named_marker(marker).is_some() { + return quote!(let #slot = ::core::option::Option::None;); + } let source = match !skips_carrier && *owner == 0 { true => quote!(__carrier_layout), false => format_ident!("__in_{owner}").to_token_stream(), diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 2f47b1909e..256a80f4eb 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -282,6 +282,7 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t }); let writes = field_writes(&node.output.shape.attrs, core_types); let named_writes = named_field_writes(node, core_types, assignments); + let named_reads = named_field_reads(node, core_types, assignments); let removes = node.output.removes.iter().map(|attr| { let marker = &attr.marker; let level = attr.level; @@ -300,6 +301,8 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t writes: ::std::vec![#(#writes),*], named_writes: ::std::vec![#(#named_writes),*], folded_names: ::std::vec![], + named_reads: ::std::vec![#(#named_reads),*], + folded_read_names: ::std::vec![], removes: ::std::vec![#(#removes),*], level_delta: #level_delta, folded: #folded, @@ -396,6 +399,28 @@ fn named_field_writes(node: &Node, core_types: &TokenStream2, assignments: &[(Id .collect() } +/// Emits one `NamedRead` per name-generic read, pairing the template minted +/// from the concrete value type with the input read and the input its +/// placeholder's name sits at. +fn named_field_reads(node: &Node, core_types: &TokenStream2, assignments: &[(Ident, Type)]) -> Vec { + node.inputs + .iter() + .enumerate() + .flat_map(|(input, source)| source.shape.attrs.iter().map(move |attr| (input, attr))) + .filter_map(|(input, attr)| { + let (placeholder, value) = crate::parsing::named_marker(&attr.marker)?; + let value = qualify_projection(node, &crate::codegen::classify::substitute_ident_types(&value, assignments), assignments); + if node.generics.iter().any(|generic| mentions_ident(&value, &generic.ident)) { + return None; + } + let name = name_input(node, &placeholder)? as u8; + let input = input as u8; + let level = attr.level; + Some(quote!(#core_types::record::NamedRead::of::<#placeholder, #value>(#input, #name, #level))) + }) + .collect() +} + /// Rewrites `V::Assoc` into `::Assoc` once the row assigns `V`. /// A value type reached through an associated type needs the generic's own /// bound to name the projection, which only the signature carries. @@ -444,9 +469,7 @@ fn mentions_ident(ty: &Type, ident: &Ident) -> bool { /// The input position carrying `placeholder`'s name, which is the parameter /// declared at that placeholder. pub(crate) fn name_input(node: &Node, placeholder: &Type) -> Option { - node.inputs - .iter() - .position(|input| input.name_source.as_ref().is_some_and(|declared| declared == placeholder)) + node.inputs.iter().position(|input| input.name_source.as_ref().is_some_and(|declared| declared == placeholder)) } fn level_delta(node: &Node) -> i8 { @@ -852,11 +875,7 @@ mod tests { let assignments = vec![(syn::parse_quote!(V), syn::parse_quote!(f64))]; let emitted = named_field_writes(&node, "e!(gcore), &assignments); assert_eq!(emitted.len(), 1, "the row carries the named write, got {emitted:?}"); - assert!( - emitted[0].to_string().contains("WireValue"), - "the projection is qualified by the generic's bound, got {}", - emitted[0] - ); + assert!(emitted[0].to_string().contains("WireValue"), "the projection is qualified by the generic's bound, got {}", emitted[0]); } #[test] diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index eac29563e7..f6c6259dc9 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -1682,7 +1682,7 @@ mod tests { description: String::new(), widget_override: ParsedWidgetOverride::None, ty: ParsedFieldType::Regular(RegularParsedField { - name_source: None, + name_source: None, lend: None, list_levels: 0, ty: parse_quote!(DVec2), diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index 13dc53bf29..36dae6c1a8 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -127,7 +127,10 @@ fn validate_record_io(parsed: &ParsedNodeFn) { _ => None, }; if async_source && token.is_some() { - emit_error!(carrier.pat_ident.span(), "an async source's element crosses the future boundary as a value; a passthrough generic element has none"); + emit_error!( + carrier.pat_ident.span(), + "an async source's element crosses the future boundary as a value; a passthrough generic element has none" + ); } let element = writes.as_ref().map(|writes| &writes.element).unwrap_or(&value); match &token { @@ -164,7 +167,10 @@ fn validate_record_io(parsed: &ParsedNodeFn) { let mut seen_writes: Vec = Vec::new(); for write in &writes.markers { if write.owned && !async_source { - emit_error!(parsed.output_type.span(), "an owned attribute crossing belongs to an async source; a synchronous write parks its value in the kernel"); + emit_error!( + parsed.output_type.span(), + "an owned attribute crossing belongs to an async source; a synchronous write parks its value in the kernel" + ); } let written = write.marker.to_token_stream().to_string(); if seen_writes.contains(&written) { diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 80cd1fc967..a9e3565243 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -663,6 +663,7 @@ mod tests { // pass records as lane-invariant. let resolved = core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), lane_invariant: u32::MAX, ..meta.resolve(inputs) }; @@ -673,6 +674,7 @@ mod tests { fn install_flip>>(mut node: N, layout: &Layout) -> N { let bundle = core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -768,6 +770,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -806,6 +810,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -852,6 +858,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -904,6 +912,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1004,6 +1014,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0, 1], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1074,6 +1086,8 @@ mod tests { let meta = || core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0, 1], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1150,6 +1164,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0, 1], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1196,6 +1212,8 @@ mod tests { let meta = || core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1207,6 +1225,8 @@ mod tests { let extend_meta = || core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0, 1], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1284,6 +1304,8 @@ mod tests { let meta = || core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1339,6 +1361,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1556,6 +1580,7 @@ mod tests { let mut node = MirrorNode::new(RecordSource::new(content, &layout, &layout), counting_value(true, &evals)); let resolved = core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), lane_invariant: 0, ..mirror_layout_meta().resolve(&[Some(&layout)]) }; @@ -1636,6 +1661,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1691,6 +1718,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1803,6 +1832,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, @@ -1931,6 +1962,8 @@ mod tests { let meta = core_types::record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![0], reads: vec![], element: core_types::record::ElementSpec::Carried, diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 58eb3e6a1b..cb43ad81da 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -390,6 +390,22 @@ pub fn write_attribute<'e, T, V: WireValue>( Ok((content, Attr(parked))) } +/// Reads the attribute `name` names off each lane. An absent attribute reads as +/// the name's own default, so the value carries the declared type either way; +/// the name is constant text the compiler folds into an offset when the graph +/// compiles. +#[node_macro::node(category("Attributes: Read"))] +pub fn read_attribute<'e>( + _: impl Ctx, + /// The content whose lanes carry the attribute. + (content, value): (f64, Attr<'e, Named>), + /// The attribute name, folded into an offset when the graph compiles. + name: Named, +) -> f64 { + let _ = content; + *value +} + /// Joins two levels of the same type, the base's lanes followed by the new's. #[node_macro::node(category("General"), extent(extend_extent))] pub fn extend( diff --git a/node-graph/nodes/graphic/src/record.rs b/node-graph/nodes/graphic/src/record.rs index b451a530fa..e71ded31f0 100644 --- a/node-graph/nodes/graphic/src/record.rs +++ b/node-graph/nodes/graphic/src/record.rs @@ -286,6 +286,7 @@ mod tests { // pass records as lane-invariant. let resolved = record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), lane_invariant: u32::MAX, ..meta.resolve(inputs) }; @@ -296,6 +297,7 @@ mod tests { fn install_flip>>(mut node: N, layout: &Layout) -> N { let bundle = record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -424,6 +426,8 @@ mod tests { record::LayoutMeta { named_writes: Vec::new(), folded_names: Vec::new(), + named_reads: Vec::new(), + folded_read_names: Vec::new(), sources: vec![source], reads: vec![], element: record::ElementSpec::Carried, diff --git a/node-graph/nodes/gstd/src/platform_application_io.rs b/node-graph/nodes/gstd/src/platform_application_io.rs index a81f750be6..378c7776b3 100644 --- a/node-graph/nodes/gstd/src/platform_application_io.rs +++ b/node-graph/nodes/gstd/src/platform_application_io.rs @@ -9,13 +9,15 @@ use core_types::gpoll::GPoll; #[cfg(target_family = "wasm")] use core_types::list::List; +#[cfg(target_family = "wasm")] +use core_types::ATTR_TRANSFORM; #[cfg(target_family = "wasm")] use core_types::math::bbox::Bbox; use core_types::runtime::SourceFuture; #[cfg(target_family = "wasm")] use core_types::transform::Footprint; #[cfg(target_family = "wasm")] -use core_types::{ATTR_TRANSFORM, WasmNotSend}; +use core_types::WasmNotSend; use core_types::{Color, Ctx}; pub use graph_craft::application_io::resource::{Resource, ResourceHash}; pub use graph_craft::application_io::*; diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 14fae7e2dc..6afb8e7acd 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -273,6 +273,7 @@ mod tests { layout: layout.clone(), lane_invariant: u32::MAX, named_writes: Vec::new(), + named_reads: 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 0b3be740ee..a4969f8fe9 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1602,6 +1602,7 @@ mod graphene_test { fn installed>>(mut node: N, layout: &Layout) -> N { node.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -1664,6 +1665,7 @@ mod graphene_test { let layout = out_layout::(); wired.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(), @@ -1711,6 +1713,7 @@ mod graphene_test { let layout = out_layout::(); wired.set_layout(core_types::record::RecordLayout { named_writes: Vec::new(), + named_reads: Vec::new(), frame_bytes: layout.frame_bytes(), plan: Vec::new(), layout: layout.clone(),