Resolve context features from the registry at compile instead of persisting them

This commit is contained in:
Dennis Kobert
2026-08-25 10:47:45 +00:00
parent ff99f3605c
commit bb3643541d
10 changed files with 40 additions and 53 deletions

View File

@@ -1,6 +1,5 @@
pub mod node {
pub const CALL_ARGUMENT: &str = "call_argument";
pub const CONTEXT_FEATURES: &str = "context_features";
pub const VISIBLE: &str = "visible";
pub const SKIP_DEDUPLICATION: &str = "skip_deduplication";
pub const REFLECTION_METADATA: &str = "reflection_metadata";

View File

@@ -1,7 +1,6 @@
use std::collections::HashMap;
use core_types::Context;
use core_types::context::ContextDependencies;
use core_types::uuid::NodeId as RuntimeNodeId;
use graph_craft::concrete;
use graph_craft::document::value::TaggedValue;
@@ -390,9 +389,6 @@ fn convert_node<M: NodeMetadataSource + ?Sized>(doc_node: &DocumentNode, locatio
attributes
.set_if_not_default(node::CALL_ARGUMENT, &doc_node.call_argument, &concrete!(Context), timestamp)
.map_err(map_serialization_error(node::CALL_ARGUMENT))?;
attributes
.set_if_not_default(node::CONTEXT_FEATURES, &doc_node.context_features, &ContextDependencies::default(), timestamp)
.map_err(map_serialization_error(node::CONTEXT_FEATURES))?;
attributes
.set_if_not_default(node::VISIBLE, &doc_node.visible, &true, timestamp)
.map_err(map_serialization_error(node::VISIBLE))?;

View File

@@ -248,11 +248,6 @@ fn test_nested_network_flattening() {
#[test]
fn test_metadata_preservation() {
// Create a network with nodes that have non-default metadata
let context_features = ContextDependencies::new(
core_types::context::ContextFeatures::FOOTPRINT | core_types::context::ContextFeatures::REAL_TIME,
core_types::context::ContextFeatures::empty(),
);
let network = NodeNetwork {
exports: vec![NodeInput::node(NodeId(1), 0)],
nodes: [
@@ -262,7 +257,6 @@ fn test_metadata_preservation() {
inputs: vec![NodeInput::import(concrete!(f64), 0), NodeInput::import(Type::Generic(Cow::Borrowed("T")), 1)],
implementation: DocumentNodeImplementation::ProtoNode(ProtoNodeIdentifier::new("test::NodeWithMetadata")),
call_argument: concrete!(String),
context_features,
visible: false, // Non-default value
skip_deduplication: true, // Non-default value
..Default::default()
@@ -296,8 +290,12 @@ fn test_metadata_preservation() {
let conv_node_1 = converted.nodes.get(&NodeId(1)).unwrap();
assert_eq!(orig_node_1.call_argument, conv_node_1.call_argument, "call_argument for node 1 should be preserved");
// Verify context_features is preserved
assert_eq!(orig_node_0.context_features, conv_node_0.context_features, "context_features should be preserved");
// Verify context_features is not stored
assert_eq!(
conv_node_0.context_features,
ContextDependencies::default(),
"context_features should resolve at compile, not round-trip"
);
// Verify visible is preserved
assert_eq!(orig_node_0.visible, conv_node_0.visible, "visible should be preserved");

View File

@@ -310,8 +310,8 @@ fn convert_node(
implementation: convert_implementation(context, &node.implementation, metadata_path, runtime_node_id, node_collector, network_collector)?,
visible: node.attributes.get_or(node::VISIBLE, true),
skip_deduplication: node.attributes.get_or(node::SKIP_DEDUPLICATION, false),
context_features: node.attributes.get_or_default(node::CONTEXT_FEATURES),
// Regenerated during compilation; not stored.
context_features: Default::default(),
original_location: Default::default(),
})
}

View File

@@ -42,7 +42,6 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
fields,
description,
properties,
context_features,
..
} = metadata;
@@ -78,7 +77,7 @@ pub(super) fn post_process_nodes(custom: Vec<DocumentNodeDefinition>) -> HashMap
implementation: DocumentNodeImplementation::ProtoNode(id.clone()),
visible: true,
skip_deduplication: false,
context_features: ContextDependencies::from(context_features.as_slice()),
context_features: ContextDependencies::default(),
..Default::default()
},
persistent_node_metadata: DocumentNodePersistentMetadata {

View File

@@ -24,7 +24,6 @@ use graph_craft::Type;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork};
use graphene_std::ContextDependencies;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
@@ -4090,18 +4089,6 @@ impl NodeNetworkInterface {
node.call_argument = call_argument;
}
pub fn set_context_features(&mut self, node_id: &NodeId, network_path: &[NodeId], context_features: ContextDependencies) {
let Some(network) = self.network_mut(network_path) else {
log::error!("Could not get nested network in set_context_features");
return;
};
let Some(node) = network.nodes.get_mut(node_id) else {
log::error!("Could not get node in set_context_features");
return;
};
node.context_features = context_features;
}
/// Lightweight version of `set_input` for bulk import operations.
/// Directly sets the input without `is_acyclic` checks, `load_structure`, position conversions,
/// or per-node cache invalidation. Call `load_structure`, `unload_all_nodes_click_targets`, and

View File

@@ -2441,15 +2441,6 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
}
// Add context features to nodes that don't have them (fine-grained context caching migration)
if node.context_features == graphene_std::ContextDependencies::default()
&& let Some(reference) = document.network_interface.reference(node_id, network_path).clone()
&& let Some(node_definition) = resolve_document_node_type(&reference)
{
let context_features = node_definition.node_template.document_node.context_features.clone();
document.network_interface.set_context_features(node_id, network_path, context_features);
}
// Add the "Scale Type" parameter to the "Decompose Scale" node
if reference == DefinitionIdentifier::ProtoNode(graphene_std::transform_nodes::decompose_scale::IDENTIFIER) && inputs_count == 1 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();

View File

@@ -57,7 +57,8 @@ pub struct DocumentNode {
#[serde(default)]
pub skip_deduplication: bool,
/// List of Extract and Inject annotations for the Context.
#[serde(default)]
/// Resolved from the registry at compile time; only code-built wrapper nodes carry a declaration here.
#[serde(skip)]
pub context_features: ContextDependencies,
/// The path to this node and its inputs and outputs as of when [`NodeNetwork::generate_node_paths`] was called.
#[serde(skip)]

View File

@@ -500,18 +500,18 @@ impl ProtoNetwork {
nullification_node_id
}
/// The node's declared index levels and per-input pushed levels, both read
/// The node's declared dependencies and per-input pushed levels, both read
/// from the registry since they follow the node's signature. A node with no
/// registry entry pushes nothing and names no level.
fn registry_index_levels(&self, node_index: usize) -> (core_types::context::IndexLevels, Vec<u8>) {
/// registry entry declares nothing and pushes nothing.
fn registry_dependencies(&self, node_index: usize) -> (ContextDependencies, Vec<u8>) {
let identifier = &self.nodes[node_index].1.identifier;
let metadata = core_types::registry::NODE_METADATA.lock().unwrap();
match metadata.get(identifier) {
Some(entry) => (
ContextDependencies::from(entry.context_features.as_slice()).index_levels,
ContextDependencies::from(entry.context_features.as_slice()),
entry.fields.iter().map(|field| field.pushed_levels).collect(),
),
None => (core_types::context::IndexLevels::empty(), Vec::new()),
None => (ContextDependencies::default(), Vec::new()),
}
}
@@ -520,14 +520,21 @@ impl ProtoNetwork {
let mut combined_deps = ContextModification::default();
let node_index = id.0 as usize;
let (declared_levels, pushed_levels) = self.registry_index_levels(node_index);
let (registry_deps, pushed_levels) = self.registry_dependencies(node_index);
let (extract, inject, own_deps) = {
let dependencies = &self.nodes[node_index].1.context_features;
let carried = &self.nodes[node_index].1.context_features;
// A code-built wrapper node declares dependencies its signature cannot
// express; everything else resolves from the registry. Sources are
// input wiring, not declarations, so they merge either way.
let mut dependencies = match carried.extract.is_empty() && carried.inject.is_empty() {
false => carried.clone(),
true => registry_deps,
};
dependencies.add_sources(carried.sources());
let index_levels = match dependencies.extract.contains(core_types::context::ContextFeatures::INDEX) {
// A wrapper node declaring its dependencies by hand keeps `INDEX`
// without the signature naming a level, and addresses the innermost.
true if declared_levels.is_empty() => core_types::context::IndexLevels::innermost(),
true => declared_levels,
// A hand-declared `INDEX` names no level and addresses the innermost.
true if dependencies.index_levels.is_empty() => core_types::context::IndexLevels::innermost(),
true => dependencies.index_levels,
false => core_types::context::IndexLevels::empty(),
};
let own_deps = ContextModification::from_sources(dependencies.extract, dependencies.sources()).with_index_levels(index_levels);
@@ -536,7 +543,15 @@ impl ProtoNetwork {
let mut inputs = match &self.nodes[node_index].1.construction_args {
// We pretend like we have already placed context modification nodes after ourselves because value nodes don't need to be cached
ConstructionArgs::Value(_) => return (own_deps, Some(id)),
ConstructionArgs::Value(value) => {
let mut deps = own_deps;
// A leveled value serves its lanes by the innermost index.
if value.value_layout().is_some_and(|layout| layout.depth > 0) {
deps |= core_types::context::ContextFeatures::INDEX;
deps.index_levels |= core_types::context::IndexLevels::innermost();
}
return (deps, Some(id));
}
ConstructionArgs::Nodes(items) => items.clone(),
ConstructionArgs::Inline(_) => return (own_deps, Some(id)),
};
@@ -571,6 +586,9 @@ impl ProtoNetwork {
self.nodes[node_index].1.construction_args = ConstructionArgs::Nodes(inputs);
// Which dependencies do we supply (and don't need ourselves)?
// TODO: a shrinking index-level mask does not count as supplying a
// dependency, so a fold that consumes its subtree's only level inserts
// no boundary here.
let net_injections = inject.difference(extract);
// Which dependencies still need to be met after this node?

View File

@@ -205,7 +205,6 @@ impl Preprocessor {
implementation: DocumentNodeImplementation::ProtoNode(id.clone()),
visible: true,
skip_deduplication: false,
context_features: ContextDependencies::from(metadata.context_features.as_slice()),
..Default::default()
};
@@ -256,7 +255,6 @@ impl Preprocessor {
call_argument: node_io.call_argument.clone(),
implementation: DocumentNodeImplementation::ProtoNode(id.clone()),
visible: true,
context_features: ContextDependencies::from(metadata.context_features.as_slice()),
..Default::default()
};
inject_scopes.insert(id.clone(), (template, node_io.return_value.clone()));