diff --git a/node-graph/graph-craft/src/proto.rs b/node-graph/graph-craft/src/proto.rs index 2fa7eb93a3..5bbfd55b0d 100644 --- a/node-graph/graph-craft/src/proto.rs +++ b/node-graph/graph-craft/src/proto.rs @@ -631,14 +631,14 @@ pub type GraphErrors = Vec; /// The `TypingContext` is used to store the types of the nodes indexed by their stable node id. #[derive(Default, Clone, dyn_any::DynAny)] pub struct TypingContext { - lookup: Cow<'static, HashMap>>, + lookup: Cow<'static, HashMap>>, inferred: HashMap, - constructor: HashMap, + constructor: HashMap, } impl TypingContext { /// Creates a new `TypingContext` with the given lookup table. - pub fn new(lookup: &'static HashMap>) -> Self { + pub fn new(lookup: &'static HashMap>) -> Self { Self { lookup: Cow::Borrowed(lookup), ..Default::default() @@ -662,7 +662,7 @@ impl TypingContext { } /// Returns the node constructor for a given node id. - pub fn constructor(&self, node_id: NodeId) -> Option { + pub fn constructor(&self, node_id: NodeId) -> Option { self.constructor.get(&node_id).copied() } diff --git a/node-graph/libraries/core-types/src/arena.rs b/node-graph/libraries/core-types/src/arena.rs index 1c97e23034..a303a62c4a 100644 --- a/node-graph/libraries/core-types/src/arena.rs +++ b/node-graph/libraries/core-types/src/arena.rs @@ -158,6 +158,21 @@ pub struct ArenaCell { _marker: PhantomData T>, } +impl Clone for ArenaCell { + fn clone(&self) -> Self { + Self { + word: AtomicU64::new(self.word.load(Ordering::Acquire)), + _marker: PhantomData, + } + } +} + +impl std::fmt::Debug for ArenaCell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ArenaCell").field("word", &self.word.load(Ordering::Relaxed)).finish() + } +} + impl Default for ArenaCell { fn default() -> Self { Self { diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 610d4cff04..1c5bbab724 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -19,7 +19,6 @@ pub mod runtime; pub mod transform; pub mod uuid; pub mod value; -pub mod wire; pub use crate as core_types; pub use blending::*; diff --git a/node-graph/libraries/core-types/src/registry.rs b/node-graph/libraries/core-types/src/registry.rs index 0d3e17cdbd..9208cd0f1f 100644 --- a/node-graph/libraries/core-types/src/registry.rs +++ b/node-graph/libraries/core-types/src/registry.rs @@ -1,7 +1,12 @@ +use crate::concrete; +use crate::context::ContextImpl; +use crate::gnode::GNode; use crate::{ContextFeature, Node, NodeIO, NodeIOTypes, ProtoNodeIdentifier, Type, WasmNotSend}; use dyn_any::{DynAny, StaticType}; +use graphene_hash::CacheHash; pub use no_std_types::registry::types; use std::collections::HashMap; +use std::hash::Hasher; use std::marker::PhantomData; use std::ops::Deref; use std::pin::Pin; @@ -57,12 +62,105 @@ pub enum RegistryValueSource { Scope(&'static str), } -type NodeRegistry = LazyLock>>>; +type NodeRegistry = LazyLock>>>; pub static NODE_REGISTRY: NodeRegistry = LazyLock::new(|| Mutex::new(HashMap::new())); pub static NODE_METADATA: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +pub type ErasedGNode = dyn for<'c> GNode, Output = T>; +pub type ErasedLendGNode = dyn for<'c> GNode, Output = &'c T>; + +pub fn cache_key(ctx: &C) -> u64 { + let mut hasher = std::hash::DefaultHasher::new(); + ctx.cache_hash(&mut hasher); + hasher.finish() +} + +#[derive(Debug, PartialEq)] +pub enum ConstructionError { + Arity { expected: usize, got: usize }, + Type { expected: Type, found: Type }, +} + +pub struct EdgeHandle { + node: Box, + ty: Type, +} + +impl std::fmt::Debug for EdgeHandle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EdgeHandle").field("ty", &self.ty).finish_non_exhaustive() + } +} + +impl EdgeHandle { + pub fn new(node: Box>) -> Self { + Self::new_erased(node, concrete!(T)) + } + + pub fn new_ref(node: Box>) -> Self { + Self::new_erased(node, Type::Ref(Box::new(concrete!(T)))) + } + + pub fn new_erased(node: Box, ty: Type) -> Self + where + Box: std::any::Any, + { + Self { node: Box::new(node), ty } + } + + pub fn ty(&self) -> &Type { + &self.ty + } + + pub fn downcast(self) -> Result>, ConstructionError> { + self.downcast_erased(concrete!(T)) + } + + pub fn downcast_lend(self) -> Result>, ConstructionError> { + self.downcast_erased(Type::Ref(Box::new(concrete!(T)))) + } + + pub fn downcast_erased(self, expected: Type) -> Result, ConstructionError> + where + Box: std::any::Any, + { + let found = self.ty; + self.node.downcast::>().map(|node| *node).map_err(|_| ConstructionError::Type { expected, found }) + } +} + +pub struct NodeIoRecord { + pub inputs: Vec, + pub output: Type, +} + +pub type NodeConstructor = fn(Vec) -> Result; + +pub struct RegistryEntry { + pub io: NodeIoRecord, + pub constructor: NodeConstructor, +} + +pub fn construct(entry: &RegistryEntry, inputs: Vec) -> Result { + if inputs.len() != entry.io.inputs.len() { + return Err(ConstructionError::Arity { + expected: entry.io.inputs.len(), + got: inputs.len(), + }); + } + for (handle, expected) in inputs.iter().zip(&entry.io.inputs) { + if handle.ty() != expected { + return Err(ConstructionError::Type { + expected: expected.clone(), + found: handle.ty().clone(), + }); + } + } + (entry.constructor)(inputs) +} + #[cfg(not(target_family = "wasm"))] pub type DynFuture<'n, T> = Pin + 'n + Send>>; #[cfg(target_family = "wasm")] @@ -85,7 +183,7 @@ pub type TypeErasedPinned<'n> = Pin>>; pub type SharedNodeContainer = std::sync::Arc; -pub type NodeConstructor = fn(Vec) -> DynFuture<'static, TypeErasedBox<'static>>; +pub type DynNodeConstructor = fn(Vec) -> DynFuture<'static, TypeErasedBox<'static>>; #[derive(Clone)] pub struct NodeContainer { @@ -291,3 +389,222 @@ impl Default for PanicNode { // TODO: Evaluate safety unsafe impl Sync for PanicNode {} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SourceId; + use crate::arena::Arena; + use crate::context::{Ctx, EvalScope, ExtractArena}; + use crate::gpoll::GPoll; + + struct ValueNode(T); + + impl GNode for ValueNode { + type Output = T; + + fn eval(&self, _input: &Input) -> GPoll { + GPoll::Final(self.0.clone()) + } + } + + struct LendNode(String); + + impl<'e, Input: Ctx + ExtractArena> GNode for LendNode { + type Output = &'e String; + + fn eval(&self, input: &Input) -> GPoll<&'e String> { + match input.arena().alloc(self.0.clone()) { + Some((parked, _)) => GPoll::Final(parked), + None => GPoll::arena_exhausted(), + } + } + } + + fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> { + EvalScope::new(Some(0.5), None, None, generations, arena) + } + + #[test] + fn borrow_carrying_value_types_wire_through_the_general_constructor() { + struct SplitBorrow<'c>(&'c str, usize); + + struct SplitNode { + content: Node0, + } + + impl<'e, Input, Node0> GNode for SplitNode + where + Input: Ctx, + Node0: GNode, + { + type Output = SplitBorrow<'e>; + + fn eval(&self, input: &Input) -> GPoll> { + self.content.eval(input).map(|value| SplitBorrow(value, value.len())) + } + } + + type ErasedSplitEdge = dyn for<'c> GNode, Output = SplitBorrow<'c>>; + + let arena = Arena::new(4096); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let lending = EdgeHandle::new_ref(Box::new(LendNode("held".to_string())) as Box>); + let upstream = lending.downcast_lend::().unwrap(); + let node: Box = Box::new(SplitNode { content: upstream }); + let handle = EdgeHandle::new_erased(node, concrete!(SplitBorrow<'static>)); + assert_eq!(*handle.ty(), concrete!(SplitBorrow<'static>)); + + let wired = handle.downcast_erased::(concrete!(SplitBorrow<'static>)).unwrap(); + let GPoll::Final(split) = wired.eval(&ctx) else { + panic!("borrow-carrying output must eval through the erased edge"); + }; + assert_eq!(split.0, "held"); + assert_eq!(split.1, 4); + } + + #[test] + fn derive_ctx_repeat_pushes_index_levels_through_the_erased_edge() { + use crate::context::{Derived, DeriveCtx, ExtractIndex}; + + struct RepeatNode { + content: Node0, + } + + impl GNode for RepeatNode + where + C: Ctx + DeriveCtx, + Node0: for<'x> GNode, Output = T>, + { + type Output = Vec; + + fn eval(&self, input: &C) -> GPoll> { + let spilled = input.index_head(); + let mut result = Vec::new(); + for index in 0..3 { + let derived = input.promoted(&spilled, index); + match self.content.eval(&derived) { + GPoll::Final(value) => result.push(value), + other => return other.map(|_| Vec::new()), + } + } + GPoll::Final(result) + } + } + + struct LevelsNode; + + impl GNode for LevelsNode { + type Output = Vec; + + fn eval(&self, input: &Input) -> GPoll> { + GPoll::Final(input.try_index().map(|levels| levels.collect()).unwrap_or_default()) + } + } + + let arena = Arena::new(1024); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let nested = RepeatNode { + content: RepeatNode { content: LevelsNode }, + }; + let erased: Box>>>> = Box::new(nested); + + let GPoll::Final(outer) = erased.eval(&ctx) else { + panic!("nested repeat must evaluate"); + }; + assert_eq!(outer.len(), 3); + assert_eq!(outer[2][1], vec![1, 2, 0]); + assert_eq!(outer[0][0], vec![0, 0, 0]); + } + + #[test] + fn derive_ctx_footprint_replace_reaches_the_content() { + use crate::context::{Derived, DeriveCtx, ExtractFootprint}; + use crate::transform::Footprint; + + struct ShiftFootprintNode { + content: Node0, + } + + impl GNode for ShiftFootprintNode + where + C: Ctx + DeriveCtx + ExtractFootprint, + Node0: for<'x> GNode, Output = T>, + { + type Output = T; + + fn eval(&self, input: &C) -> GPoll { + let mut footprint = input.try_footprint().copied().unwrap_or(Footprint::DEFAULT); + footprint.resolution.x += 7; + let derived = input.with_footprint(&footprint); + self.content.eval(&derived) + } + } + + struct ResolutionNode; + + impl GNode for ResolutionNode { + type Output = u32; + + fn eval(&self, input: &Input) -> GPoll { + GPoll::Final(input.try_footprint().map(|footprint| footprint.resolution.x).unwrap_or(0)) + } + } + + let arena = Arena::new(1024); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let graph: Box> = Box::new(ShiftFootprintNode { + content: ShiftFootprintNode { content: ResolutionNode }, + }); + assert_eq!(graph.eval(&ctx), GPoll::Final(Footprint::DEFAULT.resolution.x + 14)); + } + + #[test] + fn construct_checks_arity_and_types() { + fn construct_strlen(args: Vec) -> Result { + let mut args = args.into_iter(); + let value = args.next().ok_or(ConstructionError::Arity { expected: 1, got: 0 })?.downcast::()?; + drop(value); + Ok(EdgeHandle::new(Box::new(ValueNode(0u32)) as Box>)) + } + let entry = RegistryEntry { + io: NodeIoRecord { + inputs: vec![concrete!(String)], + output: concrete!(u32), + }, + constructor: construct_strlen, + }; + + let owned = EdgeHandle::new(Box::new(ValueNode("typed".to_string())) as Box>); + assert!(construct(&entry, vec![owned]).is_ok()); + + assert_eq!(construct(&entry, vec![]).unwrap_err(), ConstructionError::Arity { expected: 1, got: 0 }); + + let mistyped = EdgeHandle::new(Box::new(ValueNode(1.0f64)) as Box>); + assert_eq!( + construct(&entry, vec![mistyped]).unwrap_err(), + ConstructionError::Type { + expected: concrete!(String), + found: concrete!(f64), + } + ); + + let lent = EdgeHandle::new_ref(Box::new(LendNode("typed".to_string())) as Box>); + assert_eq!( + construct(&entry, vec![lent]).unwrap_err(), + ConstructionError::Type { + expected: concrete!(String), + found: Type::Ref(Box::new(concrete!(String))), + } + ); + } +} diff --git a/node-graph/libraries/core-types/src/wire.rs b/node-graph/libraries/core-types/src/wire.rs deleted file mode 100644 index e97f38b246..0000000000 --- a/node-graph/libraries/core-types/src/wire.rs +++ /dev/null @@ -1,560 +0,0 @@ -use crate::Type; -use crate::arena::{Arena, ArenaCell}; -use crate::concrete; -use crate::context::{ContextImpl, Ctx, ExtractArena}; -use crate::frame_table::{FrameTable, Lookup}; -use crate::gnode::GNode; -use crate::gpoll::{Extent, Finality, GPoll}; -use graphene_hash::CacheHash; -use std::any::Any; -use std::hash::Hasher; -use std::sync::Mutex; - -pub type ErasedGNode = dyn for<'c> GNode, Output = T>; -pub type ErasedLendGNode = dyn for<'c> GNode, Output = &'c T>; - -pub fn cache_key(ctx: &C) -> u64 { - let mut hasher = std::hash::DefaultHasher::new(); - ctx.cache_hash(&mut hasher); - hasher.finish() -} - -#[derive(Debug, PartialEq)] -pub enum WireError { - Arity { expected: usize, got: usize }, - Type { expected: Type, found: Type }, - MissingCapability { ty: Type }, -} - -#[derive(Clone, Copy, Default)] -pub struct WireCapabilities { - pub memoize: Option Result>, - pub lend: Option Result>, -} - -fn memoize_edge(edge: EdgeHandle) -> Result { - let content = edge.downcast::()?; - Ok(EdgeHandle::new(Box::new(MemoizeNode::new(content)) as Box>)) -} - -fn lend_edge(edge: EdgeHandle) -> Result { - let content = edge.downcast::()?; - Ok(EdgeHandle::new_ref(Box::new(FrameMemoNode::new(content)) as Box>)) -} - -pub struct EdgeHandle { - node: Box, - ty: Type, - capabilities: WireCapabilities, -} - -impl std::fmt::Debug for EdgeHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EdgeHandle").field("ty", &self.ty).finish_non_exhaustive() - } -} - -impl EdgeHandle { - pub fn new(node: Box>) -> Self { - Self::new_erased( - node, - concrete!(T), - WireCapabilities { - memoize: Some(memoize_edge::), - lend: Some(lend_edge::), - }, - ) - } - - pub fn new_ref(node: Box>) -> Self { - Self::new_erased(node, Type::Ref(Box::new(concrete!(T))), WireCapabilities::default()) - } - - pub fn new_erased(node: Box, ty: Type, capabilities: WireCapabilities) -> Self - where - Box: Any, - { - Self { - node: Box::new(node), - ty, - capabilities, - } - } - - pub fn wire_type(&self) -> &Type { - &self.ty - } - - pub fn memoized(self) -> Result { - match self.capabilities.memoize { - Some(wrap) => wrap(self), - None => Err(WireError::MissingCapability { ty: self.ty }), - } - } - - pub fn lent(self) -> Result { - match self.capabilities.lend { - Some(wrap) => wrap(self), - None => Err(WireError::MissingCapability { ty: self.ty }), - } - } - - pub fn downcast(self) -> Result>, WireError> { - self.downcast_erased(concrete!(T)) - } - - pub fn downcast_lend(self) -> Result>, WireError> { - self.downcast_erased(Type::Ref(Box::new(concrete!(T)))) - } - - pub fn downcast_erased(self, expected: Type) -> Result, WireError> - where - Box: Any, - { - let found = self.ty; - self.node.downcast::>().map(|node| *node).map_err(|_| WireError::Type { expected, found }) - } -} - -pub struct NodeIoRecord { - pub inputs: Vec, - pub output: Type, -} - -pub struct RegistryEntry { - pub io: NodeIoRecord, - pub wire: fn(Vec) -> Result, -} - -pub fn resolve_and_wire(entry: &RegistryEntry, inputs: Vec) -> Result { - if inputs.len() != entry.io.inputs.len() { - return Err(WireError::Arity { - expected: entry.io.inputs.len(), - got: inputs.len(), - }); - } - for (handle, expected) in inputs.iter().zip(&entry.io.inputs) { - if handle.wire_type() != expected { - return Err(WireError::Type { - expected: expected.clone(), - found: handle.wire_type().clone(), - }); - } - } - (entry.wire)(inputs) -} - -pub struct MemoizeNode { - cache: Mutex>, - content: NodeContent, -} - -impl MemoizeNode { - pub fn new(content: NodeContent) -> Self { - Self { - cache: Mutex::new(None), - content, - } - } -} - -impl GNode for MemoizeNode -where - T: Clone, - Input: Ctx + CacheHash, - NodeContent: GNode, -{ - type Output = T; - - fn eval(&self, input: &Input) -> GPoll { - let key = cache_key(input); - if let Some((hash, value, finality)) = self.cache.lock().unwrap().as_ref() { - if *hash == key { - return match finality { - Finality::AllFinal => GPoll::Final(value.clone()), - Finality::Partial => GPoll::Partial(value.clone()), - }; - } - } - let result = self.content.eval(input); - match &result { - GPoll::Final(value) => *self.cache.lock().unwrap() = Some((key, value.clone(), Finality::AllFinal)), - GPoll::Partial(value) => *self.cache.lock().unwrap() = Some((key, value.clone(), Finality::Partial)), - GPoll::Pending | GPoll::Fallback(_) | GPoll::Error(_) => {} - } - result - } - - fn extent(&self, input: &Input) -> GPoll { - self.content.extent(input) - } -} - -pub struct FrameMemoNode { - cell: ArenaCell>, - content: NodeContent, -} - -impl FrameMemoNode { - pub fn new(content: NodeContent) -> Self { - Self { - cell: ArenaCell::new(), - content, - } - } -} - -impl<'e, T, Input, NodeContent> GNode for FrameMemoNode -where - T: Clone + 'static, - Input: Ctx + CacheHash + ExtractArena, - NodeContent: GNode, -{ - type Output = &'e T; - - fn eval(&self, input: &Input) -> GPoll<&'e T> { - let arena = input.arena(); - let table = match self.cell.load(arena) { - Some(table) => table, - None => match arena.alloc(FrameTable::new()) { - Some((table, weak)) => { - self.cell.store(weak); - table - } - None => return park(arena, self.content.eval(input)), - }, - }; - match table.lookup(cache_key(input)) { - Lookup::Hit(Finality::AllFinal, value) => GPoll::Final(value), - Lookup::Hit(Finality::Partial, value) => GPoll::Partial(value), - Lookup::Vacant(slot) => match self.content.eval(input) { - GPoll::Final(value) => GPoll::Final(slot.publish(value, Finality::AllFinal)), - GPoll::Partial(value) => GPoll::Partial(slot.publish(value, Finality::Partial)), - unpublishable => { - slot.release(); - park(arena, unpublishable) - } - }, - Lookup::Full => park(arena, self.content.eval(input)), - } - } - - fn extent(&self, input: &Input) -> GPoll { - self.content.extent(input) - } -} - -pub fn park<'e, T>(arena: &'e Arena, result: GPoll) -> GPoll<&'e T> { - match result { - GPoll::Final(value) => match arena.alloc(value) { - Some((parked, _)) => GPoll::Final(parked), - None => GPoll::arena_exhausted(), - }, - GPoll::Partial(value) => match arena.alloc(value) { - Some((parked, _)) => GPoll::Partial(parked), - None => GPoll::arena_exhausted(), - }, - GPoll::Fallback(boxed) => { - let (value, error) = *boxed; - match arena.alloc(value) { - Some((parked, _)) => GPoll::Fallback(Box::new((parked, error))), - None => GPoll::arena_exhausted(), - } - } - GPoll::Pending => GPoll::Pending, - GPoll::Error(error) => GPoll::Error(error), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::context::EvalScope; - use crate::SourceId; - use std::sync::atomic::{AtomicU32, Ordering}; - - struct CountingNode(AtomicU32); - - impl GNode for CountingNode { - type Output = u32; - - fn eval(&self, _input: &Input) -> GPoll { - GPoll::Final(self.0.fetch_add(1, Ordering::Relaxed) + 1) - } - } - - struct ValueNode(T); - - impl GNode for ValueNode { - type Output = T; - - fn eval(&self, _input: &Input) -> GPoll { - GPoll::Final(self.0.clone()) - } - } - - fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> { - EvalScope::new(Some(0.5), None, None, generations, arena) - } - - #[test] - fn memo_capability_wraps_edges_type_blind() { - let arena = Arena::new(1024); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let edge = EdgeHandle::new(Box::new(CountingNode(AtomicU32::new(0))) as Box>); - let memoized = edge.memoized().unwrap().downcast::().unwrap(); - - assert_eq!(memoized.eval(&ctx), GPoll::Final(1)); - assert_eq!(memoized.eval(&ctx), GPoll::Final(1)); - } - - #[test] - fn memo_invalidates_on_generation_bump() { - let arena = Arena::new(1024); - let source: SourceId = 7; - let before = [(source, 1)]; - let after = [(source, 2)]; - let scope_before = scope_fixture(&before, &arena); - let scope_after = scope_fixture(&after, &arena); - - let edge = EdgeHandle::new(Box::new(CountingNode(AtomicU32::new(0))) as Box>); - let memoized = edge.memoized().unwrap().downcast::().unwrap(); - - assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1)); - assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1)); - assert_eq!(memoized.eval(&ContextImpl::root(&scope_after)), GPoll::Final(2)); - } - - #[test] - fn memoized_edges_stack_and_rewire() { - let arena = Arena::new(1024); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let edge = EdgeHandle::new(Box::new(CountingNode(AtomicU32::new(0))) as Box>); - let stacked = edge.memoized().unwrap().memoized().unwrap().downcast::().unwrap(); - - assert_eq!(stacked.eval(&ctx), GPoll::Final(1)); - assert_eq!(stacked.eval(&ctx), GPoll::Final(1)); - } - - #[test] - fn lend_capability_turns_an_owned_edge_into_a_lending_edge() { - let arena = Arena::new(4096); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let edge = EdgeHandle::new(Box::new(ValueNode("lent out".to_string())) as Box>); - let lending = edge.lent().unwrap(); - assert_eq!(*lending.wire_type(), Type::Ref(Box::new(concrete!(String)))); - - let node = lending.downcast_lend::().unwrap(); - let GPoll::Final(first) = node.eval(&ctx) else { - panic!("lend must fill the frame table and lend"); - }; - let GPoll::Final(second) = node.eval(&ctx) else { - panic!("second eval must lend the published value"); - }; - assert_eq!(first, "lent out"); - assert!(std::ptr::eq(first, second)); - } - - #[test] - fn ref_edges_report_missing_capabilities() { - let edge = EdgeHandle::new(Box::new(ValueNode(5u32)) as Box>); - let lending = edge.lent().unwrap(); - - match lending.memoized() { - Err(WireError::MissingCapability { ty }) => assert_eq!(ty, Type::Ref(Box::new(concrete!(u32)))), - other => panic!("expected missing capability, got {:?}", other.map(|handle| handle.ty)), - } - } - - #[test] - fn borrow_carrying_value_types_wire_through_the_general_constructor() { - struct SplitBorrow<'c>(&'c str, usize); - - struct SplitNode { - content: Node0, - } - - impl<'e, Input, Node0> GNode for SplitNode - where - Input: Ctx, - Node0: GNode, - { - type Output = SplitBorrow<'e>; - - fn eval(&self, input: &Input) -> GPoll> { - self.content.eval(input).map(|value| SplitBorrow(value, value.len())) - } - } - - type ErasedSplitEdge = dyn for<'c> GNode, Output = SplitBorrow<'c>>; - - let arena = Arena::new(4096); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let lending = EdgeHandle::new(Box::new(ValueNode("held".to_string())) as Box>).lent().unwrap(); - let upstream = lending.downcast_lend::().unwrap(); - let node: Box = Box::new(SplitNode { content: upstream }); - let handle = EdgeHandle::new_erased(node, concrete!(SplitBorrow<'static>), WireCapabilities::default()); - assert_eq!(*handle.wire_type(), concrete!(SplitBorrow<'static>)); - - let wired = handle.downcast_erased::(concrete!(SplitBorrow<'static>)).unwrap(); - let GPoll::Final(split) = wired.eval(&ctx) else { - panic!("borrow-carrying output must eval through the erased edge"); - }; - assert_eq!(split.0, "held"); - assert_eq!(split.1, 4); - } - - #[test] - fn derive_ctx_repeat_pushes_index_levels_through_the_erased_edge() { - use crate::context::{Derived, DeriveCtx, ExtractIndex}; - - struct RepeatNode { - content: Node0, - } - - impl GNode for RepeatNode - where - C: Ctx + DeriveCtx, - Node0: for<'x> GNode, Output = T>, - { - type Output = Vec; - - fn eval(&self, input: &C) -> GPoll> { - let spilled = input.index_head(); - let mut result = Vec::new(); - for index in 0..3 { - let derived = input.promoted(&spilled, index); - match self.content.eval(&derived) { - GPoll::Final(value) => result.push(value), - other => return other.map(|_| Vec::new()), - } - } - GPoll::Final(result) - } - } - - struct LevelsNode; - - impl GNode for LevelsNode { - type Output = Vec; - - fn eval(&self, input: &Input) -> GPoll> { - GPoll::Final(input.try_index().map(|levels| levels.collect()).unwrap_or_default()) - } - } - - let arena = Arena::new(1024); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let nested = RepeatNode { - content: RepeatNode { content: LevelsNode }, - }; - let erased: Box>>>> = Box::new(nested); - - let GPoll::Final(outer) = erased.eval(&ctx) else { - panic!("nested repeat must evaluate"); - }; - assert_eq!(outer.len(), 3); - assert_eq!(outer[2][1], vec![1, 2, 0]); - assert_eq!(outer[0][0], vec![0, 0, 0]); - } - - #[test] - fn derive_ctx_footprint_replace_reaches_the_content() { - use crate::context::{Derived, DeriveCtx, ExtractFootprint}; - use crate::transform::Footprint; - - struct ShiftFootprintNode { - content: Node0, - } - - impl GNode for ShiftFootprintNode - where - C: Ctx + DeriveCtx + ExtractFootprint, - Node0: for<'x> GNode, Output = T>, - { - type Output = T; - - fn eval(&self, input: &C) -> GPoll { - let mut footprint = input.try_footprint().copied().unwrap_or(Footprint::DEFAULT); - footprint.resolution.x += 7; - let derived = input.with_footprint(&footprint); - self.content.eval(&derived) - } - } - - struct ResolutionNode; - - impl GNode for ResolutionNode { - type Output = u32; - - fn eval(&self, input: &Input) -> GPoll { - GPoll::Final(input.try_footprint().map(|footprint| footprint.resolution.x).unwrap_or(0)) - } - } - - let arena = Arena::new(1024); - let generations = []; - let scope = scope_fixture(&generations, &arena); - let ctx = ContextImpl::root(&scope); - - let graph: Box> = Box::new(ShiftFootprintNode { - content: ShiftFootprintNode { content: ResolutionNode }, - }); - assert_eq!(graph.eval(&ctx), GPoll::Final(Footprint::DEFAULT.resolution.x + 14)); - } - - #[test] - fn resolve_and_wire_checks_arity_and_types() { - fn wire_strlen(args: Vec) -> Result { - let mut args = args.into_iter(); - let value = args.next().ok_or(WireError::Arity { expected: 1, got: 0 })?.downcast::()?; - drop(value); - Ok(EdgeHandle::new(Box::new(ValueNode(0u32)) as Box>)) - } - let entry = RegistryEntry { - io: NodeIoRecord { - inputs: vec![concrete!(String)], - output: concrete!(u32), - }, - wire: wire_strlen, - }; - - let owned = EdgeHandle::new(Box::new(ValueNode("typed".to_string())) as Box>); - assert!(resolve_and_wire(&entry, vec![owned]).is_ok()); - - assert_eq!(resolve_and_wire(&entry, vec![]).unwrap_err(), WireError::Arity { expected: 1, got: 0 }); - - let mistyped = EdgeHandle::new(Box::new(ValueNode(1.0f64)) as Box>); - assert_eq!( - resolve_and_wire(&entry, vec![mistyped]).unwrap_err(), - WireError::Type { - expected: concrete!(String), - found: concrete!(f64), - } - ); - - let lent = EdgeHandle::new(Box::new(ValueNode("typed".to_string())) as Box>).lent().unwrap(); - assert_eq!( - resolve_and_wire(&entry, vec![lent]).unwrap_err(), - WireError::Type { - expected: concrete!(String), - found: Type::Ref(Box::new(concrete!(String))), - } - ); - } -} diff --git a/node-graph/node-macro/src/gcodegen.rs b/node-graph/node-macro/src/gcodegen.rs index d2c681512a..809214503c 100644 --- a/node-graph/node-macro/src/gcodegen.rs +++ b/node-graph/node-macro/src/gcodegen.rs @@ -314,7 +314,7 @@ pub(crate) fn generate_gnode_code(crate_ident: &CrateIdent, parsed: &ParsedNodeF None => quote!(#core_types::gpoll::GPoll::Pending), }; let slot_check = quote! { - let __key = #core_types::wire::cache_key(__input); + let __key = #core_types::registry::cache_key(__input); { let __entries = self.slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(__state) = __entries.get(&__key) { @@ -395,9 +395,9 @@ pub(crate) fn generate_gnode_code(crate_ident: &CrateIdent, parsed: &ParsedNodeF } }; - let wire = entries_tokens(parsed, &struct_name, &data_field_generic_idents, ®ular_fields); + let entries = entries_tokens(parsed, &struct_name, &data_field_generic_idents, ®ular_fields); let cfg = crate::shader_nodes::modify_cfg(&parsed.attributes); - let wire_reexport = match wire.is_empty() { + let entries_reexport = match entries.is_empty() { true => quote!(), false => { let entries_name = format_ident!("{}_entries", fn_name); @@ -410,7 +410,7 @@ pub(crate) fn generate_gnode_code(crate_ident: &CrateIdent, parsed: &ParsedNodeF }; let top_level = quote! { - #wire_reexport + #entries_reexport #cfg #[automatically_derived] @@ -437,7 +437,7 @@ pub(crate) fn generate_gnode_code(crate_ident: &CrateIdent, parsed: &ParsedNodeF }; Ok(GNodeTokens { - in_mod: wire, + in_mod: entries, top_level: quote! { #kernel @@ -597,31 +597,31 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic let entries = rows.iter().map(|row| { let types = row.iter(); - let boxed_types = row.iter().map(|ty| quote!(::std::boxed::Box>)); + let boxed_types = row.iter().map(|ty| quote!(::std::boxed::Box>)); let output = quote!(<#struct_name<#(#boxed_types),*> as gcore::gnode::GNode>>::Output); let downcasts = names.iter().zip(row.iter()).map(|(name, ty)| { quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;) }); quote! { - gcore::wire::RegistryEntry { - io: gcore::wire::NodeIoRecord { + gcore::registry::RegistryEntry { + io: gcore::registry::NodeIoRecord { inputs: vec![#(gcore::concrete!(#types)),*], output: gcore::concrete!(#output), }, - wire: |inputs| { + constructor: |inputs| { if inputs.len() != #arity { - return Err(gcore::wire::WireError::Arity { expected: #arity, got: inputs.len() }); + return Err(gcore::registry::ConstructionError::Arity { expected: #arity, got: inputs.len() }); } let mut inputs = inputs.into_iter(); #(#downcasts)* - Ok(gcore::wire::EdgeHandle::new(::std::boxed::Box::new(#struct_name::new(#(#names),*)) as ::std::boxed::Box>)) + Ok(gcore::registry::EdgeHandle::new(::std::boxed::Box::new(#struct_name::new(#(#names),*)) as ::std::boxed::Box>)) }, } } }); quote! { - pub fn #entries_name() -> ::std::vec::Vec { + pub fn #entries_name() -> ::std::vec::Vec { vec![#(#entries),*] } } diff --git a/node-graph/nodes/gcore/src/context_modification.rs b/node-graph/nodes/gcore/src/context_modification.rs index bd36a0626e..fad88894e9 100644 --- a/node-graph/nodes/gcore/src/context_modification.rs +++ b/node-graph/nodes/gcore/src/context_modification.rs @@ -4,7 +4,7 @@ use core_types::gpoll::GPoll; use core_types::list::{AttributeDyn, AttributeValueDyn, List, ListDyn}; use core_types::transform::Footprint; use core_types::uuid::NodeId; -use core_types::{Color, OwnedContextImpl}; +use core_types::Color; use glam::{DAffine2, DVec2}; use graphic_types::vector_types::GradientStops; use graphic_types::{Artboard, Graphic, Vector}; @@ -49,72 +49,3 @@ fn context_modification( let scope = ctx.scope().nullified(features_to_keep); value.eval(&ctx.nullified(features_to_keep, &scope)) } - -#[cfg(test)] -mod tests { - use super::*; - use core_types::graphene_hash::CacheHash; - use core_types::transform::Footprint; - use std::collections::hash_map::DefaultHasher; - use std::hash::Hasher; - - /// Verifies that nullified context fields don't affect the cache hash — only the kept features matter. - #[test] - fn test_nullified_context_hash_stability() { - use core_types::Context; - use std::sync::Arc; - - let original_ctx: Context = Some(Arc::new( - OwnedContextImpl::empty() - .with_footprint(Footprint::default()) - .with_index(1) - .with_real_time(10.5) - .with_vararg(Box::new("test")) - .with_animation_time(20.25), - )); - - // A second context with different values for the nullified fields - let changed_ctx: Context = Some(Arc::new( - OwnedContextImpl::empty() - .with_footprint(Footprint::default()) - .with_index(2) - .with_real_time(999.9) - .with_vararg(Box::new("test")) - .with_animation_time(888.8), - )); - - // Nullify everything — both should hash the same regardless of their field values - let features_to_keep = ContextFeatures::empty(); - let nullified1 = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), features_to_keep); - let nullified2 = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), features_to_keep); - - let mut hasher1 = DefaultHasher::new(); - nullified1.cache_hash(&mut hasher1); - - let mut hasher2 = DefaultHasher::new(); - nullified2.cache_hash(&mut hasher2); - - assert_eq!( - hasher1.finish(), - hasher2.finish(), - "Hash of nullified context should remain stable regardless of input changes when features are nullified" - ); - - // Keep only footprint and varargs — both have the same footprint and vararg, so hash should still match - let partial_features = ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS; - let partial1 = OwnedContextImpl::from_flags(original_ctx.clone().unwrap(), partial_features); - let partial2 = OwnedContextImpl::from_flags(changed_ctx.clone().unwrap(), partial_features); - - let mut hasher3 = DefaultHasher::new(); - partial1.cache_hash(&mut hasher3); - - let mut hasher4 = DefaultHasher::new(); - partial2.cache_hash(&mut hasher4); - - assert_eq!( - hasher3.finish(), - hasher4.finish(), - "Hash should be stable when keeping only footprint and varargs and their values are the same" - ); - } -} diff --git a/node-graph/nodes/gcore/src/memo.rs b/node-graph/nodes/gcore/src/memo.rs index ddd44010b1..b55c5f887b 100644 --- a/node-graph/nodes/gcore/src/memo.rs +++ b/node-graph/nodes/gcore/src/memo.rs @@ -1,35 +1,105 @@ -use core_types::gpoll::Interrupt; +use core_types::arena::{Arena, ArenaCell}; +use core_types::context::Ctx; +use core_types::frame_table::{FrameTable, Lookup}; +use core_types::gnode::GNode; +use core_types::gpoll::{Extent, Finality, GPoll, Interrupt}; use core_types::graphene_hash::CacheHash; use core_types::memo::*; -use std::hash::DefaultHasher; -use std::hash::Hasher; +use core_types::registry::cache_key; use std::sync::Arc; use std::sync::Mutex; /// Helps speed up repeated renders in a computationally-heavy part of the node graph. /// /// Stores the last evaluated data that flowed through this node and immediately returns that data on subsequent renders if the context has not changed. -#[node_macro::node(category("General"), path(graphene_core::memo), skip_impl)] -fn memoize(input: I, #[data] cache: Arc>>, content: impl Node) -> Result { - // Caches the output of a given node called with a specific input. - // - // A cache miss occurs when the Option is None. In this case, the node evaluates the inner node and memoizes (stores) the result. - // - // A cache hit occurs when the Option is Some and has a stored hash matching the hash of the call argument. In this case, the node returns the cached value without re-evaluating the inner node. - // - // Currently, only one input-output pair is cached. Subsequent calls with different inputs will overwrite the previous cache. - - let mut hasher = DefaultHasher::new(); - input.cache_hash(&mut hasher); - let hash = hasher.finish(); - - if let Some(data) = cache.lock().as_ref().unwrap().as_ref().and_then(|data| (data.0 == hash).then_some(data.1.clone())) { - return Ok(data); +#[node_macro::node(category("General"), path(graphene_core::memo), skip_impl, extent(memoize_extent))] +fn memoize(input: I, #[data] cache: Arc>>, content: impl Node) -> GPoll { + let key = cache_key(&input); + if let Some((hash, value, finality)) = cache.lock().unwrap().as_ref() { + if *hash == key { + return match finality { + Finality::AllFinal => GPoll::Final(value.clone()), + Finality::Partial => GPoll::Partial(value.clone()), + }; + } } + let result = content.eval(&input); + match &result { + GPoll::Final(value) => *cache.lock().unwrap() = Some((key, value.clone(), Finality::AllFinal)), + GPoll::Partial(value) => *cache.lock().unwrap() = Some((key, value.clone(), Finality::Partial)), + GPoll::Pending | GPoll::Fallback(_) | GPoll::Error(_) => {} + } + result +} - let value = content.eval(input)?; - *cache.lock().unwrap() = Some((hash, value.clone())); - Ok(value) +fn memoize_extent(node: &MemoizeNode, ctx: &C) -> GPoll +where + T: Clone, + NodeContent: GNode, +{ + node.content.extent(ctx) +} + +#[node_macro::node(category(""), path(graphene_core::memo), skip_impl, extent(frame_memo_extent))] +fn frame_memo<'e, T: Clone + 'static>( + ctx: impl Ctx + CacheHash + ExtractArena<'e>, + #[data] cell: ArenaCell>, + content: impl Node, Output = T>, +) -> GPoll<&'e T> { + let arena = ctx.arena(); + let table = match cell.load(arena) { + Some(table) => table, + None => match arena.alloc(FrameTable::new()) { + Some((table, weak)) => { + cell.store(weak); + table + } + None => return park(arena, content.eval(ctx)), + }, + }; + match table.lookup(cache_key(ctx)) { + Lookup::Hit(Finality::AllFinal, value) => GPoll::Final(value), + Lookup::Hit(Finality::Partial, value) => GPoll::Partial(value), + Lookup::Vacant(slot) => match content.eval(ctx) { + GPoll::Final(value) => GPoll::Final(slot.publish(value, Finality::AllFinal)), + GPoll::Partial(value) => GPoll::Partial(slot.publish(value, Finality::Partial)), + unpublishable => { + slot.release(); + park(arena, unpublishable) + } + }, + Lookup::Full => park(arena, content.eval(ctx)), + } +} + +fn frame_memo_extent(node: &FrameMemoNode, ctx: &C) -> GPoll +where + T: Clone + 'static, + NodeContent: GNode, +{ + node.content.extent(ctx) +} + +pub fn park<'e, T>(arena: &'e Arena, result: GPoll) -> GPoll<&'e T> { + match result { + GPoll::Final(value) => match arena.alloc(value) { + Some((parked, _)) => GPoll::Final(parked), + None => GPoll::arena_exhausted(), + }, + GPoll::Partial(value) => match arena.alloc(value) { + Some((parked, _)) => GPoll::Partial(parked), + None => GPoll::arena_exhausted(), + }, + GPoll::Fallback(boxed) => { + let (value, error) = *boxed; + match arena.alloc(value) { + Some((parked, _)) => GPoll::Fallback(Box::new((parked, error))), + None => GPoll::arena_exhausted(), + } + } + GPoll::Pending => GPoll::Pending, + GPoll::Error(error) => GPoll::Error(error), + } } type MonitorValue = Arc>>>>; @@ -55,3 +125,127 @@ fn serialize_monitor) } + +#[cfg(test)] +mod tests { + use super::*; + use core_types::SourceId; + use core_types::concrete; + use core_types::context::{ContextImpl, EvalScope}; + use core_types::registry::{EdgeHandle, ErasedGNode, ErasedLendGNode}; + use core_types::Type; + use std::sync::atomic::{AtomicU32, Ordering}; + + struct CountingNode(AtomicU32); + + impl GNode for CountingNode { + type Output = u32; + + fn eval(&self, _input: &Input) -> GPoll { + GPoll::Final(self.0.fetch_add(1, Ordering::Relaxed) + 1) + } + } + + struct PartialCountingNode(AtomicU32); + + impl GNode for PartialCountingNode { + type Output = u32; + + fn eval(&self, _input: &Input) -> GPoll { + GPoll::Partial(self.0.fetch_add(1, Ordering::Relaxed) + 1) + } + } + + struct ValueNode(T); + + impl GNode for ValueNode { + type Output = T; + + fn eval(&self, _input: &Input) -> GPoll { + GPoll::Final(self.0.clone()) + } + } + + fn scope_fixture<'a>(generations: &'a [(SourceId, u64)], arena: &'a Arena) -> EvalScope<'a> { + EvalScope::new(Some(0.5), None, None, generations, arena) + } + + #[test] + fn memoize_caches_across_evals() { + let arena = Arena::new(1024); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let memoized = MemoizeNode::new(CountingNode(AtomicU32::new(0))); + + assert_eq!(memoized.eval(&ctx), GPoll::Final(1)); + assert_eq!(memoized.eval(&ctx), GPoll::Final(1)); + } + + #[test] + fn memo_invalidates_on_generation_bump() { + let arena = Arena::new(1024); + let source: SourceId = 7; + let before = [(source, 1)]; + let after = [(source, 2)]; + let scope_before = scope_fixture(&before, &arena); + let scope_after = scope_fixture(&after, &arena); + + let memoized = MemoizeNode::new(CountingNode(AtomicU32::new(0))); + + assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1)); + assert_eq!(memoized.eval(&ContextImpl::root(&scope_before)), GPoll::Final(1)); + assert_eq!(memoized.eval(&ContextImpl::root(&scope_after)), GPoll::Final(2)); + } + + #[test] + fn memo_replays_partiality_on_hit() { + let arena = Arena::new(1024); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let memoized = MemoizeNode::new(PartialCountingNode(AtomicU32::new(0))); + + assert_eq!(memoized.eval(&ctx), GPoll::Partial(1)); + assert_eq!(memoized.eval(&ctx), GPoll::Partial(1)); + } + + #[test] + fn memoized_edges_stack_and_rewire() { + let arena = Arena::new(1024); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let edge = EdgeHandle::new(Box::new(CountingNode(AtomicU32::new(0))) as Box>); + let memoized = EdgeHandle::new(Box::new(MemoizeNode::new(edge.downcast::().unwrap())) as Box>); + let stacked = MemoizeNode::new(memoized.downcast::().unwrap()); + + assert_eq!(stacked.eval(&ctx), GPoll::Final(1)); + assert_eq!(stacked.eval(&ctx), GPoll::Final(1)); + } + + #[test] + fn frame_memo_turns_an_owned_edge_into_a_lending_edge() { + let arena = Arena::new(4096); + let generations = []; + let scope = scope_fixture(&generations, &arena); + let ctx = ContextImpl::root(&scope); + + let edge = EdgeHandle::new(Box::new(ValueNode("lent out".to_string())) as Box>); + let lending = EdgeHandle::new_ref(Box::new(FrameMemoNode::new(edge.downcast::().unwrap())) as Box>); + assert_eq!(*lending.ty(), Type::Ref(Box::new(concrete!(String)))); + + let node = lending.downcast_lend::().unwrap(); + let GPoll::Final(first) = node.eval(&ctx) else { + panic!("lend must fill the frame table and lend"); + }; + let GPoll::Final(second) = node.eval(&ctx) else { + panic!("second eval must lend the published value"); + }; + assert_eq!(first, "lent out"); + assert!(std::ptr::eq(first, second)); + } +} diff --git a/node-graph/nodes/gcore/src/ops.rs b/node-graph/nodes/gcore/src/ops.rs index d42ea78d88..13f781b1e2 100644 --- a/node-graph/nodes/gcore/src/ops.rs +++ b/node-graph/nodes/gcore/src/ops.rs @@ -26,6 +26,6 @@ mod test { #[test] pub fn passthrough_node() { - assert_eq!(passthrough((), &4), &4); + assert_eq!(passthrough(&(), &4), &4); } } diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 14e48a738d..3ab621a8c3 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -1068,7 +1068,7 @@ mod graphene_test { use core_types::context::{ContextImpl, EvalScope, ExtractIndex}; use core_types::gnode::{BatchStatus, GNode}; use core_types::gpoll::{Finality, GPoll}; - use core_types::wire::{EdgeHandle, ErasedGNode, resolve_and_wire}; + use core_types::registry::{EdgeHandle, ErasedGNode, construct}; use std::mem::MaybeUninit; struct SourceNode(T); @@ -1130,7 +1130,7 @@ mod graphene_test { let entries = logical_or_entries(); let value = EdgeHandle::new(Box::new(SourceNode(true)) as Box>); let other_value = EdgeHandle::new(Box::new(SourceNode(false)) as Box>); - let wired = resolve_and_wire(&entries[0], vec![value, other_value]).unwrap().downcast::().unwrap(); + let wired = construct(&entries[0], vec![value, other_value]).unwrap().downcast::().unwrap(); assert_eq!(GNode::eval(&wired, &ctx), GPoll::Final(true)); } @@ -1150,7 +1150,7 @@ mod graphene_test { let augend = EdgeHandle::new(Box::new(SourceNode(1.5f64)) as Box>); let addend = EdgeHandle::new(Box::new(SourceNode(2.5f64)) as Box>); - let wired = resolve_and_wire(&entries[0], vec![augend, addend]).unwrap().downcast::().unwrap(); + let wired = construct(&entries[0], vec![augend, addend]).unwrap().downcast::().unwrap(); assert_eq!(GNode::eval(&wired, &ctx), GPoll::Final(4.0)); }