Part 10 · Capstone · Graded project

Capstone: RouteWise Delivery Planner

Design, build, test, and ship a real application. RouteWise plans a bakery cooperative's daily delivery routes under van capacity and customer time windows, learns how long deliveries really take, stores every plan and outcome, and reports how the day went. It pulls together almost everything in this course: graphs and shortest paths, greedy heuristics and exact dynamic programming, data pipelines, machine learning, SQL, and the professional habits that make code trustworthy.

⏱ ~30–45 hours over 3–5 weeksPrerequisites: Parts 1–9, especially M31, M32, M33, M44, M56, and M42

The problem and its stakeholders

Rise & Shine is a cooperative of four neighbourhood bakeries that share one central kitchen and a small fleet of vans. Every morning they deliver bread and pastries to between 30 and 200 customers: cafes that open at 7:00 and want croissants before then, offices that accept deliveries only between 9:00 and 11:00, restaurants, corner shops, and a growing list of households with subscriptions.

Today, a dispatcher named Priya plans routes the night before on a whiteboard, using a paper map and her memory of which streets are slow. It mostly works for 40 customers. At 120 it falls apart: vans come back to refill because they were overloaded, a cafe gets its order at 7:40 and cancels its contract, and two drivers cross the same neighbourhood in opposite directions. Nobody can say whether a new customer across the river is profitable to serve, because nobody knows how long a delivery there really takes.

This is a famous problem in operations research: the capacitated vehicle routing problem with time windows (CVRPTW). Its simplest special case, a single van with no limits, is the travelling salesman problem (TSP), which is NP-hard: no known algorithm solves every instance exactly in polynomial time. Real planners therefore combine fast heuristics that find good answers with exact methods that check those answers on small cases. That is exactly what you will build.

Dispatcher (Priya)

Needs a plan by 18:00 for tomorrow, in one command, that she trusts: no overloaded vans, no impossible time windows, and a readable summary she can sanity-check.

Drivers

Want sensible, compact routes with realistic arrival times, not plans that assume every light is green.

Customers

Care about one thing: the order arrives inside their window.

Cooperative board

Wants weekly KPIs: kilometres driven, on-time percentage, van utilization, and whether a fifth van would pay for itself.

You, the engineer

Need code that is correct, tested, typed, documented, and easy for the next engineer to change when the bakery adds refrigerated vans.

Requirements

A functional requirement says what the system does; a non-functional requirement says how well it must do it (speed, reliability, maintainability). Each requirement below has an id so tests, commits, and ADRs can refer to it.

Functional

  • F1 City data. Generate a reproducible synthetic city (road graph, depot, customers with demand, time window, and service time) and load/save it as CSV.
  • F2 Travel times. Compute shortest travel times on the road graph with Dijkstra and A*, including the actual path, and a cached matrix between the depot and all customers.
  • F3 Feasibility. Given a route, simulate it: arrival, waiting, service, and return times; report every capacity, time-window, and end-of-day violation.
  • F4 Planning. Produce a plan that serves every customer exactly once with feasible routes, using nearest neighbour, Clarke-Wright savings, or sweep, optionally improved by 2-opt.
  • F5 Verification. Solve small single-route instances exactly (Held-Karp) to measure how far heuristics are from optimal.
  • F6 ETA. Train a model on delivery history that predicts leg durations better than the planner's naive estimate, and report MAE and RMSE for both.
  • F7 Persistence. Store plans and actual deliveries in SQLite; reload them exactly.
  • F8 Reporting. Compute KPIs (travel time, van time, waiting, on-time %, utilization) and render Markdown or text.
  • F9 CLI. routewise generate | plan | report with clear errors and exit codes.

Non-functional

  • N1 Correctness. Every produced plan passes check_plan; this is tested on every method.
  • N2 Performance. Plan 200 customers in under 5 seconds on a laptop.
  • N3 Determinism. The same seed produces the same city, history, model, and plan.
  • N4 Offline. Standard library, NumPy, and scikit-learn only; no network.
  • N5 Quality. Full type hints (mypy strict clean), Google docstrings, ruff clean, Conventional Commits.
  • N6 Tests. 50+ tests including property-based checks; at least 90% line coverage.
  • N7 Observability. Library code returns values; the CLI prints. In your own repository, log with structlog.
  • N8 Maintainability. Modules with one responsibility each and no circular imports.

