Add the Record wire type, record registry rows, lift and extract adapters, and executor stack reservation

This commit is contained in:
Dennis Kobert
2026-08-05 12:24:20 +00:00
parent abe2a565d1
commit c91be74a55
9 changed files with 318 additions and 3 deletions

View File

@@ -324,6 +324,7 @@ pub(crate) fn property_from_type(
Type::Fn(_, out) => return property_from_type(node_id, index, out, number_options, unit, display_decimal_places, step, context),
Type::Future(out) => return property_from_type(node_id, index, out, number_options, unit, display_decimal_places, step, context),
Type::Ref(inner) => return property_from_type(node_id, index, inner, number_options, unit, display_decimal_places, step, context),
Type::Record(inner) => return property_from_type(node_id, index, inner, number_options, unit, display_decimal_places, step, context),
};
extra_widgets.push(widgets);

View File

@@ -390,6 +390,7 @@ macro_rules! tagged_value {
match input {
Type::Generic(_) => None,
Type::Ref(_) => None,
Type::Record(_) => None,
Type::Concrete(concrete_type) => {
let name = concrete_type.name.as_ref();
// TODO: Add default implementations for types such as TaggedValue::Subpaths, and use the defaults here and in document_node_types
@@ -651,6 +652,7 @@ impl TaggedValue {
match ty {
Type::Generic(_) => None,
Type::Ref(_) => None,
Type::Record(_) => None,
Type::Concrete(concrete_type) => {
let ty = concrete_type.id?;
use std::any::TypeId;

View File

@@ -902,6 +902,8 @@ fn valid_type(from: &Type, to: &Type) -> bool {
(Type::Fn(in1, out1), Type::Fn(in2, out2)) => valid_type(out2, out1) && valid_type(in1, in2),
// A lend edge is substitutable exactly when the lent values are.
(Type::Ref(in1), Type::Ref(in2)) => valid_type(in1, in2),
// A record edge is substitutable exactly when the elements are.
(Type::Record(in1), Type::Record(in2)) => valid_type(in1, in2),
// If either the proposed input or the allowed input are generic, we allow the substitution (meaning this is a valid subtype).
// TODO: Add proper generic counting which is not based on the name
(Type::Generic(_), _) | (_, Type::Generic(_)) => true,
@@ -925,6 +927,8 @@ fn ref_adapter(proposed: &Type, wanted: &Type) -> Option<ProtoNodeIdentifier> {
match (proposed_output.as_ref(), wanted_output.as_ref()) {
(Type::Ref(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("graphene_core::debug::CloneNode")),
(proposed_output @ Type::Concrete(_), Type::Ref(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("graphene_core::memo::LendNode")),
(Type::Record(inner), wanted_output @ Type::Concrete(_)) if valid_type(inner, wanted_output) => Some(ProtoNodeIdentifier::new("core_types::record::RecordExtractNode")),
(proposed_output @ Type::Concrete(_), Type::Record(inner)) if valid_type(proposed_output, inner) => Some(ProtoNodeIdentifier::new("core_types::record::RecordLiftNode")),
_ => None,
}
}
@@ -980,10 +984,23 @@ fn collect_generics(types: &NodeIOTypes) -> Vec<Cow<'static, str>> {
/// Checks if a generic type can be substituted with a concrete type and returns the concrete type
fn check_generic(types: &NodeIOTypes, input: &Type, parameters: &[Type], generic: &str) -> Result<Type, String> {
fn record_element(ty: Option<&Type>) -> Option<&Type> {
match ty {
Some(Type::Record(inner)) => Some(inner.as_ref()),
_ => None,
}
}
let inputs = [(Some(&types.call_argument), Some(input))]
.into_iter()
.chain(types.inputs.iter().map(|x| x.fn_input()).zip(parameters.iter().map(|x| x.fn_input())))
.chain(types.inputs.iter().map(|x| x.fn_output()).zip(parameters.iter().map(|x| x.fn_output())));
.chain(types.inputs.iter().map(|x| x.fn_output()).zip(parameters.iter().map(|x| x.fn_output())))
.chain(
types
.inputs
.iter()
.map(|x| record_element(x.fn_output()))
.zip(parameters.iter().map(|x| record_element(x.fn_output()))),
);
let concrete_inputs = inputs.filter(|(ni, _)| matches!(ni, Some(Type::Generic(input)) if generic == input));
let mut outputs = concrete_inputs.flat_map(|(_, out)| out);
let out_ty = outputs

View File

@@ -181,6 +181,7 @@ where
return Err("Output node not found in executor".into());
};
let mut arena = self.arena.lock().unwrap_or_else(PoisonError::into_inner);
core_types::record::stack::reserve(self.tree.stack_need());
let result = eval_root(&mut arena, &self.runtime, &input, |ctx| match TaggedValue::from_edge(handle.duplicate(), ctx) {
Ok(poll) => poll.map(Ok),
Err(error) => GPoll::Final(Err(error)),
@@ -469,6 +470,14 @@ impl BorrowTree {
pub fn source_map(&self) -> &HashMap<Path, (NodeId, NodeTypes)> {
&self.source_map
}
/// The record-stack bound of an evaluation: the sum over all node frames.
pub fn stack_need(&self) -> usize {
self.nodes
.values()
.map(|(handle, _)| handle.layout().map_or(0, |layout| layout.size.next_multiple_of(8)))
.sum()
}
}
#[cfg(test)]
@@ -596,6 +605,40 @@ mod test {
assert!(matches!(result, Some(GPoll::Final(_))), "the palette must evaluate through the spliced lend, got {result:?}");
}
#[test]
fn a_record_wire_types_wires_and_evaluates_through_the_registry() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(2),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), proto_node("core_types::record::RecordLiftNode", vec![NodeId(0)])),
(NodeId(2), proto_node("core_types::record::RecordExtractNode", vec![NodeId(1)])),
],
};
let executor = DynamicExecutor::new(network).unwrap();
let lift = executor.tree().get(NodeId(1)).unwrap();
assert_eq!(lift.ty(), &core_types::registry::record_edge_type::<f64>());
assert!(lift.layout().is_some());
assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.)));
}
#[test]
fn a_lift_adapter_is_spliced_between_a_plain_producer_and_a_record_consumer() {
let network = ProtoNetwork {
inputs: vec![],
output: NodeId(1),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), proto_node("core_types::record::RecordExtractNode", vec![NodeId(0)])),
],
};
let executor = DynamicExecutor::new(network).unwrap();
assert_eq!((&executor).execute(()).unwrap(), GPoll::Final(TaggedValue::F64(7.)));
}
#[test]
fn a_clone_out_adapter_is_spliced_between_a_lending_producer_and_an_owned_consumer() {
let network = ProtoNetwork {

View File

@@ -20,7 +20,7 @@ use graphene_std::transform::Footprint;
use graphene_std::uuid::NodeId;
use graphene_std::vector::Vector;
use graphene_std::{Artboard, Context, Graphic, ProtoNodeIdentifier, SourceId, concrete, fn_type};
use node_registry_macros::{async_node, clone_node, convert_node, frame_memo_node, into_node, lend_node};
use node_registry_macros::{async_node, clone_node, convert_node, frame_memo_node, into_node, lend_node, record_extract_node, record_lift_node};
use std::collections::HashMap;
#[cfg(feature = "gpu")]
use wgpu_executor::WgpuExecutorHandle;
@@ -374,6 +374,8 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, Vec<RegistryEntry>> {
#[cfg(target_family = "wasm")]
frame_memo_node!(CanvasHandle),
lend_node!(f64),
record_lift_node!(f64),
record_extract_node!(f64),
clone_node!(f64),
frame_memo_node!(f64),
lend_node!(f32),
@@ -769,6 +771,44 @@ mod node_registry_macros {
};
}
macro_rules! record_lift_node {
($type:ty) => {
(
ProtoNodeIdentifier::new("core_types::record::RecordLiftNode"),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), core_types::registry::record_type::<$type>(), vec![fn_type!(Context, $type)]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = core_types::record::RecordLift::<$type, _>::wire(inputs.next().unwrap().downcast::<$type>()?);
Ok(EdgeHandle::new_record::<$type>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
},
},
)
};
}
macro_rules! record_extract_node {
($type:ty) => {
(
ProtoNodeIdentifier::new("core_types::record::RecordExtractNode"),
RegistryEntry {
io: NodeIOTypes::new(concrete!(Context), concrete!($type), vec![core_types::registry::record_edge_type::<$type>()]),
constructor: |inputs| {
if inputs.len() != 1 {
return Err(ConstructionError::Arity { expected: 1, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
let node = core_types::record::RecordExtract::<$type, _>::wire(inputs.next().unwrap().downcast_record::<$type>()?);
Ok(EdgeHandle::new(std::sync::Arc::new(node) as std::sync::Arc<ErasedNode<$type>>))
},
},
)
};
}
macro_rules! clone_node {
($type:ty) => {
(
@@ -813,4 +853,6 @@ mod node_registry_macros {
pub(crate) use frame_memo_node;
pub(crate) use into_node;
pub(crate) use lend_node;
pub(crate) use record_extract_node;
pub(crate) use record_lift_node;
}

View File

@@ -352,6 +352,79 @@ impl<N> RecordSource<N> {
}
}
/// Lifts a plain producer onto a record wire: the element lands at offset 0
/// of a fresh element-only record. `Copy` elements only until droppable
/// elements ride records.
pub struct RecordLift<El, N> {
edge: N,
layout: Layout,
frame_bytes: usize,
_marker: std::marker::PhantomData<fn() -> El>,
}
impl<El: Copy + 'static, N> RecordLift<El, N> {
pub fn wire(edge: N) -> Self {
let layout = Layout::default().with_writes(0, (size_of::<El>(), align_of::<El>()), &[]);
let frame_bytes = layout.size.next_multiple_of(8);
Self {
edge,
layout,
frame_bytes,
_marker: std::marker::PhantomData,
}
}
}
impl<'e, C, El, N> Node<C> for RecordLift<El, N>
where
C: crate::context::ExtractArena<ArenaRef = &'e crate::arena::Arena>,
El: Copy + 'static,
N: Node<C, Output = El>,
{
type Output = RecordValue<'e>;
fn eval(&self, input: &C) -> GPoll<RecordValue<'e>> {
let dst = stack::push(self.frame_bytes);
let value = self.edge.eval(input).map(|element| {
unsafe { write_field(dst, 0, element) };
RecordValue::from_rec(unsafe { Rec::new(dst.cast_const()) })
});
stack::pop(dst);
value
}
fn layout(&self) -> Option<&Layout> {
Some(&self.layout)
}
}
/// Extracts the element from a record wire for a plain consumer.
pub struct RecordExtract<El, N> {
edge: N,
_marker: std::marker::PhantomData<fn() -> El>,
}
impl<El, N> RecordExtract<El, N> {
pub fn wire(edge: N) -> Self {
Self {
edge,
_marker: std::marker::PhantomData,
}
}
}
impl<'e, C, El, N> Node<C> for RecordExtract<El, N>
where
El: Copy + 'static,
N: Node<C, Output = RecordValue<'e>>,
{
type Output = El;
fn eval(&self, input: &C) -> GPoll<El> {
self.edge.eval(input).map(|value| unsafe { value.rec().element::<El>() })
}
}
impl<'e, C, N> Node<C> for RecordSource<N>
where
N: Node<C, Output = RecordValue<'e>>,

View File

@@ -79,6 +79,12 @@ pub type ErasedLendNode<T> = dyn for<'c> Node<ContextImpl<'c>, Output = &'c T> +
#[cfg(target_family = "wasm")]
pub type ErasedLendNode<T> = dyn for<'c> Node<ContextImpl<'c>, Output = &'c T>;
/// Element-independent by erasure; the wire's `Type::Record(El)` keeps element reads proven at wiring.
#[cfg(not(target_family = "wasm"))]
pub type ErasedRecordNode = dyn for<'c> Node<ContextImpl<'c>, Output = crate::record::RecordValue<'c>> + Send + Sync;
#[cfg(target_family = "wasm")]
pub type ErasedRecordNode = dyn for<'c> Node<ContextImpl<'c>, Output = crate::record::RecordValue<'c>>;
#[cfg(not(target_family = "wasm"))]
type DynEdge = dyn std::any::Any + Send + Sync;
#[cfg(target_family = "wasm")]
@@ -96,6 +102,22 @@ pub fn lend_edge_type<T: 'static>() -> Type {
Type::Fn(Box::new(concrete!(Context)), Box::new(ref_type::<T>()))
}
pub fn record_type<T: 'static>() -> Type {
Type::Record(Box::new(concrete!(T)))
}
pub fn record_edge_type<T: 'static>() -> Type {
Type::Fn(Box::new(concrete!(Context)), Box::new(record_type::<T>()))
}
/// The record edge type of a token row, generic over the element.
pub fn generic_record_edge_type(name: &'static str) -> Type {
Type::Fn(
Box::new(concrete!(Context)),
Box::new(Type::Record(Box::new(Type::Generic(std::borrow::Cow::Borrowed(name))))),
)
}
pub fn cache_key<C: CacheHash + ?Sized>(ctx: &C) -> u64 {
let mut hasher = graphene_hash::FxHasher64::new();
ctx.cache_hash(&mut hasher);
@@ -106,6 +128,7 @@ pub fn cache_key<C: CacheHash + ?Sized>(ctx: &C) -> u64 {
pub enum ConstructionError {
Arity { expected: usize, got: usize },
Type { expected: Box<Type>, found: Box<Type> },
MissingLayout,
}
pub struct SharedEdge<N: ?Sized> {
@@ -198,6 +221,10 @@ impl EdgeHandle {
Self::new_erased(node, lend_edge_type::<T>())
}
pub fn new_record<T: 'static>(node: std::sync::Arc<ErasedRecordNode>) -> Self {
Self::new_erased(node, record_edge_type::<T>())
}
pub fn new_erased<N>(node: std::sync::Arc<N>, ty: Type) -> Self
where
N: ?Sized + 'static + for<'c> Node<ContextImpl<'c>>,
@@ -242,6 +269,10 @@ impl EdgeHandle {
self.downcast_erased(lend_edge_type::<T>())
}
pub fn downcast_record<T: 'static>(self) -> Result<SharedEdge<ErasedRecordNode>, ConstructionError> {
self.downcast_erased(record_edge_type::<T>())
}
pub fn downcast_erased<N: ?Sized + 'static>(self, expected: Type) -> Result<SharedEdge<N>, ConstructionError> {
let found = self.ty;
self.node.downcast::<SharedEdge<N>>().map(|edge| *edge).map_err(|_| ConstructionError::Type {

View File

@@ -236,6 +236,8 @@ pub enum Type {
/// Represents a future which promises to return the inner type.
Future(Box<Type>),
Ref(Box<Type>),
/// A packed record wire over the element type; the layout stays node-resident metadata.
Record(Box<Type>),
}
impl Default for Type {
@@ -310,6 +312,7 @@ impl Type {
Self::Fn(_, _) => None,
Self::Future(_) => None,
Self::Ref(_) => None,
Self::Record(_) => None,
}
}
@@ -320,6 +323,7 @@ impl Type {
Self::Fn(_, _) => None,
Self::Future(_) => None,
Self::Ref(_) => None,
Self::Record(_) => None,
}
}
@@ -330,6 +334,7 @@ impl Type {
Self::Fn(_, output) => output.nested_type(),
Self::Future(output) => output.nested_type(),
Self::Ref(inner) => inner.nested_type(),
Self::Record(inner) => inner.nested_type(),
}
}
@@ -343,6 +348,7 @@ impl Type {
Self::Fn(_, output) => output.replace_nested(f),
Self::Future(output) => output.replace_nested(f),
Self::Ref(inner) => inner.replace_nested(f),
Self::Record(inner) => inner.replace_nested(f),
}
}
@@ -353,6 +359,7 @@ impl Type {
Type::Fn(call_arg, return_value) => format!("{} called with {}", return_value.identifier_name(), call_arg.identifier_name()),
Type::Future(ty) => ty.identifier_name(),
Type::Ref(ty) => ty.identifier_name(),
Type::Record(ty) => ty.identifier_name(),
}
}
}
@@ -448,6 +455,7 @@ impl std::fmt::Display for Type {
Type::Fn(_, return_value) => write!(f, "{return_value}"),
Type::Future(ty) => write!(f, "{ty}"),
Type::Ref(ty) => write!(f, "{ty}"),
Type::Record(ty) => write!(f, "{ty}"),
}
}
}

