Resolve a named read's offset when the graph compiles

The fold already holds the name and the read input's finished layout, so
the offset falls out there rather than at construction: `RecordLayout`
carries the resolved numbers and `set_layout` copies them into the read
slots. Constructors are untouched, and census-marker reads keep their
current installation.

A read meets the value type the name was written at, so a disagreement
between a read here and a write upstream is the same graph error as two
writes disagreeing; the one-name-one-type check now spans reads and
writes together. An absent attribute stays absent and the read serves
the forced default rather than reporting it.

`read_attribute` is the catalog's get half, typed and never `Option` at
the kernel boundary, with the name declared exactly as the write side
declares it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Kobert
2026-09-09 10:50:08 +00:00
parent fc94487d0b
commit 5eb80721dd
23 changed files with 349 additions and 69 deletions

View File

@@ -20,7 +20,9 @@ impl Compiler {
proto_networks.map(move |mut proto_network| {
proto_network.insert_context_nullification_nodes()?;
let _ = proto_network.resolve_types(registry);
proto_network.compute_layouts().map_err(|errors| errors.iter().map(|error| format!("{:?}", error.error)).collect::<Vec<_>>().join("\n"))?;
proto_network
.compute_layouts()
.map_err(|errors| errors.iter().map(|error| format!("{:?}", error.error)).collect::<Vec<_>>().join("\n"))?;
proto_network.generate_stable_node_ids();
Ok(proto_network)
})

View File

@@ -380,6 +380,7 @@ impl ProtoNetwork {
pub fn compute_layouts(&mut self) -> Result<(), GraphErrors> {
self.fold_attribute_names()?;
let mut errors = GraphErrors::new();
for index in 0..self.nodes.len() {
let lane_invariant = self.nodes[index].1.lane_invariant_inputs;
let layout = {
@@ -390,6 +391,7 @@ impl ProtoNetwork {
plan: Vec::new(),
lane_invariant,
named_writes: Vec::new(),
named_reads: Vec::new(),
layout,
}),
ConstructionArgs::Nodes(inputs) => node.resolved.layout_meta.as_ref().and_then(|meta| {
@@ -397,6 +399,10 @@ impl ProtoNetwork {
.iter()
.map(|input| self.nodes[input.0 as usize].1.resolved.layout.as_ref().map(|resolved| &resolved.layout))
.collect();
// A read meets the name's value type where the name was
// written, so a disagreement upstream is caught here
// rather than read as the wrong type at run time.
errors.extend(read_type_conflicts(node, meta, &input_layouts));
meta.sources.iter().all(|&source| input_layouts[source as usize].is_some()).then(|| core_types::record::RecordLayout {
lane_invariant,
..meta.resolve(&input_layouts)
@@ -414,7 +420,7 @@ impl ProtoNetwork {
self.nodes[index].1.resolved.layout = layout;
}
self.stack_need = self.fold_stack_peak();
Ok(())
errors.is_empty().then_some(()).ok_or(errors)
}
/// Resolves every name-from-input write against the constant its name
@@ -426,55 +432,64 @@ impl ProtoNetwork {
let mut errors = GraphErrors::new();
for index in 0..self.nodes.len() {
let Some(meta) = &self.nodes[index].1.resolved.layout_meta else { continue };
if meta.named_writes.is_empty() {
if meta.named_writes.is_empty() && meta.named_reads.is_empty() {
continue;
}
let ConstructionArgs::Nodes(inputs) = &self.nodes[index].1.construction_args else {
continue;
};
let names: Vec<Result<&'static str, GraphErrorType>> = meta
// Writes first, then reads: both draw their names the same way, and
// the one-name-one-type check runs over them together.
let sources: Vec<(u8, std::any::TypeId)> = meta
.named_writes
.iter()
.map(|named| match inputs.get(named.name_input as usize).map(|input| &self.nodes[input.0 as usize].1.construction_args) {
Some(ConstructionArgs::Value(value)) => match &**value {
value::TaggedValue::String(name) => Ok(core_types::attribute::intern_name(name)),
other => Err(GraphErrorType::AttributeName(format!(
"an attribute name must be text, but input {} is {}",
named.name_input + 1,
other.ty()
.map(|named| (named.name_input, named.template.type_id))
.chain(meta.named_reads.iter().map(|named| (named.name_input, named.template.type_id)))
.collect();
let names: Vec<Result<&'static str, GraphErrorType>> = sources
.iter()
.map(
|&(name_input, _)| match inputs.get(name_input as usize).map(|input| &self.nodes[input.0 as usize].1.construction_args) {
Some(ConstructionArgs::Value(value)) => match &**value {
value::TaggedValue::String(name) => Ok(core_types::attribute::intern_name(name)),
other => Err(GraphErrorType::AttributeName(format!("an attribute name must be text, but input {} is {}", name_input + 1, other.ty()))),
},
_ => Err(GraphErrorType::AttributeName(format!(
"input {} must be a constant, since attribute names resolve when the graph compiles rather than when it runs",
name_input + 1
))),
},
_ => Err(GraphErrorType::AttributeName(format!(
"input {} must be a constant, since attribute names resolve when the graph compiles rather than when it runs",
named.name_input + 1
))),
})
)
.collect();
let node = &self.nodes[index].1;
let mut folded: Vec<(&'static str, std::any::TypeId)> = Vec::new();
let mut failed = false;
for (position, name) in names.iter().enumerate() {
for (name, &(_, type_id)) in names.iter().zip(&sources) {
match name {
Err(error) => {
errors.push(GraphError::new(node, error.clone()));
failed = true;
}
Ok(name) => {
let template = node.resolved.layout_meta.as_ref().expect("checked above").named_writes[position].template;
if let Some(conflict) = one_name_one_type(name, template.type_id, &folded) {
if let Some(conflict) = one_name_one_type(name, type_id, &folded) {
errors.push(GraphError::new(node, conflict));
failed = true;
}
folded.push((name, template.type_id));
folded.push((name, type_id));
}
}
}
if failed {
continue;
}
let writes = self.nodes[index].1.resolved.layout_meta.as_ref().expect("checked above").named_writes.len();
let meta = self.nodes[index].1.resolved.layout_meta.as_mut().expect("checked above");
for (position, name) in names.into_iter().enumerate() {
meta.fold_name(position, name.expect("every name resolved"));
let name = name.expect("every name resolved");
match position < writes {
true => meta.fold_name(position, name),
false => meta.fold_read_name(name),
}
}
}
errors.is_empty().then_some(()).ok_or(errors)
@@ -803,6 +818,26 @@ impl ProtoNetwork {
Ok(())
}
}
/// Reports each name-from-input read whose value type disagrees with the field
/// the name already names on the input it reads. An absent field is no
/// conflict: the read serves the name's forced default.
fn read_type_conflicts(node: &ProtoNode, meta: &core_types::record::LayoutMeta, inputs: &[Option<&core_types::record::Layout>]) -> GraphErrors {
meta.named_reads
.iter()
.zip(&meta.folded_read_names)
.filter_map(|(read, name)| {
let layout = inputs.get(read.input as usize).copied().flatten()?;
let field = layout.fields.iter().find(|field| field.name == *name && field.level == read.template.level)?;
(field.type_id != read.template.type_id).then(|| {
GraphError::new(
node,
GraphErrorType::AttributeName(format!("attribute `{name}` is read at one value type but written at another, and one name carries one value type")),
)
})
})
.collect()
}
/// Reports a folded name that disagrees with a type the same name already
/// carries, over the census and the names this node folded together. One name
/// means one value type everywhere, so the layouts a graph folds can never

View File

@@ -193,9 +193,7 @@ impl DynamicExecutor {
pub fn introspect(&self, node_path: &[NodeId]) -> Result<Arc<dyn std::any::Any + Send + Sync + 'static>, IntrospectError> {
let result = self.tree.introspect(node_path)?;
if result.downcast_ref::<core_types::context::CtxSnapshot>().is_some() {
return self
.introspect_with(node_path, graphic_types::boundary::batch_to_legacy)
.map(Arc::from);
return self.introspect_with(node_path, graphic_types::boundary::batch_to_legacy).map(Arc::from);
}
Ok(result)
}
@@ -791,6 +789,71 @@ mod test {
assert_eq!(unsafe { layout.rec(&value).read::<&[NodeId]>(offset) }, path.as_slice());
}
/// Reads `read_name` off a record that a write of `write_name` produced.
fn read_attribute_network(write_name: &str, read_name: &str, value: TaggedValue) -> ProtoNetwork {
ProtoNetwork {
stack_need: 0,
inputs: vec![],
output: NodeId(5),
nodes: vec![
(NodeId(0), ProtoNode::value(ConstructionArgs::Value(TaggedValue::F64(7.).into()), vec![])),
(NodeId(1), string_value(write_name)),
(NodeId(2), ProtoNode::value(ConstructionArgs::Value(value.into()), vec![])),
(NodeId(3), proto_node("graphic_nodes::graphic::WriteAttributeNode", vec![NodeId(0), NodeId(1), NodeId(2)])),
(NodeId(4), string_value(read_name)),
(NodeId(5), proto_node("graphic_nodes::graphic::ReadAttributeNode", vec![NodeId(3), NodeId(4)])),
],
}
}
fn read_back(network: ProtoNetwork) -> f64 {
let executor = build_executor(network);
let arena = Arena::new(1 << 12).unwrap();
let generations = [];
let scope = EvalScope::new(None, None, None, &generations, &arena);
let ctx = ContextImpl::root(&scope);
let handle = executor.tree().get(NodeId(5)).unwrap();
let layout = handle.layout().clone();
let edge = handle.duplicate().downcast_record::<f64>().unwrap();
let frames = core_types::record::test_frames(executor.tree().stack_need());
let GPoll::Final(value) = core_types::record::serve_input(&edge, &ctx, &frames) else {
panic!("expected a final record");
};
unsafe { core_types::record::read_element::<f64>(layout.rec(&value)) }
}
#[test]
fn a_named_read_serves_the_written_value() {
assert_eq!(read_back(read_attribute_network("novel:count", "novel:count", TaggedValue::F64(2.5))), 2.5);
}
#[test]
fn an_absent_named_read_serves_the_forced_default() {
// Nothing upstream writes `novel:absent`, so the read collapses to the
// value type's default rather than reporting absence.
assert_eq!(read_back(read_attribute_network("novel:count", "novel:absent", TaggedValue::F64(2.5))), 0.);
}
#[test]
fn a_census_named_read_round_trips_its_written_value() {
// A declared name folds onto its census field, so the read resolves
// against the same offset the write installed.
assert_eq!(read_back(read_attribute_network("opacity", "opacity", TaggedValue::F64(0.5))), 0.5);
}
#[test]
fn a_named_read_disagreeing_with_its_write_is_refused() {
// The name is written at a path upstream and read at `f64` here, which
// is the one-name-one-type rule spanning a write and a read.
let mut network = read_attribute_network("novel:count", "novel:count", TaggedValue::NodeIdPath(vec![NodeId(1)]));
network.resolve_types(&node_registry::NODE_REGISTRY).unwrap();
let errors = network.compute_layouts().expect_err("a name at two value types must be refused");
assert!(
errors.iter().any(|error| format!("{:?}", error.error).contains("one name carries one value type")),
"the refusal names the one-name-one-type rule, got {errors:?}"
);
}
#[test]
fn a_runtime_attribute_name_is_refused_when_the_graph_compiles() {
// The outer node's name comes off another node rather than sitting on

View File

@@ -7,7 +7,7 @@ use graphene_std::raster::GPU;
#[cfg(feature = "gpu")]
use graphene_std::SourceId;
use graphene_std::raster::{CPU, Raster};
use graphene_std::registry::{ConstructionError, SourceHandle, NodeIOTypes, RegistryEntry};
use graphene_std::registry::{ConstructionError, NodeIOTypes, RegistryEntry, SourceHandle};
#[cfg(feature = "gpu")]
use graphene_std::runtime::RuntimeHandle;
@@ -227,7 +227,9 @@ mod node_registry_macros {
let handle = inputs.next().unwrap();
let layout = handle.layout().clone();
let node = graphene_std::ops::IntoNode::<$to, _, $from>::new(handle.downcast_record::<$from>()?, &layout);
Ok(SourceHandle::new_record::<$to>(std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>))
Ok(SourceHandle::new_record::<$to>(
std::sync::Arc::new(node) as std::sync::Arc<core_types::registry::ErasedRecordNode>
))
},
},
)

View File

@@ -251,7 +251,12 @@ impl Arena {
// SAFETY: the caller's contract.
unsafe { p.cast::<T>().drop_in_place() }
}
self.drops.lock().unwrap().push(DropEntry { offset, type_of, drop_fn: glue::<T>, retained });
self.drops.lock().unwrap().push(DropEntry {
offset,
type_of,
drop_fn: glue::<T>,
retained,
});
self.retained_heap.fetch_add(retained, Ordering::Relaxed);
}
// SAFETY: initialized above; insert-only, so no `&mut` to it can exist.
@@ -772,7 +777,10 @@ mod tests {
let (parked, _) = transient.alloc_sized_keyed(Owner(String::from("a keyed park")), 0).unwrap();
let src = std::ptr::from_ref(parked).cast::<u8>();
assert!(unsafe { transient.move_park::<Twin>(src, &persistent, 0) }.is_none(), "a park of another type of the same size is refused");
assert!(
unsafe { transient.move_park::<Twin>(src, &persistent, 0) }.is_none(),
"a park of another type of the same size is refused"
);
unsafe { transient.move_park::<Owner>(src, &persistent, 0) }.unwrap();
assert!(unsafe { transient.move_park::<Twin>(src, &persistent, 0) }.is_none(), "the forwarding refuses the same mistype");

View File

@@ -342,7 +342,6 @@ impl AttributeDyn {
pub fn is_empty(&self) -> bool {
self.0.len() == 0
}
}
impl Clone for AttributeDyn {

View File

@@ -136,7 +136,6 @@ impl<'a> RecordBatchMut<'a> {
// SAFETY: the constructor's contract; the exclusive borrow is consumed.
unsafe { RecordBatch::new(self.scratch.as_ptr().cast(), self.len, self.layout) }
}
}
/// One lane's record: its pointer paired with the batch's layout.

View File

@@ -371,6 +371,12 @@ pub struct RecordLayout {
/// leaves it empty; `set_layout` resolves its offsets through these
/// instead of through a marker's `NAME`.
pub named_writes: Vec<&'static str>,
/// The offsets the fold resolved for this node's name-from-input reads, in
/// placeholder order. `None` is an absent attribute, which the read serves
/// as the name's forced default. Resolved in the compiler, where the folded
/// name and the read input's finished layout sit together, so `set_layout`
/// only copies the numbers into the read slots.
pub named_reads: Vec<Option<usize>>,
}
/// A write whose name comes from the graph rather than from a marker: the
@@ -399,6 +405,34 @@ impl NamedWrite {
}
}
/// A read whose name comes from the graph rather than from a marker. The
/// compiler resolves it to an offset in the read input's own layout, where
/// the folded name and that finished layout already sit together.
#[derive(Clone, Copy, Debug)]
pub struct NamedRead {
/// The proto input the attribute is read from.
pub input: u8,
/// The proto input holding the name's constant text.
pub name_input: u8,
/// The read's field form, every facet but the name minted from the
/// concrete value type, as for a write.
pub template: FieldWrite,
}
impl NamedRead {
/// The template for a name-generic marker's read at `level`.
pub fn of<X: 'static, V: crate::attribute::AttrValue>(input: u8, name_input: u8, level: u8) -> Self
where
V::Value<'static>: graphene_hash::CacheHash + PartialEq + 'static,
{
Self {
input,
name_input,
template: FieldWrite::of::<crate::attribute::Named<X, V>>(level),
}
}
}
/// Declarative record-io metadata for a node type, emitted by the macro into
/// its registry entry so the compiler can fold each input's layout without
/// running the node's constructor. [`fold`](LayoutMeta::fold) reproduces the
@@ -426,6 +460,11 @@ pub struct LayoutMeta {
/// The names the fold gave [`named_writes`](Self::named_writes), in
/// placeholder order. Empty until the fold runs.
pub folded_names: Vec<&'static str>,
/// The attributes the node reads under a name taken from the graph.
pub named_reads: Vec<NamedRead>,
/// The names the fold gave [`named_reads`](Self::named_reads), in
/// placeholder order. Empty until the fold runs.
pub folded_read_names: Vec<&'static str>,
/// The attributes removed from the base layout, as `(name, level)`.
pub removes: Vec<(&'static str, u8)>,
/// The depth change the node applies: `0` for elementwise and flip nodes,
@@ -464,6 +503,8 @@ impl LayoutMeta {
writes: Vec::new(),
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
removes: Vec::new(),
level_delta: 0,
folded: None,
@@ -508,12 +549,22 @@ impl LayoutMeta {
// A reducer collapses its carrier's levels, so it writes a fresh record rather than copying fields down.
_ => Vec::new(),
};
// A named read resolves against the input it reads, whose layout is
// finished by the time this node folds. An absent attribute stays
// `None`, which the read serves as the name's forced default.
let named_reads = self
.named_reads
.iter()
.zip(&self.folded_read_names)
.map(|(read, name)| inputs.get(read.input as usize).copied().flatten().and_then(|layout| layout.offset_of(name, read.template.level)))
.collect();
RecordLayout {
layout,
frame_bytes,
plan,
lane_invariant: 0,
named_writes: self.folded_names.clone(),
named_reads,
}
}
@@ -531,6 +582,13 @@ impl LayoutMeta {
self.writes.push(write);
self.folded_names.push(name);
}
/// Records the name of the name-from-input read at `index`. The offset
/// itself waits for [`resolve`](Self::resolve), which is where the read
/// input's finished layout arrives.
pub fn fold_read_name(&mut self, name: &'static str) {
self.folded_read_names.push(name);
}
}
/// Field-by-field carry from `from`'s layout into `to`'s, computed at

View File

@@ -25,8 +25,8 @@ pub use access::{Rec, RecordValue, apply_plan, borrow_element, erase_static, rea
pub use frames::{FrameArena, FrameScope, Frames};
pub use input::{DerivedLazyInput, DerivedRecordInput, ElementInput, ElementLazyInput, LevelStatus, RecordExtract, RecordInput, RecordLazyInput, fill_frames, materialize_batch, materialize_level};
pub use layout::{
ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, NamedWrite, RecordLayout, copy_plan,
element_dims, element_parked, element_write, element_write_hashed, empty_layout,
ElToken, ElementSpec, ElementWrite, ElementWritePick, ElementWritePickHashed, ElementWritePickPlain, FieldDesc, FieldOffset, FieldWrite, InputReads, Layout, LayoutMeta, NamedRead, NamedWrite,
RecordLayout, copy_plan, element_dims, element_parked, element_write, element_write_hashed, empty_layout,
};
pub use owned::{OwnedRecord, deepen_field_value, has_deep_element_glue, register_deep_element_clone, register_deep_field_value, replay_field_value};
pub use promote::{Promotion, assert_promoted, register_element_promote, register_field_promote, register_retained_heap};

View File

@@ -57,10 +57,7 @@ static DEEP_FIELD_VALUES: std::sync::LazyLock<std::sync::Mutex<std::collections:
/// Registers the deep copy-out and replay pair for field values of `T`.
/// Called at startup from the crate that owns the type.
pub fn register_deep_field_value<T: 'static>(
copy_out: fn(&dyn crate::list::AnyAttributeValue) -> Option<Box<dyn crate::list::AnyAttributeValue>>,
replay: crate::list::FieldReplayFn,
) {
pub fn register_deep_field_value<T: 'static>(copy_out: fn(&dyn crate::list::AnyAttributeValue) -> Option<Box<dyn crate::list::AnyAttributeValue>>, replay: crate::list::FieldReplayFn) {
DEEP_FIELD_VALUES.lock().unwrap().insert(std::any::TypeId::of::<T>(), DeepFieldGlue { copy_out, replay });
}

View File

@@ -279,6 +279,7 @@ mod tests {
layout: layout.clone(),
lane_invariant: u32::MAX,
named_writes: Vec::new(),
named_reads: Vec::new(),
});
RecordExtract::new(graph, &layout)
}

View File

@@ -500,12 +500,7 @@ mod run_tests {
}
/// The promoted paint of one lane, at the layout the promote published.
fn promoted_paint<'p>(
span: &core_types::record::MaterializedSpan,
layout: &core_types::record::Layout,
lane: usize,
persistent: &'p core_types::arena::Arena,
) -> &'p List<Graphic<'p>> {
fn promoted_paint<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p List<Graphic<'p>> {
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let batch = span.batch(persistent, layout).expect("the span resolves in its own region");
// SAFETY: the promote wrote a record of `layout` into every lane.
@@ -522,7 +517,9 @@ mod run_tests {
// list the evaluation parked.
let published = native_group_paint(&inner_vector, &persistent);
let interior = {
let Some(Graphic::Group(group)) = published.element(0) else { panic!("the paint carries a native group") };
let Some(Graphic::Group(group)) = published.element(0) else {
panic!("the paint carries a native group")
};
group.content.lanes().get(0).rec().ptr()
};
// SAFETY: the list serves only while `persistent` is live, and the
@@ -533,7 +530,9 @@ mod run_tests {
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
let served = promoted_paint(&span, &layout, 0, &persistent);
let Some(Graphic::Group(group)) = served.element(0) else { panic!("the promote keeps the group form") };
let Some(Graphic::Group(group)) = served.element(0) else {
panic!("the promote keeps the group form")
};
assert_eq!(group.content.lanes().get(0).rec().ptr(), interior, "a persistent interior is shared pointer for pointer");
assert!(
persistent.occupancy() - occupied <= layout.frame_bytes() + size_of::<List<Graphic>>() + align_of::<List<Graphic>>(),
@@ -555,8 +554,14 @@ mod run_tests {
let (layout, span, _frames) = promote_paint_field(Some(paint), 2, &transient, &persistent);
let served = promoted_paint(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.element(0) else { panic!("the promote keeps the vector") };
assert_eq!(vector.point_domain.positions().as_ptr(), heap, "the promote moved the header, so the served paint names the pre-promote heap");
let Some(Graphic::Vector(vector)) = served.element(0) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
assert!(std::ptr::eq(served, promoted_paint(&span, &layout, 1, &persistent)), "a paint two lanes share moves once");
transient.reset();
@@ -588,7 +593,11 @@ mod run_tests {
let served = promoted_paint(&span, &layout, 0, &persistent);
let held = served.attribute::<Option<List<Graphic>>>(Stroke::NAME, 0).expect("the stroke attribute rides the promoted list");
let held = held.as_ref().expect("the stroke is present");
assert_eq!(map_groups_to_legacy(held.element(0).unwrap()), expected, "the attribute-held group serves from persistent storage after the reset");
assert_eq!(
map_groups_to_legacy(held.element(0).unwrap()),
expected,
"the attribute-held group serves from persistent storage after the reset"
);
}
#[test]
@@ -610,8 +619,14 @@ mod run_tests {
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
let served = promoted_paint(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.element(0) else { panic!("the promote keeps the vector") };
assert_eq!(vector.point_domain.positions().as_ptr(), heap, "the promote moved the header, so the served paint names the pre-promote heap");
let Some(Graphic::Vector(vector)) = served.element(0) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
transient.reset();
let served = promoted_paint(&span, &layout, 0, &persistent);

View File

@@ -984,8 +984,7 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
// more: the name is spent resolving the layout when the graph compiles, so
// it reaches neither the kernel's parameters nor its call. The wire input
// stays, since the fold reads the constant off it.
let kernel_omits =
|field: &ParsedField| injected_name(&field.pat_ident.ident) || matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { name_source: Some(_), .. }));
let kernel_omits = |field: &ParsedField| injected_name(&field.pat_ident.ident) || matches!(&field.ty, ParsedFieldType::Regular(RegularParsedField { name_source: Some(_), .. }));
let where_predicates: Vec<TokenStream2> = parsed.where_clause.iter().flat_map(|clause| clause.predicates.iter()).map(|predicate| quote!(#predicate)).collect();
let NodeFields {
@@ -1731,7 +1730,9 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
// kernel gets a fresh one; reference-valued writes name their real
// lifetime explicitly and pass through untouched. An async source's value
// outlives the evaluation, so its writes are `'static` instead.
let attr_injected = record_io.then(|| inject_attr_lifetimes(&parsed.output_type, if async_source { "'static" } else { "'__attr" })).flatten();
let attr_injected = record_io
.then(|| inject_attr_lifetimes(&parsed.output_type, if async_source { "'static" } else { "'__attr" }))
.flatten();
let attr_lifetime = (attr_injected.is_some() && !async_source).then(|| quote!('__attr,));
let lane_injected = gather_carrier
.then(|| crate::codegen::classify::inject_lane_lifetime(attr_injected.as_ref().unwrap_or(&parsed.output_type)))
@@ -2545,9 +2546,24 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
}
})
.collect();
// A name-generic read's offset was resolved against the input it reads,
// which only the compiler sees, so installing it is a copy.
let mut folded_read = 0usize;
let read_installs: Vec<TokenStream2> = flat_reads
.iter()
.enumerate()
.filter(|(_, (_, read))| crate::parsing::named_marker(&read.marker).is_some())
.map(|(slot, _)| {
let slot = format_ident!("__read_{slot}");
let position = folded_read;
folded_read += 1;
quote!(self.#slot = __resolved.named_reads[#position];)
})
.collect();
let plan = (!skips_carrier || gather_carrier).then(|| quote!(self.__plan = __resolved.plan;));
Some(quote! {
#(#write_installs)*
#(#read_installs)*
self.__frame_bytes = __resolved.frame_bytes;
self.__lane_invariant = __resolved.lane_invariant;
#plan
@@ -2671,6 +2687,12 @@ pub(crate) fn generate_node_impl(crate_ident: &CrateIdent, parsed: &ParsedNodeFn
let read_inits = flat_reads.iter().enumerate().map(|(slot, (owner, read))| {
let marker = &read.marker;
let slot = format_ident!("__read_{slot}");
// A name-generic read has no marker name to look up here; the
// compiler resolved its offset against the input's own layout, so
// `set_layout` installs the number.
if crate::parsing::named_marker(marker).is_some() {
return quote!(let #slot = ::core::option::Option::None;);
}
let source = match !skips_carrier && *owner == 0 {
true => quote!(__carrier_layout),
false => format_ident!("__in_{owner}").to_token_stream(),

View File

@@ -659,10 +659,7 @@ mod tests {
.to_string();
assert!(generated.contains("data : :: core_types :: list :: List < T >"), "the kernel takes the owned legacy list: {generated}");
assert!(generated.contains("run_to_owned_list"), "the prologue snapshots the materialized level: {generated}");
assert!(
generated.contains("record :: materialize_batch"),
"the level still materializes in the prologue: {generated}"
);
assert!(generated.contains("record :: materialize_batch"), "the level still materializes in the prologue: {generated}");
for element in ["Vector", "Raster < CPU >", "Graphic"] {
let row = format!("record_source_type :: < {element} > ()");
assert!(generated.contains(&row), "the row carries the leveled element {element}: {generated}");

View File

@@ -282,6 +282,7 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t
});
let writes = field_writes(&node.output.shape.attrs, core_types);
let named_writes = named_field_writes(node, core_types, assignments);
let named_reads = named_field_reads(node, core_types, assignments);
let removes = node.output.removes.iter().map(|attr| {
let marker = &attr.marker;
let level = attr.level;
@@ -300,6 +301,8 @@ pub(crate) fn layout_meta_tokens(node: &Node, element_spec: TokenStream2, core_t
writes: ::std::vec![#(#writes),*],
named_writes: ::std::vec![#(#named_writes),*],
folded_names: ::std::vec![],
named_reads: ::std::vec![#(#named_reads),*],
folded_read_names: ::std::vec![],
removes: ::std::vec![#(#removes),*],
level_delta: #level_delta,
folded: #folded,
@@ -396,6 +399,28 @@ fn named_field_writes(node: &Node, core_types: &TokenStream2, assignments: &[(Id
.collect()
}
/// Emits one `NamedRead` per name-generic read, pairing the template minted
/// from the concrete value type with the input read and the input its
/// placeholder's name sits at.
fn named_field_reads(node: &Node, core_types: &TokenStream2, assignments: &[(Ident, Type)]) -> Vec<TokenStream2> {
node.inputs
.iter()
.enumerate()
.flat_map(|(input, source)| source.shape.attrs.iter().map(move |attr| (input, attr)))
.filter_map(|(input, attr)| {
let (placeholder, value) = crate::parsing::named_marker(&attr.marker)?;
let value = qualify_projection(node, &crate::codegen::classify::substitute_ident_types(&value, assignments), assignments);
if node.generics.iter().any(|generic| mentions_ident(&value, &generic.ident)) {
return None;
}
let name = name_input(node, &placeholder)? as u8;
let input = input as u8;
let level = attr.level;
Some(quote!(#core_types::record::NamedRead::of::<#placeholder, #value>(#input, #name, #level)))
})
.collect()
}
/// Rewrites `V::Assoc` into `<Row as Bound>::Assoc` once the row assigns `V`.
/// A value type reached through an associated type needs the generic's own
/// bound to name the projection, which only the signature carries.
@@ -444,9 +469,7 @@ fn mentions_ident(ty: &Type, ident: &Ident) -> bool {
/// The input position carrying `placeholder`'s name, which is the parameter
/// declared at that placeholder.
pub(crate) fn name_input(node: &Node, placeholder: &Type) -> Option<usize> {
node.inputs
.iter()
.position(|input| input.name_source.as_ref().is_some_and(|declared| declared == placeholder))
node.inputs.iter().position(|input| input.name_source.as_ref().is_some_and(|declared| declared == placeholder))
}
fn level_delta(node: &Node) -> i8 {
@@ -852,11 +875,7 @@ mod tests {
let assignments = vec![(syn::parse_quote!(V), syn::parse_quote!(f64))];
let emitted = named_field_writes(&node, &quote!(gcore), &assignments);
assert_eq!(emitted.len(), 1, "the row carries the named write, got {emitted:?}");
assert!(
emitted[0].to_string().contains("WireValue"),
"the projection is qualified by the generic's bound, got {}",
emitted[0]
);
assert!(emitted[0].to_string().contains("WireValue"), "the projection is qualified by the generic's bound, got {}", emitted[0]);
}
#[test]

View File

@@ -1682,7 +1682,7 @@ mod tests {
description: String::new(),
widget_override: ParsedWidgetOverride::None,
ty: ParsedFieldType::Regular(RegularParsedField {
name_source: None,
name_source: None,
lend: None,
list_levels: 0,
ty: parse_quote!(DVec2),

View File

@@ -127,7 +127,10 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
_ => None,
};
if async_source && token.is_some() {
emit_error!(carrier.pat_ident.span(), "an async source's element crosses the future boundary as a value; a passthrough generic element has none");
emit_error!(
carrier.pat_ident.span(),
"an async source's element crosses the future boundary as a value; a passthrough generic element has none"
);
}
let element = writes.as_ref().map(|writes| &writes.element).unwrap_or(&value);
match &token {
@@ -164,7 +167,10 @@ fn validate_record_io(parsed: &ParsedNodeFn) {
let mut seen_writes: Vec<String> = Vec::new();
for write in &writes.markers {
if write.owned && !async_source {
emit_error!(parsed.output_type.span(), "an owned attribute crossing belongs to an async source; a synchronous write parks its value in the kernel");
emit_error!(
parsed.output_type.span(),
"an owned attribute crossing belongs to an async source; a synchronous write parks its value in the kernel"
);
}
let written = write.marker.to_token_stream().to_string();
if seen_writes.contains(&written) {

View File

@@ -663,6 +663,7 @@ mod tests {
// pass records as lane-invariant.
let resolved = core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
lane_invariant: u32::MAX,
..meta.resolve(inputs)
};
@@ -673,6 +674,7 @@ mod tests {
fn install_flip<N: Node<ContextImpl<'static>>>(mut node: N, layout: &Layout) -> N {
let bundle = core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),
@@ -768,6 +770,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -806,6 +810,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -852,6 +858,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -904,6 +912,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1004,6 +1014,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0, 1],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1074,6 +1086,8 @@ mod tests {
let meta = || core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0, 1],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1150,6 +1164,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0, 1],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1196,6 +1212,8 @@ mod tests {
let meta = || core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1207,6 +1225,8 @@ mod tests {
let extend_meta = || core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0, 1],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1284,6 +1304,8 @@ mod tests {
let meta = || core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1339,6 +1361,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1556,6 +1580,7 @@ mod tests {
let mut node = MirrorNode::new(RecordSource::new(content, &layout, &layout), counting_value(true, &evals));
let resolved = core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
lane_invariant: 0,
..mirror_layout_meta().resolve(&[Some(&layout)])
};
@@ -1636,6 +1661,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1691,6 +1718,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1803,6 +1832,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,
@@ -1931,6 +1962,8 @@ mod tests {
let meta = core_types::record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![0],
reads: vec![],
element: core_types::record::ElementSpec::Carried,

View File

@@ -387,6 +387,22 @@ pub fn write_attribute<'e, T, V: WireValue>(
Ok((content, Attr(parked)))
}
/// Reads the attribute `name` names off each lane. An absent attribute reads as
/// the name's own default, so the value carries the declared type either way;
/// the name is constant text the compiler folds into an offset when the graph
/// compiles.
#[node_macro::node(category("Attributes: Read"))]
pub fn read_attribute<'e>(
_: impl Ctx,
/// The content whose lanes carry the attribute.
(content, value): (f64, Attr<'e, Named<Name0, f64>>),
/// The attribute name, folded into an offset when the graph compiles.
name: Named<Name0>,
) -> f64 {
let _ = content;
*value
}
/// Joins two levels of the same type, the base's lanes followed by the new's.
#[node_macro::node(category("General"), extent(extend_extent))]
pub fn extend<T>(

View File

@@ -286,6 +286,7 @@ mod tests {
// pass records as lane-invariant.
let resolved = record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
lane_invariant: u32::MAX,
..meta.resolve(inputs)
};
@@ -296,6 +297,7 @@ mod tests {
fn install_flip<N: Node<ContextImpl<'static>>>(mut node: N, layout: &Layout) -> N {
let bundle = record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),
@@ -424,6 +426,8 @@ mod tests {
record::LayoutMeta {
named_writes: Vec::new(),
folded_names: Vec::new(),
named_reads: Vec::new(),
folded_read_names: Vec::new(),
sources: vec![source],
reads: vec![],
element: record::ElementSpec::Carried,

View File

@@ -9,13 +9,13 @@ use core_types::gpoll::GPoll;
#[cfg(target_family = "wasm")]
use core_types::list::List;
#[cfg(target_family = "wasm")]
use core_types::ATTR_TRANSFORM;
#[cfg(target_family = "wasm")]
use core_types::math::bbox::Bbox;
use core_types::runtime::SourceFuture;
#[cfg(target_family = "wasm")]
use core_types::transform::Footprint;
#[cfg(target_family = "wasm")]
use core_types::ATTR_TRANSFORM;
use core_types::{Color, Ctx};
pub use graph_craft::application_io::resource::{Resource, ResourceHash};
pub use graph_craft::application_io::*;

View File

@@ -273,6 +273,7 @@ mod tests {
layout: layout.clone(),
lane_invariant: u32::MAX,
named_writes: Vec::new(),
named_reads: Vec::new(),
},
);
let GPoll::Final(result) = core_types::record::serve_input(&graph, &ctx, &frames) else {

View File

@@ -1054,6 +1054,7 @@ mod graphene_test {
fn installed<N: Node<ContextImpl<'static>>>(mut node: N, layout: &Layout) -> N {
node.set_layout(core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),
@@ -1116,6 +1117,7 @@ mod graphene_test {
let layout = out_layout::<bool>();
wired.set_layout(core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),
@@ -1163,6 +1165,7 @@ mod graphene_test {
let layout = out_layout::<f64>();
wired.set_layout(core_types::record::RecordLayout {
named_writes: Vec::new(),
named_reads: Vec::new(),
frame_bytes: layout.frame_bytes(),
plan: Vec::new(),
layout: layout.clone(),