System architecture

RouteWise is a pipeline of small modules. Data flows left to right; each arrow is a plain function call with typed inputs and outputs, so every stage can be tested on its own. Dependencies point one way only: graph knows nothing about customers, routing knows nothing about SQL, and only cli touches everything.

flowchart LR
  subgraph Ingest
    G[data.generate_city] --> CSV[(nodes / edges / customers CSV)]
    CSV --> L[data.load_city]
  end
  subgraph Network
    L --> RG[graph.RoadGraph]
    RG --> SP[Dijkstra / A*]
    SP --> DM[DistanceMatrix cache]
  end
  subgraph Optimize
    DM --> INST[routing.Instance]
    INST --> CON[nearest / savings / sweep]
    CON --> OPT[2-opt per route]
    OPT --> CHK[check_plan]
    HK[Held-Karp exact] -. verifies .-> OPT
  end
  subgraph Predict
    HIST[eta.generate_history] --> MOD[sklearn Pipeline]
    MOD --> MET[MAE vs baseline]
  end
  CHK --> DB[(store: SQLite)]
  SIM[eta.simulate_deliveries] --> DB
  DB --> REP[report: KPIs + Markdown]
  CLI[cli: generate / plan / report] --> G
  CLI --> CON
  CLI --> REP
    

The package layout mirrors the diagram:

ModuleResponsibilityKey public API
dataCity model, generator, CSVNode, Edge, Customer, City, generate_city, save_city, load_city
graphRoad network and shortest pathsRoadGraph, dijkstra, shortest_path, a_star, reconstruct_path, DistanceMatrix
routingPlan model, feasibility, heuristics, exact DPInstance, Route, Plan, schedule, check_plan, nearest_neighbor, clarke_wright, two_opt, held_karp
assignSplitting customers across vanspolar_angle, sweep_clusters, sweep, plan_routes
etaHistory, model, evaluation, simulationgenerate_history, build_model, train_and_evaluate, evaluate, simulate_deliveries
storeSQLite persistenceStore.save_plan, load_plan, list_plans, save_deliveries, load_deliveries
reportKPIs and renderingKpis, compute_kpis, render_report
cliUser interfacemain(argv)

Here is the end-to-end data flow as records move through the stages. Try batch and streaming modes: the nightly planning run is a batch job, while a live dispatch system would stream delivery events.

Data model

Two kinds of data exist. The city (nodes, edges, customers) is input, stored as CSV so a human can inspect and edit it. Plans and deliveries are outputs, stored in SQLite so they can be queried and joined. In Python every record is a frozen dataclass (M13): immutable, hashable, comparable, and printable for free.

