GLaDOS Vol 7 · The Body
ch 72 / 99
Chapter 72

Physical Assembly

The screw you cannot reach

The shell came off the printer in chapter 63 and has been a pile of parts ever since. Everything built after it was built on the bench: one subsystem at a time, jumper leads long enough to reach across a desk, each board sitting where your hands could get to it. Now all of it goes inside a box barely larger than the sum of the parts.

Two hours in you meet the first ordering problem. The shoulder servo bolts to the base plate from underneath, and the Pi's tray sits directly over those four M3 heads. Seat the Pi first and there is nowhere to put a driver: the Pi comes back off, and if you already dressed the ribbon, that comes off too. The head has a smaller version of the same thing, where the LED ring presses into the diffuser recess and covers the two M2 screws holding the tilt bracket. A build this size has a dozen of these, and not one of them announces itself until you are holding the part.

Assembly is also the one phase you cannot re-run in a second. Software you can delete and regenerate; a stripped insert in a printed boss is an evening with a soldering iron and a brass repair kit. So the cost of losing your place is real, and losing your place is the normal case. You stop at eleven at night with the arm half wired and come back two days later. Did you put the inline resistor on the ring's data line? A paper list says nothing. Memory says "probably", and probably is not a thing you want to test by applying power.

Both problems have the same fix, and it is not a longer list. It is putting the dependencies in the list itself: every step names what must already be done before it, so the order is a property a program can check, and every confirmed step is written to disk the moment you confirm it, so your position in the build is a file and not a memory.

◆ Note — the plan below is one build's, and yours is not it

Seventeen steps closed up the bench machine: a printed shell, a Pi 4B, two eye servos, the ring, four arm joints on the first of the two PCA9685 boards, the amplifier and mic array, the MCP3008 board, and a 5 V motion rail. The GPIO numbers quoted in the step text are the ones chapter 71 settled, and the pin card that run wrote is what you check them against before a wire goes on a header. Your enclosure will have a step this one lacks (a fan, a switch, a different mic) and will not need one this one has. That is the argument for keeping the plan in a file the program writes and you edit, instead of in the program.

The plan, in a file you are meant to edit

▣ Build · stage 1 — a plan that writes itself once
# labs/assembly_plan.py
"""The assembly plan: an ordered list of steps, each naming what must be done first."""
import json
import sys
from pathlib import Path

PLAN_FILE = Path("configs/assembly_steps.json")

DEFAULT_PLAN = [
    {"id": "standoffs", "desc": "Press four M2.5 brass standoffs into the base plate.",
     "tools": ["M2.5 driver", "standoffs"], "requires": []},
    {"id": "mount_shoulder", "desc": "Bolt the MG996R shoulder to the base plate, heads underneath.",
     "tools": ["M3 driver"], "requires": []},
    {"id": "mount_pi", "desc": "Seat the Pi 4B on the standoffs. Do not overtighten.",
     "tools": ["M2.5 driver"], "requires": ["standoffs", "mount_shoulder"],
     "why": "the tray covers the shoulder's four M3 heads"},
    {"id": "mount_led_ring", "desc": "Press the LED ring into the diffuser recess, JST tail down.",
     "tools": ["hot glue"], "requires": ["mount_eye_tilt"],
     "why": "the ring sits over the tilt bracket's two M2 screws"},
    {"id": "cable_eye", "desc": "Route eye servos to GPIO 12/13 and the ring data line, 330 ohm inline, to GPIO 10.",
     "tools": ["zip ties", "heatshrink"], "requires": ["mount_led_ring", "mount_pi"],
     "why": "both bundles leave the head through the neck channel"},
    # ...twelve more, ending at final_check
]


def load_plan(path: Path = PLAN_FILE) -> list[dict]:
    """Read the plan. On a machine that has never run this, write the default first."""
    if not path.exists():
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(DEFAULT_PLAN, indent=2) + "\n")
        print(f"wrote {path} ({len(DEFAULT_PLAN)} steps). Edit it to match your build.")
    return json.loads(path.read_text())
