aoc2024/day17.py
2025-01-04 18:40:21 +01:00

334 lines
9.2 KiB
Python

#!/usr/bin/env python3
import re
from random import randrange
from array import array
from copy import copy
class VM():
def __init__(self, reg: array = array('q'), mem: list = [], pc: int = 0):
self.reg = copy(reg)
self.mem = copy(mem)
self.pc = copy(pc)
self.outbuf = []
def run(self, abort: int = 0):
while self.pc < len(self.mem):
if abort>0 and len(self.mem) > abort: return
op = self.mem[self.pc]
self.pc += 1
if op == 0b000: # adv
self.reg[0] = self.reg[0] // (2**self._combo(self.mem[self.pc]))
self.pc += 1
if op == 0b001: # bxl
self.reg[1] = self.reg[1] ^ self.mem[self.pc]
self.pc += 1
if op == 0b010: # bst
self.reg[1] = self._combo(self.mem[self.pc]) & 0b111
self.pc += 1
if op == 0b011: # jnz
self.pc = self.mem[self.pc] if self.reg[0] != 0 else self.pc+1
if op == 0b100: # bxc
self.reg[1] = self.reg[1] ^ self.reg[2]
self.pc += 1
if op == 0b101: # out
self.outbuf.append(self._combo(self.mem[self.pc]) & 0b111)
self.pc += 1
if op == 0b110: # bdv
self.reg[1] = self.reg[0] // (2**self._combo(self.mem[self.pc]))
self.pc += 1
if op == 0b111: # cdv
self.reg[2] = self.reg[0] // (2**self._combo(self.mem[self.pc]))
self.pc += 1
def _combo(self, c) -> int:
if c == 0: return 0
if c == 1: return 1
if c == 2: return 2
if c == 3: return 3
if c == 4: return self.reg[0]
if c == 5: return self.reg[1]
if c == 6: return self.reg[2]
raise RuntimeError()
def flush(self, binfmt = False):
if not binfmt:
print(','.join(map(str, self.outbuf)))
else:
for o in self.outbuf:
print(f"{o:03b},", end="")
print()
def cmp(self, prog):
return self.outbuf == prog
reg: array = array('q')
mem: list = []
pc: int = 0
with open('day17') as f:
reg_a = int(re.match('Register A: ([0-9]+)', f.readline()).groups()[0])
reg_b = int(re.match('Register B: ([0-9]+)', f.readline()).groups()[0])
reg_c = int(re.match('Register C: ([0-9]+)', f.readline()).groups()[0])
reg = array('q', [reg_a, reg_b, reg_c])
f.readline()
mem_str = re.match('Program: (.*)', f.readline()).groups()[0]
for c in mem_str:
if c == ',': continue
mem.append(int(c))
# day 1
vm = VM(reg, mem, pc)
vm.run()
vm.flush()
# day 2
# enumerates all 3 bit number, then increases size by one
# enumerates them again
# -> we can find first boundaries by getting numbers where
# output buffer length is too small or too large
# but first we need to find where to even start
# we know the reg_a from the puzzle input is
# too small, but start from here and increas rapidly:
reg_a = 17323786
while True:
reg = array('q', [reg_a, reg_b, reg_c])
vm = VM(reg, mem, pc)
vm.run(abort=len(mem))
if vm.cmp(mem): break
if len(mem) == len(vm.outbuf): break
reg_a *= 10
print(reg_a)
vm.flush()
# we get: 173237860000000
# producing a program: 4,4,4,4,1,6,0,0,0,2,1,0,0,1,3,0
# that's exactly the length we want
# let's find our first set of boundaries so we can start a search
known_bytes = vm.outbuf[-2:]
init_reg_a = reg_a
upper_bound = -1
lower_bound = -1
reg_a = init_reg_a
while True:
reg = array('q', [reg_a, reg_b, reg_c])
vm = VM(reg, mem, pc)
vm.run(abort=len(mem))
if vm.cmp(mem): break
if len(vm.outbuf) > len(mem):
upper_bound = reg_a
break
reg_a *= 10
print(reg_a)
vm.flush()
reg_a = init_reg_a
while True:
reg = array('q', [reg_a, reg_b, reg_c])
vm = VM(reg, mem, pc)
vm.run(abort=len(mem))
if vm.cmp(mem): break
if len(vm.outbuf) < len(mem):
lower_bound = reg_a
break
reg_a //= 10
print(reg_a)
vm.flush()
# that was fast again and did not produce very enlightening bounds,
# nevertheless we have bounds:
#
# ub: 1732378600000000
# out: 4,4,0,7,0,7,6,0,3,5,1,5,4,2,4,4,2
#
# mid: 173237860000000
# out: 4,4,4,4,1,6,0,0,0,2,1,0,0,1,3,0
#
# lb: 17323786000000
# out: 4,4,5,5,5,0,1,4,6,4,4,4,7,3,7
# helper function
def cmp_bytes(a,b):
if len(a) != len(b): return []
same = []
for i in range(1, len(a)):
if a[-i] == b[-i]:
same.append(a[-i])
continue
break
same.reverse()
return same
# params for our algorithm to tune
generation = 1
population = 1
mu = 173237860000000 # current best guess
mu_hit = 2 # correct positions of mu
mu_len = 16
sd = 171505481400000 # standard deviation, (up-low)*0.1 - I pulled that out of my nose
lo = 17323786000000 # lower bound
lo_hit = 0
lo_len = 15
up = 1732378600000000 # upper bound
up_hit = 0
up_len = 17
from day17_truncnorm import Truncnorm
truncnorm = Truncnorm(mu, sd, lo, up)
import random
prodigy = mu
prodigy_hit = mu_hit
prodigy_len = mu_len
mus = [(mu, mu_hit, mu_len)]
evolve = True
while evolve:
# each generation we birth 10000 kin. The new generation's genes center
# around the best produced offsprings so far. However, we allow for
# mutations with decreasing likelihood the more deformed the kin would be.
#
# The mutations are bounded. Mutations outside these bounds lead to
# a miscarriage.
#
# With each generation we re-asses and adapt our parameters.
#
# Occasionally a catastrophic event happens erasing the whole
# popluation. This hopefully prevents us from being trapped in a
# local optima.
#
# To preserve their legacy during these catastrophic events the mu
# collect and freeze prodigies - the most promising genes they
# know.
#
# All this allows us to search a search space probabilistically which
# would be way too large to search exhaustively.
catastrophy = random.randint(0,100)
if catastrophy % 10 == 0:
mus.append((mu, mu_hit, mu_len))
print(f"\033[31mA catastrophy erased the entire population. Not all is lost, a new {prodigy=} awakes.\033[0m")
mu = prodigy
mu_hit = prodigy_hit
mu_len = prodigy_len
print(f"\033[31mIn a last attempt to preserve their culture the best known mus are engraved in a stone wall:\033[0m {mus=}")
sd = round(random.uniform(0.00001, 1.0)*(up-lo))
print(f"\033[31mEvolution restarts with {sd=}\033[0m")
population += 1
generation = 1
print(f"\033[35mGeneration {generation} (Population {population}):\033[0m {mu=}, {sd=}, {lo=}, {up=}")
for kin in truncnorm.draw(30000):
kin = round(kin)
reg = array('q', [kin, reg_b, reg_c])
vm = VM(reg, mem, pc)
vm.run()
if len(cmp_bytes(vm.outbuf, mem))>mu_hit:
mu = kin
mu_hit = len(cmp_bytes(vm.outbuf, mem))
mu_len = len(vm.outbuf)
print(f"\033[32mFound new best kin: {mu=}, {mu_hit=}\033[0m", end=" ")
vm.flush()
continue
if len(cmp_bytes(vm.outbuf, mem))==mu_hit and kin<mu:
mu = kin
mu_hit = len(cmp_bytes(vm.outbuf, mem))
mu_len = len(vm.outbuf)
print(f"\033[32mFound new best kin: {mu=}, {mu_hit=}\033[0m", end=" ")
vm.flush()
continue
if kin < mu and kin < prodigy and len(cmp_bytes(vm.outbuf, mem)) > 3:
prodigy = kin
prodigy_hit = len(cmp_bytes(vm.outbuf, mem))
prodigy_len = len(vm.outbuf)
print(f"Found new prodigy: {prodigy=}, {prodigy_hit=}", end=" ")
vm.flush()
if len(vm.outbuf) < mu_len and kin>lo:
lo = kin
lo_hit = len(cmp_bytes(vm.outbuf, mem))
lo_len = len(vm.outbuf)
print(f"Found new lower bound: {lo}, {lo_hit=}, {lo_len=}", end=" ")
vm.flush()
continue
if len(vm.outbuf) > mu_len and kin<up:
up = kin
up_hit = len(cmp_bytes(vm.outbuf, mem))
up_len = len(vm.outbuf)
print(f"Found new upper bound: {up}, {up_hit=}, {up_len=}", end=" ")
vm.flush()
continue
assert(lo < mu)
assert(mu < up)
sd = sd//generation
if sd<100:
sd = round(random.uniform(0.00001, 1.0)*(up-lo))
print(f"\033[36mA fungus infected the generation and changed mutations. New mutation with {sd=}\033[0m")
print(f"{sd=}")
truncnorm = Truncnorm(mu, sd, lo, up)
generation += 1
# we evolved to a species capable of genetic engineering the very best
# prodigy we found is 164278764924544. We even found that multiple
# times. Now brute force ourselves to the top of the food chain.
#
# Our probabilistic algorithm is good a finding a a very good solution
# (up to 15 elements correct) but it is actually bad at pinpointing
# the exactly best solution.
mu = 164278764924544
for breed in range(mu-(2**(5*3)), mu+(2**(5*3))):
reg = array('q', [breed, reg_b, reg_c])
vm = VM(reg, mem, pc)
vm.run()
if vm.cmp(mem):
print(f"Self replicating {breed=}")
vm.flush()
print(reg_a)
vm.flush()