erDiagram
  NODE ||--o{ EDGE : "connects"
  NODE ||--o| CUSTOMER : "hosts"
  PLAN ||--|{ PLAN_STOP : "contains"
  CUSTOMER ||--o{ PLAN_STOP : "visited in"
  PLAN ||--o{ DELIVERY : "executed as"
  CUSTOMER ||--o{ DELIVERY : "receives"
  NODE {
    int id PK
    float x
    float y
    bool is_depot
  }
  EDGE {
    int u FK
    int v FK
    float minutes
  }
  CUSTOMER {
    int id PK
    int node FK
    int demand
    float ready
    float due
    float service
  }
  PLAN {
    int id PK
    text label
    text method
    float capacity
    text created_at
  }
  PLAN_STOP {
    int plan_id PK
    int route_index PK
    int position PK
    int customer_id
  }
  DELIVERY {
    int plan_id PK
    int customer_id PK
    int van
    float planned_arrival
    float actual_arrival
    float due
  }
    

Times are minutes after the start of the working day (minute 0 is, say, 05:00), and the day ends at the horizon, 600 minutes, by which every van must be back. A customer's time window [ready, due] bounds when service may start. Arriving early is allowed: the van waits. Arriving after due makes the route infeasible.

Getting started with Pixi

The capstone ships with starter code: every module, class, and function exists with its signature, type hints, and docstring, and every body is a TODO followed by raise NotImplementedError. The grading tests define the contract. Start your working copy and run the tests; almost all of them fail, and your job is to turn them green milestone by milestone.

pixi run projects                       # list every project
pixi run start capstone                 # copy the starter into work/capstone_routewise/
pixi run grade capstone                 # run all 100+ grading tests against your work/
pixi run grade capstone -- -k graph -x  # one milestone, stop at the first failure
pixi run grade capstone -- --lf         # rerun only what failed last time

Once a milestone passes, use the CLI from inside your working copy:

cd work/capstone_routewise
pixi run python -m routewise generate --customers 120 --seed 7 --out city
pixi run python -m routewise plan --city city --db routewise.db --method savings --simulate
pixi run python -m routewise report --city city --db routewise.db --format markdown --out report.md

Milestones

Ten milestones, each a vertical slice you can finish, test, and commit in a session or two. Each lists the concepts it uses with links back to the modules that teach them, an acceptance checklist (your progress is saved), and hints. Work in order: later milestones import earlier ones.

Milestone 1: City data and a deterministic generator

Goal. Model the city with frozen dataclasses and generate realistic, reproducible test cities of any size. Every later test depends on this, so determinism is not optional: use one random.Random(seed) instance and never the global random functions.

The generator lays intersections on a jittered square grid (side about √(1.6 n)), joins horizontal and vertical neighbours plus a few random diagonals, and gives each edge a travel time of length ÷ MAX_SPEED × congestion with congestion ≥ 1. That last detail matters in Milestone 2: because no road is faster than MAX_SPEED, straight-line distance ÷ MAX_SPEED never overestimates travel time. The depot sits at the centre; customers get distinct other nodes, demand 1–8 crates, a window opening between minute 0 and 360 of width 60–180, and 2–8 minutes of service. Make sure every customer is reachable before its window closes, or the instance is unsolvable.

Concepts: dataclasses and validation in __post_init__ (M13), seeded randomness and csv (M40, M09), type hints (M16), raising good exceptions (M07).

  • Customer rejects ids below 1, negative demand, and ready > due
  • generate_city(n, seed) is deterministic and different seeds give different cities
  • Customer ids are 1..n on distinct non-depot nodes; the network is connected
  • No edge is faster than MAX_SPEED; every customer is reachable before due
  • save_city then load_city returns an equal City
Hints
  • Rounding a travel time down could make it slightly faster than MAX_SPEED. Round up (math.ceil(x * 1000) / 1000).
  • To guarantee reachability, compute Dijkstra from the depot inside the generator (that is why data imports graph, never the other way round) and widen any window that closes too early.
  • Python's float → str → float round trip is exact (repr is shortest round-tripping), so CSV equality tests are fair.

Milestone 2: Road graph, Dijkstra, A*, and the distance matrix

Goal. A RoadGraph stored as an adjacency list (a dict from node to a list of (neighbour, minutes) arcs), shortest paths with Dijkstra's algorithm and A*, path reconstruction, and a DistanceMatrix that computes each row lazily and caches it.

Dijkstra settles nodes in order of distance using a binary heap; with lazy deletion (push duplicates, skip stale pops) it runs in O((V + E) log V). A* orders the heap by g + h, where g is the cost so far and h estimates the rest. If h never overestimates (it is admissible) and satisfies the triangle inequality (it is consistent), A* returns the same optimal cost as Dijkstra while settling fewer nodes. Our heuristic is straight-line distance ÷ the fastest arc speed in the graph, which is admissible by construction.

Routing needs travel times between the depot and every customer, often millions of lookups. Running one full Dijkstra per source (n + 1 runs) and caching the rows turns each lookup into a dict access.

Concepts: graph representations (M26), Dijkstra and A* (M31), heaps (M25), hash maps as caches (M23), complexity (M19), NumPy arrays (M48).

Draw some walls and compare Dijkstra with A*: both find a shortest path, but A* explores a narrow corridor toward the goal.

  • add_edge rejects unknown nodes (KeyError) and negative times (ValueError)
  • dijkstra returns settled distances and predecessors, with optional early exit
  • a_star equals Dijkstra's cost on random graphs, including directed and unreachable cases
  • A* settles fewer nodes than Dijkstra on generated cities
  • DistanceMatrix computes each row at most once and is symmetric on two-way roads
Hints
  • Return PathResult(math.inf, ()) for unreachable targets rather than raising: "no path" is a normal answer, not an error.
  • max_speed() is the maximum of length / minutes over arcs. If some arc has zero minutes but positive length, the speed is infinite and the only admissible heuristic is 0.
  • Put a tie-breaker in heap tuples ((f, g, node)) so Python never compares incomparable payloads.

Milestone 3: Routes, plans, and feasibility

Goal. The core domain model. An Instance bundles the depot, customers, a travel-time oracle, van capacity, and the horizon. A Route is a tuple of customer ids (the depot is implicit at both ends); a Plan is a tuple of routes, one per van. schedule(route, inst) simulates the van and returns a Schedule of Visits plus every violation; check_plan adds plan-level problems: unknown, duplicated, or missing customers.

The simulation is the heart of the system, so make it obviously correct: arrival = clock + travel; start = max(arrival, ready) (waiting is allowed); late if start > due; clock = start + service. After the last stop, drive back and compare with the horizon. Every heuristic later calls this function, so the tests for it are the foundation of trust.

Concepts: frozen dataclasses and dataclasses.replace (M13), typing.Protocol for the travel oracle (M12, M16), the data model (M11), designing APIs (M46).

The two orders have the same travel time, yet one waits 20 minutes and the other does not. Time windows make route direction matter, which is why Clarke-Wright (Milestone 5) tries reversed merges.

  • Route and Plan are immutable and coerce lists to tuples
  • schedule waits for windows and reports capacity, lateness, horizon, and unreachable stops
  • route_cost includes both depot legs; plan_cost sums routes
  • check_plan catches unknown, duplicated, and unserved customers
  • Instance.from_city, subset, and relaxed work
Hints
  • Use the pseudo id DEPOT = 0 so inst.time(a, b) works for depot legs without special cases. That is why customer ids start at 1.
  • Return violations as strings in a tuple; feasible is just not violations.
  • Compare floats with a small epsilon (1e-9) so 59.999999 vs 60 does not flip feasibility.

Milestone 4: An exact baseline with Held-Karp

Goal. Before writing heuristics, write the referee. Held-Karp solves the single-van TSP exactly with dynamic programming over subsets: best[S][j] is the cheapest way to leave the depot, visit exactly the customers in set S, and stop at j. Represent S as a bitmask, and best[S ∪ {k}][k] = min over j of best[S][j] + t(j, k). The answer closes the tour back to the depot. That is O(n² 2ⁿ) time instead of brute force's O(n!): for n = 10, about 100,000 steps instead of 3.6 million permutations.

Held-Karp ignores capacity and windows, so its answer is a lower bound for any route over the same customers. Tests use it two ways: Held-Karp must equal brute force on 6 customers, and every heuristic must be at least the optimum and, after 2-opt, within 15% of it on 9-customer instances.

Concepts: dynamic programming and state design (M32), bitmasks (M34), recursion vs tabulation (M28), test oracles (M17).

  • held_karp matches brute force on several random 6-customer instances
  • The returned order visits each customer once and achieves the returned cost
  • Empty input returns (0.0, ()); more than 12 customers raises ValueError
Hints
  • Store a parent[mask][k] table alongside best, then walk it backwards from the best final customer, removing each bit as you go.
  • Lists of lists indexed by [mask][j] are several times faster than a dict of tuples here.

Milestone 5: Construction heuristics and 2-opt

Goal. Three ways to build a feasible plan fast, and one way to polish it.

  • Nearest neighbour (greedy): from the current stop, drive to the closest customer that keeps the route feasible (capacity, window, and still home by the horizon). If none fits, go home and start a new van. O(n²), simple, and often 20–30% worse than optimal.
  • Clarke-Wright savings: start with one van per customer. Serving i and j on one route instead of two saves s(i, j) = t(0, i) + t(0, j) − t(i, j). Process savings from largest to smallest and merge two routes when i and j are at their ends and the merged route is feasible. It is the classic workhorse of vehicle routing and usually beats nearest neighbour.
  • 2-opt local search: remove two edges and reconnect by reversing the segment between them. The change in cost needs only four lookups, so you can test every pair in O(n²). Accept a move only if it strictly shortens the route and keeps it feasible, and the route can never get worse.

Concepts: greedy algorithms and their limits (M33), sorting with keys and deterministic tie-breaks (M29), slicing and reversal (M20), problem-solving patterns: construct then improve (M35).

  • nearest_neighbor and clarke_wright produce feasible plans covering every customer
  • Both raise InfeasibleCustomerError for a customer no dedicated van could serve
  • two_opt never lengthens a route, keeps the same customers, and preserves feasibility
  • Heuristic + 2-opt is at least Held-Karp's optimum and within 15% of it on 9 customers
  • Clarke-Wright beats nearest neighbour in total travel on generated cities
Hints
  • For Clarke-Wright, keep routes: dict[key, list[int]] and owner: dict[customer, key] so finding a customer's route is O(1). Try the four merge orientations (a+b, b+a, a+reversed b, reversed a+b) and take the first feasible one.
  • Sort savings with key (-saving, i, j) so ties break the same way every run.
  • In 2-opt, the O(1) delta assumes symmetric travel times. Double-check with a full route_cost before accepting, then call schedule for feasibility.

Milestone 6: Assigning customers to vans (the sweep algorithm)

Goal. A cluster-first, route-second method. Imagine a clock hand rotating around the depot. Sort customers by polar angle (math.atan2(dy, dx), normalized to [0, 2π)) and fill a van until the next customer would overflow its capacity, then start the next van. Route each cluster with nearest neighbour, which may split it further if windows conflict. Finally, plan_routes(inst, method, improve=True) becomes the single entry point the CLI calls.

Sweep produces geographically compact "pizza slice" routes, which drivers like, but it ignores time windows while clustering, so on tight-window days it uses more vans than savings. Comparing the two on your generated cities and writing down why is exactly the kind of analysis interviewers love.

Concepts: trigonometry and floating point (M34), sorting with composite keys (M29), strategy dispatch with a dict of functions (M18, M46).

  • polar_angle returns 0, π/2, π, 3π/2 for east, north, west, south
  • sweep_clusters partitions the customers in angle order without overflowing a van
  • sweep and every method of plan_routes return feasible plans
  • plan_routes rejects unknown methods; 2-opt never makes a plan longer
  • A 200-customer city is planned feasibly in a few seconds
Hints
  • Start the running load at infinity so the first customer always opens the first cluster: no special case.
  • Try several start_angle values and keep the cheapest plan: a cheap, effective improvement.

Milestone 7: Predicting delivery times (ETA model)

Goal. The planner assumes a leg takes planned travel + service minutes. Reality differs: rush hour, rain, big orders, and one van with a sticky tail lift. Generate synthetic history whose actual minutes depend on those factors plus noise, then train a scikit-learn Pipeline: a ColumnTransformer that one-hot encodes weekday, weather, and van and passes numeric columns through, feeding a GradientBoostingRegressor (or Ridge for a linear baseline).

The rule of honest ML evaluation: always compare with a naive baseline on held-out data. A model with MAE 3 minutes is impressive if the planner's own estimate is off by 6, and worthless if it is off by 2.5. Split deterministically, fit on the training part only, and report MAE and RMSE for model and baseline side by side.

Concepts: supervised learning, train/test splits, and baselines (M55), pipelines and encoders (M56), reproducibility and monitoring (M59), error metrics and noise (M54).

  • generate_history is deterministic with valid fields
  • split is reproducible and rejects bad fractions
  • Both gbr and ridge beat the naive baseline's MAE on the test split
  • Gradient boosting cuts the baseline's MAE by more than half; training is reproducible
  • simulate_deliveries yields one delivery per customer, deterministic per seed
Hints
  • Encode records as a float matrix (weather as its index) and give ColumnTransformer column indexes. No pandas needed.
  • Ridge needs scaled numeric features; gradient boosting does not. Choose the numeric transformer per model.
  • RMSE ≥ MAE always. If your RMSE is far above MAE, a few huge errors dominate: look at them.

Milestone 8: Persistence with SQLite

Goal. A Store class wrapping sqlite3 with the schema from the ER diagram. Saving a plan inserts one plans row and many plan_stops rows in a single transaction (with self._conn: commits on success and rolls back on error). Always use ? placeholders, never f-strings: that is how you prevent SQL injection.

with self._conn:                      # one transaction
    cur = self._conn.execute(
        "INSERT INTO plans (label, method, capacity, created_at) VALUES (?, ?, ?, ?)",
        (label, plan.method, capacity, stamp),
    )
    self._conn.executemany(
        "INSERT INTO plan_stops (plan_id, route_index, position, customer_id) VALUES (?, ?, ?, ?)",
        [(cur.lastrowid, r, p, c) for r, route in enumerate(plan.routes) for p, c in enumerate(route.stops)],
    )

Concepts: relational modelling, keys, and transactions (M44), context managers (M15), analytical SQL for KPIs (M61), secure code (M43).

  • A saved plan loads back equal, with route and stop order preserved
  • Plan ids increase; latest_plan_id and list_plans summarize correctly
  • Data survives closing and reopening the database file
  • Unknown plan ids raise PlanNotFoundError
  • Deliveries round-trip, and re-recording a customer replaces its row
Hints
  • Turn on PRAGMA foreign_keys = ON per connection; SQLite leaves it off by default.
  • list_plans is one LEFT JOIN with COUNT(DISTINCT route_index), not a Python loop of queries (the N+1 query problem).

Milestone 9: KPIs and the daily report

Goal. Turn a plan (and, after the day, its actual deliveries) into numbers the board cares about: customers served, vans used, total travel and van time, waiting time, planned and actual on-time percentage, mean delay, and van utilization (mean load ÷ capacity). Render them as a Markdown table for the wiki or as aligned text for the terminal.

Keep calculation and presentation separate: compute_kpis returns a frozen Kpis dataclass, and render_report only formats. You can then test the numbers exactly and the rendering loosely, and add an HTML or JSON renderer later without touching the maths.

Concepts: string formatting and f-string format specs (M03), summary statistics (M54), communicating results (M53), separation of concerns (M46).

  • KPIs for a hand-checked single-route plan match exactly (travel 40, van time 70, wait 20)
  • Utilization, actual on-time %, and mean delay are right with two vans and one late delivery
  • An empty plan gives 0 customers, 0 utilization, and 100% planned on-time
  • Markdown output has a KPI table and a routes table; text output has no pipes
  • Unknown formats raise ValueError
Hints
  • Build a list of (metric, value) rows once, then format it two ways.
  • Use f"{x:.1%}" for percentages and f"{x:+.1f}" to show the sign of a delay.

Milestone 10: The CLI, end to end

Goal. An argparse interface with three subcommands. generate writes a city; plan loads it, plans with the chosen method, stores the plan, and optionally simulates the day; report loads a plan (the latest by default) and prints or writes the report. main(argv) takes a list and returns an exit code, so tests call it directly with tmp_path instead of spawning processes. Expected failures (no plans, unknown id) print to stderr and return 1; usage errors exit with 2 (argparse does that for you).

Concepts: modules, packages, and __main__.py (M08), packaging and entry points (M41), testing with fixtures and capsys (M17), tooling (M42).

  • generate, plan, and report work end to end in a temporary directory
  • plan --simulate stores deliveries and the report shows actual on-time %
  • report --out writes a file and uses the stored capacity
  • Missing plans return exit code 1 with a message on stderr; bad arguments exit with 2
  • All grading tests pass and ruff is clean
Hints
  • Dispatch with a dict: {"generate": _generate, "plan": _plan, "report": _report}[args.command](args).
  • Close the store before rendering (use with Store(path) as store:) so tests on Windows can delete the temporary database.

Testing strategy

The grading suite has over 100 tests in eight files, one per area. Read them: they are the executable specification, and they model how a professional tests an optimization system.

Unit tests

Small, hand-checked cases: a three-node line where you can compute every arrival on paper, a diamond graph with a known shortest path, a square whose optimal tour is its perimeter. If these fail, the bug is local.

Oracle tests

A slow, obviously correct implementation checks a fast clever one: brute-force permutations check Held-Karp, Dijkstra checks A*, Held-Karp bounds every heuristic.

Property tests

Invariants that must hold for every input: A* cost equals Dijkstra cost on random graphs; 2-opt never increases cost and keeps the same customers; every plan from every method passes check_plan; the same seed gives the same output.

Integration tests

The CLI writes CSVs, plans into a SQLite file, and reports, all inside tmp_path, so nothing leaks between tests.

The suite uses parametrized seeds as a lightweight form of property-based testing. In your portfolio version, go further with Hypothesis (pixi add --feature dev hypothesis), which generates inputs and shrinks failures to a minimal example:

from hypothesis import given, strategies as st

@given(st.permutations(list(range(1, 9))), st.integers(0, 10_000))
def test_two_opt_never_worsens(order, seed):
    inst = tiny_tsp(seed, 8)
    assert route_cost(two_opt(Route(tuple(order)), inst), inst) <= route_cost(order, inst) + 1e-9

Coverage follows the repository standard: pytest --cov --cov-fail-under=90. The reference solution reaches 97%. Coverage tells you what was executed, not what was checked, so pair it with good assertions; a test with no assertion still counts as covered.

pixi run test     # pytest tests/unit/ (milliseconds)
pixi run cov      # pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=90

Professional practice

The capstone is graded on more than passing tests. Publish it as your own repository, set up the way a professional team would. Every item below is something interviewers look for.

A Pixi workspace with standard tasks

[workspace]
name = "routewise"
channels = ["conda-forge"]
platforms = ["osx-arm64", "linux-64", "win-64"]

[dependencies]
python = "3.14.*"
numpy = "*"
scikit-learn = "*"
structlog = "*"

[pypi-dependencies]
routewise = { path = ".", editable = true }

[feature.dev.dependencies]
pytest = "*"
pytest-cov = "*"
hypothesis = "*"
mypy = "*"
ruff = "*"
pre-commit = "*"

[feature.dev.tasks]
fmt = "ruff format ."
lint = "ruff check ."
check = "mypy src/"
test = "pytest tests/unit/"
cov = "pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=90"
ci = { depends-on = ["lint", "check", "cov"] }

[environments]
default = { features = ["dev"] }

Quality gates

Continuous integration

name: CI
on:
  push: { branches: ["main"] }
  pull_request: { branches: ["main"] }
jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: prefix-dev/setup-pixi@v0.8.1
      - run: pixi run ci

Commits, README, and ADRs

# 2. Use Clarke-Wright savings as the default planner

Status: accepted

## Context
Nearest neighbour is simple but was 31% longer than savings on our 10 benchmark cities.
Sweep gives compact routes but used 2x the vans on tight-window days.

## Decision
Default to `--method savings` followed by 2-opt.

## Consequences
Planning 200 customers takes ~0.5 s. Sweep stays available for comparison.

Rubric

Grade yourself honestly (or ask a peer). The automated tests cover most of Correctness and part of Tests; the rest is judged by reading your repository and watching your demo.

CriterionWeightExcellentNeeds work
Correctness25%All grading tests pass; every plan from every method is feasible and complete; CLI handles errors with proper exit codesSome plans violate windows or capacity; crashes on edge cases
Algorithms and efficiency15%Heap-based Dijkstra, admissible A*, cached matrix, O(1) 2-opt deltas; 200 customers in seconds; complexity stated in docstringsRe-running shortest paths per lookup; quadratic scans inside loops; minutes per plan
Code quality10%Typed (mypy strict clean), documented, ruff clean; small functions; no circular imports; immutable recordsLong functions, duplicated logic, missing types, mutable shared state
Tests15%Unit, oracle, property, and integration tests; ≥ 90% coverage; regression tests for fixed bugsOnly happy-path tests or tests without meaningful assertions
Data pipeline10%Deterministic generation, validated loading, schema with keys and constraints, transactional writesUnvalidated CSV, SQL built with f-strings, partial writes on error
ML evaluation10%Held-out split, naive baseline, MAE and RMSE reported side by side, reproducible training, honest discussion of limitsMetrics on training data, no baseline, unreproducible results
Documentation7%README with quick start and results, architecture diagram, three or more ADRs, changelogNo README or one that does not explain how to run the project
Demo8%A 5-minute walkthrough: problem, live CLI run, one design trade-off, results, what you would do nextNo demo, or a code scroll without a story
pixi run grade capstone
pixi run ruff check work/capstone_routewise
pixi run ruff format --check work/capstone_routewise
pixi run mypy --strict work/capstone_routewise/routewise

Stretch goals

A FastAPI service

Add pixi add fastapi uvicorn in a service feature and expose POST /plans (body: city id, method, capacity) and GET /plans/{id}/report. Use Pydantic models at the boundary and keep the domain code unchanged (M45).

A map visualization

Plot the road network in light grey and each van's route in its own colour with matplotlib, annotate late stops in red, and embed the PNG in the report (M53).

Real streets with osmnx

In a separate, network-enabled Pixi feature, pixi add osmnx and convert a real neighbourhood's OpenStreetMap graph into a RoadGraph (travel time from length and speed limits). Cache the download so tests stay offline.

Free-threaded parallel optimization

On the free-threaded 3.14 build (pixi add python-freethreading), run 2-opt on each route concurrently, or try many sweep start angles in parallel, with a ThreadPoolExecutor. Measure the speedup honestly (M37, M39).

Lazy imports in 3.15 3.15

scikit-learn takes a noticeable fraction of a second to import, yet routewise generate never uses it. Python 3.15's explicit lazy imports defer the cost until first use (3.15 preview).

Better optimization

Add Or-opt (move a run of 1–3 stops elsewhere) and an inter-route relocate move, or a simulated-annealing loop around them. Report the gap to Held-Karp on small cases and to your best previous plan on large ones.

Alternative capstone tracks

If delivery routing does not excite you, build one of these instead. They exercise the same skills and are assessed with the same rubric; there is no grading suite, so writing the 50+ tests yourself is part of the work.

Personal-finance insights platform

  • Data pipeline: ingest bank CSV exports in several formats, normalize, de-duplicate, and load into SQLite or DuckDB.
  • Algorithms: detect recurring subscriptions with interval analysis and hashing; budget allocation as a knapsack-style DP.
  • ML: categorize transactions from descriptions (TF-IDF + logistic regression) against a keyword-rule baseline; forecast monthly spend; flag anomalies.
  • Report: monthly Markdown statement with charts. Builds on Project 1 and Project 9.

Community-event scheduler

  • Data pipeline: import events, rooms, volunteers, and attendee preferences; validate conflicts.
  • Algorithms: interval scheduling and graph colouring for room assignment, backtracking with constraint propagation for volunteer rosters, shortest paths between venues.
  • ML: predict attendance from past events against a "same as last time" baseline, and use it to pick room sizes.
  • Report: a schedule, utilization KPIs, and a conflicts report. Builds on Project 4 and Project 6.

How to present your capstone

A capstone that nobody understands is worth little to your career. Treat the presentation as part of the project.

In your portfolio

In an interview

Final self-assessment

Fifteen questions across the whole capstone. Aim for at least 80% before presenting your work.

Recap

Going further