From 40f99152ce153085bd726e7ca1c6c68134d52031 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 13 Aug 2026 15:35:07 +0000 Subject: [PATCH] Bridge-verify build against the resolved class --- node-graph/node-macro/src/codegen/ir.rs | 162 ++++++++++++++++++++++++ node-graph/node-macro/src/parsing.rs | 2 +- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/node-graph/node-macro/src/codegen/ir.rs b/node-graph/node-macro/src/codegen/ir.rs index 561c384c69..e37960b550 100644 --- a/node-graph/node-macro/src/codegen/ir.rs +++ b/node-graph/node-macro/src/codegen/ir.rs @@ -236,3 +236,165 @@ pub(crate) enum Effect { Progressive, AsyncSource, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::codegen::classify::{Class, analyze}; + use crate::parsing::parse_node_fn; + use proc_macro2::TokenStream as TokenStream2; + use quote::{ToTokens, quote}; + + /// The layout facts every emitter expresses, derived from either the intent + /// IR or the resolved class, so the two paths can be checked equal. + #[derive(Debug, PartialEq)] + struct Facts { + sources: Vec, + carried: bool, + writes: Vec, + removes: Vec, + delta: i8, + } + + fn markers<'a>(types: impl IntoIterator) -> Vec { + types.into_iter().map(|ty| ty.to_token_stream().to_string()).collect() + } + + fn facts_from_ir(node: &Node) -> Facts { + let carried = match &node.output.shape.element { + Element::Opaque => true, + Element::Generic(_) => node.monomorphizations.is_empty(), + Element::Concrete(_) => false, + }; + 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(), + carried, + writes: markers(node.output.shape.attrs.iter().map(|attr| &attr.marker)), + removes: markers(node.output.removes.iter().map(|attr| &attr.marker)), + delta: node.output.shape.depth as i8 - subject_depth, + } + } + + fn facts_from_class(class: &Class, fields: &[&ParsedField]) -> Facts { + let source_ty = |field: &ParsedField| match &field.ty { + ParsedFieldType::Node(NodeParsedField { output_type, .. }) => output_type.clone(), + ParsedFieldType::Regular(RegularParsedField { ty, .. }) => ty.clone(), + }; + match class { + Class::Flip { carrier } => Facts { + sources: if *carrier { vec![0] } else { vec![] }, + carried: false, + writes: vec![], + removes: vec![], + delta: 0, + }, + Class::Opaque => { + let record = fields.iter().position(|field| matches!(&field.ty, ParsedFieldType::Node(NodeParsedField { output_type, .. }) if is_record_value(output_type))); + Facts { + sources: record.into_iter().collect(), + carried: true, + writes: vec![], + removes: vec![], + delta: 0, + } + } + Class::Routing(routing) => Facts { + sources: fields.iter().enumerate().filter(|(_, field)| bare_ident(&source_ty(field)) == Some(&routing.generic)).map(|(index, _)| index).collect(), + carried: true, + writes: vec![], + removes: vec![], + delta: 0, + }, + Class::RecordIo(shape) => Facts { + sources: if shape.skips_carrier() { vec![] } else { vec![0] }, + carried: shape.carries_element(), + writes: markers(&shape.write_markers), + removes: markers(&shape.removes), + delta: 0, + }, + } + } + + fn assert_bridge(attr: TokenStream2, item: TokenStream2) -> Node { + let mut parsed = parse_node_fn(attr, item).unwrap(); + parsed.replace_impl_trait_in_input(); + let model = analyze(&parsed).expect("representative resolves to a class"); + let fields: Vec<&ParsedField> = parsed.fields.iter().filter(|field| !field.is_data_field).collect(); + let node = build(&parsed); + assert_eq!(facts_from_ir(&node), facts_from_class(&model.class, &fields)); + node + } + + #[test] + fn bridge_flip_concrete() { + assert_bridge(quote!(category("")), quote!(fn negate(_: impl Ctx, x: f64) -> f64 { -x })); + } + + #[test] + fn bridge_flip_generic() { + assert_bridge( + quote!(category("")), + quote! { + fn add, B>(_: impl Ctx, #[implementations(f64, u32)] augend: A, #[implementations(f64, u32)] addend: B) -> >::Output { augend + addend } + }, + ); + } + + #[test] + fn bridge_record_write() { + assert_bridge(quote!(category("")), quote!(fn set_opacity(_: impl Ctx, val: f64) -> (f64, Attr) { (val, Attr(1.)) })); + } + + #[test] + fn bridge_record_remove() { + assert_bridge(quote!(category("")), quote!(fn strip(_: impl Ctx, val: f64) -> (f64, RemoveAttr) { (val, RemoveAttr) })); + } + + #[test] + fn bridge_record_fresh() { + assert_bridge(quote!(category("")), quote!(fn make(_: impl Ctx, _: (), fill: f64) -> (f64, Attr) { (fill, Attr(1.)) })); + } + + #[test] + fn bridge_routing() { + assert_bridge( + quote!(category("")), + quote! { + fn switch(_: impl Ctx, condition: bool, off: impl Node<(), Output = T>, on: impl Node<(), Output = T>) -> T { if condition { on.eval(()) } else { off.eval(()) } } + }, + ); + } + + #[test] + fn bridge_opaque() { + assert_bridge( + quote!(category("")), + quote! { + fn memo<'e>(_: impl Ctx, #[data] cache: Store, content: impl Node, Output = RecordValue<'e>>) -> GPoll> { content.eval(()) } + }, + ); + } + + #[test] + fn monomorphizations_key_by_generic() { + let node = assert_bridge( + quote!(category("")), + quote! { + fn add, B>(_: impl Ctx, #[implementations(f64, u32)] augend: A, #[implementations(f64, u32)] addend: B) -> >::Output { augend + addend } + }, + ); + let rows: Vec> = node + .monomorphizations + .iter() + .map(|row| row.assignments.iter().map(|(generic, ty)| (generic.to_string(), ty.to_token_stream().to_string())).collect()) + .collect(); + assert_eq!( + rows, + vec![ + vec![("A".to_string(), "f64".to_string()), ("B".to_string(), "f64".to_string())], + vec![("A".to_string(), "u32".to_string()), ("B".to_string(), "u32".to_string())], + ] + ); + } +} diff --git a/node-graph/node-macro/src/parsing.rs b/node-graph/node-macro/src/parsing.rs index 4564962655..fb663d5817 100644 --- a/node-graph/node-macro/src/parsing.rs +++ b/node-graph/node-macro/src/parsing.rs @@ -648,7 +648,7 @@ impl Parse for NodeFnAttributes { } } -fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { +pub(crate) fn parse_node_fn(attr: TokenStream2, item: TokenStream2) -> syn::Result { let attributes = syn::parse2::(attr.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse node_fn attributes:\n{e}")))?; let input_fn = syn::parse2::(item.clone()).map_err(|e| Error::new(e.span(), format!("Failed to parse function: {e}. Make sure it's a valid Rust function.")))?;