$ uv run python -m labs.assembly_plan
wrote configs/assembly_steps.json (17 steps). Edit it to match your build.
17 steps, 0 plan problem(s)
$ uv run python -m labs.assembly_plan   # second run
17 steps, 0 plan problem(s)

The program writes the file it reads, once, and never again. That removes the setup ritual nobody remembers three months later, and it makes the JSON the thing you own: reorder it, delete the fan step, add a rear panel, and the code is untouched. The requires list is the part doing new work. A flat ordered list already implies an order, but implies it silently, so an edit that moves a step past its prerequisite reads exactly like an edit that did not. Naming the prerequisite makes the order checkable, and it gives every step somewhere to record why it sits where it does, in the sentence you will want at two in the morning.

▣ Build · stage 2 — checking the plan before you pick up a driver
# labs/assembly_plan.py — continued
def plan_problems(steps: list[dict]) -> list[str]:
    """Faults you can find before touching hardware: bad ids, and order that contradicts requires."""
    problems: list[str] = []
    position: dict[str, int] = {}
    for i, step in enumerate(steps):
        if step["id"] in position:
            problems.append(f"step id {step['id']!r} appears twice")
            continue
        position[step["id"]] = i
    for i, step in enumerate(steps):
        for need in step.get("requires", []):
            if need not in position:
                problems.append(f"step {step['id']!r} requires {need!r}, which is not in the plan")
            elif position[need] > i:
                problems.append(f"step {step['id']!r} at position {i + 1} requires "
                                f"{need!r} at position {position[need] + 1}")
    return problems


if __name__ == "__main__":
    steps = load_plan()
    problems = plan_problems(steps)
    for problem in problems:
        print(f"  [x] {problem}")
    print(f"{len(steps)} steps, {len(problems)} plan problem(s)")
    sys.exit(1 if problems else 0)
$ uv run python -m labs.assembly_plan   # after an evening of editing the JSON
  [x] step 'cable_eye' at position 7 requires 'mount_led_ring' at position 12
  [x] step 'cable_audio' requires 'mount_pi4', which is not in the plan
17 steps, 2 plan problem(s)

Two different mistakes, both from the same editing session. The first is an ordering fault: the head cabling got dragged up the file while the ring was left below it, so the plan now tells you to dress a cable that runs from a part you have not fitted. The second is a typo in an id, and it is the one worth catching hardest, because a misspelled prerequisite is a dependency that silently does not exist. Both checks run in a tenth of a second on a laptop, before a single fastener has gone in.

The two passes are not interchangeable. The first builds position for every id in the file; only then can the second ask whether a named prerequisite exists and where it sits. Do it in one pass and a step that requires something declared four lines below it looks identical to a step that requires something declared nowhere, and you get a fake error on a correct plan.

Why a fastener becomes unreachable, and the order that falls out of it Left: a side view of the base plate. The shoulder servo is bolted down from underneath, so its four M3 heads sit below the plate, and the Pi tray on its standoffs covers them. Right: seven steps drawn as boxes with arrows pointing from each step to the step that needs it. Standoffs and mount_shoulder both point at mount_pi; mount_pi and mount_led_ring point at cable_eye; cable_eye and cable_arm point at power_rail. SIDE VIEW · THE HEADS UNDER THE TRAY base plate four M3 heads, reachable only from below shoulder Pi 4B tray once the tray is down, the driver has nowhere to go THE SAME FACT, WRITTEN AS REQUIRES standoffs mount_shoulder mount_pi mount_led_ring cable_eye cable_arm power_rail an arrow means "this one first"; the file is one valid reading of the arrows, not the only one
Figure 72.1 — The left half is a fact about printed parts and a driver's clearance. The right half is that fact typed into a file, where it constrains every future edit instead of living in whoever bolted it down.

Where you actually are