View File

@@ -1731,7 +1731,7 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic
return quote!();
}
if has_record_io(parsed) {
return quote!();
return record_entries_tokens(parsed, struct_name, regular_fields);
}
let Some(rows) = implementation_rows(parsed, regular_fields) else {
return quote!();
@@ -1815,6 +1815,104 @@ fn entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, data_field_generic
}
}
fn record_entries_tokens(parsed: &ParsedNodeFn, struct_name: &Ident, regular_fields: &[&ParsedField]) -> TokenStream2 {
let Some(shape) = record_shape(parsed) else {
return quote!();
};
let carrier_in_fields = !shape.skips_carrier();
let values_concrete = regular_fields.iter().skip(carrier_in_fields as usize).all(|field| match &field.ty {
ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) => !contains_open_generic(parsed, ty) && (lend.is_some() || !type_disqualifies(ty)),
_ => false,
});
if !values_concrete {
return quote!();
}
let fn_name = &parsed.fn_name;
let entries_name = format_ident!("{}_entries", fn_name);
let arity = regular_fields.len();
let names: Vec<&Ident> = regular_fields.iter().map(|field| &field.pat_ident.ident).collect();
let input_types = regular_fields.iter().enumerate().map(|(index, field)| {
let ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) = &field.ty else {
unreachable!("record nodes take no lazy inputs")
};
if carrier_in_fields && index == 0 {
return match &shape.carrier {
RecordCarrier::Token(token) => {
let name = token.to_string();
quote!(gcore::registry::generic_record_edge_type(#name))
}
RecordCarrier::Read(carrier_ty) => quote!(gcore::registry::record_edge_type::<#carrier_ty>()),
RecordCarrier::None => unreachable!(),
};
}
match lend.is_some() {
true => quote!(gcore::registry::lend_edge_type::<#ty>()),
false => quote!(gcore::registry::edge_type::<#ty>()),
}
});
let downcasts = regular_fields.iter().enumerate().map(|(index, field)| {
let name = &field.pat_ident.ident;
let ParsedFieldType::Regular(RegularParsedField { ty, lend, .. }) = &field.ty else {
unreachable!("record nodes take no lazy inputs")
};
if carrier_in_fields && index == 0 {
return quote! {
let __carrier_handle = inputs.next().unwrap();
let __carrier_ty = __carrier_handle.ty().clone();
let Some(__carrier_layout) = __carrier_handle.layout().cloned() else {
return Err(gcore::registry::ConstructionError::MissingLayout);
};
let #name = __carrier_handle.downcast_erased::<gcore::registry::ErasedRecordNode>(__carrier_ty.clone())?;
};
}
match lend.is_some() {
true => quote!(let #name = inputs.next().unwrap().downcast_lend::<#ty>()?;),
false => quote!(let #name = inputs.next().unwrap().downcast::<#ty>()?;),
}
});
let wire_layout_arg = carrier_in_fields.then(|| quote!(&__carrier_layout,)).into_iter();
let (io_output, construct_output) = match (&shape.carrier, &shape.element_write) {
(RecordCarrier::Token(token), _) => {
let name = token.to_string();
(
quote!(gcore::Type::Record(Box::new(gcore::Type::Generic(::std::borrow::Cow::Borrowed(#name))))),
quote!(Ok(gcore::registry::EdgeHandle::new_erased(
::std::sync::Arc::new(__node) as ::std::sync::Arc<gcore::registry::ErasedRecordNode>,
__carrier_ty,
))),
)
}
(_, Some(element)) => (
quote!(gcore::registry::record_type::<#element>()),
quote!(Ok(gcore::registry::EdgeHandle::new_record::<#element>(::std::sync::Arc::new(__node)))),
),
(_, None) => unreachable!("non-token record nodes write an element"),
};
quote! {
pub fn #entries_name() -> ::std::vec::Vec<gcore::registry::RegistryEntry> {
vec![gcore::registry::RegistryEntry {
io: gcore::registry::NodeIOTypes::new(
gcore::concrete!(gcore::context::ContextImpl<'static>),
#io_output,
vec![#(#input_types),*],
),
constructor: |inputs| {
if inputs.len() != #arity {
return Err(gcore::registry::ConstructionError::Arity { expected: #arity, got: inputs.len() });
}
let mut inputs = inputs.into_iter();
#(#downcasts)*
let __node = #struct_name::wire(#(#names,)* #(#wire_layout_arg)*);
#construct_output
},
}]
}
}
}
fn implementation_rows(parsed: &ParsedNodeFn, regular_fields: &[&ParsedField]) -> Option<Vec<Vec<Type>>> {
let ctx_ident = context_param(parsed).map(|ctx| ctx.ident.clone());
let open_generics: Vec<&Ident> = parsed