← Learning notesFly-in live demoMade by Claude (Anthropic) to teach me while I built Fly-in. Code shown is from that stage of the project.

Building the scheduler

Section 6, step by step: from your BFS to a planner that moves every drone without collisions. The concept is on page 4 · Time & bookings; this page is about how to build it.

parser→network→pathfinder (BFS)→scheduler→output→display
Why this is worth it: I prototyped exactly the algorithm on this page (about 40 lines) and ran it on all 10 subject maps. It reaches the benchmark optimum on every one (easy 4/4/4, medium 8/10/6, hard 13/16/26, challenger 43), and the reference validator accepts all outputs. No tricks, no special cases. You're building something that gets full bonus points.

1 · What changes from your BFS

Your shortest_path() is the skeleton. Almost every line survives; these are the changes:

Your BFS (section 5)Scheduler search (section 6)
A state is…a zone: "junction"a zone at a turn: ("junction", 2)
Where you can gothe neighboursthe neighbours + staying put (waiting)
Cost of a stepalways 11 turn, or 2 into a restricted zone
Extra checkskip blockedskip blocked + skip if the calendar is full
came_from keyzone(zone, turn)
QueueFIFO, pop(0)earliest turn first, heapq (section 4 explains why)
Result["start", "junction", …][("start", 0), ("start", 1), ("junction", 2), …]
Afterwardsnothingbook the route in the calendar, then plan the next drone

2 · The state: (zone, turn)

("junction", 2) means: at the end of turn 2, the drone is in junction. Every drone starts at (start, 0). A route is the list of states from there to the goal, one entry per turn the drone is in a zone:

[("start", 0), ("start", 1), ("junction", 2), ("path_a", 3), ("goal", 4)]
#  waited at start in turn 1 ──┘        then one zone per turn

Because the turn is part of the state, ("start", 0) and ("start", 1) are different states. That's what makes waiting possible, and it's why came_from never loops forever on the same zone.

3 · The three moves

From a state (zone, t) there are exactly three kinds of next states. Each one has a time it lands, things it must check, and things it books once the route is chosen.

Wait

