From 4a042bade2b3bfe73f6ccd36f56958b7d6ba3c32 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Mon, 17 Aug 2026 14:46:48 +0000 Subject: [PATCH] Key materialization on declared list nesting and carriers on non-materialized subjects --- node-graph/node-macro/src/codegen.rs | 6 ++-- node-graph/node-macro/src/codegen/entries.rs | 2 +- node-graph/node-macro/src/codegen/ir.rs | 37 +++++++++++++++----- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index a3aa6af8c9..8304b0ad8c 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -63,10 +63,10 @@ pub(crate) fn generate_node_code(crate_ident: &CrateIdent, parsed: &ParsedNodeFn (Some(crate::codegen::ir::NodeKind::Routing), crate::codegen::ir::Element::Generic(ident)) => Some(ident.clone()), _ => None, }; + // A `_: ()` primary stays visible in the metadata but claims no struct field. + let record_unit_carrier = record_io && crate::codegen::classify::record_shape(parsed).is_some_and(|shape| shape.skips_carrier()); let record_skips_carrier = record_io && !carrier_present; - // Record nodes with a `_: ()` primary input have no carrier edge; the unit - // field stays visible in the metadata but claims no struct field. - let struct_regular_fields: Vec<_> = regular_fields.iter().skip(record_skips_carrier as usize).copied().collect(); + let struct_regular_fields: Vec<_> = regular_fields.iter().skip(record_unit_carrier as usize).copied().collect(); let struct_regular_field_names: Vec<_> = struct_regular_fields.iter().map(|f| &f.pat_ident.ident).collect(); // Extract function generics used by data fields diff --git a/node-graph/node-macro/src/codegen/entries.rs b/node-graph/node-macro/src/codegen/entries.rs index ab1e4ff1a8..ff69c73a66 100644 --- a/node-graph/node-macro/src/codegen/entries.rs +++ b/node-graph/node-macro/src/codegen/entries.rs @@ -327,7 +327,7 @@ fn single_row_entries(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields let (prelude, new_layout_args, layout_meta) = match ir::node_kind(&node) { ir::NodeKind::RecordIo => { - let carrier_arg = node.inputs.first().is_some_and(|input| input.subject).then(|| quote!(&__layout_0,)); + let carrier_arg = (node.inputs.first().is_some_and(|input| input.subject) && ir::materialized_levels(&node, 0) == 0).then(|| quote!(&__layout_0,)); let layout_meta_fn = format_ident!("{}_layout_meta", fn_name); (quote!(), quote!(#carrier_arg #(#value_layout_args)*), quote!(Some(self::#layout_meta_fn()))) } diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index fee5d38533..5a06a5550b 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -215,7 +215,13 @@ fn ilist_inner(ty: &Type) -> Option { /// Emits the `LayoutMeta` literal from the IR. `element_spec` is supplied by the /// caller since it is the one row-dependent facet; the rest folds from the node. pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_types: &TokenStream2) -> TokenStream2 { - let sources = node.inputs.iter().enumerate().filter(|(_, input)| input.subject).map(|(index, _)| index as u8); + // A materialized subject is folded, not carried: it contributes no layout. + let sources = node + .inputs + .iter() + .enumerate() + .filter(|(index, input)| input.subject && materialized_levels(node, *index) == 0) + .map(|(index, _)| index as u8); let reads = node.inputs.iter().enumerate().filter_map(|(index, input)| { (matches!(input.evaluation, Evaluation::Eager) && !input.shape.attrs.is_empty()).then(|| { let descs = field_writes(&input.shape.attrs, core_types); @@ -254,8 +260,15 @@ fn field_writes(attrs: &[LevelAttr], core_types: &TokenStream2) -> Vec i8 { - let subject_depth = node.inputs.iter().find(|input| input.subject).map_or(0, |input| input.shape.depth as i8); - node.output.shape.depth as i8 - subject_depth + // A materialized subject contributes no base layout, so the delta is + // relative to the fresh (empty) base. + let base_depth = node + .inputs + .iter() + .enumerate() + .find(|(index, input)| input.subject && materialized_levels(node, *index) == 0) + .map_or(0, |(_, input)| input.shape.depth as i8); + node.output.shape.depth as i8 - base_depth } /// How an eager value input binds in eval. @@ -323,11 +336,11 @@ fn has_attr_io(node: &Node) -> bool { /// into a `List` before the kernel. pub(crate) fn materialized_levels(node: &Node, index: usize) -> u8 { let input = &node.inputs[index]; - // A ranked subject folds the levels the output collapses; a ranked - // non-subject input is consumed whole. - match input.subject { - true => input.shape.depth.saturating_sub(node.output.shape.depth), - false => input.shape.depth, + // An eager input's declared `IList` nesting IS its materialization count, + // independent of the rank delta; lazy edges never materialize. + match input.evaluation { + Evaluation::Eager => input.shape.depth, + Evaluation::Lazy => 0, } } @@ -472,7 +485,13 @@ mod tests { }; let subject_depth = node.inputs.iter().find(|input| input.subject).map_or(0, |input| input.shape.depth as i8); Facts { - sources: node.inputs.iter().enumerate().filter(|(_, input)| input.subject).map(|(index, _)| index).collect(), + sources: node + .inputs + .iter() + .enumerate() + .filter(|(index, input)| input.subject && materialized_levels(node, *index) == 0) + .map(|(index, _)| index) + .collect(), carried, writes: markers(node.output.shape.attrs.iter().map(|attr| &attr.marker)), removes: markers(node.output.removes.iter().map(|attr| &attr.marker)),