diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index cdf04440de..33d20e1840 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -791,12 +791,17 @@ mod test { /// 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 { + read_attribute_network_over(TaggedValue::F64(7.), write_name, read_name, value) + } + + /// The same graph over any content element, which the read never looks at. + fn read_attribute_network_over(content: TaggedValue, 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(0), ProtoNode::value(ConstructionArgs::Value(content.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)])), @@ -834,6 +839,21 @@ mod test { assert_eq!(read_back(read_attribute_network("novel:count", "novel:absent", TaggedValue::F64(2.5))), 0.); } + #[test] + fn one_read_node_serves_every_content_element() { + // The read never looks at the element, so one registry row covers any + // upstream record wire rather than a row per element type. + assert_eq!(read_back(read_attribute_network_over(TaggedValue::F64(7.), "novel:count", "novel:count", TaggedValue::F64(2.5))), 2.5); + assert_eq!( + read_back(read_attribute_network_over(TaggedValue::Bool(true), "novel:count", "novel:count", TaggedValue::F64(2.5))), + 2.5 + ); + assert_eq!( + read_back(read_attribute_network_over(TaggedValue::DVec2(glam::DVec2::ONE), "novel:count", "novel:count", TaggedValue::F64(2.5))), + 2.5 + ); + } + #[test] fn a_census_named_read_round_trips_its_written_value() { // A declared name folds onto its census field, so the read resolves diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 626d4ead38..1681e0a846 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -808,7 +808,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn // its generic stays a struct parameter. let record_token = match (kind, &node.output.shape.element) { (crate::codegen::ir::NodeKind::RecordIo, crate::codegen::ir::Element::Generic(ident)) if !gather_carrier => Some(ident.clone()), - _ => None, + // An opaque reading input's element is byte-carried the same way, even + // though the output replaces it rather than carrying it through. + _ => crate::codegen::classify::opaque_reading_carrier(parsed), }; // The record-io write set, resolved from the output item and carrier input. let write_markers: Vec<&Type> = node.output.shape.attrs.iter().map(|attr| &attr.marker).collect(); diff --git a/node-graph/node-macro/src/codegen/classify.rs b/node-graph/node-macro/src/codegen/classify.rs index 43a83e4c8d..1683ed8a6d 100644 --- a/node-graph/node-macro/src/codegen/classify.rs +++ b/node-graph/node-macro/src/codegen/classify.rs @@ -266,6 +266,36 @@ pub(crate) fn contains_open_generic(parsed: &ParsedNodeFn, ty: &Type) -> bool { .any(|param| matches!(param, GenericParam::Type(type_param) if Some(&type_param.ident) != ctx_ident.as_ref() && type_contains_ident(ty, &type_param.ident))) } +/// The element generic of a primary input that is read but never looked at: +/// declared as an unbounded passthrough, carrying attribute reads, and absent +/// from everywhere else in the signature. Such a node maps its declared +/// attributes onto a fresh element, so it accepts any upstream record wire and +/// registers one generic row rather than a row per element type. +/// +/// Inferred rather than marked: a generic with nowhere to go and nothing to be +/// is opaque by construction, and the signature already says so. +pub(crate) fn opaque_reading_carrier(parsed: &ParsedNodeFn) -> Option { + let carrier = parsed.fields.first()?; + if carrier.is_data_field || carrier.attribute_reads.is_empty() { + return None; + } + let ParsedFieldType::Regular(RegularParsedField { ty, lend: None, implementations, .. }) = &carrier.ty else { + return None; + }; + if !implementations.is_empty() { + return None; + } + let token = unbounded_generic(parsed, ty)?; + // A token that reaches the output or another input is a passthrough + // element, which the node does carry; only one going nowhere is opaque. + let escapes = type_contains_ident(&parsed.output_type, &token) + || parsed.fields.iter().skip(1).any(|field| match &field.ty { + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => type_contains_ident(ty, &token), + ParsedFieldType::Node(NodeParsedField { input_type, output_type, .. }) => type_contains_ident(input_type, &token) || type_contains_ident(output_type, &token), + }); + (!escapes).then_some(token) +} + pub(crate) fn unbounded_generic(parsed: &ParsedNodeFn, ty: &Type) -> Option { let ident = bare_ident(ty)?.clone(); let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone()); @@ -362,6 +392,13 @@ pub(crate) fn record_shape(parsed: &ParsedNodeFn) -> Option { None => (value, Vec::new(), Vec::new()), }; match &token { + // An opaque reading carrier never looks at its element, so it writes a + // fresh one instead of passing the token through. + Some(_) if opaque_reading_carrier(parsed).is_some() => { + if contains_open_generic(parsed, &element) { + return None; + } + } Some(token) => { if !matches!(bare_ident(&element), Some(ident) if ident == token) { return None; diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 256a80f4eb..eaa58f8627 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -912,6 +912,68 @@ mod tests { assert_eq!(generics, vec!["V".to_string()], "only the value generic rides the node"); } + #[test] + fn a_reading_input_whose_element_goes_nowhere_is_opaque() { + let mut parsed = crate::parsing::parse_node_fn( + quote!(category("")), + quote!( + fn peek<'e, T>(_: impl Ctx, (content, opacity): (T, Attr<'e, Opacity>)) -> f64 { + let _ = content; + *opacity + } + ), + ) + .unwrap(); + parsed.replace_impl_trait_in_input(); + let token = crate::codegen::classify::opaque_reading_carrier(&parsed).expect("the element generic is opaque"); + assert_eq!(token.to_string(), "T"); + + // The node still reads, so it keeps the record-io tail, and the element + // rides it as the byte-carried token: one row covers every element type. + let node = build(&parsed); + assert!(matches!(node_kind(&node), NodeKind::RecordIo), "an opaque reading input still reads attributes"); + assert!( + matches!(crate::codegen::record_shape(&parsed), Some(shape) if matches!(shape.carrier, crate::codegen::RecordCarrier::Token)), + "the element is carried as a token rather than read" + ); + assert_eq!(node.inputs[0].shape.attrs.len(), 1, "the declared read survives the blessing"); + } + + #[test] + fn an_element_generic_the_node_uses_is_not_opaque() { + // Returned rather than dropped, so the element is carried, not opaque: + // the usual passthrough rule still governs it. + let mut carried = crate::parsing::parse_node_fn( + quote!(category("")), + quote!( + fn keep<'e, T>(_: impl Ctx, (content, opacity): (T, Attr<'e, Opacity>)) -> (T, Attr<'e, Opacity>) { + (content, opacity) + } + ), + ) + .unwrap(); + carried.replace_impl_trait_in_input(); + assert!(crate::codegen::classify::opaque_reading_carrier(&carried).is_none(), "an element the output carries is not opaque"); + + // No reads at all, so the generic is an ordinary element and still owes + // an implementations list. + let mut plain = crate::parsing::parse_node_fn( + quote!(category("")), + quote!( + fn plain(_: impl Ctx, content: T) -> f64 { + let _ = content; + 0. + } + ), + ) + .unwrap(); + plain.replace_impl_trait_in_input(); + assert!( + crate::codegen::classify::opaque_reading_carrier(&plain).is_none(), + "a generic element without reads still needs implementations" + ); + } + #[test] fn bridge_record_remove() { assert_bridge( diff --git a/node-graph/node-macro/src/validation.rs b/node-graph/node-macro/src/validation.rs index aafeee31b1..db469efefc 100644 --- a/node-graph/node-macro/src/validation.rs +++ b/node-graph/node-macro/src/validation.rs @@ -135,7 +135,14 @@ fn validate_record_io(parsed: &ParsedNodeFn) { let element = writes.as_ref().map(|writes| &writes.element).unwrap_or(&value); match &token { Some(token) => { - if !matches!(crate::codegen::bare_ident(element), Some(ident) if ident == token) { + // An opaque reading input never looks at its element, so it writes + // a fresh one rather than carrying the input's through. + let opaque_reading = crate::codegen::classify::opaque_reading_carrier(parsed).is_some(); + if opaque_reading { + if crate::codegen::contains_open_generic(parsed, element) { + emit_error!(parsed.output_type.span(), "an opaque reading input writes a concrete element, since `{}` is never read", token); + } + } else if !matches!(crate::codegen::bare_ident(element), Some(ident) if ident == token) { emit_error!(parsed.output_type.span(), "a generic element passes through unchanged: return `{}` in the first tuple position", token); } } @@ -430,13 +437,16 @@ fn validate_implementations_for_generics(parsed: &ParsedNodeFn) { (crate::codegen::ir::NodeKind::RecordIo, crate::codegen::ir::Element::Generic(ident)) => Some(ident.clone()), _ => None, }; + // An opaque reading carrier's element is never looked at, so it needs no + // rows: the node registers one generic row and accepts any record wire. + let opaque_reading = crate::codegen::classify::opaque_reading_carrier(parsed); let opaque_record_generic = |ty: &Type| { let (stripped, _) = crate::codegen::ir::strip_ilist(ty); let ident = match &stripped { Type::Path(path) => path.path.get_ident(), _ => None, }; - ident.is_some() && (ident == routing.as_ref().map(|routing| &routing.generic) || ident == record_token.as_ref()) + ident.is_some() && (ident == routing.as_ref().map(|routing| &routing.generic) || ident == record_token.as_ref() || ident == opaque_reading.as_ref()) }; if !has_skip_impl && !parsed.fn_generics.is_empty() { diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 419a377272..334175dad1 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -394,10 +394,10 @@ pub fn write_attribute<'e, T, V: WireValue>( /// A name written at another value type is a graph error rather than a /// conversion, so reading a number is never a coercion of one. #[node_macro::node(category("Attributes: Read"))] -pub fn read_number_attribute<'e>( +pub fn read_number_attribute<'e, T>( _: impl Ctx, - /// The content whose lanes carry the attribute. - (content, value): (f64, Attr<'e, Named>), + /// The content whose lanes carry the attribute; its element is never read. + (content, value): (T, Attr<'e, Named>), /// The attribute name, folded into an offset when the graph compiles. name: Named, ) -> f64 {