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.
- Turn a messy business problem into precise requirements, a data model, and an architecture
- Implement Dijkstra and A* on a road network and cache an all-pairs travel-time matrix
- Solve a capacitated vehicle routing problem with time windows using construction heuristics and 2-opt
- Verify heuristics against an exact Held-Karp dynamic program on small instances
- Train and evaluate an ETA model honestly against a naive baseline
- Persist plans and outcomes in SQLite and turn them into KPIs and a report
- Ship it like a professional: Pixi tasks, strict typing, linting, 50+ tests, CI, ADRs, and a demo
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 | reportwith 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:
| Module | Responsibility | Key public API |
|---|---|---|
data | City model, generator, CSV | Node, Edge, Customer, City, generate_city, save_city, load_city |
graph | Road network and shortest paths | RoadGraph, dijkstra, shortest_path, a_star, reconstruct_path, DistanceMatrix |
routing | Plan model, feasibility, heuristics, exact DP | Instance, Route, Plan, schedule, check_plan, nearest_neighbor, clarke_wright, two_opt, held_karp |
assign | Splitting customers across vans | polar_angle, sweep_clusters, sweep, plan_routes |
eta | History, model, evaluation, simulation | generate_history, build_model, train_and_evaluate, evaluate, simulate_deliveries |
store | SQLite persistence | Store.save_plan, load_plan, list_plans, save_deliveries, load_deliveries |
report | KPIs and rendering | Kpis, compute_kpis, render_report |
cli | User interface | main(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).
Customerrejects ids below 1, negative demand, andready > duegenerate_city(n, seed)is deterministic and different seeds give different cities- Customer ids are
1..non distinct non-depot nodes; the network is connected - No edge is faster than
MAX_SPEED; every customer is reachable beforedue save_citythenload_cityreturns an equalCity
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
dataimportsgraph, never the other way round) and widen any window that closes too early. - Python's
float→str→floatround trip is exact (repris 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_edgerejects unknown nodes (KeyError) and negative times (ValueError)dijkstrareturns settled distances and predecessors, with optional early exita_starequals Dijkstra's cost on random graphs, including directed and unreachable cases- A* settles fewer nodes than Dijkstra on generated cities
DistanceMatrixcomputes 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.
RouteandPlanare immutable and coerce lists to tuplesschedulewaits for windows and reports capacity, lateness, horizon, and unreachable stopsroute_costincludes both depot legs;plan_costsums routescheck_plancatches unknown, duplicated, and unserved customersInstance.from_city,subset, andrelaxedwork
Hints
- Use the pseudo id
DEPOT = 0soinst.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;
feasibleis justnot 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_karpmatches 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 raisesValueError
Hints
- Store a
parent[mask][k]table alongsidebest, 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_neighborandclarke_wrightproduce feasible plans covering every customer- Both raise
InfeasibleCustomerErrorfor a customer no dedicated van could serve two_optnever 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]]andowner: 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_costbefore accepting, then callschedulefor 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_anglereturns 0, π/2, π, 3π/2 for east, north, west, southsweep_clusterspartitions the customers in angle order without overflowing a vansweepand every method ofplan_routesreturn feasible plansplan_routesrejects 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_anglevalues 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_historyis deterministic with valid fieldssplitis reproducible and rejects bad fractions- Both
gbrandridgebeat the naive baseline's MAE on the test split - Gradient boosting cuts the baseline's MAE by more than half; training is reproducible
simulate_deliveriesyields one delivery per customer, deterministic per seed
Hints
- Encode records as a float matrix (weather as its index) and give
ColumnTransformercolumn 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_idandlist_planssummarize 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 = ONper connection; SQLite leaves it off by default. list_plansis oneLEFT JOINwithCOUNT(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 andf"{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, andreportwork end to end in a temporary directoryplan --simulatestores deliveries and the report shows actual on-time %report --outwrites 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
- ruff for linting and formatting (rules E, F, I, UP, B, RUF, D with Google docstrings), and
mypy with
strict = true(M42, M16). - pre-commit runs ruff and mypy before every commit and checks Conventional Commit messages
at the
commit-msgstage. Never bypass it with--no-verify; fix the problem. - structlog instead of
printin library code, configured with a JSON renderer so logs are machine-readable (M43):log.info("plan.saved", plan_id=7, routes=5, minutes=812.4).
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
- Conventional Commits:
feat(routing): add Clarke-Wright savings,fix(graph): reject negative edge weights,test(eta): baseline comparison. Generate a changelog with git-cliff. - README: one-line pitch, a screenshot or report excerpt, install (
pixi install), quick start (the three CLI commands), architecture diagram, results table (heuristic vs optimal gap, ETA MAE vs baseline), and how to run tests. - Architecture Decision Records (ADRs): short Markdown files in
docs/adr/, one per significant decision, with Context, Decision, Consequences. Write at least three, for example: "Use Clarke-Wright as the default planner", "Store plans in SQLite, cities in CSV", and "Precompute a depot-and-customer distance matrix instead of querying A* per leg".
# 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.
| Criterion | Weight | Excellent | Needs work |
|---|---|---|---|
| Correctness | 25% | All grading tests pass; every plan from every method is feasible and complete; CLI handles errors with proper exit codes | Some plans violate windows or capacity; crashes on edge cases |
| Algorithms and efficiency | 15% | Heap-based Dijkstra, admissible A*, cached matrix, O(1) 2-opt deltas; 200 customers in seconds; complexity stated in docstrings | Re-running shortest paths per lookup; quadratic scans inside loops; minutes per plan |
| Code quality | 10% | Typed (mypy strict clean), documented, ruff clean; small functions; no circular imports; immutable records | Long functions, duplicated logic, missing types, mutable shared state |
| Tests | 15% | Unit, oracle, property, and integration tests; ≥ 90% coverage; regression tests for fixed bugs | Only happy-path tests or tests without meaningful assertions |
| Data pipeline | 10% | Deterministic generation, validated loading, schema with keys and constraints, transactional writes | Unvalidated CSV, SQL built with f-strings, partial writes on error |
| ML evaluation | 10% | Held-out split, naive baseline, MAE and RMSE reported side by side, reproducible training, honest discussion of limits | Metrics on training data, no baseline, unreproducible results |
| Documentation | 7% | README with quick start and results, architecture diagram, three or more ADRs, changelog | No README or one that does not explain how to run the project |
| Demo | 8% | A 5-minute walkthrough: problem, live CLI run, one design trade-off, results, what you would do next | No 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
- Lead with the problem, not the code. First line of the README: "RouteWise plans delivery routes for 200 customers in under a second, with every time window respected."
- Show results with numbers. A table: nearest neighbour vs savings vs sweep (vans, minutes), gap to Held-Karp on small cases, ETA MAE vs baseline. Numbers prove you measured.
- Make it run in one minute.
pixi installand three commands. If a reviewer cannot run it, it does not exist. - Show the green CI badge and the coverage figure. They signal professionalism before anyone reads a line.
In an interview
- Use a story structure: situation (the bakery), task (plan feasible routes), action (your architecture and one hard decision), result (the numbers), and what you learned.
- Prepare one deep trade-off. "Why savings and not an exact solver?" Explain NP-hardness, the O(n² 2ⁿ) cost of Held-Karp, and how you used it as a test oracle instead.
- Prepare one bug story. For example, the 2-opt delta that silently assumed symmetric travel times, and the property test that caught it.
- Know your limits. Synthetic data, a single depot, no driver breaks, static traffic. Saying what you would do next shows judgement.
- Be ready to extend it live: "How would you add a second depot?" Walk through which modules change (the instance and the construction heuristics) and which do not (graph, store, report).
Final self-assessment
Fifteen questions across the whole capstone. Aim for at least 80% before presenting your work.
Recap
- Real problems start with stakeholders and checkable requirements, then a data model and an architecture with one-way dependencies.
- Shortest paths (Dijkstra, admissible A*) and a cached matrix turn a road network into fast travel-time lookups.
- Vehicle routing is NP-hard: build plans with greedy construction (nearest neighbour, savings, sweep), polish with 2-opt, and verify with an exact Held-Karp oracle on small cases.
- One trusted feasibility simulator underpins every algorithm and test.
- ML is only as good as its evaluation: held-out data, a naive baseline, and reproducible training.
- SQLite with keys, constraints, transactions, and placeholders keeps outputs correct and safe.
- Professional practice (Pixi tasks, strict typing, linting, CI, ADRs, a README, and a demo) is what turns a working program into a portfolio piece.
Going further
- heapq, sqlite3, and argparse in the Python documentation
- scikit-learn: pipelines and composite estimators
- Google OR-Tools vehicle routing guide, for how industrial solvers approach the same problem
- adr.github.io for ADR templates
- Conventional Commits and pixi.sh