▣ Build · stage 3 — the position, saved as a sorted list
# labs/assembly_checklist.py
"""Interactive assembly checklist: saves after every confirmed step, resumes from disk."""
import argparse
import json
import os
import sys
from pathlib import Path

from labs.assembly_plan import load_plan, plan_problems

PROGRESS_FILE = Path("glados/data/assembly_progress.json")


def load_progress(path: Path = PROGRESS_FILE) -> set[str]:
    """The ids already confirmed. A build that has never run is an empty set, not a crash."""
    if not path.exists():
        return set()
    return set(json.loads(path.read_text()).get("done", []))


def save_progress(done: set[str], path: Path = PROGRESS_FILE) -> None:
    """Write the set as a sorted list, and land the file in one move."""
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_suffix(".tmp")
    temp.write_text(json.dumps({"done": sorted(done)}, indent=2) + "\n")
    os.replace(temp, path)
$ uv run python -m labs.progress_demo
loaded    []
saved     glados/data/assembly_progress.json
{
  "done": [
    "mount_shoulder",
    "standoffs"
  ]
}
reloaded  ['mount_shoulder', 'standoffs']

A set is the right type in memory: membership is what every question asks, and adding an id twice, which happens the moment you re-confirm a step, cannot duplicate anything. A set is also not a JSON type, so something has to translate on the way out, and sorted() does the translation and buys a stable file as well. Two runs that finish the same steps produce byte-identical JSON, so the file diffs cleanly and you can read it without a tool.

The temporary file and os.replace are there because of what this file is. It is the only record of an hour of physical work, and it gets rewritten seventeen times in an evening. Writing directly means the file spends a few milliseconds truncated each time, and a crash inside that window leaves nothing readable behind. os.replace is atomic on a single filesystem: the new file is complete before it takes the name, and the reader either sees all of the old one or all of the new one.

▣ Build · stage 4 — what you can do now, and what is waiting
# labs/assembly_checklist.py — continued
def partition(steps: list[dict], done: set[str]) -> tuple[list[dict], list[tuple[dict, list[str]]]]:
    """Split what is left into steps you can do now and steps something else is holding up."""
    ready: list[dict] = []
    blocked: list[tuple[dict, list[str]]] = []
    for step in steps:
        if step["id"] in done:
            continue
        missing = [need for need in step.get("requires", []) if need not in done]
        if missing:
            blocked.append((step, missing))
        else:
            ready.append(step)
    return ready, blocked


def status(steps: list[dict], done: set[str]) -> str:
    ready, blocked = partition(steps, done)
    lines = [f"{len(done)}/{len(steps)} done, {len(ready)} ready, {len(blocked)} blocked"]
    for step in ready:
        lines.append(f"  ready    {step['id']}")
    for step, missing in blocked:
        lines.append(f"  blocked  {step['id']:<20} waiting on {', '.join(missing)}")
    return "\n".join(lines)
$ uv run python -m labs.assembly_checklist --status
2/17 done, 6 ready, 9 blocked
  ready    mount_pi
  ready    mount_pca
  ready    mount_eye_pan
  ready    mount_elbow
  ready    mount_audio
  ready    mount_adc
  blocked  mount_eye_tilt       waiting on mount_eye_pan
  blocked  mount_led_ring       waiting on mount_eye_tilt
  blocked  mount_wrist_gripper  waiting on mount_elbow
  blocked  cable_eye            waiting on mount_led_ring, mount_pi
  blocked  cable_arm            waiting on mount_wrist_gripper, mount_pca
  blocked  cable_audio          waiting on mount_audio, mount_pi
  blocked  cable_adc            waiting on mount_adc, mount_pi
  blocked  power_rail           waiting on cable_arm, cable_eye, cable_adc
  blocked  final_check          waiting on power_rail, cable_audio

Nothing in the program remembers that two steps are finished. It reads the file, then derives everything from the plan and that set: done by membership, ready by a prerequisite test, blocked by what fails the test and why. Six things you could do next is real information on a build with parts spread over a desk, and it is information a linear list cannot give you, because a list only knows what comes next in it.

