From d32caeab52c2128391d1c1a7f6d1ca2917c7bb84 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Sun, 16 Aug 2026 11:28:11 +0000 Subject: [PATCH] Add a generic lazy repeat that evaluates its content at each copy index --- node-graph/libraries/core-types/src/record.rs | 5 + node-graph/node-macro/src/codegen.rs | 4 + node-graph/nodes/gcore/src/record.rs | 128 +++++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/node-graph/libraries/core-types/src/record.rs b/node-graph/libraries/core-types/src/record.rs index 6bfc272079..4fb74e9107 100644 --- a/node-graph/libraries/core-types/src/record.rs +++ b/node-graph/libraries/core-types/src/record.rs @@ -438,6 +438,7 @@ impl<'e> RecordValue<'e> { /// unconstrained ones. pub trait DerivedRecordEdge<'derived, C> { fn eval_derived(&self, cell: &crate::node::StatusCell, input_index: usize, ctx: &C) -> Result, crate::gpoll::Interrupt>; + fn extent_at_derived(&self, ctx: &C, level: u8) -> GPoll; } impl<'derived, C, N> DerivedRecordEdge<'derived, C> for N @@ -447,6 +448,10 @@ where fn eval_derived(&self, cell: &crate::node::StatusCell, input_index: usize, ctx: &C) -> Result, crate::gpoll::Interrupt> { cell.eval_input(input_index, self, ctx) } + + fn extent_at_derived(&self, ctx: &C, level: u8) -> GPoll { + self.extent_at(ctx, level) + } } /// A record edge at a caller-chosen lifetime; the lifetime is a trait diff --git a/node-graph/node-macro/src/codegen.rs b/node-graph/node-macro/src/codegen.rs index 9988d830ed..f636b93190 100644 --- a/node-graph/node-macro/src/codegen.rs +++ b/node-graph/node-macro/src/codegen.rs @@ -1712,6 +1712,10 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn #plan self.__layout = __resolved.layout; }) + } else if routing_generic.is_some() { + Some(quote! { + self.__layout = __resolved.layout; + }) } else { None }; diff --git a/node-graph/nodes/gcore/src/record.rs b/node-graph/nodes/gcore/src/record.rs index 7b2a8a49d3..cffe5343bb 100644 --- a/node-graph/nodes/gcore/src/record.rs +++ b/node-graph/nodes/gcore/src/record.rs @@ -6,7 +6,7 @@ //! wiring is by hand until the compiler pass constructs layouts. use core_types::attribute::{Attr, Opacity, RemoveAttr}; -use core_types::context::{ExtractArena, ExtractIndex, InjectIndex}; +use core_types::context::{DeriveCtx, ExtractArena, ExtractIndex, InjectIndex}; use core_types::gpoll::{ErrorKind, GraphError, Interrupt}; use core_types::{Context, Ctx}; @@ -76,6 +76,35 @@ where } } +/// Test-only generic structure creator: evaluates the lazy content once per copy +/// with the copy's index pushed in, producing a rank level of `count` copies. +#[node_macro::node(category("Test"), extent(repeat_extent))] +fn repeat(ctx: impl Ctx + DeriveCtx + ExtractIndex, content: impl Node, Output = T>, count: u32) -> Result, Interrupt> { + let spilled = ctx.index_head(); + let copy = ctx.innermost_index() % count as u64; + content.eval(&ctx.promoted(&spilled, copy)) +} + +/// The pushed level's extent is the copy count; inner levels forward to the +/// content, whose extent is taken uniform across copies (queried at copy 0). +fn repeat_extent<'r, C, In0, In1>(node: &RepeatNode, ctx: &C, level: u8) -> core_types::gpoll::GPoll +where + C: core_types::context::DeriveCtx + core_types::context::ExtractIndex, + In0: for<'d> core_types::record::DerivedRecordEdge<'d, core_types::context::Derived<'d, C>>, + In1: core_types::node::Node>, +{ + use core_types::node::Node; + if level + 1 == node.__layout.depth { + node.count.eval(ctx).map(|value| { + let count: u32 = unsafe { core_types::record::read_element(node.__in_1.rec(&value)) }; + core_types::gpoll::Extent::Exactly(count as usize) + }) + } else { + let spilled = ctx.index_head(); + node.content.extent_at_derived(&ctx.promoted(&spilled, 0), level) + } +} + #[node_macro::node(category("Test"))] fn source_opacity(_: impl Ctx, _: (), element: f64, opacity: f64) -> (f64, Attr) { (element, Attr(opacity)) @@ -208,6 +237,29 @@ mod tests { } } + struct IndexSourceNode { + layout: Layout, + } + + impl<'e> Node> for IndexSourceNode { + type Output = RecordValue<'e>; + + fn eval(&self, input: &ContextImpl<'e>) -> GPoll> { + let element = input.innermost_index() as f64; + let mut value = RecordValue::zeroed(); + let dst = match self.layout.frame_bytes() { + 0 => value.as_mut_ptr(), + bytes => stack::push(bytes), + }; + unsafe { dst.cast::().write(element) }; + if self.layout.frame_bytes() != 0 { + stack::pop(dst); + value = RecordValue::spilled(unsafe { Rec::new(dst.cast_const()) }); + } + GPoll::Final(value) + } + } + fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> { stack::reserve(1 << 16); EvalScope::new(Some(0.5), None, None, generations, arena) @@ -308,6 +360,80 @@ mod tests { assert_eq!(node.extent_at(&ctx, 0), core_types::gpoll::GPoll::Final(core_types::gpoll::Extent::Exactly(3))); } + #[test] + fn generic_repeat_pushes_a_level_and_forwards_the_element() { + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let base = f64_layout(&[]); + let (count_edge, count_layout) = lifted_value(3u32); + reserve_for(&[&base, &count_layout]); + + let meta = core_types::record::LayoutMeta { + sources: vec![0], + reads: vec![], + element: core_types::record::ElementSpec::Carried, + writes: vec![], + removes: vec![], + level_delta: 1, + }; + let node = install( + RepeatNode::new(RecordSource::new(bare_source(&base, 7.), &base, &base), count_edge, &base, &count_layout), + meta, + &[Some(&base)], + ); + let leveled = Node::::layout(&node).clone(); + assert_eq!(leveled.depth, 1, "the IList return pushed one rank level above the depth-0 content"); + assert_eq!(node.extent_at(&ctx, 0), GPoll::Final(core_types::gpoll::Extent::Exactly(3))); + + let GPoll::Final(value) = node.eval(&ctx) else { + panic!("expected a final record"); + }; + assert_eq!(unsafe { leveled.rec(&value).element::() }, 7., "the opaque generic element forwarded unchanged"); + } + + #[test] + fn repeat_evaluates_content_at_each_copy_index() { + let arena = Arena::new(1024).unwrap(); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let base = f64_layout(&[]); + let (count_edge, count_layout) = lifted_value(4u32); + reserve_for(&[&base, &count_layout]); + + let meta = core_types::record::LayoutMeta { + sources: vec![0], + reads: vec![], + element: core_types::record::ElementSpec::Carried, + writes: vec![], + removes: vec![], + level_delta: 1, + }; + let repeat = install( + RepeatNode::new(RecordSource::new(IndexSourceNode { layout: base.clone() }, &base, &base), count_edge, &base, &count_layout), + meta, + &[Some(&base)], + ); + let leveled = Node::::layout(&repeat).clone(); + + let head = ctx.index_head(); + for copy in 0..4 { + let mark = stack::sp(); + let lane = ctx.promoted(&head, copy); + let GPoll::Final(value) = repeat.eval(&lane) else { + panic!("expected a final record"); + }; + // The copy evaluated its content at its own pushed index. + assert_eq!(unsafe { leveled.rec(&value).element::() }, copy as f64); + // SAFETY: the copy's element was read out above, so no borrow into its frame remains. + unsafe { stack::rewind(mark) }; + } + } + #[test] fn reducer_folds_a_repeated_level() { let arena = Arena::new(1024).unwrap();