Tutorial 19 - Extended Attribute Types#

Extended attribute types are so-named because they extend the types of data an attribute can accept from one type to several types. Extended attributes come in two flavours. The _any_ type is the most flexible. It allows a connection with any other attribute type:

"inputs": {
    "myAnyAttribute": {
        "description": "Accepts an incoming connection from any type of attribute",
        "type": "any",
    }
}

The union type, represented as an array of type names, allows a connection from a limited subset of attribute types. Here’s one that can connect to attributes of type _float[3]_ and _double[3]_:

"inputs": {
    "myUnionAttribute": {
        "description": "Accepts an incoming connection from attributes with a vector of a 3-tuple of numbers",
        "type": ["float[3]", "double[3]"],
    }
}

Note

“union” is not an actual type name, as the type names are specified by a list. It is just the nomenclature used for the set of all attributes that can be specified in this way. More details about union types can be found in omni.graph.docs.ogn_attribute_types.

As you will see in the code examples, the value extracted from the database for such attributes has to be checked for the actual resolved data type. Until an extended attribute is connected its data type will be unresolved and it will not have a value. For this reason _”default”_ values are not allowed on extended attributes.

OgnTutorialExtendedTypes.ogn#

The ogn file shows the implementation of a node named “omni.graph.tutorials.ExtendedTypes”, which has inputs and outputs with the extended attribute types.

 1{
 2    "ExtendedTypes": {
 3        "version": 1,
 4        "categories": "tutorials",
 5        "scheduling": ["threadsafe"],
 6        "description": ["This is a tutorial node. It exercises functionality for the manipulation of the extended",
 7                        "attribute types."
 8        ],
 9        "uiName": "Tutorial Node: Extended Attribute Types",
10        "inputs": {
11            "floatOrToken": {
12                "$comment": [
13                    "Support for a union of types is noted by putting a list into the attribute type.",
14                    "Each element of the list must be a legal attribute type from the supported type list."
15                ],
16                "type": ["float", "token"],
17                "description": "Attribute that can either be a float value or a token value",
18                "uiName": "Float Or Token",
19                "unvalidated": true
20            },
21            "toNegate": {
22                "$comment": "An example showing that array and tuple types are also legal members of a union.",
23                "type": ["bool[]", "float[]"],
24                "description": "Attribute that can either be an array of booleans or an array of floats",
25                "uiName": "To Negate",
26                "unvalidated": true
27            },
28            "tuple": {
29                "$comment": "Tuple types are also allowed, implemented as 'any' to show similarities",
30                "type": "any",
31                "description": "Variable size/type tuple values",
32                "uiName": "Tuple Values",
33                "unvalidated": true
34            },
35            "flexible": {
36                "$comment": "You don't even have to have the same shape of data in a union",
37                "type": ["float[3][]", "token"],
38                "description": "Flexible data type input",
39                "uiName": "Flexible Values",
40                "unvalidated": true
41            }
42        },
43        "outputs": {
44            "doubledResult": {
45                "type": "any",
46                "description": ["If the input 'simpleInput' is a float this is 2x the value.",
47                                "If it is a token this contains the input token repeated twice."
48                ],
49                "uiName": "Doubled Input Value",
50                "unvalidated": true
51            },
52            "negatedResult": {
53                "type": ["bool[]", "float[]"],
54                "description": "Result of negating the data from the 'toNegate' input",
55                "uiName": "Negated Array Values",
56                "unvalidated": true
57            },
58            "tuple": {
59                "type": "any",
60                "description": "Negated values of the tuple input",
61                "uiName": "Negative Tuple Values",
62                "unvalidated": true
63            },
64            "flexible": {
65                "type": ["float[3][]", "token"],
66                "description": "Flexible data type output",
67                "uiName": "Inverted Flexible Values",
68                "unvalidated": true
69           }
70        }
71    }
72}

OgnTutorialExtendedTypes.cpp#

