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 41fa0ba955
commit fffcaa6555
22 changed files with 349 additions and 64 deletions

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;
@@ -241,7 +241,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>
))
},
},
)