(zone, t) → (zone, t+1)
Check
zone has room at t+1
Book
zone slot (zone, t+1)
Output
nothing (waiting drones aren't printed)

Normal move

(zone, t) → (nb, t+1)
Check
nb not blocked
nb has room at t+1
link zone-nb has room at t+1
Book
zone slot (nb, t+1)
link slot (zone-nb, t+1)
Output
turn t+1: D1-nb

Into restricted

(zone, t) → (r, t+2)
Check
r has room at t+2 (it can't hover!)
link zone-r has room at t+1
Book
zone slot (r, t+2)
link slot (zone-r, t+1)
Output
turn t+1: D1-zone-r
turn t+2: D1-r
Spot the pattern: the normal move and the restricted move are the same thing with move_cost 1 or 2. Landing turn = t + network.zones[nb].move_cost, and you wrote that property back in section 1. The link is always booked at t+1, the turn the drone starts crossing. That one rule covers both "link capacity counts drones starting to cross" and "a restricted connection is free again the turn the drone lands".

4 · Why the queue must be sorted by turn

With plain BFS every step costs 1, so the FIFO queue is automatically in turn order. A restricted move costs 2, and that breaks it. Small example: from s you can go via restricted r (slow) or via a (fast), and both lead to g.

FIFO queue (pop(0))
pop (s,0) → push (s,1) (r,2) (a,1)
pop (s,1) → push (s,2) (r,3) (a,2)
pop (r,2) → push (s,3) (g,3)
pop (a,1) → push (g,2)
pop (s,2) … (r,3) … (a,2) … (s,3)
pop (g,3) → "done, 3 turns" ✗
         (g,2) is still in the queue behind it
Earliest turn first (heapq)
pop (s,0) → push (s,1) (r,2) (a,1)
heap order:  (a,1) (s,1) (r,2)
pop (a,1) → push (g,2)
pop (s,1) …
pop (a,2) …
pop (g,2) → "done, 2 turns" ✓
         every turn-1 state came first

FIFO happened to look at the slow branch first, and returned the first goal it pulled out. I measured this on the real maps too: with FIFO, hard/03 takes 27 turns instead of 26 and medium/03 takes 7 instead of 6.

The fix is the standard library's heapq: a list that always hands you the smallest item. Tuples compare by their first element first, so put the turn first:

import heapq

heap: list[tuple[int, str]] = [(0, "start")]   # (turn, zone): turn first!
heapq.heappush(heap, (2, "r"))
heapq.heappush(heap, (1, "a"))
turn, zone = heapq.heappop(heap)               # → (0, "start"), then (1, "a"), then (2, "r")

That's the only change: queue.append(x) → heapq.heappush(heap, x), queue.pop(0) → heapq.heappop(heap). (For the evaluation: this makes it Dijkstra's algorithm. With costs of only 1 and 2 it's the same idea as BFS, just always expanding the earliest state.)

5 · The two calendars

Both are plain dicts that count how many drones use a slot. They belong to the scheduler, not the models: the map is static, the calendar is the plan.

zone_use: dict[tuple[str, int], int]              # ("junction", 2) → 1
link_use: dict[tuple[frozenset[str], int], int]   # (frozenset({"start", "junction"}), 1) → 2

"Is there room?" for a zone:

For a link it's the same with network.link(a, b).capacity, keyed by link.endpoints (the frozenset, so a-b and b-a share one slot).

Booking a finished route: walk through it in pairs, (a, ta) → (b, tb):

Why this gets rule 1 for free ("a zone a drone leaves is free in the same turn"): the calendar only counts who is in a zone at the end of a turn. D1 leaving waypoint1 at turn 2 simply means D1 has no booking at (waypoint1, 2), so D2 can take it.

6 · Watch the calendar fill

This runs the algorithm from this page, live, one drone at a time. Red cells are full. Dashed chips are drones in flight on a connection. The newest drone is outlined.

7 · The search, in plain words

This is the loop you'll write. Compare it with your shortest_path() line by line:

  1. Heap starts with (0, start). came_from starts with {(start, 0): None}.
  2. Pop the earliest (turn, zone). If zone is the goal: walk back through came_from and return the route.
  3. Build the candidate next states: wait (zone, turn+1), plus for every non-blocked neighbour (nb, turn + move_cost).
  4. For each candidate: skip it if already in came_from, if the zone has no room at the landing turn, or (for a real move) if the link has no room at turn+1.
  5. Otherwise: record it in came_from and push it on the heap.

And around it, the planner:

for each drone 1..nb_drones:
    route = search()          # sees the bookings of every drone before it
    book(route)
    keep route
Guard against infinite search. Waiting at start is always possible, so if the goal is unreachable, the heap never runs empty and the search never ends. Run your Pathfinder first: if it raises UnsolvableError, stop there. If a path exists, the search always finishes, because sooner or later the earlier drones are gone and the calendar is empty.

8 · The class skeleton

Types and names only. The bodies are yours.

class Scheduler:
    """Plan every drone's route through a shared booking calendar."""

    def __init__(self, network: Network) -> None:
        # store network, create the two empty calendars

    def plan(self) -> list[list[tuple[str, int]]]:
        # one route per drone, in drone order (Pathfinder guard first)

    def _search(self) -> list[tuple[str, int]]:
        # section 7: heap, came_from, the three moves

    def _zone_has_room(self, zone: str, turn: int) -> bool:
    def _link_has_room(self, a: str, b: str, turn: int) -> bool:
    def _book(self, route: list[tuple[str, int]]) -> None:

Suggested order, each with a check in between:

  1. __init__, _zone_has_room, _link_has_room, _book: small and testable on their own.
  2. _search with only wait + normal moves. Test: easy/01 → 4 turns, easy/02 → 4 turns.
  3. Restricted moves (+2) and heapq. Test: the restricted example above.
  4. plan() with the Pathfinder guard. Then all 10 maps against the benchmark table in PROGRESS.md.

Priority zones: skip them for now. The algorithm already hits the optimum on medium/03_priority_puzzle without special handling. If you want them later, they're a tie-breaker in the heap tuple, never a reason to take a slower route.

9 · Check yourself