Sotolf's thoughts and oddities

Some More Nim

I was kind of enjoying explaining some nim in my previous post, so I will do another one, this time of a project that is a bit more complex.

This time working on day 7 of 2015 in the adventofcode

I like starting out with defining my types, that way I know what I have to work with, and it’s good to have it all gathered in one place.

 1type
 2  Op = enum
 3    Assign
 4    And
 5    Or
 6    LShift
 7    RShift
 8    Not
 9  
10  Wire = string
11  
12  Pkind = enum
13    PWire
14    Puint16
15    PEmpty
16
17  Parameter = object
18    case kind: Pkind
19    of PWire: wire: string
20    of Puint16: val: uint16
21    of PEmpty: discard
22
23  Instruction = tuple[a: Parameter, b: Parameter, op: Op, dest: Wire]

I think the Op enum is pretty self explaining, we’re basically setting up the different operation types that we are having to deal with, it’s nice to have since it will help us later, nim case statements checks that we deal with all the versions we said we will, so it makes it harder to forget to deal with something that we really should.

The Wire type is basically just an alias of a string, so not strictly needed, it’s just nice to have, it makes the code read a bit better. I could have made it distinct, so that the code only accepts strings marked as Wire explicitly like Wire("a") for example, but for this little sript it’s not really needed.

The Pkind and Parameter types are a bit more interesting, The Parameter type here is a “variant object” bascially nim’s tagged union type, Pkind is the tag that we use here, it basically tells us if the Parameter is a Wire, a uint16 or if it’s not there, this makes it so that we can store different types in a sequence or Instruction, as long as we are okay with dealing with the syntactic noise of unpacking it all.

1func split2(str: string, sep: string): (string, string) =
2  let parts = str.split(sep, 1)
3  do_assert(parts.len == 2, &"Split2 {str} on {sep} did not return 2 parts")
4  (parts[0], parts[1])
5
6func split3(str: string, sep: string): (string, string, string) =
7  let parts = str.split(sep, 2)
8  do_assert(parts.len == 3, &"Split3 {str} on {sep} did not return 3 parts")
9  (parts[0], parts[1], parts[2])

These two functions are basically just utility functions to use dealing with string splitting a bit more comfortable for our parsing, they do the split and return a tuple of 2/3 strings, and also asserts that they are used correctly, so that we don’t get bitten by making assumptions about them.

I do kind of wish that nim had a split once function, but it’s decent enough to deal with it like this, it’s not that much hassle to write them, but I do think that it would be nice to write up a little utility library with snippets like these that I like having around.

1func to_param(str: string): Parameter =
2  if str[0].is_digit:
3    return Parameter(kind: Puint16, val: str.parse_uint.uint16)
4  else:
5    return Parameter(kind: PWire, wire: str)

to_param is another function that I use to save myself some repetition, here we take in a string, check if it is a string, or a string representation of a number, and return the Parameter with the correct tag.

 1proc parse(lines: seq[string]): seq[Instruction] =
 2  for line in lines:
 3    let (main, dest) = line.split2 " -> "
 4    if "AND" in main:
 5      let op = And
 6      let (a, _, b) = main.split3 " "
 7      result.add (a.to_param, b.to_param, op, dest)
 8    elif "OR" in main:
 9      let op = Or
10      let (a, _, b) = main.split3 " "
11      result.add (a.to_param, b.to_param, op, dest)
12    elif "LSHIFT" in main:
13      let op = LShift
14      let (a, _, b) = main.split3 " "
15      result.add (a.to_param, b.to_param, op, dest)
16    elif "RSHIFT" in main:
17      let op = RShift
18      let (a, _, b) = main.split3 " "
19      result.add (a.to_param, b.to_param, op, dest)
20    elif "NOT" in main:
21      let op = Not
22      let (_, a) = main.split2 " "
23      let b = Parameter(kind: PEmpty)
24      result.add (a.to_param, b, op, dest)
25    else:
26      let op = Assign
27      let b = Parameter(kind: PEmpty)
28      result.add (main.to_param, b, op, dest)

