Supply Chain
→
The end-to-end idea
I've been weirdly fascinated by retail supply chains for a while — how they actually work, and
how you'd build the next generation of mathematical models behind resilient, efficient ones. The
projects below are me working through that: learning the math, and learning how it's actually done.
Strategic Where do facilities go? → 01 facility location
Tactical What goes on which truck? → 02 consolidation
Operational What route does it drive? → 03 routing / TSP & VRP
Reverse Where does it go coming back? → 04 return routing
01
Facility Location
Why
At UPL I got to work on redesigning our 3PL warehouse network strategy — SKU placement across
warehouses, import port allocation, and inventory positioning against seasonal demand. I did
something similar in school, where our project advised the City of Goldsboro on fire station siting:
8 candidate sites, 2,724 demand points, drive-time and drive-distance matrices, and a hard
4-minute first-truck response standard.
The big takeaway is that "optimal" is a function of which objective you picked. Goldsboro's
answer changes depending on whether you minimise total distance, minimise the worst-case response,
or maximise the population covered inside 4 minutes. Same 8 candidates, different stations open.
That's the whole point of this project.
How it works
Four families of model over the same candidate set, so the answers can be put side by side.
Cost-minimising
- UFLP — uncapacitated fixed-charge location, exact ILP (PuLP/CBC). Minimise fixed
cost plus total transport cost.
- CFLP — adds capacity constraints per facility. Surfaces the capacity
premium: the extra distance overflow demand has to travel once a facility's cap binds.
Sweeps capacity levels to show where it starts to hurt and where it goes infeasible.
- p-median — fix the facility count, minimise total weighted distance.
Coverage-driven
- LSCP — minimum facilities to cover every demand point within threshold T.
- MCLP — fix the budget, maximise demand covered within T. Reports the coverage
neighbourhood distribution |Nᵢ(T)| so you can see which points only one facility can reach
— those are the fragile ones.
- p-center — minimax. Minimise the worst-case distance any customer travels. Reports
the theoretical lower bound maxᵢ minⱼ dᵢⱼ and flags when the solution
hits it, so you know it's provably optimal rather than just solver-optimal.
Heuristic
- Greedy-Add — opens one facility at a time, each time picking whichever candidate
saves the most, and stops when nothing saves anything.
- Greedy-Drop — starts with everything open, closes the least useful.
- Both benchmarked against the exact ILP: gap and runtime, side by side.
Multi-objective
- Weighted-sum — normalise distance and time to [0,1], sweep w from 0 to 1, trace the
Pareto frontier. Flags the exact weights where the open-facility set flips.
- ε-constraint — minimise time subject to total distance ≤ ε.
Gives the complete frontier rather than just the supported points.
↑ Index
02
Consolidation Centres & Cross-Dock Terminals
Why
I've always wondered how Lowe's and companies at that scale actually run consolidation centres
— how freight from hundreds of suppliers gets pooled, and how anyone decides what goes on
which truck. A consolidation centre makes two decisions and they interact. When do you release a
truck? Holding freight fills trailers and cuts cost per hundredweight, but every shipment on
the dock is a shipment not at the DC. What goes on the truck? A trailer has a weight ceiling
and a cube ceiling, and freight that fills one leaves the other slack — so the second decision
changes how many trailers the first one has to pay for.
So I built both on the same generated freight, priced everything off a realistic LTL rate structure,
and asked where a consolidation centre stops paying for itself at all.
How it works
The freight cost model first, because it makes or breaks everything else. LTL freight is not
linear in weight. A tariff quotes a rate per hundredweight that drops at published weight
breaks, and the bracket's rate applies to the whole shipment — breaks at 500 / 1,000
/ 2,000 / 5,000 / 10,000 / 20,000 lb, running from $42.00/cwt down to $9.60/cwt. On top of the
staircase sits the over-declare rule: a shipment may be billed as if it weighed exactly a
higher break point whenever the cheaper rate more than offsets the phantom weight. A 4,800 lb
shipment moves as 5,000 lb and costs 23% less than its own bracket says. So the billed cost is the
lower envelope of the staircase, not the staircase itself. Model freight as linear in
weight and every conclusion about consolidation is worthless, because crossing a break is the whole
point of pooling.
- Dispatch. Freight accumulates and a policy decides each day whether to release: a fixed
schedule, a fill threshold, or whichever comes first. The policies differ in a second way that
matters more than the trigger — a fill-triggered release ships the full trailers and
holds the remainder, while a time-triggered release ships everything, however empty.
That asymmetry is what produces the cost-versus-dwell curve.
- Load build. Vector bin packing, not 3D geometric packing: a shipment is a resource vector
(weight, cube), with no orientation or stacking rule. That's the right abstraction for the
question being asked — which ceiling binds. Four heuristics, including
residual_fit, which picks the shipment whose resource vector has the highest cosine
similarity with the trailer's remaining capacity. Since an empty 53' van's residual
points at 45,000 lb / 3,800 ft³ — 11.8 lb/ft³ — maximising that cosine
means picking freight whose density matches the density the trailer still has room for. It's the
formal version of mixing dense and light freight on the same load.
- Break-even. Landed cost of shipping every supplier direct to the DC, against pooling
through a centre first, swept across supplier count, shipment size and geometry. Two choices
deliberately made against consolidation: tenders are pooled by (supplier, day) so
pooling can't "recover" an LTL penalty I invented, and every leg is priced at min(LTL, TL) so a
supplier with enough freight to justify a truck buys one. Supplier→DC distance uses the law
of cosines on a random bearing, so the centre is not assumed to be on the way.
One simplification worth stating, because it shapes two of the findings. The rate table
scales linearly with distance; real tariffs taper the mileage factor, so real long-haul LTL
is much cheaper per mile than this. With linear scaling and truckload at $2.75/mi, a truckload wins
above ~1,100 lb on any lane — distance cancels out of both sides — where real crossovers
sit at 10,000+ lb. The staircase here therefore prices small tenders, and the outbound leg
is effectively a flat charge per trailer.
What it found
Waiting is worth a lot, and the price is a long tail. Shipping to a fill threshold instead of
a clock costs $3.83/cwt against $7.44/cwt — −$3.61/cwt (95% CI
[−3.74, −3.48], Cohen's d = −9.71), cheaper on all 30 paired freight streams
— for 1.68 extra days of mean dwell. But the mean hides the real cost: under a pure fill rule
the worst shipment waits 30.8 days while p95 dwell is only 6.0. A fill rule has no
upper bound on how long an awkward shipment can sit in the remainder, because there is always
something better to load. That's the argument for a hybrid, and it shows up in no average anywhere.
The clock rule isn't even monotone: releasing every 7 days is 9.4% more expensive per cwt
than every 5, because seven days of arrivals overshoot one trailer and spawn a near-empty second one
every cycle.
The weight breaks do not drive the release decision, which is not what I expected. I'd
assumed optimal fill thresholds would snap to weight breaks — plateaus and jumps rather than a
smooth curve. They don't. At $2.75/mi truckload, only 1.0% of dispatched trailers were
cheaper rated as LTL, so the outbound leg is a flat charge and cost per cwt is almost exactly
proportional to 1/fill: the correlation across all 510 runs is 0.9993. There's nothing for a
threshold to snap to. The staircase is real and it does bind — but on the inbound
leg, where individual supplier tenders sit deep in the expensive brackets. Over-declaring binds on
32.7% of tenders and takes 4.6% off the total freight bill.
Which ceiling binds is set by the freight mix, and it flips. Plot every dispatched trailer by
weight and cube utilisation and you get an L: trailers pin against one ceiling or the other, almost
never both. Sweeping the light-freight share at constant total weight, the binding constraint flips
at 43% light freight, where mean freight density is 11.9 lb/ft³ — against the
trailer's own break-even density of 45,000/3,800 = 11.84 lb/ft³. That flip point isn't a
fitted number; it's the trailer's geometry showing up in the data. It's also where trailer count is
minimised, because it's the only mix where both ceilings can be reached at once.
The adaptive packer saves 3.54 trailers per 1,000 shipments over sorting on weight alone
(95% CI [2.99, 3.99], −13.5%, d = −2.47; 26 wins, 4 ties, 0 losses over 30 paired
instances) and matches the LP lower bound ceil(max(ΣW/45000,
ΣV/3800)) on 100% of instances — it cannot be improved on.
But my own claim that no fixed rule could match it doesn't hold. A flat 50/50 blend of
normalised weight and cube captures 93% of the gain: residual_fit beats it by 0.26
trailers per 1,000 (−1.1%, d = −0.26; 2 wins, 28 ties, 0 losses), and across the mix
sweep the fixed blend actually wins one instance. Adapting to the residual is not wrong, it
is barely distinguishable.
Fragmentation is a real cost and it lands entirely on the inbound leg. Going from a few
dominant suppliers to all suppliers alike, at fixed total volume, drops mean tender weight from
1,306 lb to 1,029 lb and raises inbound cost by +$4.94/cwt (+19.3%, d = 1.71). Outbound fill
is untouched at 0.98 — the centre absorbs fragmentation completely on the way out and pays for
it completely on the way in.
And the break-even is driven by shipment size first, geography second, supplier count third
— spreads of 107, 46 and 24 points of mean saving across the three axes. Supplier count acts
almost entirely through tender size: at fixed volume, going from 10 to 100 suppliers cuts
what each one tenders, and consolidation improves from +4.0% to +28.9%. It's the same mechanism, not
a third one.
The cost decomposition is the part that changed how I think about this. Averaged over the grid, the
consolidated stack is inbound LTL $46.08/cwt, the centre's own fixed cost $54.67/cwt,
outbound linehaul $4.38/cwt, handling $3.20/cwt. The fixed cost is the largest single line —
larger than all the freight it handles — because at 7,400 lb/day this centre is grossly
under-utilised. It's constant per cwt across the grid while direct-ship cost per cwt falls as
tenders grow toward a truckload, so that, not the weight table, is what sets the crossover.
A siting decision should start with throughput, not with rates.
All freight is synthetic. The rate table's structure — break points, the shape of the
taper, the over-declare rule — is representative of published LTL tariffs, but no figure in it
is claimed to be measured from any real carrier or shipper, and the linear mileage scaling is a
documented simplification carried into the findings above. Nothing here is a rate quote. Every
headline number is a paired comparison on identical freight streams with a 95% percentile-bootstrap
CI, and every function that consumes randomness takes an explicit generator, so any figure
regenerates exactly.
↑ Index
03
Routing — TSP & VRP Solvers
Why
The truck is loaded. Now what order does it visit the stops in, and how many trucks do you need?
This is the operational layer of the stack above — the travelling salesman problem, then the
vehicle routing problem that generalises it once capacity and multiple vehicles come in. I wanted
to find out for myself where exact solving stops being worth the wait.
How it works
Three layers, run on the same instances so the answers are comparable.
- Construction — nearest neighbour, nearest insertion, Clarke-Wright savings, sweep.
Fast, no guarantees.
- Improvement — 2-opt and Or-opt on the constructions, plus capacity-checked
relocate and swap moves across routes, since per-route local search can never move work between
vehicles.
- Exact — both models are cutting-plane loops: solve, and if what comes back isn't a
valid set of routes, add the constraint it violated and solve again. DFJ with lazy subtour
elimination for the TSP; a two-index formulation with lazy capacity cuts
(
x(E(S)) ≤ |S| − ⌈d(S)/Q⌉) for the CVRP.
What it found
Benchmarked over 150 instances — sizes 10 to 100, 15 seeds each, uniform and clustered
layouts. The same cutting-plane idea behaves completely differently on the two problems: DFJ proves
a 150-node TSP optimal in about 21 seconds, while the CVRP model already fails to prove
optimality on 43% of 15-customer instances inside a 45-second limit.
That gap isn't about size, it's about how well the relaxation describes the problem. Drop subtour
constraints from a TSP and what's left is still tour-shaped, so a handful of cuts closes it. Drop
capacity constraints from a CVRP and what's left barely resembles routing — the solver
proposes overloaded trucks and each cut forbids exactly one violation, so cuts pile up faster than
the bound moves. The practical read: for a TSP, just solve it exactly. For a CVRP past about
fifteen customers, the exact model's real job is calibrating the heuristics on small instances so
you can trust them on large ones.
The result I didn't expect: on the TSP, the better construction heuristic produces the worse
final answer. Nearest insertion beats nearest neighbour by a wide margin on its own
— 19.9% vs 24.2% above optimal at n=100 — but run identical local search on both and it
reverses, to 4.47% and 3.22%. Nearest neighbour's flaw is a few long crossing edges, which is
exactly what 2-opt removes; nearest insertion spreads its error thinly as slightly-wrong local
orderings that no single move can fix. Judge a construction heuristic on what it leaves for the
search, not on its own tour length.
↑ Index
04
Return Routing Optimisation
Why
I returned a North Face jacket. It was unworn — wrong size, sent back within a few days
— and it still got shipped across the country to a facility so somebody could open the box and
decide it was resellable. Everything needed to make that call was knowable before the box moved.
Most retailers route every return to a central warehouse, inspect it there, and only then decide
what to do with it. Deciding after the item has already travelled wastes the shipping and lets value
decay; the industry loses an estimated 10–20% of a product's value this way. So: decide where
a return goes before it ships, not after.
How it works
Three layers, fired at the moment the return label is generated.
- L1 — predicts a probability distribution over the item's condition from label-time
features only.
- L2 — prices every route in expected dollars:
EV(j) = Σₖ P(k) × [R(j,k) − T(j) − P(j,k)]
- L3 — an integer program assigns the whole day's returns under every facility's
capacity limit.
What it found
+$5.20 recovered per return over the credible baseline — about $520,000/year at
100,000 returns. Paired over the same returns: 95% bootstrap CI [+$4.92, +$5.48],
t = 36.3, Cohen's d = 0.57, with 61.9% of returns routed differently. Using label-time
features alone, it captures 83% of the value a perfect-information router would achieve.
Most of that is not the machine learning, and it's worth saying so before anyone asks. The Layer-1
quality sweep doubles as a decomposition: at skill = 0 the classifier is switched off
entirely and every return gets the population base rate, so whatever survives is earned by the
channel change and the optimiser alone. Free in-store returns plus capacity-aware routing account
for +$4.68 — 90% of the uplift. The trained condition classifier accounts for +$0.51
— 10%. Most of the money is a policy change and an assignment problem.
The second output is a capacity plan. The dual on each capacity constraint says what one more unit
of daily throughput at that node is worth, which turns a router into a where-to-invest
recommendation.
The North Face anecdote is motivation only — it contributed no data. The network, capacities,
recovery rates, costs, and the returns themselves are synthetic; parameter ranges are anchored to
published industry figures, and no value is claimed to be measured from any real company. Every
figure above is scored against the item's true condition, which no model ever saw.
↑ Index
05
Supply Chain Risk Intelligence
Why
Disruptions show up in the news days before they show up in anyone's numbers. I wanted to see
whether that gap could be turned into a usable signal — something that tells a supply chain
team to look at a corridor or a port before the shipment is already late.
How it works
Multi-source news ingestion via NewsAPI, NLP and GPT risk scoring, corridor and port risk indices,
executive heatmaps, and export to CSV, PDF or Power BI.
I'm rebuilding it, because the scoring layer is an LLM asked to rate a headline, and that is not a
risk model — no calibration, no base rate, no way for it to be wrong in a measurable
direction. The rebuild replaces it with event extraction against a defined taxonomy plus an explicit
exposure map, so an index number traces back to a named supply node rather than to a vibe, and the
whole thing can be scored against disruptions that actually happened.
Demo video
↑ Index
06
Commodity Stockout Risk Platform
Why
Commodity markets price scarcity for a living. If a raw material is about to get tight, the futures
curve tends to know before a planner does — so I wanted to see how far you could get using
market data as an early proxy for stockout risk.
How it works
Live OHLCV from Yahoo Finance, technical indicators (ATR, RSI, MACD), a unified 0–1 risk
index, a Random Forest shock predictor, and GPT-generated summaries on top.
It's being reworked, because the premise is both the interesting part and the weak part: commodity
price volatility is a leading signal for some disruptions and a lagging one for
others, and this version doesn't distinguish. Until the index is validated against actual stockout
events rather than price moves, it's a market dashboard wearing a supply chain label.
Demo video
↑ Index
07
PolicyPulse
Why
Say you run an online clothing shop. A big chunk of what you sell comes back, and shipping each
return costs you money. So someone runs the numbers and says: start charging $9.95 to mail
something back. On a spreadsheet it's obviously right — it saves millions a year.
Then you announce it, and people are angry about it online for a week, and some of them quietly
never shop with you again. The spreadsheet had no way of seeing that coming. It knows what things
cost. It has no idea how people feel. That's the gap this fills: before you announce a policy,
get some sense of how people will take it.
You could just ask them — survey 500 customers, average the answers. The problem is that
isn't how these things actually play out. What usually decides the outcome is one person with a
big following posting about it, the bargain hunters piling on behind them, and the whole thing
snowballing into something none of the individual answers predicted. A survey measures what people
think on their own. This tries to measure what happens once they start talking to each other.
How it works
- First, who does this actually hurt? Not everyone feels $9.95 the same way. If your usual
order is $45, that's a fifth of what you spent and it stings. If your usual order is $165, you
barely notice. So the model thinks in proportions, not dollars. It also assumes the pain levels
off: a $50 fee isn't five times worse than a $10 one — past a point it's just
no.
- Then build a crowd. 500 imaginary customers in five types — loyal regulars, casual
shoppers, bargain hunters, people with big followings, and sustainability-minded buyers. Each
type spends differently, is annoyed more or less easily, and posts more or less often.
- Announce it, and let them talk for 45 days. Every day each person reads ten posts and
adjusts how they feel. Which ten is the part that matters: six come from accounts with a big
following, because loud voices dominate any feed; two come from people like them, because we
mostly hear from people who already agree with us; one is simply recent; and one is random, so
nobody is completely sealed off. Then they might write something themselves, which everyone
else can see tomorrow.
- Keep "annoyed" and "leaving" as separate things. They aren't the same. Plenty of people
think a fee is unfair and keep buying anyway. Tracking them apart is the difference between a
useful answer and a useless one.
- Finally, put a price on it. Feelings aren't a decision. So you feed in your own numbers
— how many customers you have, what they spend, your margin, how often things come back
— and it turns the mood into money: what the fee saves you, against what the customers
you lost were worth, at every price point. The useful output isn't a single figure, it's the
point where saving money starts costing you more than it saves.
Two versions, kept honest against each other. There are two simulators doing the same job. In
the fast one, customers are just numbers following rules — it's free, it gives the same answer
every time, and a full run takes half a second, so you can try hundreds of settings. In the
slow one, every customer is a real AI that writes actual posts and replies to other people. It costs
$174 and up per run and takes minutes, but it reads the actual wording of the announcement,
which is the only way to check whether how you say something changes how it lands.
What it found
The bit that surprised me: casual shoppers turn out to be worth more than bargain hunters,
even though far fewer of them leave. Bargain hunters complain the loudest and are the
cheapest to lose. The customers who quietly stop coming back are the expensive ones.
Running both simulators is the point. If the expensive one agrees with the cheap one, you can trust the cheap
one to do the exploring. On which customer types get hit hardest, they agree 80% of the
time. They don't agree on the exact numbers, and I wouldn't expect them to — the fast version
knows how everyone privately feels, while the AI version only ever sees what people chose to post,
and people mostly post when they feel strongly. So the ordering is the thing to check, not the
figures. On those, the two still disagree about which single group has it worst.
The most convincing moment wasn't a number. One of the AI customers, told nothing except that it
shops on a budget, wrote this on its own:
"$12.95 return fee?? SERIOUSLY?? When my average order is $25–40, that's 30–50% of what
I spent! …5 other retailers I use have FREE returns."
That's the same "judge the fee against what you usually spend" reasoning I'd hand-written into the
fast version — arrived at independently, by something that had never seen my code. Two
completely different methods landing on the same idea is worth more than any two numbers matching.
↑ Index