(Teletype) Asynchronous tasks and variable scope

teletype

#1

Moved from the Teletype 2.3 beta 1 thread

I noticed some issues with variables while I attempted to make a strumming patch for Just Type. Below is a stripped-down version of that script which isolates just the issues. While the issues I’m encountering aren’t strictly bugs, I think part of the current behaviour could be changed to be much more useful. And part of it just raises tricky questions.

Clock goes either into input 1 or 3, adjust strumming rate with param. Strumming is represented on trigger outs.

M:
M RSH PARAM 4
S.POP

I:
M.ACT 1

1:
O 1
L 1 4: SCRIPT 2

2:
S: TR.PULSE O

3: 
X 0
L 1 4: SCRIPT 4

4:
X ADD X 1
S: TR.PULSE X

What this scene does is use a loop to add multiple pulses to a stack and then play them out at the metro rate. It does so in two different ways.

Because I has been made local to each script, it’s not possible to use it within scripts called from a loop. This is one of the things that I think could be made much more useful: extend the scope of I to scripts called from loops and while pre statements. (and in the case of a loop within a script called from a loop, make it so the innermost I dominates)

Because relying on I within the called script is not an option, and we have to rig some other less elegant way of keeping track of indices. Script 1-2 and 3-4 do it in two different ways; 1-2 use O, which auto-increments on each read, and 3-4 simply use the X variable, incrementing it manually after pushing to stack. However, these do not yield the same result. Script 1-2 only triggers output D, while script 3-4 strum A-B-C-D. It seems that this is because variables within a command pushed to stack are read only when the stack is executed: and by that time the whole loop has ran it’s course and X = 4. This works with O because it is only incremented after it is read from the stack.

The question is: should we make the stack read values of (simple) variables at the time they are pushed? I understand that for some operators that interact with data it’s more interesting for them to be ran when the stack pops. However, for simple variables this seems like a tricky question because there are good and bad things to both ways of treating them. Maybe applying this to only a subset of the variables would be good, ie X and Y, as this allows both “on pop” and “on push” behaviours, and also makes it possible to extend these two different behaviours to “dynamic” operators like P.NEXT when needed (by storing it in those variables, then running S using those); maybe this is too much complexity. Or maybe this problem is specific to using the stack within loops, and this can be addressed by simply making I read on push (assuming the last proposition regarding I is accpeted).

EDIT: after some thought, maybe the best thing we can do to provide both ways of treating variables while reducing confusion is to make it explicit by having some kind of S.NOW (or perhaps S! for short?) operator that reads the value of the operator passing to it at the moment the command is pushed to the stack. This would also make it possible to extend it from variables to any operator, and wouldn’t break any previous scripts using the stack.

Maybe this is more fitting in the 2.2 beta thread? I’ve read discussions about I's scope somewhere but I can’t remember. And knowing how much thought has been given to the inner workings of Teletype, I’m pretty sure some people have thought about the way the stack operates before. Either way, please let me know what you think.


Teletype 3.0
#2

L and I

One solution to part of this may be to copy I during the call to SCRIPT. Additionally, I can be cached between execution contexts in the event that you want to know the last value the loop reached.

Let me revisit the code before I comment further.


Aha! Okay, full story:

I was moved to the execution context in 2.1. Calling SCRIPT pushes a frame onto the execution stack. Here’s the push:

size_t es_push(exec_state_t *es) {
    if (es->exec_depth < EXEC_DEPTH) {
        es->variables[es->exec_depth].delayed = false;
        es->variables[es->exec_depth].while_depth = 0;
        es->variables[es->exec_depth].while_continue = false;
        es->variables[es->exec_depth].if_else_condition = true;
        es->variables[es->exec_depth].i = 0;
        es->variables[es->exec_depth].breaking = false;
        es->exec_depth += 1;  // exec_depth = 1 at the root
    }
    else
        es->overflow = true;
    return es->exec_depth;
}

I didn’t really consider, when moving I to this structure, that anything beyond a zero initialization would be required. I kind of wrote this code out-of-order and wasn’t thinking straight.

Now I can see that of course I should be copied from the previous frame. It acheives your behaviour goal and the obviously correct expectation:

Also, I think we can resuscitate the old advanced if / else usage by copying the condition from the previous frame, as well.

Example of potential behaviour with a fix:

1: L 1 4: SCRIPT 2
2: TR.P I

Triggering 1 will pulse all 4 outputs.

Triggering 2 will call TR.P 0

Mea Culpa

I had, at one time, described the changes being made to L, I, and IF et al. as isolating their effects to a given script. This was absolutely incorrect. These things were isolated to execution contexts. This misunderstanding made me miss this glaring functionality deficiency.

That I robbed I of appropriate functionality for 2 releases, forgive me. :pray:


#3

This is great! Many thanks for figuring it out!


#4

In the interest of completeness, I’m thinking more about this.

I looked back to the issue that triggered this change and found this example case by @sam as to how I might behave:

1: L 1 4: SCRIPT 2; A I

2: I 100

A -> 100

