From 348e9022a6c82ac764be7eee5f72ec0ae08a04b6 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 9 Sep 2026 08:51:05 +0000 Subject: [PATCH] Fold attribute names out of their constant inputs when the graph compiles `compute_layouts` resolves every name-from-input write against the text its name input carries, so a folded meta is indistinguishable from a marker node's and the layout holds a `&'static str` from there on. That leaves nowhere for a name to be computed, which is the whole of the rule: a name input that is not a constant is refused right here, per node, with the path the editor pins its diagnostic to. One name carries one value type, checked across the census and the names a node folds together, so no graph can declare one field at two widths. `compute_layouts` returns those refusals rather than panicking. Co-Authored-By: Claude Fable 5 --- .../graph-craft/src/graphene_compiler.rs | 2 +- node-graph/graph-craft/src/proto.rs | 87 ++++++++++++++++++- .../src/dynamic_executor.rs | 2 +- node-graph/node-macro/src/codegen/ir.rs | 33 +++++++ node-graph/node-macro/src/parsing.rs | 36 ++++++++ 5 files changed, 157 insertions(+), 3 deletions(-) diff --git a/node-graph/graph-craft/src/graphene_compiler.rs b/node-graph/graph-craft/src/graphene_compiler.rs index 8a53835d5e..04b9df01ef 100644 --- a/node-graph/graph-craft/src/graphene_compiler.rs +++ b/node-graph/graph-craft/src/graphene_compiler.rs @@ -20,7 +20,7 @@ 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(); + 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 81ddb9d3d9..00f908fdab 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -378,7 +378,8 @@ impl ProtoNetwork { Ok(()) } - pub fn compute_layouts(&mut self) { + pub fn compute_layouts(&mut self) -> Result<(), GraphErrors> { + self.fold_attribute_names()?; for index in 0..self.nodes.len() { let lane_invariant = self.nodes[index].1.lane_invariant_inputs; let layout = { @@ -388,6 +389,7 @@ impl ProtoNetwork { frame_bytes: layout.frame_bytes(), plan: Vec::new(), lane_invariant, + named_writes: Vec::new(), layout, }), ConstructionArgs::Nodes(inputs) => node.resolved.layout_meta.as_ref().and_then(|meta| { @@ -412,6 +414,70 @@ impl ProtoNetwork { self.nodes[index].1.resolved.layout = layout; } self.stack_need = self.fold_stack_peak(); + Ok(()) + } + + /// Resolves every name-from-input write against the constant its name + /// input carries, leaving each node's meta indistinguishable from a marker + /// node's. A name input that is not a constant is refused here, which is + /// the whole of the no-runtime-names rule: past this point a name is a + /// `&'static str` in a layout, so there is nowhere for one to be computed. + fn fold_attribute_names(&mut self) -> Result<(), GraphErrors> { + 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() { + continue; + } + let ConstructionArgs::Nodes(inputs) = &self.nodes[index].1.construction_args else { + continue; + }; + let names: Vec> = 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() + ))), + }, + _ => 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() { + 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) { + errors.push(GraphError::new(node, conflict)); + failed = true; + } + folded.push((name, template.type_id)); + } + } + } + if failed { + continue; + } + 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")); + } + } + errors.is_empty().then_some(()).ok_or(errors) } /// Peak record-stack bytes for evaluating [`output`](Self::output)'s cone. A node holds its @@ -737,9 +803,27 @@ impl ProtoNetwork { Ok(()) } } +/// 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 +/// declare a field twice at two widths. +fn one_name_one_type(name: &str, value_type: std::any::TypeId, folded: &[(&'static str, std::any::TypeId)]) -> Option { + let census = core_types::attribute::info(name); + let declared = census + .filter(|row| row.value_type != value_type) + .map(|row| row.value_type_name.to_string()) + .or_else(|| folded.iter().find(|(other, ty)| *other == name && *ty != value_type).map(|_| "another type on this node".to_string()))?; + Some(GraphErrorType::AttributeName(format!( + "attribute `{name}` is already declared at {declared}, and one name carries one value type" + ))) +} + #[derive(Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum GraphErrorType { NodeNotFound(NodeId), + /// A name-from-input attribute write whose name could not be resolved: it + /// is not a constant, or it disagrees with the name's declared value type. + AttributeName(String), UnexpectedGenerics { index: usize, inputs: Vec, @@ -764,6 +848,7 @@ impl Debug for GraphErrorType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { GraphErrorType::NodeNotFound(id) => write!(f, "Input node {id} is not present in the typing context"), + GraphErrorType::AttributeName(error) => write!(f, "{error}"), GraphErrorType::UnexpectedGenerics { index, inputs } => write!(f, "Generic inputs should not exist but found at {index}: {inputs:?}"), GraphErrorType::NoImplementations => write!(f, "No implementations found"), GraphErrorType::NoConstructor => write!(f, "No construct found for node"), diff --git a/node-graph/interpreted-executor/src/dynamic_executor.rs b/node-graph/interpreted-executor/src/dynamic_executor.rs index 294731eb70..6c721465be 100644 --- a/node-graph/interpreted-executor/src/dynamic_executor.rs +++ b/node-graph/interpreted-executor/src/dynamic_executor.rs @@ -721,7 +721,7 @@ mod test { fn build_executor(mut network: ProtoNetwork) -> DynamicExecutor { network.resolve_types(&node_registry::NODE_REGISTRY).unwrap(); - network.compute_layouts(); + network.compute_layouts().unwrap(); DynamicExecutor::new(network).unwrap() } diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 5db2418450..74edf6bfde 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -67,6 +67,7 @@ fn inputs(parsed: &ParsedNodeFn, fields: &[&ParsedField], generics: &[Ident]) -> shape: item_shape(&element, depth, &field.attribute_reads, generics), subject: subject(index, field, carrier_subject, routing.as_ref()), lend: matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { lend: Some(_), .. })), + name_source: crate::parsing::named_source(&element), } }) .collect() @@ -274,6 +275,7 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t quote!(#core_types::record::InputReads { input: #index, reads: ::std::vec![#(#descs),*] }) }); let writes = field_writes(&node.output.shape.attrs, core_types); + let named_writes = named_field_writes(node, core_types); let removes = node.output.removes.iter().map(|attr| { let marker = &attr.marker; let level = attr.level; @@ -290,6 +292,8 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t reads: ::std::vec![#(#reads),*], element: #element_spec, writes: ::std::vec![#(#writes),*], + named_writes: ::std::vec![#(#named_writes),*], + folded_names: ::std::vec![], removes: ::std::vec![#(#removes),*], level_delta: #level_delta, folded: #folded, @@ -354,6 +358,7 @@ pub(crate) fn folded_subject(node: &Node) -> Option<(u8, u8)> { fn field_writes(attrs: &[LevelAttr], core_types: &TokenStream2) -> Vec { attrs .iter() + .filter(|attr| crate::parsing::named_marker(&attr.marker).is_none()) .map(|attr| { let marker = &attr.marker; let level = attr.level; @@ -362,6 +367,30 @@ fn field_writes(attrs: &[LevelAttr], core_types: &TokenStream2) -> Vec Vec { + node.output + .shape + .attrs + .iter() + .filter_map(|attr| { + let (placeholder, value) = crate::parsing::named_marker(&attr.marker)?; + let input = name_input(node, &placeholder)? as u8; + let level = attr.level; + Some(quote!(#core_types::record::NamedWrite::of::<#placeholder, #value>(#input, #level))) + }) + .collect() +} + +/// 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)) +} + fn level_delta(node: &Node) -> i8 { // A folded subject contributes no base layout, so the delta is relative to // the fresh (empty) base. @@ -527,6 +556,10 @@ pub(crate) struct Input { pub(crate) subject: bool, /// Written `&T`; the kernel borrows the evaluated element. pub(crate) lend: bool, + /// The placeholder this input names, written `Named`. Such an input + /// carries constant text the compiler folds into a layout name, so it is + /// never evaluated per lane. + pub(crate) name_source: Option, } /// `Lazy` = `impl Node<..>`, the kernel drives it. diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index 7f370026b3..7f0899d286 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -110,6 +110,42 @@ pub(crate) fn remove_attr_marker(ty: &Type) -> Option { marker_of(ty, "RemoveAttr") } +/// Splits a `Named` marker into its placeholder and value type. A write +/// of one takes its name from the input the placeholder is declared at rather +/// than from the marker, so the name folds at graph compile time. +pub(crate) fn named_marker(ty: &Type) -> Option<(Type, Type)> { + let mut args = named_arguments(ty)?.into_iter(); + let (placeholder, value) = (args.next()?, args.next()?); + args.next().is_none().then_some((placeholder, value)) +} + +/// The placeholder a `Named` parameter declares. Such a parameter is the +/// name source for every `Attr>` the signature writes, and crosses +/// the wire as constant text. +pub(crate) fn named_source(ty: &Type) -> Option { + let mut args = named_arguments(ty)?.into_iter(); + let placeholder = args.next()?; + args.next().is_none().then_some(placeholder) +} + +fn named_arguments(ty: &Type) -> Option> { + let Type::Path(path) = ty else { return None }; + let segment = path.path.segments.last()?; + if segment.ident != "Named" { + return None; + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { return None }; + Some( + args.args + .iter() + .filter_map(|argument| match argument { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) + .collect(), + ) +} + fn marker_of(ty: &Type, wrapper: &str) -> Option { let Type::Path(path) = ty else { return None }; let segment = path.path.segments.last()?;