The cpp file contains the implementation of the compute method. It illustrates how to determine and set the data types on extended attribute types.

  1// SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
  2// SPDX-License-Identifier: LicenseRef-NvidiaProprietary
  3//
  4// NVIDIA CORPORATION, its affiliates and licensors retain all intellectual
  5// property and proprietary rights in and to this material, related
  6// documentation and any modifications thereto. Any use, reproduction,
  7// disclosure or distribution of this material and related documentation
  8// without an express license agreement from NVIDIA CORPORATION or
  9// its affiliates is strictly prohibited.
 10#include <OgnTutorialExtendedTypesDatabase.h>
 11#include <algorithm>
 12
 13//
 14// Attributes whose data types resolve at runtime ("any" or "union" types) are resolved by having connections made
 15// to them of a resolved type. Say you have a chain of A->B->C where B has inputs and outputs of these types. The
 16// connection from A->B will determine the type of data at B's input and the connection B->C will determine the type
 17// of data at B's output (assuming A's outputs and C's inputs are well-defined types).
 18//
 19// For this reason it is the node's responsibility to verify the type resolution of the attributes as part of the
 20// compute method. Any unresolved types (db.Xputs.attrName().resolved() == false) that are required by the compute
 21// should result in a warning and compute failure. Any attributes resolved to incompatible types, for example an input
 22// that resolves to a string where a number is needed, should also result in a warning and compute failure.
 23//
 24// It is up to the node to decide how flexible the resolution requirements are to be. In the string/number case above
 25// the node may choose to parse the string as a number instead of failing, or using the length of the string as the
 26// input number. The only requirement from OmniGraph is that the node handle all of the resolution types it has
 27// claimed it will handle in the .ogn file. "any" attributes must handle all data types, even if some types result in
 28// warnings or errors. "union" attributes must handle all types specified in the union.
 29//
 30
 31class OgnTutorialExtendedTypes
 32{
 33public:
 34    static bool compute(OgnTutorialExtendedTypesDatabase& db)
 35    {
 36        bool computedOne = false;
 37
 38        auto typeWarning = [&](const char* message, const Type& type1, const Type& type2)
 39        { db.logWarning("%s (%s -> %s)", message, getOgnTypeName(type1).c_str(), getOgnTypeName(type2).c_str()); };
 40        auto typeError = [&](const char* message, const Type& type1, const Type& type2)
 41        { db.logError("%s (%s -> %s)", message, getOgnTypeName(type1).c_str(), getOgnTypeName(type2).c_str()); };
 42
 43        auto computeSimpleValues = [&]()
 44        {
 45            // ====================================================================================================
 46            // Compute for the union types that resolve to simple values.
 47            // Accepted value types are floats and tokens. As these were the only types specified in the union
 48            // definition the node does not have to worry about other numeric types, such as int or double.
 49
 50            // The node can decide what the meaning of an attempt to compute with unresolved types is.
 51            // For this particular node they are treated as silent success.
 52            const auto& floatOrToken = db.inputs.floatOrToken();
 53            auto& doubledResult = db.outputs.doubledResult();
 54
 55            if (floatOrToken.resolved() && doubledResult.resolved())
 56            {
 57                // Check for an exact type match for the input and output
 58                if (floatOrToken.type() != doubledResult.type())
 59                {
 60                    // Mismatched types are possible, and result in no compute
 61                    typeWarning("Simple resolved types do not match", floatOrToken.type(), doubledResult.type());
 62                    return false;
 63                }
 64
 65                // When extracting extended types the templated get<> method returns an object that contains the cast
 66                // data. It can be cast to a boolean for quick checks for matching types.
 67                //
 68                // Note: The single "=" in these if statements is intentional. It facilitates one-line set-and-test of
 69                // the
 70                //       typed values.
 71                //
 72                if (auto floatValue = floatOrToken.get<float>())
 73                {
 74                    // Once the existence of the cast type is verified it can be dereferenced to get at the raw data.
 75                    if (auto doubledValue = doubledResult.get<float>())
 76                    {
 77                        *doubledValue = *floatValue * 2.0f;
 78                    }
 79                    else
 80                    {
 81                        // This could be an assert because it should never happen. The types were confirmed above to
 82                        // match, so they should have cast to the same types without incident.
 83                        typeError("Simple types were matched as bool then failed to cast properly", floatOrToken.type(),
 84                                  doubledResult.type());
 85                        return false;
 86                    }
 87                }
 88                else if (auto tokenValue = floatOrToken.get<OgnToken>())
 89                {
 90                    if (auto doubledValue = doubledResult.get<OgnToken>())
 91                    {
 92                        std::string inputString{ db.tokenToString(*tokenValue) };
 93                        inputString += inputString;
 94                        *doubledValue = db.stringToToken(inputString.c_str());
 95                    }
 96                    else
 97                    {
 98                        // This could be an assert because it should never happen. The types were confirmed above to
 99                        // match, so they should have cast to the same types without incident.
100                        typeError("Simple types were matched as token then failed to cast properly",
101                                  floatOrToken.type(), doubledResult.type());
102                        return false;
103                    }
104                }
105                else
106                {
107                    // As Union types are supposed to restrict the data types being passed in to the declared types
108                    // any unrecognized types are an error, not a warning.
109                    typeError("Simple types resolved to unknown types", floatOrToken.type(), doubledResult.type());
110                    return false;
111                }
112            }
113            else
114            {
115                // Unresolved types are reasonable, resulting in no compute
116                return true;
117            }
118            return true;
119        };
120
121        auto computeArrayValues = [&]()
122        {
123            // ====================================================================================================
124            // Compute for the union types that resolve to arrays.
125            // Accepted value types are arrays of bool or arrays of float, which are extracted as interfaces to
126            // those values so that resizing can happen transparently through the fabric.
127            //
128            // These interfaces are similar to what you've seen in regular array attributes - they support resize(),
129            // operator[], and range-based for loops.
130            //
131            const auto& toNegate = db.inputs.toNegate();
132            auto& negatedResult = db.outputs.negatedResult();
133
134            if (toNegate.resolved() && negatedResult.resolved())
135            {
136                // Check for an exact type match for the input and output
137                if (toNegate.type() != negatedResult.type())
138                {
139                    // Mismatched types are possible, and result in no compute
140                    typeWarning("Array resolved types do not match", toNegate.type(), negatedResult.type());
141                    return false;
142                }
143
144                // Extended types can be any legal attribute type. Here the types in the extended attribute can be
145                // either an array of booleans or an array of integers.
146                if (auto boolArray = toNegate.get<bool[]>())
147                {
148                    auto valueAsBoolArray = negatedResult.get<bool[]>();
149                    if (valueAsBoolArray)
150                    {
151                        valueAsBoolArray.resize(boolArray->size());
152                        size_t index{ 0 };
153                        for (auto& value : *boolArray)
154                        {
155                            (*valueAsBoolArray)[index++] = !value;
156                        }
157                    }
158                    else
159                    {
160                        // This could be an assert because it should never happen. The types were confirmed above to
161                        // match, so they should have cast to the same types without incident.
162                        typeError("Array types were matched as bool[] then failed to cast properly", toNegate.type(),
163                                  negatedResult.type());
164                        return false;
165                    }
166                }
167                else if (auto floatArray = toNegate.get<float[]>())
168                {
169                    auto valueAsFloatArray = negatedResult.get<float[]>();
170                    if (valueAsFloatArray)
171                    {
172                        valueAsFloatArray.resize(floatArray->size());
173                        size_t index{ 0 };
174                        for (auto& value : *floatArray)
175                        {
176                            (*valueAsFloatArray)[index++] = -value;
177                        }
178                    }
179                    else
180                    {
181                        // This could be an assert because it should never happen. The types were confirmed above to
182                        // match, so they should have cast to the same types without incident.
183                        typeError("Array types were matched as float[] then failed to cast properly", toNegate.type(),
184                                  negatedResult.type());
185                        return false;
186                    }
187                }
188                else
189                {
190                    // As Union types are supposed to restrict the data types being passed in to the declared types
191                    // any unrecognized types are an error, not a warning.
192                    typeError("Array type not recognized", toNegate.type(), negatedResult.type());
193                    return false;
194                }
195            }
196            else
197            {
198                // Unresolved types are reasonable, resulting in no compute
199                return true;
200            }
201            return true;
202        };
203
204        auto computeTupleValues = [&]()
205        {
206            // ====================================================================================================
207            // Compute for the "any" types that only handle tuple values.  In practice you'd only use "any" when the
208            // type of data you handle is unrestricted. This is more an illustration to show how in practical use the
209            // two types of attribute are accessed exactly the same way, the only difference is restrictions that the
210            // OmniGraph system will put on potential connections.
211            //
212            // For simplicity this node will treat unrecognized type as a warning with success.
213            // Full commentary and error checking is elided as it will be the same as for the above examples.
214            // The algorithm for tuple values is a component-wise negation.
215            const auto& tupleInput = db.inputs.tuple();
216            auto& tupleOutput = db.outputs.tuple();
217
218            if (tupleInput.resolved() && tupleOutput.resolved())
219            {
220                if (tupleInput.type() != tupleOutput.type())
221                {
222                    typeWarning("Tuple resolved types do not match", tupleInput.type(), tupleOutput.type());
223                    return false;
224                }
225
226                // This node will only recognize the float[3] and int[2] cases, to illustrate that tuple count and
227                // base type are both flexible.
228                if (auto float3Input = tupleInput.get<float[3]>())
229                {
230                    if (auto float3Output = tupleOutput.get<float[3]>())
231                    {
232                        (*float3Output)[0] = -(*float3Input)[0];
233                        (*float3Output)[1] = -(*float3Input)[1];
234                        (*float3Output)[2] = -(*float3Input)[2];
235                    }
236                }
237                else if (auto int2Input = tupleInput.get<int[2]>())
238                {
239                    if (auto int2Output = tupleOutput.get<int[2]>())
240                    {
241                        (*int2Output)[0] = -(*int2Input)[0];
242                        (*int2Output)[1] = -(*int2Input)[1];
243                    }
244                }
245                else
246                {
247                    // As "any" types are not restricted in their data types but this node is only handling two of
248                    // them an unrecognized type is just unimplemented code.
249                    typeWarning("Unimplemented type combination", tupleInput.type(), tupleOutput.type());
250                    return true;
251                }
252            }
253            else
254            {
255                // Unresolved types are reasonable, resulting in no compute
256                return true;
257            }
258            return true;
259        };
260
261        auto computeFlexibleValues = [&]()
262        {
263            // ====================================================================================================
264            // Complex union type that handles both simple values and an array of tuples. It illustrates how the
265            // data types in a union do not have to be related in any way.
266            //
267            // Full commentary and error checking is elided as it will be the same as for the above examples.
268            // The algorithm for tuple array values is to negate everything in the float3 array values, and to reverse
269            // the string for string values.
270            const auto& flexibleInput = db.inputs.flexible();
271            auto& flexibleOutput = db.outputs.flexible();
272
273            if (flexibleInput.resolved() && flexibleOutput.resolved())
274            {
275                if (flexibleInput.type() != flexibleOutput.type())
276                {
277                    typeWarning("Flexible resolved types do not match", flexibleInput.type(), flexibleOutput.type());
278                    return false;
279                }
280
281                // Arrays of tuples are handled with the same interface as with normal attributes.
282                if (auto float3ArrayInput = flexibleInput.get<float[][3]>())
283                {
284                    if (auto float3ArrayOutput = flexibleOutput.get<float[][3]>())
285                    {
286                        size_t itemCount = float3ArrayInput.size();
287                        float3ArrayOutput.resize(itemCount);
288                        for (size_t index = 0; index < itemCount; index++)
289                        {
290                            (*float3ArrayOutput)[index][0] = -(*float3ArrayInput)[index][0];
291                            (*float3ArrayOutput)[index][1] = -(*float3ArrayInput)[index][1];
292                            (*float3ArrayOutput)[index][2] = -(*float3ArrayInput)[index][2];
293                        }
294                    }
295                }
296                else if (auto tokenInput = flexibleInput.get<OgnToken>())
297                {
298                    if (auto tokenOutput = flexibleOutput.get<OgnToken>())
299                    {
300                        std::string toReverse{ db.tokenToString(*tokenInput) };
301                        std::reverse(toReverse.begin(), toReverse.end());
302                        *tokenOutput = db.stringToToken(toReverse.c_str());
303                    }
304                }
305                else
306                {
307                    typeError("Unrecognized type combination", flexibleInput.type(), flexibleOutput.type());
308                    return false;
309                }
310            }
311            else
312            {
313                // Unresolved types are reasonable, resulting in no compute
314                return true;
315            }
316
317            return true;
318        };
319
320        // This approach lets either section fail while still computing the other.
321        computedOne = computeSimpleValues();
322        computedOne = computeArrayValues() || computedOne;
323        computedOne = computeTupleValues() || computedOne;
324        computedOne = computeFlexibleValues() || computedOne;
325
326        if (!computedOne)
327        {
328            db.logWarning("None of the inputs had resolved type, resulting in no compute");
329        }
330        return !computedOne;
331    }
332
333    static void onConnectionTypeResolve(const NodeObj& nodeObj)
334    {
335        // The attribute types resolve in pairs
336        AttributeObj pairs[][2]{ { nodeObj.iNode->getAttributeByToken(nodeObj, inputs::floatOrToken.token()),
337                                   nodeObj.iNode->getAttributeByToken(nodeObj, outputs::doubledResult.token()) },
338                                 { nodeObj.iNode->getAttributeByToken(nodeObj, inputs::toNegate.token()),
339                                   nodeObj.iNode->getAttributeByToken(nodeObj, outputs::negatedResult.token()) },
340                                 { nodeObj.iNode->getAttributeByToken(nodeObj, inputs::tuple.token()),
341                                   nodeObj.iNode->getAttributeByToken(nodeObj, outputs::tuple.token()) },
342                                 { nodeObj.iNode->getAttributeByToken(nodeObj, inputs::flexible.token()),
343                                   nodeObj.iNode->getAttributeByToken(nodeObj, outputs::flexible.token()) } };
344        for (auto& pair : pairs)
345        {
346            nodeObj.iNode->resolveCoupledAttributes(nodeObj, &pair[0], 2);
347        }
348    }
349};
350
351REGISTER_OGN_NODE()

