Repository navigation
pyiron_workflow-independent dict containing workflow info #576
Description
Note: I will work on this myself. You can just tell me what you think @liamhuber
Currently, we have graph_as_dict, which returns a dict containing all the information about the workflow. However, when we try to make semantikon work with any workflow manager, the most natural thing for me is to ask every workflow manager to provide a dictionary that does not require semantikon to depend on the workflow manager. In addition, that would be consistent with the effort that @jan-janssen is making here.
I was initially thinking we should extend @jan-janssen's universal representation of workflows by adding optional args required by semantikon, and potentially also allowing nodes_dict to contain sub-nodes to represent also the Macro nodes. However, I started realizing that this might not be the right way, because:
- There's no consensus on how macro nodes or metadata should be defined
- (Maybe more importantly) @jan-janssen's universal representation specifies the numerical logic of execution and in this regard it is conceptually complete
So my first suggestion is to separate semantikon-dict from the universal representation dict, in order to represent the workflow including the scientific logic.
Coming to the actual content, semantikon would need:
- IO-values
- Edges
- Unique node labels
- IO labels
- Underlying python function
- (Optionally) type hints
The type hints are optional, because semantikon can parse them, but that could be a bit dangerous because it might fail to assign the types to the IO labels. The reason why we need the original python function is because since this PR the function metadata is attached to f._semantikon_metadata, which I would not ask the workflow manager to provide.
Concerning the Macro nodes, I was testing the following code:
@Workflow.wrap.as_function_node
def add_one(a: int):
result = a + 1
return result
@Workflow.wrap.as_function_node
def add_two(b: int):
result = b + 2
return result
@Workflow.wrap.as_macro_node
def add_three(macro, c: int):
macro.one = add_one(a=c)
macro.two = add_two(b=macro.one)
result = macro.two
return result
wf = Workflow("my_wf")
wf.three = add_three(c=1)
wf.four = add_one(a=wf.three)
wf.run()Then I realized that wf.graph_as_dict does explicitly not provide the connection between wf.three.inputs.c and wf.three.one.inputs.a as well wf.three.two.outputs.result and wf.three.outputs.result. They may not be edges in the sense of workflow edges, but they still somehow have to be included in the dict.
Well, thanks for reading, but actually I'm probably the one who profited from it the most as it helped me figure out what I actually want XD
Activity
samwaseda commentedon Feb 5, 2025
Today, I had a discussion with @jan-janssen while jogging, and he sounded he's actually interested in extending the universal representation, and I thought about it now for quite some time for macro nodes. With this in mind, question for @jan-janssen, but also for @liamhuber: On this page @jan-janssen defined the universal representation of the following code:
via (I defined a
dictinstead of individual variables for the reasons that will follow, but the content is the same)Now my suggestion: How about changing it to the following?
The reason is fairly easy: Let's say I have the following macro node:
Then I can define:
Now let's say we have the following workflow:
Then the new definition would allow us to write something like:
From my point of view, this conceptually incorporates macro nodes. Besides, after the execution
valuecan be optionally set to all the edges as well. What do you think?samwaseda commentedon Feb 5, 2025
I forgot to mention, but if we replace the enumeration in the node_dict keys by the node labels, with this representation we can extract all the information that I listed above except for the type hints, which we can probably also optionally return.
jan-janssen commentedon Feb 5, 2025
I like the idea of an output handle, as previously it was not directly clear which property the user is interested in.
In terms of nesting the macro dictionary inside the node dictionary, I am not so happy as I initially wanted to separate the nodes / python functions from the edges / logic. Now with the nested structure both get merged.
Another suggestion would be:
jan-janssen commentedon Feb 5, 2025
Comment on the use of values in the edges. I feel it is dangerous as the user might not realize that two values are related to each other. Let's say I hard code the lattice constant for aluminium in my workflow. When I use it in multiple structure generators, I might define the same value in multiple edges. For somebody using my workflow it is not clear how these values are related.
liamhuber commentedon Feb 5, 2025
At a high level I have three pieces:
liamhuber commentedon Feb 6, 2025
Given
I would imagine a graph representation like this:
Note that the IO is always explicitly defined, and thus macros are able to indicate how their IO communicates with their children, but there is no direct connection between a macro's siblings and its children. (Maybe this is already the case in @jan-janssen's example! I am just having a bit of trouble grokking it.)
Then notice that here even the functions are required to have an explicty "graphy" representation dictionary. In
pyiron_workflowwe can always scrape all of this. For general python functions it gets harder when it comes to the output. We have tools in pwf to scrape labels from even the symbols of the code in the return function, but you still can't do it generically because what to do if there's more than one return function? So no matter how we slice it, I think we will need to force generic python callables into some more constrained and explicit form. Here I do it with a dictionary that is based on the returned tuple. One advantage here is that if you imagine I wanted to be working with the tuple(x, y, z)in my graph, the same underlying function could get the following representation to accomplish that:Ok, and finally note that we can imagine extending such a terminology to include annotations:
or
So coming back to a high level after a bit more percolation: I like where you guys are going with this. Excluding the fact that I've dropped values, I think we've got basically all the pieces you mentioned in your first post, @samwaseda. I think my favourite thing so far is my example above + the (label, annotation) dictionary for inputs and outputs.
Until I wrote up this example, I hadn't really thought about it in this generic workflow language context, but now I see we really do need to constrain the bottom-most graph callables -- i.e. the root most functions. We certainly can't graph-ize anything, e.g. functions with multiple return branches with different numbers/natures of items would totally sink this approach, so we'd better constrain them.
samwaseda commentedon Feb 6, 2025
The whole idea is to represent a workflow that a user has run or is going to run, that means there is no way whether two values are supposed to be the same or accidentally the same, i.e. we could think about:
Let's assume
structure_1andstructure_2happen to be the same. In this case all three inputs are the same, but while the last two are "supposed" to be the same, the first one "happens" to be the same. So actually it might be more dangerous to group them by their values. Instead, I think the user should make another node to explicitly say that the last two should get the same structure, i.e.:and include
structure_generatorinto the workflow.I can live with @liamhuber's representation (not to mention also with @jan-janssen's), except for the fact that I would like to have a straightforward extension to include values, i.e. instead of:
I would suggest:
This being said, it might be slightly more difficult for @jan-janssen to accept this representation, because it would involve more parsing when it's read by the workflow manager. This being said, our suggestions are not so different from each other, so I'm gonna make an example for @liamhuber's one for now and open a PR here.
samwaseda commentedon Feb 6, 2025
Sorry actually since one dictionary should be returned, the representation should look like this:
I also removed
nodesbecause they are defined by the keys.liamhuber commentedon Feb 6, 2025
About to go to sleep, but quickly: I think you probably still want a nodes field, otherwise it's purely implicit which node belongs to which macro/workflow, accessible only by parsing the edges. Might be fine to not require it to specify the dictionary, but for reading the dictionary I'd still like to see these.
jan-janssen commentedon Feb 6, 2025
At least for the general workflow definition, I do not expect anybody to write this graph directly. Rather I expect them to use one of the existing workflow frameworks and export the graph in this format. The advantage is that the current workflow frameworks all have a way how to declare the expected output, even though they might not agree to do it in the same way.
samwaseda commentedon Feb 6, 2025
That's true... One possibility is to merge the ideas of you two: I changed @jan-janssen's version with node and variable combined
And I claim this is closer to what pyiron_workflow does than your representation, because in your representation the combination of
my_first_node = {"function": add_x_and_y, ...}andmy_macro = {... "nodes": {"xy": my_first_node, ...} ...}impliesadd_x_and_yis first instantiated (tomy_first_node) and assigned tomy_macro.xy, which is not the case. In reality,add_x_and_yis directly instantiated tomy_macro.xy, which is what the representation of @jan-janssen's corresponds to.jan-janssen commentedon Feb 6, 2025
I am not bound to a specific notation and for me the two are equivalent:
{'target': 'xyz', 'targetHandle': 'x', 'source': 'xy', 'sourceHandle': 'x'},and:
{'target': 'xyz.inputs.x', 'source': 'xy.outputs.x'},so from that side, I am fine with either solution.
The part where I am still not sure, is where to store the values. I could live with using the "value" field of the edges for optional storage of outputs, but at least for the default input values and the parameters the user is able to change, I would prefer those to be nodes.
samwaseda commentedon Feb 6, 2025
I'm not sure if I'm understanding the problem correctly. I was thinking about writing a function like
get_dict_representationwith a default argumentvalues=False. IfvaluesisTrueand the workflow has already run, then I would put the values to all the edges, otherwise only the default values would be attached, which would correspond to the case that I presented above. That's not good enough?jan-janssen commentedon Feb 6, 2025
Does it make sense to have the outside input and output variables specifically marked. I mean when I search for a workflow which calculates a bulk modulus, I do not really want to parse all the edges to see if the bulk modulus is an output edge.
11 remaining items
liamhuber commentedon Feb 6, 2025
Actually, talking through it, I'd really like to change (b). It's going to take some thinking for how to not make it too much more verbose for users though
samwaseda commentedon Feb 6, 2025
ok actually it looks like I misunderstood something. I reformulate my question: How can I know that in the example above that
my_macro.xyz.value_receiveismy_macro.outputs.W? I just compare all the nodes and all the outputs?liamhuber commentedon Feb 6, 2025
How can you know as a human, or how does the computer know?
As a human you just read the macro definition and know that return values are getting mapped to outputs, and that value receivers are the functionality we use to handle that mapping.
As a computer, so far we only care about knowing at subgraph creation time (aka macro instantiation time). For the technicalities of building the mapping it's best to just go read the macro class.
As a computer post facto, yes you could iterate over macro inputs and all the child outputs and search for their value receivers (all the macro inputs will have one, most of the macro child output usually won't, but the fraction depends totally on your graph)
liamhuber commentedon Feb 6, 2025
Ah, unless you meant my common workflow definition. There I stuck it in the edges, despite the fact I keep asserting it isn't one:
("xy.outputs.w", "outputs.W"). Of course it can't be a "true" edge though, because it's output-to-output. I think we need these "value "edges"" to get user data into and results out of workflows anyhow.I also had a typo, that should be "xyz"
samwaseda commentedon Feb 7, 2025
ah now I finally got it. I don't know how I got stuck, but I was stuck for some weird reasoning not even worth explaining. I'm gonna work on the prototype and will present it probably today.
samwaseda commentedon Feb 7, 2025
Now my next suggestion:
I'm still reluctant to put input and output arguments in the main workflow definition, because
{'target': 'mymacro.inputs.X', 'source': "input.X", "value": 1}simply does not correspond to whatpyiron_workflowdoes. If it's really really required, then I would putinputs.mymacro__X.liamhuber commentedon Feb 7, 2025
Yeah, by and large I'm happy with this. I still think it's a technical criticality to wrap the underlying functions -- again in this example I don't see a way for the machine to parse
'xy.outputs.z'without something likeAt a high level, I will still push for these to be included. First, because I don't see a reason for "macros" to have a fundamentally different syntax from "workflows". We need IO for macros, so I think it improves consistency and readability to give them to workflows too. Second, I will obviously promote
pyiron_workflowabove all other tools, but at this abstract level I do think we should avoid getting too hung up on it -- rather let's only focus on whether it makes sense for describing workflows in general.Very specifically, I'm totally content with
inputs.mymacro__X. You're right this is whatpyiron_workflowdoes automatically. But! There is also no barrier to giving it a more specific name likeinputs.X-- theWorkflowclass automatically scrapes its IO from unconnected child channels, but it does allow you to provide an IO map for renaming these to something more convenient. So there's zero conflict here.As a nit, what do you guys think of "from" and "to" instead of "source" and "target"? I also think we can truncate "nodes_dict" down to "nodes", and truncate and then specify "edges_lst" to "data_edges".
Finally, I was trying to think whether there's a way to express the I-I/O-O value coupling by exploiting the "value" field of the edge dictionary. I didn't manage to find a way that I both liked more and that carried the right logical information for the machine, but I want to bring it up in case you guys can see a more clever way to attack along that route than I do. It would be nice to have only "true" edges in the from-to edge data, and to have some really clear indication when we're supplying information for a "false" edge.
liamhuber commentedon Feb 9, 2025
Ok, I slept on it and some more ideas came.
First, regarding where to store values: they can live either on the inputs/outputs, or on the edges themselves. There is no difference between these on the quality of information we stored, but they differ in readability and memory efficiency; storing on edges the value gets repeated n_connections times, storing on the I/O channels it gets repeated n_connections+1 times -- i.e. the worst case (and most typical case) is that IO storage is 2x worse. For now, let's optimize for human readability, and we can always convert our representation to/from a more efficient machine-storage representation as needed.
Next, if we are storing values on the IO itself, then the edges only need to be string references to the scoped IO labels. I realized that we can nicely distinguish "true" data edges from "fake" value synchronizing edges by the fact that the former must be directional (output->input) whereas the latter, in principle, is a non-directional property. Of course, in practice input-input value synchronizations always see the outer-scope value get updated and this propagates to the inner-scope, and vice-versa for output-output value synchronizations, but the result is that the values are always synchronized. Thus, they can be easily distinguished by an ordered tuple
("output", "input")vs an unordered set{"outer", "inner"}.Finally, I extended the underlying example functions a bit to demonstrate how important the import and output fields are:
I think this gives us the power to capture everything we want, and (for me at least...) it is still pretty readable given the density of information it contains. One thing I really like is that now the only difference between the "recipe" class-view and the post-run instance view is that the
"value":fields on the various"inputs"and"outputs"get populated!A big change here is that everything has been defined in a nested way. This is fine, and probably preferable, when we don't reuse nodes. I could imagine breaking these into reusable parts by simply having the node/macro dictionaries defined independently in the same scope as the macro/workflow is defined. E.g.,
Since this is now reusable, note that I've purged the values. This works beautifully for the "recipe" view, but for the instance-view, I guess we'd again want to convert the dictionary to a more verbose nested view so that each occurrence of
xyz_dictis able to get its own"value":fields populated individually.samwaseda commentedon Feb 11, 2025
Now I also slept on it, and I guess we can agree on your final version. Here, I try to formulate the specs:
Requirements
inputsandoutputs, in the form of a dictionary containing the argument names in the keys and their metadata in the values.Optional fields
nodesandedges1 (if composite node), orfunction(if atomic node)nodes, more nodes are nestededgesconsists of a tuple of tuples of length 2, indicating the data flow from port to another.io.variable(io=inputs/outputs). It can be preceded by the child node names (nested_node_1.nested_node_2.io.variable). The variable names must correspond to the ones specified in theinputsandoutputsfields.functioncontains the underlying python function of the nodevalue,annotation,default2Footnotes
I'm also fine with
data_edgesbut if nodes are justnodeswhy not justedges? ↩Not sure if we need
default↩niklassiemer commentedon Feb 11, 2025
I am in favor of an optional
defaultfield. It ensures the same workflow even if implicit inputs change after some time.samwaseda commentedon Feb 11, 2025
I support this idea, but I find it also somewhat inconvenient that the label is not included in the dictionary, because I would need it in the knowledge graph.
liamhuber commentedon Feb 11, 2025
You don't mention how we pass information from the scope of one graph to another graph (i.e. where I used sets earlier). Even if we don't go for the set notation, this layer of information passing should be explicitly identified.
liamhuber commentedon Feb 11, 2025
Can you clarify what you mean by "label"? From my reading the label is
"xyz"in both the full nested view and also the abstracted view where we insert an existing dict. In the later case the variablexyz_dictis just that, a variable that has some scope and only lives while the interpreter lives.In principle, we could add a "label" field:
But at this stage it would really be a "recipe label", and IMO would anyhow get overridden to the "instance label" of
"xyz"once it appears in the other workflow/macro.liamhuber commentedon Feb 11, 2025
With
nodesI was trimming off a data type specifier, which is redundant because we can anyhow just look at the code and see the data type. Withdata_edgesI did this trimming, then added back a "meaning" prefix. This is a little bit of forward-engineering and perhaps YAGNI, but my best understanding is that there are certain graph topologies that we will not be able to express with data edges alone. In particular, consider a bifurcating graph with an "if" clause. In my experience the most practical way to resolve this is to additionally haveexecution_edges(Signalchannels inpwf). If we have an alternate plan in the works for how to extend this generic description to a wider set of graphs that doesn't reuse the termedges, then I'm fine with truncatingdata_edgesdown...but I want to hear a reasonable concept of such a plan before we ditch thedata_😝