Making the isotopic state of frames be "interpret this frame as an action when run through a word reference" is a deep change. The implications haven't been fully absorbed, and there are some things I noticed that create troubles for optimization.
Looking again at a simple example:
>> f: make frame! :append
== make frame! [
>> f.value: 5
>> append5: isotopic f
== ~make frame! [
]~ ; isotope
>> append5 [a b c]
== [a b c 5]
That's cool, for sure. And it removes the number of things we have to name in the system (I was particularly annoyed by having to differentiate "action" and "activation" when there was a separate ACTION! datatype).
Say the user writes this:
>> append5 (f.series: [d e f], [a b c])
When running the specialization append5 we'd already decided that the series parameter wasn't specialized and needed to be fulfilled. But the user is specializing the series parameter during the fulfillment.
At best, this is semantically messy (and can manifest as hard-to-comprehend behavior when the example is less obvious than this one). At worst, the internal bookkeeping of the evaluator gets confused and it crashes due to having the slot it's filling changed out from under it.
If we are forced to make a snapshot of the frame state at the start of execution, then that means making a copy, which takes time and space.
The space isn't actually the problem...because we can just put the snapshot in the frame space we're already making for the function call.
Where we pay is that it effectively adds an extra traversal of the arguments. We're traversing the space to make the copy (with some slots unspecialized and needing to be fulfilled). Then we're traversing the space again to do the fulfillment of the unspecialized arguments.
With no snapshot, we could leave the memory for the frame cells completely garbage at the outset... and fill them as we go with either a fulfillment or a specialization. Empirically, avoiding the snapshot could save as much as 5% of the runtime.
When there was a separate "make an action! out of this frame" step, the action was a way of saying "freeze!" on the arguments, so they could no longer be changed. So it avoids the problem of changes during fulfillment.
But it did something else: it meant properties of the arguments could be studied...to remember things like "what's the first unspecialized argument slot".
(It might seem that it would be easy to find the first unspecialized argument slot. But this isn't just a search from the beginning of the frame, because it's possible to reorder arguments or have partial specializations. So it's tricky.)
The simplest idea here is just to say that once you invoke a frame, it's frozen...you can't change its fields anymore. Then that freezing process can do the caching of properties that accelerate action execution, and all is well.
If you want to keep a frame mutable, then COPY it before making it isotopic. The issue is just that changes to the frame won't be seen by the isotope you made.
Well, it's a delicate balance of choices, and I'm still feeling it out.
The worry is that the number of concerns being exposed to users is such that the properties of "the kind frame you can run is different enough that you basically have to think of it as a different type".
To avoid that in this case, it's better to do the freezing implicitly vs. saying that you need a special routine like RUNS to bless a frame as runnable. This way, RUNS can be understood as simply taking in a plain frame! and giving back an isotopic frame!... something you could easily write yourself.
Crafting a uniformity of experience with FRAME! so it doesn't feel like it's making up for a missing ACTION! type is certainly something to continue to be mindful of. But performance needs to be minded too at some point, so I'm doing what I can about that.