Advent of Code 2025; Day 7: Laboratories

Tracing tachyon beams through a manifold and counting all possible timelines.

Part 1: Tachyon Beam Splits

I’ve made it to the North Pole research laboratories where some teleportation technology is being developed. Unfortunately, In my eagerness to play with the teleporter, it seems to have malfunctioned and the diagnostic has identified an issue with the tachyon manifold.

There’s a diagram showing how a tachyon beam behaves as it travels through the manifold:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.......S.......
...............
.......^.......
...............
......^.^......
...............
.....^.^.^.....
...............
....^.^...^....
...............
...^.^...^.^...
...............
..^...^.....^..
...............
.^.^.^.^.^...^.
...............

The beam enters at S and travels downward through the manifold space. When it encounters a splitter (^), the original beam stops and two new beams emerge - one travelling left, one travelling right. These new beams then continue downward and can hit further splitters. In order to repair the teleporter I need to understand the beam splitting fully, and to do that I need to count how many times beams split within the manifold.

I figured it would be simplest to process the manifold space row by row, tracking the horizontal positions of all active beams:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def part1(input_data: str) -> int:
    rows = input_data.strip().splitlines()
    # We start with 1 beam at the position marked "S"
    beams = set([rows[0].index("S")])

    splits = 0
    for row in rows[1:]:
        # Collate the splitters from the current row
        splitters = [i for i, v in enumerate(row) if v == "^"]
        # Determine if these collide with the active beams
        hits = beams.intersection(splitters)
        if any(hits):
            splits += len(hits)

            # Retain position of beams that didn't hit anything
            beams = beams.difference(hits)
            # For those that hit, recreate them either side of the splitter
            for i in hits:
                if row[i-1] != "^":
                    beams.add(i-1)
                if row[i+1] != "^":
                    beams.add(i+1)

    return splits

The key insight here is that I don’t need to track each beam individually - I just need to know which horizontal positions have active beams at any given row. Using a set means that if two beams happen to converge on the same position, they’re effectively the same beam for counting purposes.

When a beam hits a splitter, I remove it from the set and add two new positions either side (unless there’s another splitter immediately adjacent). The total number of hits across all rows gives me my split count.

That seems to do the trick, I’ll get to work with the repairs.

Part 2: Counting Timelines

Right, so it turns out that this is a quantum tachyon manifold. It’s not the beam that is splitting, but time itself creating multiple timelines where the particle takes eiher the left or right path at a splitter, allowing a single particle to simultaneously travel all possible paths through the manifold.

Rather than counting splits, I now need to count how many possible timelines exist - essentially how many different paths could the beam take through the manifold?

My first instinct was that this should be straightforward: each split produces two paths, so the total timelines should just be 2^splits or something similar. The example seemed to support a simple calculation of (splits - 1) * 2 but this proved incorrect. The problem is that the relationship between splits and timelines isn’t that simple - it depends on the structure of where the splitters are positioned in the manifold space.

I gave up on the simple formula and was planning to write out an algorithm to calculate each beams path, I could speed it up with some memoisation. If any given splitter always produces the same number of downstream timelines regardless of how the beam got there, I can reuse that value for other beams and save the computation. But the had a bit of a light bulb moment, what if I work backwards from the exit?

Think about it: at the manifold exit, there are no more splits possible. Each beam that exits represents exactly one timeline. So I can initialise every position with a timeline count of 1:

1
2
3
4
5
  manifold exit (bottom)
  ─────────────────────────
  timelines:  1 1 1 1 1 1 1
              ─ ─ ─ ─ ─ ─ ─
  positions:  0 1 2 3 4 5 6

If I trace backwards up through the manifold, every time I encounter a splitter I know that the timelines from its left and right outputs need to be combined - because originally that splitter would have split one beam into those two directions.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
              . . . . . ^ . . . . .  splitter at position 5
  timelines:  1 1 1 1 1 1 1 1 1 1 1
              combine ◄─┴─► timelines from positions 4 and 6
              timeline[5] = timeline[4] + timeline[6]
  timelines:  1 1 1 1 1 2 1 1 1 1 1
                        └─ now represents 2 possible paths

By reversing the rows and processing from exit to entry, I accumulate the number of possible timelines at each position. When I hit a splitter going backwards, I sum the timelines from both adjacent positions - because in forward time, that splitter would have split into those two paths.

Working through a small example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
                     . . . . S . . . .     entry point
                     . . . . . . . . .
                     . . . . ^ . . . .     splitter A
                     . . . . . . . . .
                     . . . ^ . ^ . . .     splitters B and C
                     . . . . . . . . .     exit

  Step 1: initialise all positions to 1
          timelines: 1 1 1 1 1 1 1 1 1

  Step 2: process splitters B(3) and C(5)
          timeline[3] = timeline[2] + timeline[4] = 1 + 1 = 2
          timeline[5] = timeline[4] + timeline[6] = 1 + 1 = 2
          timelines: 1 1 1 2 1 2 1 1 1

  Step 3: process splitter A(4)
          timeline[4] = timeline[3] + timeline[5] = 2 + 2 = 4
          timelines: 1 1 1 2 4 2 1 1 1
                             └─ entry point S: 4 possible timelines

At the end, I just look up the timeline count at the beam’s entry position. No need to enumerate every possible path.

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def part2(input_data: str) -> int:
    rows = input_data.strip().splitlines()
    max_x = len(rows[0])
    beam = rows[0].index("S")

    # At the exit, each position represents 1 possible timeline
    timeline: list[int] = [1] * max_x

    rows.reverse()
    for row in rows[:-1]:
        # Collate splitters in current manifold space
        splitters = [i for i, v in enumerate(row) if v == "^"]
        for splitter in splitters:
            # Travelling in reverse: recombine the beams to trace their timelines
            # The accumulated timeline of each beam is combined into the
            # manifold space where it would originally have split
            timeline[splitter] = timeline[splitter-1] + timeline[splitter+1]

    # The tachyon entry point determines possible timelines it will create
    return timeline[beam]

I’ll admit this one took me longer than I’d like to figure out. The initial assumption that it would be a simple mathematical relationship led me down a frustrating path before I stepped back and thought about it differently.

As always, full code is available at github.com/lordmoocow/aoc25.


Day 7 done, albeit a bit late. I’ve been struggling to find enough time to get around to these challenges at the moment, but I’ll do my best to keep up with it. The reverse-traversal approach in part 2 was quite satisfying once it clicked - sometimes the best solution is to look at the problem from the opposite direction. Five days to go.