The Benefits of Conforming

Consider a useful idiom for linear interpolation in K:

lerp:{x+z*y-x}

The arguments x and y are start and end values, and z is a “tweening” value ranging between 0 and 1:

  .2*!6
0 0.2 0.4 0.6 0.8 1

  lerp[10;20]'.2*!6
10 12 14 16 18 20

Every programmer has their favorite languages, and- statistically- K is rarely among them. Thus, when K comes up in online discussions, it is not unusual to observe the peanut gallery supply translations for K (and other APL-family languages) into more seemly tongues:

function lerp(x, y, tween){
	return x + tween * (y - x)
}

In addition to being more verbose, such translations frequently miss an important property: definitions can generalize over arguments of varying dimensions (rank polymorphism). For example, our lerp definition can accept a list of tweening values and produce every interpolation from x to y without a need for explicit mapping with ' (each):

  lerp[10;20;.2*!6]
10 12 14 16 18 20

It can also accept x and y as lists, interpolating e.g. points in 3D space:

  lerp[10 20 30;40 10 20]'.2*!6
(10 20 30
 16 18 28
 22 16 26
 28 14 24
 34 12 22
 40 10 20)

Multitudinous such variations are possible. The secret is a mechanism built into most of K’s arithmetic operators like dyadic +, -, and *: they conform their arguments. If a scalar argument is added to a list, the scalar is “spread” to each element of the list:

  100+11 22 33
111 122 133

  11 22 33-100
-89 -78 -67

If a list is added to a list, corresponding elements of each list are added:

  100 200 300+11 22 33
111 222 333

The same pattern is carried out recursively for any depth: a scalar added to a matrix adds the scalar to each row of the matrix, and in turn to each cell. Some languages call behavior along these lines broadcasting, with their own distinct quirks, edge-cases, and varying degrees of generality.

A modestly more accurate JavaScript translation of the semantics of our original lerp is rather more than a one-liner, even if we use a terse style:

arr=Array.isArray
conform=f=>function rec(x,y){
	return arr(x)&&arr(y)?x.map((v,i)=>rec(v,y[i])):
	       arr(x)        ?x.map( v   =>rec(v,y   )):
	       arr(y)        ?y.map( v   =>rec(x,v   )): f(x,y)
}
add=conform((x,y)=>x+y)
mul=conform((x,y)=>x*y)
sub=conform((x,y)=>x-y)
lerp=(x,y,tween)=>add(x,mul(tween,sub(y,x)))

When one becomes used to arithmetic operators which conform, their absence seems almost absurd:

> 100 + [11,22,33]
'10011,22,33'

> [11,22,33] - 100
NaN

As does case-specific mapping and zipping to handle some higher rank generalization:

> y=[11,22,33]
> [100,200,300].map((x,i)=>x+y[i])
[ 111, 222, 333 ]

> [[11,22],[33,44]].map(x=>x.map(y=>y+100))
[ [ 111, 122 ], [ 133, 144 ] ]

Writing this sort of thing makes me itch.

Filling a Semantic Hole

There is one minor gap in K’s conforming that I have glossed over: it is an error to conform two lists of differing length:

 11 22 33+100 200
11 22 33+100 200
        ^
length error 

From a certain point of view, this is the only sensible choice: trying to add a pair of numbers to a 4x4 matrix is probably a mistake (so it is said) and it’s better to shout “Foul!” immediately and screech to a halt than to allow that mistake to propagate forward, sending ripples of nonsense through the entire data path of a complex program.

For Lil, this sensible choice is not an option. Lil does not have runtime errors; if a program is syntactically well-formed, it has well-defined semantics. Lil has conforming:

100+11,22,33  # (111,122,133)

Lil, therefore, must do something if it is asked to conform lists with different lengths. A wide design space presents itself: for example, we could repeat the shorter argument or truncate the longer argument (in either direction!) to make the left and right lists match.

Lil chooses to reshape the right argument (as by the take primitive) to match the count of the left argument. Several interesting idioms fall out of this choice.

If we compare a truncated list against itself, we can find inflection points:

x:2,3,5,7,6,9,2

(1 drop x)<x   # (0,0,0,1,0,1)

In K, this would involve slightly clumsier truncation of both arguments, or an explicit ': (each-prior) adverb (which handles the initial case differently):

  (1_ x)<-1_x
0 0 0 1 0 1

  <':x
0 0 0 0 1 0 1

If you enlist the right argument, conforming will “spread” the single-element list to match the left argument, allowing us to compute a cartesian product without explicit iteration:

(2,3,4)*list(5,6)  # ((10,12),(15,18),(20,24))

In K, you’d need to impose the same mapping with an explicit \: (each-left) adverb:

  2 3 4*\:5 6
(10 12
 15 18
 20 24)

In both cases, the “ragged” conforming behavior of Lil helps it compensate for its much smaller collection of adverb-like implicit iteration patterns relative to K.

back