With that all set up we can go on with the real work of parsing. Since the format is very simple we don’t really have to do anything fancy here. We set up the split2 and split3 functions earlier, so that we here easily can assign 2 or 3 parameters in one go, this makes the code a lot nicer to write. We pick out the parts we want with our splits, and put them into our return list in the end.

Parsing is now done, and we have objects that are a lot nicer to work with than the plain text itself, so now we can start the real work in itself.

The list of different elements are not ordered in the correct order here, so we will have to set up a dependency chains so that we have the things we need when we need them. To make lookup a bit easier we create a hashtable of the instructions, to have a recipe for each piece, and use that later for the work we have to do.

1proc part1(insts: seq[Instruction]) =
2  let lookup = insts.to_lookup
3  let ans = lookup.get_wire "a"
4  echo "Part 1: ", ans

This of course is a very high level function, so we have to look into how we do this, to translate our list into a lookuptable we just invert the instructions and put them into a table:

1proc to_lookup(insts: seq[Instruction]): Table[Wire, Instruction] =
2  for inst in insts:
3    result[inst.dest] = inst

Now we can do fast lookups something we will really need when we start to figure out the dependencies for the signal that we really want to have.

 1proc get_wire(lookup: Table[Wire, Instruction], goal: Wire, override: bool = false, wire: Wire = "", val: uint16 = 0): uint16 =
 2  var signals: Table[Wire, uint16]
 3  var qu: Deque[Instruction]
 4
 5  if override:
 6    signals[wire] = val
 7
 8  qu.add_last lookup[goal]
 9  while qu.len > 0:
10    let cur = qu.popFirst
11    if not cur.can_do signals:
12      qu.add_first cur
13      if cur.a.kind == PWire and cur.a.wire not_in signals:
14        qu.add_first lookup[cur.a.wire]
15      if cur.b.kind == PWire and cur.b.wire not_in signals:
16        qu.add_first lookup[cur.b.wire]
17      continue
18    case cur.op
19    of Assign:
20      signals[cur.dest] = cur.a.get_val signals
21    of And:
22      let a = cur.a.get_val signals
23      let b = cur.b.get_val signals
24      signals[cur.dest] = a and b
25    of Or:
26      let a = cur.a.get_val signals
27      let b = cur.b.get_val signals
28      signals[cur.dest] = a or b
29    of Lshift:
30      let a = cur.a.get_val signals
31      let b = cur.b.get_val signals
32      signals[cur.dest] = a shl b
33    of Rshift:
34      let a = cur.a.get_val signals
35      let b = cur.b.get_val signals
36      signals[cur.dest] = a shr b
37    of Not:
38      let a = cur.a.get_val signals
39      signals[cur.dest] = bitnot a
40
41  return signals[goal]

This is where the majority of the work takes place. We here have some default arguements which are basically there so that I didn’t have to rewrite the function just to add support for the second part of the problem we will look at them a bit later.

We set up a hashtable of signal values, so that we don’t have to recompute them in case we come across them multiple times, so basically for memoisation.

We need the value of a specific signal here, and since the input is not in the order we need them, with the inputs on top, we will fetch stuff as we need them. So I set up a working queue a deque (Double ended queue) so that we can add stuff to the beginning or end. Looking at the code a second time, just using a seq as a stack would be enough in this case, but I didn’t think of that at the time, and it’s more fun showing off some more datastructures anyway.

Before we start working off the queue, we put the current value onto it, and we’re set to go.

We pop the top instruction off the stack and see if we can already compute the signal, and if we can’t yet calculate it because we’re missing values we put it back on the stack, and also add the prerequisits onto the stack if they haven’t already been computed.

When we can calculate an instruction we fetch the signals off the wires that we have, do the calculation, and we’re done with that task, and can go on with the next one.

Here we have one of those case statements where it’s really nice to have an enumeration, since it is exhaustive it means that we’ll always handle all the cases that we promised to. This way if we add another kind of operation, the compiler will yell at us until we also implement how to calculate it. This is one of the things that I really enjoy about statically typed languages, if I do something silly, the compiler will yell at me and tell me what I did wrong, instead of having to puzzle it out from some strange interaction that I did not expect.

We have two helper functions that helps us do this get_val and can_do and we’ll go through them.

