Skip to content

Commit 207000d

Browse files
committed
feat: aoc 2025 day 10 solution + tests
1 parent a857a88 commit 207000d

File tree

6 files changed

+386
-0
lines changed

6 files changed

+386
-0
lines changed

_2025/solutions/day10.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""Day 10: Factory
2+
3+
This module provides the solution for Advent of Code 2025 - Day 10.
4+
5+
It works with incomplete machine manuals that only list indicator light
6+
diagrams, button wiring schematics, and joltage requirements.
7+
8+
Part 1 ignores joltages and finds the minimum number of button presses
9+
needed to reach each machine's target light pattern from all-off.
10+
Part 2 uses the same button sets but treats joltages as a linear system
11+
to satisfy numeric requirements for each machine.
12+
"""
13+
14+
import math
15+
import re
16+
17+
from collections import deque
18+
from itertools import combinations
19+
from numpy import transpose
20+
from scipy.optimize import linprog
21+
from typing import ClassVar
22+
23+
from aoc.models.base import SolutionBase
24+
25+
26+
class Solution(SolutionBase):
27+
"""Configure factory machines by solving light and joltage constraints.
28+
29+
Each input line encodes a single machine description with three parts:
30+
- Square brackets []: the target indicator light pattern ('.' = off, '#' = on)
31+
- Parentheses () : button wiring schematics listing which lights they toggle
32+
- Curly braces {} : joltage requirements for the machine
33+
34+
Part 1 starts from all lights off and uses BFS over light states to find
35+
the fewest presses to reach the target pattern, ignoring joltages.
36+
Part 2 models joltages as a linear integer system and uses a solver to
37+
find the minimum number of button presses that satisfies the target
38+
joltage vector.
39+
"""
40+
41+
REGEX: ClassVar[re.Pattern[str]] = re.compile(r"\[([^\]]*)]|\(([^)]*)\)|\{([^}]*)}")
42+
43+
def parse_data(self, line: str) -> tuple[list[str], list[tuple[int, ...]], list[int]]:
44+
"""Parse a single machine description into lights, buttons, and joltage.
45+
46+
The line must contain exactly one indicator light diagram in square
47+
brackets, zero or more button wiring schematics in parentheses, and
48+
one joltage requirements list in curly braces.
49+
50+
Args:
51+
line: Raw input line describing a machine
52+
53+
Returns
54+
-------
55+
tuple[list[str], list[tuple[int, ...]], list[int]]:
56+
- List of characters for the light diagram
57+
- List of buttons, each as a tuple of light indices they toggle
58+
- List of joltage targets for the machine
59+
"""
60+
square: list[str] = []
61+
parentheses: list[tuple[int, ...]] = []
62+
curly: list[int] = []
63+
64+
for match in self.REGEX.finditer(line):
65+
gr1, gr2, gr3 = match.groups()
66+
if gr1 is not None:
67+
square = list(gr1)
68+
elif gr2 is not None:
69+
parentheses.append(tuple(map(int, gr2.split(","))))
70+
elif gr3 is not None:
71+
curly = list(map(int, gr3.split(",")))
72+
73+
if square is None or curly is None:
74+
raise ValueError(f"Invalid line (missing [] or {{}}): {line!r}")
75+
76+
if curly:
77+
useful_parentheses: list[tuple[int, ...]] = []
78+
for btn in parentheses:
79+
if any(curly[idx] > 0 for idx in btn):
80+
useful_parentheses.append(btn)
81+
82+
parentheses = useful_parentheses
83+
84+
return square, parentheses, curly
85+
86+
def to_light_state(self, lights: list[str]) -> tuple[int, ...]:
87+
"""Convert a light pattern ('.'/'#') into a boolean tuple state."""
88+
return tuple(ch == "#" for ch in lights)
89+
90+
def apply_button(
91+
self, state: tuple[int, ...], button: tuple[int, ...]
92+
) -> tuple[int, ...]:
93+
"""Toggle a set of indicator lights according to a button wiring.
94+
95+
Args:
96+
state: Current boolean light state as a tuple
97+
button: Tuple of indices of lights to toggle
98+
99+
Returns
100+
-------
101+
tuple[int, ...]: New light state after pressing the button once
102+
"""
103+
arr = list(state)
104+
for idx in button:
105+
arr[idx] = not arr[idx]
106+
107+
return tuple(arr)
108+
109+
def min_presses_for_lights(
110+
self, lights: list[str], buttons: list[tuple[int, ...]]
111+
) -> int:
112+
"""Compute minimum presses to reach target light pattern using BFS.
113+
114+
Treats each distinct light state as a node in a graph and each button
115+
press as an edge to a new state. Performs a breadth-first search from
116+
the all-off state until the target pattern is reached.
117+
118+
Args:
119+
lights: Target light diagram as list of '.' and '#'
120+
buttons: List of button wirings as tuples of indices
121+
122+
Returns
123+
-------
124+
int: Minimum number of button presses to reach target pattern
125+
126+
Raises
127+
------
128+
ValueError: If the target pattern cannot be reached
129+
"""
130+
goal = self.to_light_state(lights)
131+
start: tuple[int, ...] = tuple(False for _ in goal)
132+
133+
q: deque[tuple[tuple[int, ...], int]] = deque()
134+
q.append((start, 0))
135+
visited: set[tuple[int, ...]] = set()
136+
137+
while q:
138+
curr, steps = q.popleft()
139+
if curr == goal:
140+
return steps
141+
142+
if curr in visited:
143+
continue
144+
145+
visited.add(curr)
146+
147+
for btn in buttons:
148+
nxt = self.apply_button(curr, btn)
149+
if nxt not in visited:
150+
q.append((nxt, steps + 1))
151+
152+
raise ValueError(f"Unreachable lights pattern {lights} with given buttons")
153+
154+
def button_to_vector(self, button: tuple[int, ...], num_slots: int) -> list[int]:
155+
"""Convert a button wiring into a vector for the joltage equation system.
156+
157+
Args:
158+
button: Tuple of indices affected by this button
159+
num_slots: Length of the target joltage vector
160+
161+
Returns
162+
-------
163+
list[int]: Vector with 1s at affected indices and 0 otherwise
164+
"""
165+
vec = [0] * num_slots
166+
for idx in button:
167+
vec[idx] = 1
168+
return vec
169+
170+
def min_presses_for_machine(
171+
self, buttons: list[tuple[int, ...]], target: list[int],
172+
) -> int:
173+
"""Compute minimum button presses to satisfy machine joltage constraints.
174+
175+
Models each button as contributing a fixed amount to one or more joltage
176+
slots and solves a linear system with integrality constraints where
177+
the objective is to minimize the total number of button presses.
178+
179+
Args:
180+
buttons: List of button wirings as tuples of indices
181+
target: Desired joltage values for the machine
182+
183+
Returns
184+
-------
185+
int: Minimum number of button presses to meet the joltage target
186+
187+
Raises
188+
------
189+
ValueError: If no combination of button presses can satisfy target
190+
"""
191+
if not target:
192+
return 0
193+
194+
N = len(buttons)
195+
num_jolt = len(target)
196+
197+
if N == 0:
198+
if any(t != 0 for t in target):
199+
raise ValueError(f"Unreachable target {target} with given buttons")
200+
return 0
201+
202+
# Objective: minimize total button presses
203+
c = [1] * N
204+
205+
# Build equality constraints: sum(button_vectors * presses) = target
206+
A_eq = [self.button_to_vector(btn, num_jolt) for btn in buttons]
207+
A_eq = transpose(A_eq)
208+
b_eq = target
209+
integrality = [1] * N
210+
211+
res = linprog(
212+
c,
213+
A_eq=A_eq,
214+
b_eq=b_eq,
215+
integrality=integrality,
216+
)
217+
218+
if not res.success:
219+
raise ValueError(f"Unreachable target {target} with given buttons")
220+
221+
return int(math.ceil(sum(res.x)))
222+
223+
def part1(self, data: list[str]) -> int:
224+
"""Sum minimum button presses to match indicator lights for all machines.
225+
226+
For each machine, parses the light diagram and button schematics, then
227+
runs a BFS to find the fewest presses needed to reach the target
228+
light configuration from all-off, ignoring joltage requirements.
229+
230+
Args:
231+
data: List of machine descriptions, one per line
232+
233+
Returns
234+
-------
235+
int: Total of minimum button presses across all machines
236+
"""
237+
score = 0
238+
239+
for line in data:
240+
lights, buttons, _ = self.parse_data(line)
241+
score += self.min_presses_for_lights(lights, buttons)
242+
243+
return score
244+
245+
def part2(self, data: list[str]) -> int:
246+
"""Sum minimum button presses to satisfy joltage requirements for all machines.
247+
248+
For each machine, parses the button schematics and joltage requirements.
249+
It then builds and solves an integer linear program to find the minimum
250+
total presses needed so the combined button effects match the joltage
251+
target exactly.
252+
253+
Args:
254+
data: List of machine descriptions, one per line
255+
256+
Returns
257+
-------
258+
int: Total minimum button presses to satisfy all joltage constraints
259+
"""
260+
score = 0
261+
262+
for line in data:
263+
_, buttons, joltage = self.parse_data(line)
264+
score += self.min_presses_for_machine(buttons, joltage)
265+
266+
return score
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[.##.] (3) (1,3) (2) (2,3) (0,2) (0,1) {3,5,4,7}
2+
[...#.] (0,2,3,4) (2,3) (0,4) (0,1,2) (1,2,3,4) {7,5,12,7,2}
3+
[.###.#] (0,1,2,3,4) (0,3,4) (0,1,2,4,5) (1,2) {10,11,11,5,10,5}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[.##.] (3) (1,3) (2) (2,3) (0,2) (0,1) {3,5,4,7}
2+
[...#.] (0,2,3,4) (2,3) (0,4) (0,1,2) (1,2,3,4) {7,5,12,7,2}
3+
[.###.#] (0,1,2,3,4) (0,3,4) (0,1,2,4,5) (1,2) {10,11,11,5,10,5}

_2025/tests/test_10.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Test suite for Day 10: Factory
2+
3+
This module contains tests for the Day 10 solution, which configures factory
4+
machines by pressing buttons to match indicator light diagrams and, later,
5+
joltage requirements. The tests verify:
6+
7+
1. Part 1: Fewest total button presses to match all indicator light patterns
8+
2. Part 2: Fewest total button presses to satisfy all joltage requirements
9+
"""
10+
from aoc.models.tester import TestSolutionUtility
11+
12+
13+
def test_day10_part1() -> None:
14+
"""Test computing minimal presses to match indicator lights for all machines.
15+
16+
This test runs the solution for Part 1 of the puzzle against the
17+
provided test input and compares the result with the expected output.
18+
"""
19+
TestSolutionUtility.run_test(
20+
year=2025,
21+
day=10,
22+
is_raw=False,
23+
part_num=1,
24+
expected=7,
25+
)
26+
27+
28+
def test_day10_part2() -> None:
29+
"""Test computing minimal presses to satisfy joltage requirements for all machines.
30+
31+
This test runs the solution for Part 2 of the puzzle against the
32+
provided test input and compares the result with the expected output.
33+
"""
34+
TestSolutionUtility.run_test(
35+
year=2025,
36+
day=10,
37+
is_raw=False,
38+
part_num=2,
39+
expected=33,
40+
)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"requests<3.0.0,>=2.32.3",
1111
"bs4<1.0.0,>=0.0.2",
1212
"rustworkx>=0.17.1",
13+
"scipy>=1.16.3",
1314
]
1415

1516
[dependency-groups]

0 commit comments

Comments
 (0)