So in order for THIS functionality to work, I would have to propagate backwards upon stack pop. The problem is illustrated in the following script:

I: B 0

1: L 1 4: SCRIPT 2; B + B 1

2: L 1 10: A I

B -> 1
A -> 10

If I propagates backwards, then the loop in script 1 will run only once, as I will be 10 when script 2 finishes. Essentially, this reintroduces the initial bug of “L + SCRIPT misbehaves” (https://github.com/monome/teletype/issues/94)

So while it makes sense to copy I to the inner scope to solve the issue described in the top post, it does not make sense to copy it back out.


#5

so i think we’re talking about a couple of somewhat related but different things:

  • the scope of I variable
  • evaluating variables in delayed commands

i’ll say right away i agree with @tehn in that i would go with the behaviour that would be most intuitive to a user (while also keeping in mind what could be useful).


the scope of I variable

the most intuitive and useful case to me would be:

  • if a script is executed from a trigger or manually I is set to 0
  • if a script is executed from another script I is copied from the calling script
  • I changed in a called script is not copied back to the calling script

this means something like L 1 4: SCRIPT 2 is possible without having to use another variable to pass the value of I to script 2, but also that it’s always safe to use I as a temp variable within a script without worrying about breaking the behaviour of the calling script.


delayed command:
(by delayed i mean both S and DEL)

i see benefits in both evaluating variables when a delayed command is added, and when it’s executed. a use case for evaluating when it’s added:

X 1
DEL 20: TR.P X
X 2

i think intuitively you would expect it to trigger output 1, not 2 (right now it will trigger 2). a use case for evaluating when executed is when you use variable O, for instance. if you do DEL 20: TR.P O expecting it to increment when it actually gets executed is much more intuitive as opposed to it incrementing when it adds the delayed command.

so our choices are either picking one use case, or allowing it to be stated more explicitly (what you suggest with S.NOW). i guess in some cases you might want a mixed case, but i doubt that would be often (another option would be applying it to specific variables, like this: DEL 20: TR.P X! or something like that). i think making some variables “special” would create more confusion than help.

an alternative could also be just using I as one special variable that due to its local nature gets evaluated at the time S or DEL are added. so for the use case above you could simply do:

X 1; I X
DEL 20: TR.P I
X 2

Teletype 3.0
#6

Agreed 100% Have a patch that does this.

I love this idea. ! as a prefix or postfix could force an evaluation of variables on the delay / s push routine. Best solution IMO, although I haven’t looked closely at implementing such a thing. It should be feasible as it only applies to DEL / S. Those two push routines would be the only parts to do anything special with the ! token, which would be interpreted as whitespace in other contexts.


PR opened for I fix. Should merge cleanly whenever it’s applied.


#7

I wholeheartedly agree on what’s been brought out regarding loops.

Regarding evaluation time in delays and stacks:

I think using a single character is really convenient because it’s very compact, although we should also think about the namespace it uses up. One conflict I’d see with postfix notation is M and M!. For the sake of not having a special case in syntax, I’d prefer it being a simple operator. Also, I think it being able to evaluate not only variables but also operators on push would open up a lot of use cases.


#8

The following should be possible with # for the operator (! is taken for logical NOT, & for bitwise AND)

A 1; B 3
DEL 20: CV # + A B N 64
B - B 1

The post-command will need to be translated to:

CV 4 N 64

Implementation shouldn’t be too difficult.


#9

We may also want to consider !! as it’s very small and would save us from using up a nice bit of namespace — if timeline is still in the plans, I think this could come in really handy later on.


#10

for ! i was actually thinking using it as a prefix or suffix for variable names themselves, so it would be something like !X or X! with no space in between.

if we expand it to expressions, then why not simply =? it’s not used iirc and seems intuitive: both 5 and = 5 would provide the same result, you could even use it for readability: X = + X 1. the only difference would be when using it with S / DEL.

i do have some reservations about expanding this concept to expressions, i wonder if there are some cases where it can create undesirable or unpredictable behaviour. this is just a feeling though, need to think about it some more.


#11

After sleeping on it, I realize that, yes, S needs a copy of I in the same way that DEL does, regardless of an evaluation operator.

This was fixed in 2.2 by adding I to the DEL structure. Similar modification to S could carry I through to there.

Reviewing the code revealed another bug. Here’s what happens in DEL:

    ss->delay.origin_script[i] = es_variables(es)->script_number;
    ss->delay.origin_i[i] = es_variables(es)->i;

We carry the script number in addition to I, to make SCRIPT work properly as a getter (primarily for use with LAST).

Without carrying it over to S, SCRIPT-as-getter will not work in the stack.


Teletype 3.0
#12

are you able to fix these as part of 2.2? if not i can take a look but it’ll be after everything i planned for 2.3 is done.

for forcing evaluation of variables and/or expressions when using S / DEL i think we need to hear from @tehn


#13

Looks like the I depth fix was merged after the 2.2 release.

I can prepare another PR to master for the stack stuff for you to rebase onto if that works for you.


#14

if you could do that that’d be great! i’ll include the fixes in the next 2.3 beta so we can test them.