Advent of Code 2025 - Day 10

Well some of us wanted a difficulty spike - and today we got one :sweat_smile:

https://github.com/sevenseacat/advent_of_code/blob/main/lib/y2025/day10.ex

I don’t think there’s any way to solve part 2 in the “naive” way (eg. with a breadth-first search). There’s no way to reduce the search space enough.

The brainwave is that each set of buttons/target joltage can be modelled as a set of simultaneous equations. Of course they’re not nice simultaneous equations, because there’s more variables (buttons) than equations (output values), and you need to add constraints for non-negative and whole numbers….

Name                     ips        average  deviation         median         99th %
day 10, part 1         78.99       12.66 ms     ±3.07%       12.63 ms       13.76 ms
day 10, part 2          6.90      144.82 ms     ±6.17%      141.61 ms      175.05 ms

1 Like

I didn’t find any way to solve it with a BFS or some smart DFS, BUT: for those who balk at implementing a equations solver (like me, I was not in the mood today), it is solvable with a genetic programming / iterative reparation approach, the hard part being to get enough guaranties that the solution is the actual minimum.

It is pretty random, and maybe not very elegant, but at least it did the job with my input (and took a lot more time to run than an equation solver would :laughing: ).

Thats been a tough one.
I used glpsol now. Took one second to run it for some reason:

defmodule Day10.Glpk do
@glpsol “glpsol”

buttons :: [[int]]  (indices der Counters pro Button)

jolts   :: [int]    (Targets)

def solve_machine(buttons, jolts) do
lp = build_lp(buttons, jolts)

tmp_dir = System.tmp_dir!()
id = :erlang.unique_integer([:positive]) |> Integer.to_string()
lp_path = Path.join(tmp_dir, "day10_#{id}.lp")

File.write!(lp_path, lp)

{output, exit_code} =
  System.cmd(@glpsol, ["--lp", lp_path], stderr_to_stdout: true)

File.rm(lp_path)

if exit_code != 0 do
  raise "glpsol failed (exit #{exit_code}):\n#{output}"
end

parse_objective(output)

end

defp build_lp(buttons, jolts) do
k = length(buttons)
m = length(jolts)

var_names = for j <- 0..(k - 1), do: "x#{j}"

objective = """
Minimize
  z: #{Enum.join(var_names, " + ")}
"""

constraints =
  [
    "Subject To"
    | Enum.map(0..(m - 1), fn i ->
        lhs_terms =
          buttons
          |> Enum.with_index()
          |> Enum.reduce([], fn {btn_indices, j}, acc ->
            if i in btn_indices do
              ["x#{j}" | acc]
            else
              acc
            end
          end)
          |> Enum.reverse()

        lhs =
          case lhs_terms do
            [] -> "0"
            _ -> Enum.join(lhs_terms, " + ")
          end

        "  c#{i}: #{lhs} = #{Enum.at(jolts, i)}"
      end)
  ]
  |> Enum.join("\n")

bounds =
  [
    "",
    "Bounds"
    | Enum.map(var_names, fn name ->
        "  #{name} >= 0"
      end)
  ]
  |> Enum.join("\n")

generals = """

Generals
  #{Enum.join(var_names, " ")}

End
"""

[objective, constraints, bounds, generals]
|> Enum.join("\n")

end

“Objective:  z =  33 (MINimum)”

defp parse_objective(output) do
with nil ← match_objective_line(output),
nil ← match_mip_line(output),
nil ← match_objective_value_line(output) do
raise “Could not parse objective value from glpsol output:\n#{output}”
else
value when is_integer(value) → value
end
end

Objective:  z =  33 (MINimum)

defp match_objective_line(output) do
case Regex.run(~r/Objective:\s+\w+\s*=\s*([-0-9.eE+]+)/, output,
capture: :all_but_first
) do
[num_str] → parse_number(num_str)
_ → nil
end
end

+     1: mip =   1.100000000e+01 >=   …

defp match_mip_line(output) do
case Regex.run(~r/mip\s*=\s*([-0-9.eE+]+)/, output,
capture: :all_but_first
) do
[num_str] → parse_number(num_str)
_ → nil
end
end