1proc get_val(self: Parameter, wires: Table[Wire, uint16]): uint16 =
2  case self.kind
3  of Puint16:
4    self.val
5  of PWire:
6    wires[self.wire]
7  of PEmpty:
8    echo "Trying to get value of empty parameter"
9    quit 69

So get_val takes in a parameter, and the memoisation table, and basically unpacks the case object, if it’s an number we just return that, and if it’s a wire we look it up in the table and use that value. This means that the function will only work once the parameter is stored in the table, which is what our task queue that we set up does for us.

Empty values here are a special case, they should never be turned into a value since they are a placeholder, so this is basically just an Option[int|string] kind of type, so if we use this on a value that is empty we just write out a small reason and crash the program.

 1proc can_do(self: Instruction, wires: Table[Wire, uint16]): bool =
 2  if self.a.kind == PWire and self.b.kind == PWire and
 3     self.a.wire in wires and
 4     self.b.wire in wires:
 5       return true
 6  if self.a.kind == PWire and (self.b.kind == Puint16 or self.b.kind == PEmpty) and
 7     self.a.wire in wires:
 8       return true
 9  if self.b.kind == PWire and (self.a.kind == Puint16 or self.a.kind == PEmpty) and
10     self.b.wire in wires:
11       return true
12  if self.a.kind == Puint16 and self.b.kind == PEmpty:
13    return true
14  return false

can_do checks if we with our current state can actually calculate the value, it checks if parameters that are given as a reference and not a plain value actually exists, and if all of the needed ones are there we’re a’okay to continue on. This way we know if we can go on to calculate the value of the instruction, or if we will have to get some prerequisites first.

1proc part2(insts: seq[Instruction]) = 
2  let lookup = insts.to_lookup
3  let mid = lookup.get_wire "a"
4  let ans = lookup.get_wire("a", true, "b", mid)
5  echo "Part 2: ", ans

For the second part we have to go through once to get the value of wire “a” set value “b” and then rerun the calculation of the new “a”. This is where the optional parameters for the get_value procedure comes into play. All that I needed to add was the extra parameters, and this little thing in the function to preset our memoisation:

1  if override:
2    signals[wire] = val

As a nice sideffect this will also collapse the calculation of the “b” wire, which means that our second run through will be a bit faster.

Of course I could also reuse the value that we got from part 1 without recalculating it, but I like in these small programs just to have the two parts be completely independent.