OgnTutorialExtendedTypesPy.py#

This is a Python version of the above C++ node with exactly the same set of attributes and the same algorithm. It shows the parallels between manipulating extended attribute types in both languages. (The .ogn file is omitted for brevity, being identical to the previous one save for the addition of a "language": "python" property.

  1"""
  2Implementation of the Python node accessing attributes whose type is determined at runtime.
  3This class exercises access to the DataModel through the generated database class for all simple data types.
  4"""
  5
  6import omni.graph.core as og
  7
  8# Hardcode each of the expected types for easy comparison
  9FLOAT_TYPE = og.Type(og.BaseDataType.FLOAT)
 10TOKEN_TYPE = og.Type(og.BaseDataType.TOKEN)
 11BOOL_ARRAY_TYPE = og.Type(og.BaseDataType.BOOL, array_depth=1)
 12FLOAT_ARRAY_TYPE = og.Type(og.BaseDataType.FLOAT, array_depth=1)
 13FLOAT3_TYPE = og.Type(og.BaseDataType.FLOAT, tuple_count=3)
 14INT2_TYPE = og.Type(og.BaseDataType.INT, tuple_count=2)
 15FLOAT3_ARRAY_TYPE = og.Type(og.BaseDataType.FLOAT, tuple_count=3, array_depth=1)
 16
 17
 18class OgnTutorialExtendedTypesPy:
 19    """Exercise the runtime data types through a Python OmniGraph node"""
 20
 21    @staticmethod
 22    def compute(db) -> bool:
 23        """Implements the same algorithm as the C++ node OgnTutorialExtendedTypes.cpp.
 24
 25        It follows the same code pattern for easier comparison, though in practice you would probably code Python
 26        nodes differently from C++ nodes to take advantage of the strengths of each language.
 27        """
 28
 29        def __compare_resolved_types(input_attribute, output_attribute) -> og.Type:
 30            """Returns the resolved type if they are the same, outputs a warning and returns None otherwise"""
 31            resolved_input_type = input_attribute.type
 32            resolved_output_type = output_attribute.type
 33            if resolved_input_type != resolved_output_type:
 34                db.log_warn(f"Resolved types do not match {resolved_input_type} -> {resolved_output_type}")
 35                return None
 36            return resolved_input_type if resolved_input_type.base_type != og.BaseDataType.UNKNOWN else None
 37
 38        # ---------------------------------------------------------------------------------------------------
 39        def _compute_simple_values():
 40            """Perform the first algorithm on the simple input data types"""
 41
 42            # Unlike C++ code the Python types are flexible so you must check the data types to do the right thing.
 43            # This works out better when the operation is the same as you don't even have to check the data type. In
 44            # this case the "doubling" operation is slightly different for floats and tokens.
 45            resolved_type = __compare_resolved_types(db.inputs.floatOrToken, db.outputs.doubledResult)
 46            if resolved_type == FLOAT_TYPE:
 47                db.outputs.doubledResult.value = db.inputs.floatOrToken.value * 2.0
 48            elif resolved_type == TOKEN_TYPE:
 49                db.outputs.doubledResult.value = db.inputs.floatOrToken.value + db.inputs.floatOrToken.value
 50
 51            # A Pythonic way to do the same thing by just applying an operation and checking for compatibility is:
 52            #    try:
 53            #        db.outputs.doubledResult = db.inputs.floatOrToken * 2.0
 54            #    except TypeError:
 55            #        # Gets in here for token types since multiplying string by float is not legal
 56            #        db.outputs.doubledResult = db.inputs.floatOrToken + db.inputs.floatOrToken
 57
 58            return True
 59
 60        # ---------------------------------------------------------------------------------------------------
 61        def _compute_array_values():
 62            """Perform the second algorithm on the array input data types"""
 63
 64            resolved_type = __compare_resolved_types(db.inputs.toNegate, db.outputs.negatedResult)
 65            if resolved_type == BOOL_ARRAY_TYPE:
 66                db.outputs.negatedResult.value = [not value for value in db.inputs.toNegate.value]
 67            elif resolved_type == FLOAT_ARRAY_TYPE:
 68                db.outputs.negatedResult.value = [-value for value in db.inputs.toNegate.value]
 69
 70            return True
 71
 72        # ---------------------------------------------------------------------------------------------------
 73        def _compute_tuple_values():
 74            """Perform the third algorithm on the 'any' data types"""
 75
 76            resolved_type = __compare_resolved_types(db.inputs.tuple, db.outputs.tuple)
 77            # Notice how, since the operation is applied the same for both recognized types, the
 78            # same code can handle both of them.
 79            if resolved_type in (FLOAT3_TYPE, INT2_TYPE):
 80                db.outputs.tuple.value = tuple(-x for x in db.inputs.tuple.value)
 81            # An unresolved type is a temporary state and okay, resolving to unsupported types means the graph is in
 82            # an unsupported configuration that needs to be corrected.
 83            elif resolved_type is not None:
 84                type_name = resolved_type.get_type_name()
 85                db.log_error(f"Only float[3] and int[2] types are supported by this node, not {type_name}")
 86                return False
 87
 88            return True
 89
 90        # ---------------------------------------------------------------------------------------------------
 91        def _compute_flexible_values():
 92            """Perform the fourth algorithm on the multi-shape data types"""
 93
 94            resolved_type = __compare_resolved_types(db.inputs.flexible, db.outputs.flexible)
 95            if resolved_type == FLOAT3_ARRAY_TYPE:
 96                db.outputs.flexible.value = [(-x, -y, -z) for (x, y, z) in db.inputs.flexible.value]
 97            elif resolved_type == TOKEN_TYPE:
 98                db.outputs.flexible.value = db.inputs.flexible.value[::-1]
 99
100            return True
101
102        # ---------------------------------------------------------------------------------------------------
103        compute_success = _compute_simple_values()
104        compute_success = _compute_array_values() and compute_success
105        compute_success = _compute_tuple_values() and compute_success
106        compute_success = _compute_flexible_values() and compute_success
107
108        # ---------------------------------------------------------------------------------------------------
109        # As Python has a much more flexible typing system it can do things in a few lines that require a lot
110        # more in C++. One such example is the ability to add two arbitrary data types. Here is an example of
111        # how, using "any" type inputs "a", and "b", with an "any" type output "result" you can generically
112        # add two elements without explicitly checking the type, failing only when Python cannot support
113        # the operation.
114        #
115        #    try:
116        #        db.outputs.result = db.inputs.a + db.inputs.b
117        #        return True
118        #    except TypeError:
119        #        a_type = inputs.a.type().get_type_name()
120        #        b_type = inputs.b.type().get_type_name()
121        #        db.log_error(f"Cannot add attributes of type {a_type} and {b_type}")
122        #    return False
123
124        return True
125
126    @staticmethod
127    def on_connection_type_resolve(node) -> None:
128        # There are 4 sets of type-coupled attributes in this node, meaning that the base_type of the attributes
129        # must be the same for the node to function as designed.
130        # 1. floatOrToken <-> doubledResult
131        # 2. toNegate <-> negatedResult
132        # 3. tuple <-> tuple
133        # 4. flexible <-> flexible
134        #
135        # The following code uses a helper function to resolve the attribute types of the coupled pairs.  Note that
136        # without this logic a chain of extended-attribute connections may result in a non-functional graph, due to
137        # the requirement that types be resolved before graph evaluation, and the ambiguity of the graph without knowing
138        # how the types are related.
139        og.resolve_fully_coupled(
140            [node.get_attribute("inputs:floatOrToken"), node.get_attribute("outputs:doubledResult")]
141        )
142        og.resolve_fully_coupled([node.get_attribute("inputs:toNegate"), node.get_attribute("outputs:negatedResult")])
143        og.resolve_fully_coupled([node.get_attribute("inputs:tuple"), node.get_attribute("outputs:tuple")])
144        og.resolve_fully_coupled([node.get_attribute("inputs:flexible"), node.get_attribute("outputs:flexible")])