Objective value =   5.900000000e+01

defp match_objective_value_line(output) do
case Regex.run(~r/Objective value\s*=\s*([-0-9.eE+]+)/, output,
capture: :all_but_first
) do
[num_str] → parse_number(num_str)
_ → nil
end
end

defp parse_number(str) do
case Integer.parse(str) do
{i, “”} → 
i

_ ->
  case Float.parse(str) do
    {f, _} -> round(f)
    _ -> nil
  end

end
end

end

In solver function like this:

  def solve_part2(input) do
    input
    |> parse()
    |> Task.async_stream(
      fn %{button_indices: buttons, jolts: jolts} ->
        Day10.Glpk.solve_machine(buttons, jolts)
      end,
      max_concurrency: System.schedulers_online(),
      timeout: :infinity
    )
    |> Enum.reduce(0, fn
      {:ok, presses}, acc -> acc + presses
      {:exit, reason}, _ -> raise "GLPK failed: #{inspect(reason)}"
    end)
  end

I tried all sorts of things to speed up my native approach to two, including parallelizing it, but alas it’s too large for this approach.

Thanks for the hint on simul equations. Maybe I’ll circle back to see if I can do that before I cheat further and look at your code.

P1 was simple thanks to few observations, but P2 was pain (and there were some issues with Dantzig library that I needed to provide workarounds.

Parse

defmodule Decoder do
  def decode("[" <> pattern), do: do_lights(String.reverse(String.trim_trailing(pattern, "]")), 0)

  def decode("(" <> rest) do
    <<seq::binary-size(byte_size(rest) - 1), ")">> = rest

    seq
    |> String.split(",")
    |> Enum.map(&String.to_integer/1)
  end

  def decode("{" <> rest) do
    <<seq::binary-size(byte_size(rest) - 1), "}">> = rest

    seq
    |> String.split(",")
    |> Enum.map(&String.to_integer/1)
  end
  
  defp do_lights("", num), do: num
  defp do_lights("." <> rest, num), do: do_lights(rest, 2 * num)
  defp do_lights("#" <> rest, num), do: do_lights(rest, 2 * num + 1)
end

indicators =
  puzzle_input
  |> String.split("\n", trim: true)
  |> Enum.map(fn raw ->
    [lights | rest] =
      raw
      |> String.split()
      |> Enum.map(&Decoder.decode/1)

    {buttons, [whatever]} = Enum.split(rest, -1)

    {lights, buttons, whatever}
  end)

Part 1

defmodule Comb do
  def all_possible([]), do: [[]]
  def all_possible([a | rest]) do
    sub = all_possible(rest)

    sub ++ Enum.map(sub, &[a | &1])
  end
end

indicators
|> Enum.sum_by(fn {p, a, _} ->
  a
  |> Enum.map(&Enum.sum_by(&1, fn p -> 2 ** p end))
  |> Comb.all_possible()
  |> Enum.sort_by(&length/1)
  |> Enum.find(fn seq ->
    r = Enum.reduce(seq, 0, &Bitwise.bxor/2)

    p == r
  end)
  |> length()
end)

Part 2

# Here is implementation of Dantzig.HiGHSv1 implementation that adds support for externally provided executable and support for integers

defmodule Joltage do
  alias Dantzig.Polynomial
  alias Dantzig.Constraint
  alias Dantzig.Problem

  def solve({_pat, buttons, goal}) do
    p = Problem.new(direction: :minimize)

    {vars, {p, map}} =
      buttons
      |> Enum.with_index()
      |> Enum.map_reduce({p, %{}}, fn {list, idx}, {p, acc} ->
        {p, var} = Problem.new_variable(p, "v#{idx}", min: 0, type: :integer)

        acc =
          Enum.reduce(list, acc, fn key, map ->
            Map.update(map, key, [var], &[var | &1])
          end)

        {var, {p, acc}}
      end)

    p =
      map
      |> Enum.sort()
      |> Enum.map(&elem(&1, 1))
      |> Enum.zip(goal)
      |> Enum.reduce(p, fn {vars, target}, p ->
        poly = Polynomial.sum(vars)
        const = Constraint.new(poly, :==, target)

        Problem.add_constraint(p, const)
      end)

    p = Problem.increment_objective(p, Polynomial.sum(vars))

    {:ok, s} = Dantzig.HiGHSv1.solve(p)

    Enum.sum_by(vars, &Dantzig.Solution.evaluate(s, &1))
  end
end

indicators
|> Task.async_stream(&Joltage.solve/1, ordered: false)
|> Enum.sum_by(&elem(&1, 1))
|> trunc()

I finally abandonned my heuristic/cache/sorting/pruning semi-brute force approach, couldn not make it work.

I did not know about z3, which seems that everyones uses on the subreddit, so here it goes

defmodule AdventOfCode.Solutions.Y25.Day10 do
  alias AoC.Input

  def parse(input, _part) do
    input
    |> Input.stream!(trim: true)
    |> Enum.map(&parse_line/1)
  end

  defp parse_line(line) do
    "[" <> rest = line
    [lights, " (" <> rest] = String.split(rest, "]")

    lights =
      lights
      |> String.to_charlist()
      |> :lists.reverse()
      |> Enum.map(fn
        ?. -> 0
        ?# -> 1
      end)

    [prev, jolts] = String.split(rest, ") {")
    buttons = String.split(prev, ") (")

    buttons =
      Enum.map(buttons, fn str ->
        str |> String.split(",") |> Enum.map(&String.to_integer/1)
      end)

    jolts =
      jolts
      |> String.trim_trailing("}")
      |> String.split(",")
      |> Enum.map(&String.to_integer/1)

    {lights, buttons, jolts}
  end

  def part_one(machines) do
    machines
    |> Enum.map(fn {lights, buttons, _} ->
      lights = Integer.undigits(lights, 2)
      buttons = Enum.map(buttons, &button_to_int/1)
      {lights, buttons}
    end)
    |> Enum.sum_by(&best_combination/1)
  end

  defp button_to_int(indexes) do
    Enum.reduce(indexes, 0, &(2 ** &1 + &2))
  end

  defp best_combination(machine) do
    {lights, buttons} = machine

    stream_buttons(buttons)
    |> Enum.find_value(fn pressed_buttons ->
      if lights == Enum.reduce(pressed_buttons, 0, &Bitwise.bxor(&1, &2)) do
        length(pressed_buttons)
      else
        nil
      end
    end)
  end

  defp stream_buttons(buttons) do
    n_presses = Stream.iterate(1, &(&1 + 1))

    Stream.flat_map(n_presses, fn n ->
      stream_buttons(n, buttons)
    end)
  end

  # TODO we are repeating: [0, 1] and [1, 0] should be the same XOR result, we
  # should just iterate on digits, right ? But we still need to return [0, 0]
  # somehow

  defp stream_buttons(1, buttons) do
    Enum.map(buttons, &[&1])
  end

  defp stream_buttons(n, buttons) when n > 0 do
    stream_buttons(n - 1, buttons)
    |> Stream.flat_map(fn [top | _] = pressed_buttons ->
      # Basic iteration
      # Stream.map(buttons, fn btn -> [btn | pressed_buttons] end)

      # Only try each combination once
      Stream.flat_map(buttons, fn
        btn when btn >= top -> [[btn | pressed_buttons]]
        _ -> []
      end)
    end)
  end

  def part_two(machines) do
    machines
    |> Task.async_stream(&solve_machine/1, max_concurrency: 200, ordered: false)
    |> Enum.sum_by(&elem(&1, 1))
  end

  defp solve_machine(machine) do
    {_, buttons, target} = machine

    args = Enum.take([:a, :b, :c, :d, :e, :f, :g, :h, :i, :j, :k, :l, :m], length(buttons))

    counter_dependencies =
      target
      |> Enum.with_index()
      |> Enum.map(fn {_, counter_index} ->
        buttons
        |> Enum.zip(args)
        |> Enum.filter(fn {button, _arg} ->
          counter_index in button
        end)
        |> Enum.map(fn {_button, arg} -> arg end)
      end)

    smt2 =
      [
        Enum.map(args, fn arg -> "(declare-const #{arg} Int)" end),
        Enum.map(args, fn arg -> "(assert (>= #{arg} 0))" end),
        Enum.zip_with(target, counter_dependencies, fn t, deps ->
          "(assert (= #{t} (+#{Enum.map(deps, &" #{&1}")})))"
        end),
        """
        (define-fun sum () Int (+#{Enum.map(args, &" #{&1}")}))
        (minimize sum)
        (check-sat)
        (get-value (sum))
        """
      ]

    z3 = System.find_executable("z3")
    port = Port.open({:spawn_executable, z3}, [:binary, args: ["-in"]])
    send(port, {self(), {:command, smt2}})

    int = receive_result(port)
    true = Port.close(port)
    int
  end

  defp receive_result(port) do
    receive do
      {^port, {:data, "sat\n"}} -> receive_result(port)
      {^port, {:data, "((sum " <> result}} -> parse_result(result)
      {^port, {:data, "sat\n((sum " <> result}} -> parse_result(result)
    end
  end

  defp parse_result(result) do
    {int, "))" <> _} = Integer.parse(result)
    int
  end
end

Also maybe @mudasobwa could you tell me how to use z3 with erlang modules. I’ve seen that Cures uses z3 but is there an API for it? I found the same kind of string that we would input to z3 stdin.

Yeah there’s some PRs open to fix both of those issues, so I used that branch from GitHub :smiley:

Has anybody seen any solution that doesn’t use a solver library? I’ve got to think there is something about the simplicity, 1 and 0 coefficients, of these linear equations that could be taken advantage of.

I found two, one claimed 35minutes runtime, the other one 20 (python)

If anyone is still interested in thinking about this, here is a thought I have. This is my set of simultaneous equations for the first machine of the sample data. The format is a tuple of the coefficients list and result of each equation.

Equations: [
  {[1, 1, 0, 0, 0, 0], 3},
  {[1, 0, 0, 0, 1, 0], 5},
  {[0, 0, 1, 0, 1, 1], 7},
  {[0, 1, 1, 1, 0, 0], 4}
]

The first equation tells us the [b0,b1] pair must be one of these combination: [0,3],[1,2],[2,1],[3,0].

Those combinations will then combine in the next equation to give a set of possible values for b4. For example [3,0] only allows b4 to be 0,1, or 2

Then those combinations will combine in the 3rd equation to add in the combinations of b2 and b5

And then finally add all the combinatorics for the 4th equation.

Also, along the way we’re going to find empty set combinations, i.e. no possible solutions, which means that we can eliminate previous combinations as we go.

It feels like I should sort the equations to minimize the number of new combinations I need to build up in the next step.

Granted this is the simplest machine. But, if you follow this thinking, and did it for one of the “real” problems, would it blow up into the stratosphere? Or would that incremental removal of unsolvable combinations whittle it down as you go into a set of combinations that could actually be evaluated?

There is no interface, Cure invokes z3 via Erlang port cure-lang/src/smt/cure_smt_process.erl at main · am-kantox/cure-lang · GitHub

1 Like

Even with this PRs it doesn’t work, as the solver always used downloaded HiGHS binary instead of using user-provided one. In addition to that HiGHS 1.9.0 (which is downloaded) has some bug, which causes wrong result for my input, and if I change version to 1.12.0 it fails, as it requires additional external libraries (OpenBLAS IIRC). I have forked Dantzig to remove the HiGHS downloader (as it is working improperly anyway) in general and make it more into “CPLEX library” that can be then used with any solver and move solvers downloading and management to separate libs. Something like Nx or Ecto is doing. That way it can use Z3, HiGHS or anything else that can ingest this particular file format (and potentially implement other export formats as well).

@mudasobwa I was also thinking about implementing SMT-LIB generator in Elixir, so it would provide a way to use any SMT solver with Elixir without worrying about compiling binaries and stuff.

2 Likes

Yes, my solution does not use any solver library:

defmodule Y2025.D10 do
  use Day, input: "2025/10", part1: ~c"l", part2: ~c"l"

  defp part2(input) do
    lines = input |> parse_input() |> Enum.with_index()

    lines
    |> Map.new(fn {_, idx} -> {idx, 1000} end)
    |> Stream.unfold(fn old_map ->
      new_map =
        Enum.reduce(lines, old_map, fn {l, idx}, acc ->
          maybe_new_min = maybe_min_presses(l)
          Map.update!(acc, idx, &min(&1, maybe_new_min))
        end)

      old_sum = old_map |> Map.values() |> Enum.sum()
      new_sum = new_map |> Map.values() |> Enum.sum()

      {min(old_sum, new_sum), new_map}
    end)
    |> Stream.map(&IO.inspect/1)
    |> Stream.transform({nil, 100, 100}, &until_stable/2)
    |> Enum.take(1)
    |> hd()
  end

  defp until_stable(value, {last_value, requirement, count}) do
    case {value == last_value, count} do
      {true, 0} ->
        {:halt, {value, requirement, count - 1}}

      {true, 1} ->
        {[value], {value, requirement, count - 1}}

      {true, _} ->
        {[], {value, requirement, count - 1}}

      {false, _} ->
        {[], {value, requirement, requirement - 1}}
    end
  end

  defp maybe_min_presses(%{lights: _, buttons: buttons, requirements: requirements}) do
    target_map = index(requirements)

    repair(buttons, target_map)
  end

  defp repair(buttons, target_map) do
    [initial_candidate] =
      buttons
      |> Enum.sort_by(&Enum.count/1, :desc)
      |> Enum.take(1)

    target_sum =
      target_map
      |> Map.values()
      |> Enum.sum()

    initial_presses = div(target_sum, length(initial_candidate))

    buttons_map =
      buttons
      |> Enum.map(&{&1, if(&1 == initial_candidate, do: initial_presses, else: 0)})
      |> Map.new()

    initial_map =
      target_map
      |> Map.keys()
      |> Enum.map(&if Enum.member?(initial_candidate, &1), do: initial_presses, else: 0)
      |> index()

    repair_rec(initial_map, target_map, buttons_map)
  end

  defp repair_rec(target_map, target_map, buttons_map) do
    buttons_map
    |> Map.values()
    |> Enum.sum()
  end

  defp repair_rec(current_map, target_map, buttons_map) do
    candidates =
      buttons_map
      |> Map.keys()
      |> Enum.map(&{score(&1, current_map, target_map), &1})
      |> Enum.sort_by(&elem(&1, 0), :desc)

    add = Enum.take(candidates, Enum.random(1..3))
    sub = Enum.take(candidates, -Enum.random(1..3))

    mutations =
      if Enum.random(0..3) == 0 do
        candidates
        |> Enum.shuffle()
        |> Enum.take(Enum.random(1..3))
      else
        []
      end

    {next_map, new_buttons_map} =
      add
      |> Enum.concat(sub)
      |> Enum.concat(mutations)
      |> Enum.reduce({current_map, buttons_map}, &apply_button/2)

    repair_rec(next_map, target_map, new_buttons_map)
  end

  defp apply_button({score, button}, {current_map, buttons_map}) do
    dir =
      cond do
        score > 0 and Map.get(buttons_map, button) > 0 and Enum.random(0..49) == 0 -> -1
        score > 0 -> 1
        score < 0 and Map.get(buttons_map, button) > 0 -> -1
        true -> 0
      end

    new_map =
      Enum.reduce(button, current_map, fn idx, acc ->
        Map.update!(acc, idx, &(&1 + dir))
      end)

    new_buttons_map = Map.update!(buttons_map, button, fn count -> count + dir end)

    {new_map, new_buttons_map}
  end

  defp score(button, current_map, target_map) do
    Enum.reduce(button, 0, fn idx, acc ->
      c = Map.fetch!(current_map, idx)
      t = Map.fetch!(target_map, idx)

      cond do
        c < t ->
          acc + Math.sqrt(t - c)

        c > t ->
          acc - Math.sqrt(c - t)

        true ->
          acc
      end
    end)
  end

  defp index(lights) do
    lights
    |> Enum.with_index()
    |> Enum.reduce(%{}, fn {light, idx}, acc -> Map.put(acc, idx, light) end)
  end

  defp parse_input(input), do: Enum.map(input, &parse_line/1)

  defp parse_line(line) do
    [lights | rest] =
      ~r/[\[\(\{]([\d\.\#,]+)[\}\)\]]/
      |> Regex.scan(line)
      |> Enum.map(&Enum.at(&1, 1))

    {buttons, [requirements]} =
      rest
      |> Enum.map(
        &(&1
          |> Utils.splitrim(",")
          |> Enum.map(fn num_str -> String.to_integer(num_str) end))
      )
      |> Enum.split(-1)

    %{lights: Utils.splitrim(lights, ""), buttons: buttons, requirements: requirements}
  end
end

It’s basically a recursive reparation approach with genetic programming inspiration. Those parameters converged “pretty quickly” :

  • A single iteration takes around ~1 second, it processes all the machines
  • We wait for the minimum (sum) to be stable for 100 iterations
  • Each iteration “repairs” each machine until it reaches the target numeric counters state, keeping track of the button presses count
  • The initial state (numeric counters and presses count) is filled with a very rough approximation, it is reset on each reparation
  • Each reparation step presses between 1 and 3 buttons, prioritized by a score (how relevant each button is to converge)
  • Each reparation step unpresses between 1 and 3 buttons, prioritized by a score (how irrelevant each button is to converge)
  • There is 25% chance that 1 to 3 additional buttons (mutations) get triggered, pressed or unpressed depending on their relevance to converge
  • There is a slight 2% chance that any button that should be pressed would be unpressed (only if it can be unpressed, it helps to converge quicker by jumping out of rabbit holes caused by the very basic / poor scoring method)

It took ~5min20sec to give the answer for my input, but I suspect it may vary a lot :grin:

1 Like

That seems pretty quick from where I’m at. I’m running it now and it does seem to be slowly working it’s way down to the correct result, but it’s been going for about 2 hours. It might take another hour or two to finally get to the right number at the rate it’s been counting down.

I can’t say I’m surprised, the time it takes to converge vary a lot for my input and seems very sensible to the parameters (but I did not benchmark it cleanly, so it might just be an impression). It’s not a very reliable solution in its current state, and I’m not sure I’ll take time to improve it :smiley:

I let it run overnight and it was still working its way down toward the correct value, but I gave up on it in the end. Still, I think it was a good attempt without using a solver. :slightly_smiling_face:

1 Like

I’m guessing this solution for part 2 did not work. The problem I see is that z3 doesn’t provide the actual optimal (minimum) value on it’s first run. It provides a guess at what the optimal value is. What the official z3 api bindings do is they repeatedly call z3 behind the scenes. Each time z3 is called, the previous value it returned is used to add a new constraint. This is repeated until the result doesn’t change.

For example, consider this z3 input which attempts to find the 2 integers which multiply to 24, and have the minimum sum:

(declare-const x Int)
(declare-const y Int)
(assert (= (* x y) 24))
(assert (> x 0))
(assert (> y 0))
(minimize (+ x y))
(check-sat)
(get-model)

That outputs 8 and 3:

(
  (define-fun x () Int
    8)
  (define-fun y () Int
    3)
)

This is obviously wrong because the sum of 8 and 3 is 11 which is not optimal. The optimal answer is 6 and 4 which has a sum of 10. To get the optimal value, you have to run z3 again with the added constraint that the next answer should try to be more optimal than the previous answer: (assert (< (+ x y) 11))

(declare-const x Int)
(declare-const y Int)
(assert (= (* x y) 24))
(assert (> x 0))
(assert (> y 0))
(minimize (+ x y))
(assert (< (+ x y) 11))
(check-sat)
(get-model)

Now that will output the correct values of 6 and 4.

Well I added a unit test with my expected solution and it works 100% of times without a second call.

z3 worked on my input. I tried a couple of the different solver options in this thread, having no experience with any of them, and z3 was the only one that worked without error for me. Thanks btw, @lud . I fully expected it not to work on my input or to give me some error, but I just brew installed z3, set the path, and it worked on the first try. Now I just need to go back when I have time and dig into how it all works so I can actually understand it.

(Time, who has time? :rofl:)

1 Like