And now at the end here is the complete listing of code where everything comes together, with imports and a little bit of plumbing to grab the lines of a file, here is the full listing of the program:

  1import 
  2  bitops,
  3  deques, 
  4  sequtils, 
  5  strformat, 
  6  strutils, 
  7  tables 
  8
  9type
 10  Op = enum
 11    Assign
 12    And
 13    Or
 14    LShift
 15    RShift
 16    Not
 17  
 18  Wire = string
 19  
 20  Pkind = enum
 21    PWire
 22    Puint16
 23    PEmpty
 24
 25  Parameter = object
 26    case kind: Pkind
 27    of PWire: wire: string
 28    of Puint16: val: uint16
 29    of PEmpty: discard
 30
 31  Instruction = tuple[a: Parameter, b: Parameter, op: Op, dest: Wire]
 32
 33func split2(str: string, sep: string): (string, string) =
 34  let parts = str.split(sep, 1)
 35  do_assert(parts.len == 2, &"Split2 {str} on {sep} did not return 2 parts")
 36  (parts[0], parts[1])
 37
 38func split3(str: string, sep: string): (string, string, string) =
 39  let parts = str.split(sep, 2)
 40  do_assert(parts.len == 3, &"Split3 {str} on {sep} did not return 3 parts")
 41  (parts[0], parts[1], parts[2])
 42
 43func to_param(str: string): Parameter =
 44  if str[0].is_digit:
 45    return Parameter(kind: Puint16, val: str.parse_uint.uint16)
 46  else:
 47    return Parameter(kind: PWire, wire: str)
 48
 49proc parse(lines: seq[string]): seq[Instruction] =
 50  for line in lines:
 51    let (main, dest) = line.split2 " -> "
 52    if "AND" in main:
 53      let op = And
 54      let (a, _, b) = main.split3 " "
 55      result.add (a.to_param, b.to_param, op, dest)
 56    elif "OR" in main:
 57      let op = Or
 58      let (a, _, b) = main.split3 " "
 59      result.add (a.to_param, b.to_param, op, dest)
 60    elif "LSHIFT" in main:
 61      let op = LShift
 62      let (a, _, b) = main.split3 " "
 63      result.add (a.to_param, b.to_param, op, dest)
 64    elif "RSHIFT" in main:
 65      let op = RShift
 66      let (a, _, b) = main.split3 " "
 67      result.add (a.to_param, b.to_param, op, dest)
 68    elif "NOT" in main:
 69      let op = Not
 70      let (_, a) = main.split2 " "
 71      let b = Parameter(kind: PEmpty)
 72      result.add (a.to_param, b, op, dest)
 73    else:
 74      let op = Assign
 75      let b = Parameter(kind: PEmpty)
 76      result.add (main.to_param, b, op, dest)
 77
 78proc can_do(self: Instruction, wires: Table[Wire, uint16]): bool =
 79  if self.a.kind == PWire and self.b.kind == PWire and
 80     self.a.wire in wires and
 81     self.b.wire in wires:
 82       return true
 83  if self.a.kind == PWire and (self.b.kind == Puint16 or self.b.kind == PEmpty) and
 84     self.a.wire in wires:
 85       return true
 86  if self.b.kind == PWire and (self.a.kind == Puint16 or self.a.kind == PEmpty) and
 87     self.b.wire in wires:
 88       return true
 89  if self.a.kind == Puint16 and self.b.kind == PEmpty:
 90    return true
 91  return false
 92
 93proc get_val(self: Parameter, wires: Table[Wire, uint16]): uint16 =
 94  case self.kind
 95  of Puint16:
 96    self.val
 97  of PWire:
 98    wires[self.wire]
 99  of PEmpty:
100    echo "Trying to get value of empty parameter"
101    quit 69
102
103proc get_wire(lookup: Table[Wire, Instruction], goal: Wire, override: bool = false, wire: Wire = "", val: uint16 = 0): uint16 =
104  var signals: Table[Wire, uint16]
105  var qu: Deque[Instruction]
106
107  if override:
108    signals[wire] = val
109
110  qu.add_last lookup[goal]
111  while qu.len > 0:
112    let cur = qu.popFirst
113    if not cur.can_do signals:
114      qu.add_first cur
115      if cur.a.kind == PWire and cur.a.wire not_in signals:
116        qu.add_first lookup[cur.a.wire]
117      if cur.b.kind == PWire and cur.b.wire not_in signals:
118        qu.add_first lookup[cur.b.wire]
119      continue
120    case cur.op
121    of Assign:
122      signals[cur.dest] = cur.a.get_val signals
123    of And:
124      let a = cur.a.get_val signals
125      let b = cur.b.get_val signals
126      signals[cur.dest] = a and b
127    of Or:
128      let a = cur.a.get_val signals
129      let b = cur.b.get_val signals
130      signals[cur.dest] = a or b
131    of Lshift:
132      let a = cur.a.get_val signals
133      let b = cur.b.get_val signals
134      signals[cur.dest] = a shl b
135    of Rshift:
136      let a = cur.a.get_val signals
137      let b = cur.b.get_val signals
138      signals[cur.dest] = a shr b
139    of Not:
140      let a = cur.a.get_val signals
141      signals[cur.dest] = bitnot a
142
143  return signals[goal]
144
145proc to_lookup(insts: seq[Instruction]): Table[Wire, Instruction] =
146  for inst in insts:
147    result[inst.dest] = inst
148
149proc part1(insts: seq[Instruction]) =
150  let lookup = insts.to_lookup
151  let ans = lookup.get_wire "a"
152  echo "Part 1: ", ans
153
154proc part2(insts: seq[Instruction]) = 
155  let lookup = insts.to_lookup
156  let mid = lookup.get_wire "a"
157  let ans = lookup.get_wire("a", true, "b", mid)
158  echo "Part 2: ", ans
159
160let input = toseq "day07.txt".lines
161let parsed = input.parse
162part1 parsed
163part2 parsed