Walking it with a driver in your hand

▣ Build · stage 5 — one step at a time, saved on the spot
# labs/assembly_checklist.py — continued
def walk(steps: list[dict], done: set[str]) -> None:
    """Offer every step whose prerequisites are met, in plan order, saving after each."""
    total = len(steps)
    for position, step in enumerate(steps, start=1):
        if step["id"] in done:
            continue
        missing = [need for need in step.get("requires", []) if need not in done]
        if missing:
            print(f"[skip] Step {position}/{total}: {step['id']} is blocked by {', '.join(missing)}")
            continue
        print(f"[{100 * len(done) // total:3d}%] Step {position}/{total}: {step['desc']}")
        print(f"       tools: {', '.join(step['tools'])}")
        if step.get("why"):
            print(f"       after: {', '.join(step['requires'])} — {step['why']}")
        try:
            answer = input("       done? [enter=yes  s=skip  q=quit]: ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            answer = "q"
        if answer == "q":
            print(f"\nstopped at {len(done)}/{total}. Run again to pick this up.")
            return
        if answer == "s":
            print(f"       skipped {step['id']}")
            continue
        done.add(step["id"])
        save_progress(done)
    remaining = total - len(done)
    print(f"\n{len(done)}/{total} done" + ("" if remaining else " — the body is assembled."))
$ uv run python -m labs.assembly_checklist --reset   # first evening; the PCA9685 is still in the post
progress cleared
[  0%] Step 1/17: Press four M2.5 brass standoffs into the base plate.
       tools: M2.5 driver, standoffs
       done? [enter=yes  s=skip  q=quit]: [  5%] Step 2/17: Bolt the MG996R shoulder to the base plate, heads underneath.
       tools: M3 driver
       done? [enter=yes  s=skip  q=quit]: [ 11%] Step 3/17: Seat the Pi 4B on the standoffs. Do not overtighten.
       tools: M2.5 driver
       after: standoffs, mount_shoulder — the tray covers the shoulder's four M3 heads
       done? [enter=yes  s=skip  q=quit]: [ 17%] Step 4/17: Mount the PCA9685 on its own standoffs beside the Pi.
       tools: M2.5 driver
       after: standoffs — it shares the base plate's standoff row
       done? [enter=yes  s=skip  q=quit]:        skipped mount_pca
[ 17%] Step 5/17: Drop the pan servo into the eye socket, horn centred.
       tools: M2 driver, servo horn
       done? [enter=yes  s=skip  q=quit]: [ 23%] Step 6/17: Screw the tilt bracket onto the pan horn.
       tools: M2 driver
       after: mount_eye_pan — the bracket bolts to the horn
       done? [enter=yes  s=skip  q=quit]:
stopped at 4/17. Run again to pick this up.

The --reset on that command threw away the two ids the persistence demo put on disk, which is the only honest way to start a real build after experimenting with the file. The flag itself arrives in the next stage, along with the argument parsing around it.

save_progress runs inside the loop, after every confirmation, and that single placement is what makes the whole thing survivable. Close the laptop, lose power, kill the terminal: the last step you pressed enter on is already on disk. Save once at the end instead and the program is a paper list with extra steps.

The percentage is computed before the current step is added, so it reports work finished and never work in progress. Step 4 shows 17 percent because three steps were done when it was offered. Skipping is a separate answer from confirming for a physical reason: a part that has not arrived is not an optional step, it is a step you will come back to, and marking it done to make the number go up is how a missing standoff becomes a mystery two weeks later.

▣ Build · stage 6 — the last session, and what the skip cost
# labs/assembly_checklist.py — continued
def main() -> None:
    parser = argparse.ArgumentParser(description="GLaDOS physical assembly checklist")
    parser.add_argument("--status", action="store_true", help="print what is done, ready and blocked")
    parser.add_argument("--reset", action="store_true", help="forget every confirmed step")
    args = parser.parse_args()

    steps = load_plan()
    problems = plan_problems(steps)
    if problems:
        print(f"plan is not buildable — {len(problems)} problem(s):")
        for problem in problems:
            print(f"  [x] {problem}")
        sys.exit(1)

    if args.reset:
        PROGRESS_FILE.unlink(missing_ok=True)
        print("progress cleared")
    done = load_progress()
    if args.status:
        print(status(steps, done))
        return
    walk(steps, done)


if __name__ == "__main__":
    main()
$ uv run python -m labs.assembly_checklist   # third evening, multimeter still packed
[ 76%] Step 14/17: Wire I2S BCLK/LRCLK/DIN to GPIO 18/19/21 and the mic USB through the grommet.
       tools: soldering iron, grommet
       after: mount_audio, mount_pi — both ends have to exist before the wire between them
       done? [enter=yes  s=skip  q=quit]: [ 82%] Step 15/17: Wire the MCP3008 to SPI4 on GPIO 4/5/6/7 and the divider taps to the rails.
       tools: multimeter
       after: mount_adc, mount_pi — both ends have to exist before the wire between them
       done? [enter=yes  s=skip  q=quit]:        skipped cable_adc
[skip] Step 16/17: power_rail is blocked by cable_adc
[skip] Step 17/17: final_check is blocked by power_rail

14/17 done
$ uv run python -m labs.assembly_checklist   # multimeter found
[ 82%] Step 15/17: Wire the MCP3008 to SPI4 on GPIO 4/5/6/7 and the divider taps to the rails.
       tools: multimeter
       after: mount_adc, mount_pi — both ends have to exist before the wire between them
       done? [enter=yes  s=skip  q=quit]: [ 88%] Step 16/17: Land the 5 V motion rail on the capacitor bank and tie every ground together.
       tools: crimper, wire stripper
       after: cable_arm, cable_eye, cable_adc — nothing gets a rail until its signal wire is dressed and anchored
       done? [enter=yes  s=skip  q=quit]: [ 94%] Step 17/17: Power disconnected: tug every connector, look for a strand touching the Pi.
       tools: flashlight
       after: power_rail, cable_audio — it is the inspection of everything above it
       done? [enter=yes  s=skip  q=quit]:
17/17 done — the body is assembled.

Skipping the divider wiring cost two more steps, and the program said so instead of letting you find out with a screwdriver. That is the whole return on the requires lists: the consequence of a skip propagates on its own, and the two lines it prints name the missing prerequisite, not just the fact that something is missing. The last step is gated behind every wiring step in the plan, which is deliberate. A visual inspection that happens before the last connector goes in has inspected a machine that no longer exists.

Why this works: three values that never touch each other

There are exactly three pieces of information here, and each one lives in its own place. The plan is data on disk, ordered and edited by you. The position is a set of ids, also on disk, written by the program and read by nothing else. The walk-through is presentation: it renders one from the other and holds nothing of its own. Every launch reconstructs what is left by filtering the plan against the position, so there is no third copy of the truth to fall out of date.

Resumability then stops being a feature and becomes a consequence. A program with no in-memory position cannot lose one. Run it twice in a row with no work done between and the second run offers exactly what the first offered, which is the same property that makes the plan file safe to write only when it is missing: both operations can be repeated without changing the answer. That is the whole reason to reach for a set of completed ids instead of a step counter. A counter says "you were at 12" and means nothing after you edit the plan; a set of ids survives insertions, deletions and reordering, because it names the work rather than counting it.

The dependency lists add one more property, and it is the one you feel on the bench. A list is a total order: it claims that step 5 comes after step 4, which is usually a lie, since fitting the mic array has nothing to do with the elbow. requires declares a partial order, the real constraints and only those, and the file's order is one valid reading of it. This is why the checker can call an edit wrong while still letting you rearrange most of the plan freely, and why status can hand you six independent things to do next. It also generalises past screwdrivers: any procedure where a step forecloses access to an earlier one, from a migration that drops a column to a deployment that revokes a key, is the same partial order with different tools.

There is one seam in all this, and it is better to know where it is than to pretend it closed. The two files are joined by the id string alone. Rename mount_adc to mount_battery_board after you have confirmed it and the step comes back as undone, because the set holds the old name and the plan now offers a new one. That is the right default for a checklist about physical objects: a step you cannot account for should reappear, and the cost of reappearing is one glance under the side rail. It does mean an id is a promise. Edit descriptions, tools and order as freely as you like; treat the id as the thing you named the work, and leave it alone once a build is underway.

⚠ Worked failure — the save that ate the file it was saving

The obvious save_progress is one line shorter than the real one. A set of strings looks like a list of strings, so hand it straight to json.dump and write it where it belongs:

# labs/progress_naive.py
def save_progress(done: set[str], path: Path = PROGRESS_FILE) -> None:
    with path.open("w") as f:
        json.dump({"done": done}, f, indent=2)     # BUG: a set is not a JSON type
$ uv run python -m labs.progress_naive
before:  ['mount_shoulder', 'standoffs']
Traceback (most recent call last):
  File "labs/progress_naive.py", line 16, in <module>
    save_progress({"standoffs", "mount_shoulder", "mount_pi"})
  File "labs/progress_naive.py", line 11, in save_progress
    json.dump({"done": done}, f, indent=2)     # BUG: a set is not a JSON type
  ...
  File ".../json/encoder.py", line 180, in default
    raise TypeError(f'Object of type {o.__class__.__name__} '
TypeError: Object of type set is not JSON serializable
when serializing dict item 'done'

The type error is the easy half, and JSON is right to refuse: the format has objects, arrays, strings, numbers, booleans and null, and no member with set semantics. The expensive half is what the disk looks like afterwards. Look at the file the run left behind, then start the checklist again:

$ wc -c glados/data/assembly_progress.json && cat glados/data/assembly_progress.json
12 glados/data/assembly_progress.json
{
  "done": 
$ uv run python -m labs.assembly_checklist --status
Traceback (most recent call last):
  ...
  File "labs/assembly_checklist.py", line 17, in load_progress
    return set(json.loads(path.read_text()).get("done", []))
  ...
json.decoder.JSONDecodeError: Expecting value: line 2 column 11 (char 12)

Twelve bytes: an opening brace, the key, and nothing after it. Reason backwards from that number. open("w") truncates the file at the moment it opens, long before anything is serialised, and json.dump streams its output as it encodes, so the encoder had already written the key when it reached the value and raised. The old contents were gone before the failure happened. Now the next launch cannot even get to the checklist, because load_progress hits a file that exists and does not parse, and an hour of confirmed steps is unrecoverable from a program that never printed a warning.

One fix answers both halves. sorted(done) hands the encoder a list, and writing to assembly_progress.tmp before os.replace keeps the real file untouched until a complete document exists. Any failure now leaves a stray temp file and the last good progress, which is the trade every durable writer makes: the crash costs you the newest change, never the record.

Checkpoint, and a body whose limits just moved

✓ Checkpoint — what you can now do
  • Given a fastener that a neighbouring part covers, I can write the constraint as a requires entry and say which of the two steps carries it.
  • I can explain why plan_problems needs two passes over the plan and what a single pass would report falsely.
  • I can name the two jobs sorted() does between a set in memory and a file on disk.
  • I can trace a twelve-byte progress file back to truncation on open, and say what os.replace changes about that.
  • I know why the checklist offers a skipped step again on the next run while a confirmed one never comes back.
  • I can say what a set of completed ids survives that a step counter does not.
⚡ Exercises — try first, then reveal
Exercise 1 — add the step your build needs. Put a 30 mm fan in the plan with its own prerequisite, deliberately in the wrong place first, and watch the checker refuse it.

The fan bolts through the rear lip of the Pi tray, so the tray has to be down first. Insert it above mount_pi and run the checker:

{
  "id": "mount_fan",
  "desc": "Mount the 30 mm fan on the rear panel, 100 ohm inline for a quiet speed.",
  "tools": ["M3 driver"],
  "requires": ["mount_pi"],
  "why": "it bolts through the tray's rear lip"
}
$ uv run python -m labs.assembly_plan
  [x] step 'mount_fan' at position 3 requires 'mount_pi' at position 4
18 steps, 1 plan problem(s)
$ uv run python -m labs.assembly_plan   # moved one line down
18 steps, 0 plan problem(s)

Notice what did not happen: your saved progress is untouched by an eighteenth step, because progress names ids and knows nothing about positions. The new id is simply not in the done set, so it turns up as ready the next time you run the checklist.

Exercise 2 — what to carry to the bench. Print every tool the remaining steps ask for, with how many steps ask for it, so one trip to the toolbox covers the evening.
# labs/tool_list.py
from collections import Counter

from labs.assembly_checklist import load_progress
from labs.assembly_plan import load_plan

if __name__ == "__main__":
    steps = load_plan()
    done = load_progress()
    tally = Counter(tool for step in steps if step["id"] not in done for tool in step["tools"])
    print(f"tools still needed for {len(steps) - len(done)} remaining steps:")
    for tool, count in tally.most_common():
        print(f"  {count:2d}x  {tool}")
$ uv run python -m labs.tool_list
tools still needed for 13 remaining steps:
   3x  M2.5 driver
   2x  M2 driver
   2x  soldering iron
   2x  zip ties
   1x  hot glue
   1x  M3 driver
   1x  heatshrink
   1x  grommet
   1x  multimeter
   1x  crimper
   1x  wire stripper
   1x  flashlight

The same filter as everywhere else, with a different renderer on the end. That is the payoff of keeping the plan, the position and the presentation apart: a new view of the build is six lines and touches neither of the first two.

Exercise 3 (stretch) — find what is holding up the most. For each unfinished step, count how many other steps are waiting on it, directly or through a chain, and rank them.
# labs/unblocks.py
from labs.assembly_checklist import load_progress
from labs.assembly_plan import load_plan

if __name__ == "__main__":
    steps = load_plan()
    done = load_progress()
    waiting: dict[str, set[str]] = {}
    for step in steps:                       # plan order is dependency order, so one pass does it
        if step["id"] in done:
            continue
        blockers = set(need for need in step["requires"] if need not in done)
        for other in list(blockers):
            blockers |= waiting.get(other, set())
        waiting[step["id"]] = blockers
    holds: dict[str, int] = {}
    for held, blockers in waiting.items():
        for blocker in blockers:
            holds[blocker] = holds.get(blocker, 0) + 1
    ranked = sorted(waiting, key=lambda s: (-holds.get(s, 0), s))
    for step_id in ranked[:6]:
        print(f"  {step_id:<20} holds up {holds.get(step_id, 0):2d} of the {len(waiting)} steps left")
$ uv run python -m labs.unblocks
  mount_elbow          holds up  4 of the 13 steps left
  mount_eye_tilt       holds up  4 of the 13 steps left
  mount_adc            holds up  3 of the 13 steps left
  mount_led_ring       holds up  3 of the 13 steps left
  mount_pca            holds up  3 of the 13 steps left
  mount_wrist_gripper  holds up  3 of the 13 steps left

The single pass works only because the plan is already checked: every prerequisite appears earlier, so waiting holds a finished answer for it by the time you need it. Run this on an unchecked plan and a forward reference gives you a silently short count. Two of the six are themselves blocked, so a sharper version filters the ranking down to steps that are ready right now, which is the honest answer to "what should I pick up".

The last line of that final run is the end of a box of parts and the start of a machine. It also invalidated something. Every travel limit in the arm and eye code was measured on servos sitting loose on a bench, and those servos now live in printed mounts that push against them a few degrees earlier than a horn in free air. The next chapter drives each joint to the stop it actually has, inside the body, and writes what it measures into the one file every motion routine will trust from then on.