Figured it out. The two bits I was stuck on I found solutions to almost as soon as I'd published that last post: Zip for joining the two lists and I was very close to sorting the nested lists, I was just missing the unary2 combinator.

The hardest thing about Joy is figuring out functions with multiple arguments that are used multiple times and manipulating the stack to suit. Take, for example, this bit of code I'd figured out which takes a list as an argument and uses it twice:

DEFINE
    ratio ==
        dup
        [34.0 swap /] map
        swap
        zip
        [[34.0] concat] map.

So when applied to a list of sprockets it gives the following results:

>[12 13 15 17 19 21 23 26] ratio.
>[[2.83333 12 34] [2.61538 13 34] [2.26667 15 34] [2 17 34] [1.78947 19 34] ... ]

Step by step, this is what happens on the stack:

(* Stack: [12 13 15 17 19 21 23 26] *)

dup

(* Stack: [12 13 15 17 19 21 23 26] [12 13 15 17 19 21 23 26] *)

[34.0 swap /] map

(* Stack: [12 13 15 17 19 21 23 26] [2.83333 2.61538 2.26667 2 1.78947 1.61905 1.47826 1.30769] *)

swap

(* Stack: [2.83333 2.61538 2.26667 2 1.78947 1.61905 1.47826 1.30769] [12 13 15 17 19 21 23 26] *)

zip

(* Stack: [[2.83333 12] [2.61538 13] [2.26667 15] [2 17] [1.78947 19] [1.61905 21] ] *)

[[34.0] concat] map.

(* Stack: [[2.83333 12 34] [2.61538 13 34] [2.26667 15 34] ... ] *)

dup is used to create two copies of the list argument to the programme and after the first one has been used ([34.0 swap /] map) the stack is swapped around so we can get to the second one. The [34.0 swap /] map bit I kind of explained last time: [34.0 /] map takes each item in the list and "divides by 34.0" so using swap just means it does "34.0 divided by" each item in the list. The [[34.0] concat] map adds on the 34.0 to what is a list of pairs at this point to create a list of triples.

All well and good, but what if I want the 34.0 as an argument to the programme as well as the list? It's used twice and inside a list both times! This would be a piece of cake in any other language as we just make it a variable, but we can't do that with Joy, we've got to introduce it onto the stack along with the list and then manipulate the stack to form the functions we need. This requires more manipulation of the stack than swap:

ratios ==

(* Stack: 34.0 [12 13 15 17 19 21 23 26] *)

    dup

(* Stack:  34.0 [12 13 15 17 19 21 23 26] [12 13 15 17 19 21 23 26] *)

    rotate

(* Stack: [12 13 15 17 19 21 23 26] [12 13 15 17 19 21 23 26] 34.0 *)

    dup

(* Stack: [12 13 15 17 19 21 23 26] [12 13 15 17 19 21 23 26] 34.0 34.0 *)

    unitlist [swap /] concat

(* Stack: [12 13 15 17 19 21 23 26] [12 13 15 17 19 21 23 26] 34.0 [34.0 swap /] *)

    rolldown swap

(* Stack: [12 13 15 17 19 21 23 26] 34.0 [12 13 15 17 19 21 23 26] [34.0 swap /] *)

    map

(* Stack: [12 13 15 17 19 21 23 26] 34.0 [2.83333 2.61538 2.26667 2 1.78947 1.61905 1.47826 1.30769] *)

    rolldown

(* Stack: 34.0 [2.83333 2.61538 2.26667 2 1.78947 1.61905 1.47826 1.30769] [12 13 15 17 19 21 23 26] *)

    zip

(* Stack: 34.0 [[2.83333 12] [2.61538 13] [2.26667 15] [2 17] [1.78947 19] [1.61905 21]  ... ] *)

    swap

(* Stack: [[2.83333 12] [2.61538 13] [2.26667 15] [2 17] [1.78947 19] [1.61905 21]  ... ] 34.0 *)

    unitlist [unitlist concat] concat

(* Stack: [[2.83333 12] [2.61538 13] [2.26667 15] [2 17] [1.78947 19] [1.61905 21]  ... ] [34.0 unitlist concat] *)

    map.

(* Stack: [[2.83333 12 34] [2.61538 13 34] [2.26667 15 34] [2 17 34] [1.78947 19 34] [1.61905 21 34] ... ] *)

Here we use rolldown, rotate and swap to manipulate the stack as required. Whereas swap only works on the top two items on the stack, rolldown and rotate work on the top three, giving us a bit more reach. There are other operators as well, some that re-order up to four items on the stack.

The other function that needs mentioning here is unitlist. This simply takes whatever is on the stack and wraps it in a list - this is how we get the 34.0 from being on the stack to inside a list. The same would be true if we'd defined a constant we wanted to use inside a list:

DEFINE

    gear == 34.

If we wanted to use this like so:

>[[1 2] [3 4] [5 6]] [[gear] concat] map.

Expecting we'd get:

>[[1 2 34] [3 4 34] [5 6 34]]

We'd actually get:

>[[1 2 gear] [3 4 gear] [5 6 gear]]

Because gear is wrapped in the list it doesn't get executed. It actually gets concatenated as a function. By using unitlist we can execute gear on the stack and then wrap it in a list:

>[[1 2] [3 4] [5 6]] [gear unitlist concat] map.

Taking away the plethora of comments you actually end up with a reasonably concise programme, but it takes a bigger brain than mine not to have a running commentary on the stack.

For comparison, in Ruby I'd use a looping construct and the whole thing is a bit more succinct (and could probably be made more so):

def joycog(biggears, littlegears)
    triples = []
    biggears.each do |bg|
        #Calc ratios
        ratios = littlegears.map{ |lg| bg.to_f/lg }
        #Add littlegears
        ratios = ratios.zip(littlegears)
        #Add big gear
        triples.concat(ratios.map{ |pair| pair.concat([bg]) })
    end
    triples.sort
end

The end result of all this? This is the actual progression of gears on my road bike:

  1. 1.30769 26 34
  2. 1.47826 23 34
  3. 1.61905 21 34
  4. 1.78947 19 34
  5. 1.92308 26 50
  6. 2.00000 17 34
  7. 2.17391 23 50
  8. 2.26667 15 34
  9. 2.38095 21 50
  10. 2.61538 13 34
  11. 2.63158 19 50
  12. 2.83333 12 34
  13. 2.94118 17 50
  14. 3.33333 15 50
  15. 3.84615 13 50
  16. 4.16667 12 50

Even discounting gear combinations at the extreme of chain angles (which is an exercise for another day as far as doing it in Joy goes), there is still more overlap than I realised:

  1. 1.30769 26 34
  2. 1.47826 23 34
  3. 1.61905 21 34
  4. 1.78947 19 34
  5. 2.00000 17 34
  6. 2.17391 23 50
  7. 2.26667 15 34
  8. 2.38095 21 50
  9. 2.61538 13 34
  10. 2.63158 19 50
  11. 2.94118 17 50
  12. 3.33333 15 50
  13. 3.84615 13 50
  14. 4.16667 12 50

In practice though changing gear like this is not going to happen. Rather I find it's a case of picking the front chain ring to suit the conditions (hills and silly headwinds for the 34) and only shifting to the other one when you run out of room on the cassette.