2026-10-20 · containerboard · network flow · public data · claude
The paper network: every containerboard mill, every box market, and the flow between them
A corrugated box starts as rolls of linerboard and medium from one of about sixty mills and ends, nine times in ten, back in a bale. The business runs on a map of mills, plants, freight and prices that everyone in it carries in their head and nobody publishes. The inputs are public. So I built the map, with Claude as the pair, from filings, registries and a weekly diesel number, and solved it as the one problem it has always been: a minimum-cost network flow.
This post is the long version: why the problem is a network flow and what that buys you, where every number comes from, how the data was cleaned and how far off it still is, what each page of the app does, how the thing is built and refreshed, and what someone could do with it who wanted to dream a little. Every figure traces to a public page. Every chart on this post has a "show the code" under it, in the house style.
1. Why build a map the industry already has
Containerboard is a commodity that behaves like a relationship business. The price of 42-pound kraft liner is a published index. Freight is distance times diesel. Mill capacities are in the 10-K. Box plants are in the EPA's facility registry with an address. And yet "who ships to whom, at what delivered cost, and who would switch for how much" is treated as private knowledge, because the only people who have ever put the pieces together are the people whose job it is to keep them apart: the mill sales desk, the integrated producer's planning group, the broker.
I spent eleven years on the corrugator side of that map: the plants that take the mills' rolls and turn them into board and boxes, which the mill side calls box plants and this model calls demand. The asymmetry is real and it is expensive for everyone below the mill: independents pay it, box buyers pay it, and the people inside the integrated companies who want to argue with a transfer price pay it too. The inputs to the map are free. Putting them together is a week of work and one linear program. That is the whole thesis of the project: the public draft of this map is good enough to argue with, and arguing with it is the point.
What follows is what the public draft says, with its error bars printed next to it.
2. The network flow problem is the whole thing
Strip the business to its skeleton and it is this: a set of supply nodes (mills) with capacities, a set of demand nodes (box markets) with requirements, and an arc from every mill to every market with a cost per ton (freight). Choose the tons on each arc to meet every demand without exceeding any capacity, at minimum total cost. That is the transportation problem, the oldest special case of minimum-cost flow, and it is the problem Lecture 7 of a graduate algorithms course spends on successive shortest paths and network simplex.
Three things make it the right tool rather than a convenient one.
It is exactly solvable at full size. The real instance has 93,423 variables and 7,139 constraints once the expansions below are in. HiGHS, the open-source solver behind SciPy, finds the optimum in 1.9 seconds. No heuristics, no sampling, no "approximately".
The answer is integral for free. The constraint matrix of a network flow is totally unimodular, so the LP optimum is already whole tons on whole arcs. You never round.
The dual solution is the product. Every linear program has a shadow problem whose variables are prices. For a network flow those prices sit on the nodes: a price per ton of capacity at each mill, and a price per delivered ton at each market. They fall out of the same solve, and they are the numbers the industry actually trades on.
Watch the algorithm reroute a ton
Here is a toy instance you can step through: three mills, four markets, freight costs on the arcs. The algorithm is successive shortest paths: find the cheapest residual path from the source to the sink, push as much flow as it can carry, repeat. The moment to watch is the last step. Atlanta still needs five tons, the two cheap mills are full, and the only mill with capacity left is on the West Coast at $120 a ton. The algorithm does not pay $120. It finds a path that sends West Coast tons to Dallas and takes five tons back off the Gulf mill's Dallas lane (the dashed reverse arc) so the Gulf mill can serve Atlanta. Net cost $78 a ton. That reverse arc is the thing a greedy planner never sees, and in the real business it is the phone call where a mill tells one customer "we are moving you to the other machine so we can take the new account".
When the flow is optimal the same search gives the prices. Run one more shortest-path pass over the residual network and the distance to each node is its dual: the West mill has capacity to spare, so its scarcity value is $0; the Gulf mill is full and freeing one ton there costs the network $48 of rerouting, so a ton of Gulf capacity is worth $48; one more ton of demand in Chicago would cost $93 delivered, not the $18 of the direct lane, because the direct lane is spoken for. Check the complementary slackness yourself in the table: on every arc that carries flow, market price minus mill price equals the freight; on every empty arc it is less. That identity is what makes the duals prices rather than opinions.
The real network, expanded
The toy has one kind of ton and no owners. The real model needs four expansions, all of which stay inside the network-flow family so the solve stays fast and exact.
Owner copies of every market. Integrated producers ship their own board to their own plants first. Each of the 441 markets is split into an owner copy for International Paper, Smurfit Westrock, Packaging Corp of America, Georgia-Pacific, Pratt, Green Bay Packaging and Cascades, plus an open-market copy. The copies take fixed shares of the market's demand (31%, 20%, 15.5%, 9.5%, 6%, 3%, 2%, and 13% open), set from the companies' disclosed integration and their share of box shipments. A mill can serve its owner's copy at freight; it can serve another owner's copy only through the premium arc, a $150-a-ton penalty that stands in for "buying outside your own system".
Grade split. Board is 65% linerboard and 35% medium by weight, and mills make one or both. Each market copy fans into a liner node and a medium node, and a mill's arcs go only to the grades it makes.
Export sinks. 3.6 million tons leave through the ports. Ports are sink nodes with their own demands, fed by the mills nearest them.
Unmet demand. Every demand node has an escape arc at $400 a ton, so the model never reports "infeasible"; it reports how many tons it could not place and where. In the October 2026 footprint that number is zero. In the counterfactual that runs 2026's mills against 2024's demand it is 2.1 million tons, which is the cleanest statement of what the 2025 closures did.
Freight on each arc is a rule, not a quote: road miles are 1.2 times the great-circle distance, truck costs $8 plus $0.125 a mile and rail $35 plus $0.065 a mile per ton at $3.75 diesel, and the model takes the cheaper of the two, which puts the truck-to-rail break near 450 miles. Diesel moves the per-mile terms through the fuel share of each mode, so the live EIA price is a scenario rather than a parameter.
Why the duals matter more than the flows
The flows are a cost-minimizer's answer, not a record of who ships to whom today; a sales organization with history and contracts will differ from them everywhere. The duals are sturdier, because they describe the margins of the system rather than its interior:
Capacity scarcity value. The dual on a mill's capacity constraint is what one more ton there is worth to the whole network. In the October 2026 run a ton of West Coast capacity carries a scarcity value of about $62 (nine mills, capacity-weighted) against $35 east of the Rockies. That spread is the entire business case for the recycled mills built on the West Coast, and the model produces it from freight alone.
Delivered marginal cost. The dual on a market's demand is what the last ton costs to deliver there. Map it and you have the contour of the price a buyer can actually negotiate, before anyone has quoted anything.
Switching gap. For each market, the model knows the cheapest mill by delivered freight and the one behind it. The gap between them is the price of loyalty. Median gap: $5.20 a ton. 49% of markets have a second mill within $5; 71% within $10.
Outages as knockouts. Remove a mill, re-solve, and the objective's change is the yearly cost of that mill being down: extra freight, tons bought outside the system at the premium, tons that go unserved. Twenty mills, twenty numbers, no judgment required.
Maintenance as an assignment problem. Twelve months, each with its own demand season and its own solve, give twelve sets of scarcity values. Assigning each mill's annual outage to a month is then a small integer program priced by those duals, with caps on how many mills (nine) and how many of one owner's mills (two) can be down at once. The model's own best calendar costs the network $122 million a year in outage effects against $276 million for a staggered calendar drawn by hand.
The fiber loop. Thirty million tons of old corrugated containers come back. Route them from where boxes are used to the recycled mills and the export docks and it is the same network run in reverse, with its own duals: what a ton of OCC is worth in each county.
That is why the algorithm is the post and the map is the illustration. Everything the app shows is either a flow, a dual, or a difference between two solves.
3. What the model says
Freight bill, Oct 2026$2.08B32.2 Mt domestic + 3.6 Mt export
Average haul509 mi42% of tons by rail
Freight per ton$59.56$58.35 at $3 diesel, $63.16 at $6.20
Mill utilization95%53 of 64 US mills at capacity
Scarcity, West vs East$49 vs $20per ton of capacity
Switching gap, median$5.30/t49% of markets within $5
Costliest outageMonticello, MS$98M a year; Mansfield $78M
Maintenance calendar$180M → $30Mstaggered vs priced by duals
Some of the findings, in the order they surprised me.
A unilateral price increase does not survive the model. Raise one producer's open-market price by $70 a ton with everyone else unchanged and the open-market tons leave: International Paper loses all 534 kt across 82 market-grade accounts for $6.4 million a year of extra cost to the rest of the network; Pratt loses 522 kt across 74. Increases hold in practice because everyone announces together and because the index covers only about 5% of consumption. The app turns each shock into a call sheet: which market, how many tons, from which mill to which.
The 2025 closures were not where freight said they would be. Run 2024's footprint and the mills that actually closed (Cedar Springs, Riceboro, Savannah, Red River, Forney, Niagara Falls) sit 85% to 100% loaded, ranked 22nd to 75th of 79 on the idle list, because they are in the Southeast and on the Gulf next to the demand and the docks. Regional surplus does better: capacity within 300 miles of those mills ran 0.5 to 3.6 times the demand there. What they shared is in no free dataset: age, cash cost per ton, the capital they needed. The model says so on its own Backtest tab.
The middle tier is the loose part. Board does not go from mills to markets; it goes to 458 corrugator plants, 70 sheet feeders and 634 sheet plants. Those counts are public (Fibre Box Association, AICC) and their locations are not, so the app allocates machines to markets statistically and prices the sheet trade at 1.8 times roll freight because sheets cube out a trailer. National corrugator utilization comes out at 71%, and 180 of the 441 markets have no corrugator at all and live on bought sheets. Whoever publishes the real corrugator list changes this tab.
Diesel is a bigger lever on mode than on cost. From $3 to $6.20 a gallon the freight bill rises 8%; the rail share rises from 39% to 48%. The live weekly EIA price is a scenario in the app.
4. Where every number comes from
Nothing in the model requires a subscription, a membership or a relationship. Here is the full list, what each source gives, and how it gets in.
Source
What it gives the model
How it gets in
SEC EDGAR 10-K filings (International Paper, Smurfit Westrock, Packaging Corp of America, Graphic Packaging, Greif, Cascades)
Mill lists, capacities by grade, closures and conversions, integration ("~80% to own plants"). The supply side of the model is reconciled to these tables.
Read and reconciled by hand with Claude, one row per mill with the citation; EDGAR pulls need only a User-Agent header (a name and an email).
Company announcements and trade press (Packaging Dive, Recycling Today, PPI Pulp & Paper Week as reported)
The 2025 and 2026 closures, new machines, acquisitions, index price moves, outage guidance.
Judgment layer: a monthly and a quarterly review that edits the CSVs and the price points, with sources, and appends to the changelog.
EPA Facility Registry Service
Every facility coded NAICS 322211 (corrugated boxes) with name, address and coordinates: 1,796 records, 1,605 distinct sites after de-duplication, plus 498 paper and board mills.
plants.yml downloads the national file and filters it; no key.
Census County Business Patterns 2023
Establishments and employment for NAICS 322211 by state (1,159 establishments, 90,228 employees) and by county where not suppressed. Sets each state's share of box demand.
cbp.yml reads the bulk county and state files; no key.
US Atlas / Census TIGER centroids and population
3,142 counties, aggregated to 441 demand markets (metro clusters) with population as the within-state fallback weight.
Static files in data/.
AF&PA and Fibre Box Association totals
Industry production, box shipments, operating rate, end-use shares (food and beverage 36%, e-commerce 16–20%), the 458 / 1,092 plant counts. The model's national totals are calibrated to these.
Quarterly, from the press summaries of the reports.
AICC
634 sheet plants (86% independent), about 70 sheet feeders.
Public statements; counts only.
EIA weekly on-highway diesel
National and regional price, every Monday. The live price is a scenario and the series is on the Prices tab.
refresh.yml, Mondays, with a free EIA key.
FRED (St. Louis Fed)
Producer price indexes for paperboard mills and corrugated boxes, industrial production of paper, retail and e-commerce sales: the demand backdrop.
refresh.yml, monthly, with a free FRED key.
BLS QCEW
County employment for 322211, quarterly, a second opinion on the Census weights.
refresh.yml, quarterly, open endpoint.
Published containerboard index points as they reach the press
42-lb kraft liner East and West, 26-lb medium, OCC national and regional: the mill-price inputs on the Prices tab.
Judgment layer, monthly.
Google Places (New) text search
Names, addresses, coordinates, place types and an operational / permanently-closed flag for box plants near each market: a second source against the EPA list.
places.yml on demand, 1,764 calls, inside the free tier.
Claude with web search
For each EPA site: operating or closed, corrugator or sheet plant, owner today, whether it is hiring for a second or third shift. Retires the stale third of the EPA list.
verify.yml on demand, budget-capped, about ten cents a site.
The keys, and what they cost
Five keys, all stored as GitHub repository secrets, none in the code or the page. Each workflow step is skipped when its key is absent, so the repo runs with none of them.
Key
Used by
Cost
EIA_API_KEY
Weekly diesel
Free
FRED_API_KEY
Price and production series
Free
BLS_API_KEY
QCEW county employment (optional; the open slice works without it)
Free
EDGAR_UA
A User-Agent string for SEC pulls: a name and an email, not a key
Free
GOOGLE_MAPS_API_KEY
Places text search near each market
Free up to 5,000 calls a month on the Pro tier; the run uses 1,764
ANTHROPIC_API_KEY
The site-by-site verification with web search
About $0.10 a site on Claude Opus 5.5, $0.06 on Sonnet 5.5, $0.02 on Haiku 5.5; roughly $160 for the whole list, half through the Batches API
The chat on the public page, through the proxy described in section 8
Tokens per round, a few cents on Opus; the proxy caps rounds per day and the console caps the month. The claude.ai copy needs none of these: it runs on the viewer's own account
5. What each API actually returns
"Free public data" is a slogan until you see the fields. This is what each endpoint gives back, so you can judge what else could be built on it.
API
What comes back
Grain, cadence, limits
EIA v2, petroleum/pri/gnd/data
Weekly retail on-highway diesel, dollars a gallon, for the US, the five PADD districts, their sub-districts and a few states. The model uses the national series; the regional ones are a diesel-by-region freight factor waiting to happen.
Weekly since 1994, Monday afternoons. 5,000 rows a request. Free key.
FRED, series/observations
Date and value for any of roughly 800,000 series. In the model: producer price indexes for paperboard mills and for corrugated boxes, industrial production of paper, e-commerce retail sales, retail sales ex food service. Also available and not yet used: trucking PPI, rail carload indexes, manufacturing employment by state, housing starts.
Monthly or quarterly; 120 requests a minute. Free key.
BLS QCEW, open CSV slices and API v2
Establishments, employment and wages by county, by NAICS code, by quarter. The 322211 slice is the fastest-moving public measure of where box plants are adding or shedding people.
Quarterly, five months behind. Open slices need no key; the API allows 50 series a query and 500 queries a day with one.
Census County Business Patterns, API and bulk files
Establishments, employment, payroll and size-class counts by county and state and NAICS. Employment is suppressed where one plant would be identifiable, which is why the state file is complete and the county file is thin.
Annual, two years behind. Bulk files need no key.
EPA Facility Registry Service, national files
About three million facilities: registry id, names, address, coordinates with a stated accuracy, NAICS and SIC codes, and the ids of every environmental program the site reports under (air permits, TRI, RCRA, water). No operating-status field in the facility file; the program files carry active flags per program, which is the next thing to join.
Updated continuously, published as a bulk zip. No key. Envirofacts REST serves single-facility queries.
SEC EDGAR
Per company: the submissions index (every filing, with its accession number and primary document), the XBRL company-facts file (every tagged financial number by period: revenue, capex, segment data), full-text search across filings, and the documents themselves. Mill lists and capacity appendices live in the 10-K text, which is why that part is read rather than parsed.
Continuous. Ten requests a second with a User-Agent header. No key.
Google Places API (New), Text Search
For a query biased to a point: up to 20 places a page with id, display name, formatted address, coordinates, types and primary type, and a businessStatus of operational, temporarily closed or permanently closed. Website, phone and hours cost more. The permanently-closed flag is the cheapest signal there is against the EPA list's stale third.
Billed by field tier: 5,000 free calls a month on the Pro tier, then $32 a thousand; website or phone move a call to the Enterprise tier with 1,000 free. The run takes 1,764 calls.
Google Routes API, route matrix
Real driving distance and time for each origin-destination pair, with traffic if asked. Would replace the model's 1.2 × great-circle road factor with measured road miles for all 27,783 mill-market pairs.
Billed per element; a one-time run over every pair is a few hundred dollars. Not yet used; listed because it is the obvious next freight upgrade.
Anthropic Messages API
Content blocks: the text, the tool calls, the server-side web searches with the URLs and titles they returned, and citations into them; a usage object with input, output and cached tokens and the count of searches; a stop reason. With a strict tool schema the answer is a typed record, not prose. The Batches endpoint takes the same requests at half the token price with a 24-hour window.
Billed per token and per search; see the key table. The model's calls are capped by the workflow budget and logged per site.
What stays behind the wall
Mill cash costs. Contract prices and the discounts off the index. The index series themselves (Fastmarkets RISI is the record; only the moves reach the press). FBA's regional shipment detail. Which plants a corrugator list actually contains. Those four things are the difference between this model and the one the planning groups run, and the Method tab says so.
6. Cleaning, and how far off it still is
Transparency means the misses are on the page next to the hits. In order of effort:
The mill table. Eighty-seven mills in the US and Canada, from the 10-K property lists and capacity appendices, the Smurfit Westrock mill table, PCA's capacity disclosures, company sites for the privates (Pratt, Green Bay Packaging, New-Indy, Cascades) and the closure notices for everything that changed in 2025 and 2026. Each row carries its source and a confidence grade. Mexican mills were dropped after the first run showed them feeding Texas with a fan of arcs that no one in Texas would recognize; Canadian mills serve the US at 35% of capacity. Geocoding ran through a free ZIP database with a dozen manual fixes (Solvay, Gladstone, the Ontario towns) that would otherwise have silently dropped 770 kt from the model.
Demand. The first version put box demand where people live, scaled by state manufacturing shares. Against the EPA plant registry that proxy had an RMS log error of 0.45 by state: Florida at twice its real weight, Illinois and New Jersey at half. The second version replaced it: Census employment for 322211 sets each state's share exactly, the EPA plant counts place 70% of it within the state and population 30%. Moving the demand shortened the average haul from 568 to 514 miles and cut freight from $64 to $60 a ton; the switching gaps barely moved, which is the kind of stability you want from a finding.
The EPA list. 1,796 records collapse to 1,605 sites once duplicate registrations at the same coordinates are merged. The registry has no operating-status field, so the list is a superset of the roughly 1,100 plants the FBA and the Census count: about a third are closed, mis-coded or an office. The Claude verification pass exists to retire those one by one, with a URL on each verdict.
The sheet tier. Counts are public; locations are not. The corrugator layer is statistical and the tab says "allocated" wherever it is.
And the calibration, which is the honest scoreboard:
Check
Model
Reported
US containerboard capacity, 2024
40.8 Mt
~41.4 Mt (AF&PA), −1.6%
Capacity retired, Feb 2025 to Mar 2026
4.03 Mt
~3.9 Mt (Fastmarkets), +3.4%
International Paper US capacity, 2024
12.98 Mt
12.98 Mt (10-K); the public per-mill figures alone sum to 12.42 Mt (−4.4%) and are scaled to IP's own total
Packaging Corp of America, year-end 2025
5.80 Mt
5.8 Mt (10-K); PCA's last per-mill disclosures sum to 5.55 Mt (−4.3%) and are scaled to it; 5.55 Mt after Wallula W2 in 2026
Smurfit Westrock, US
7.55 Mt
the 10-K mill table, row by row (the audit added Roanoke Rapids, which the first version missed)
2024 tons shipped
38.8 Mt
37.7 Mt produced; the +1.06 Mt is exactly the Canadian board the LP under-pulls (1.40 Mt imported, 0.34 Mt in the model)
Operating rate, 2026
95%
~95%
IP board to own plants
85% own + 11% exported
~80% own (10-K)
Smurfit Westrock integration
83%
~81% implied
Demand by state
exact
by construction, from Census employment; what remains is within-state placement
Sensitivity re-solves
truck or rail costs ±20%: freight $55.0 to $63.2 a ton, average haul 494 to 523 mi; open-market premium $80 to $200 a ton: the Monticello outage from +$32M to +$142M a year. The capacity duals are the unstable part, because the network runs within about 5% of sold out and a small shift flips which mill is marginal
7. A tour, page by page
Nine tabs and a chat. Each one is a view of the same solved network; the scenario picker on the right (2024 footprint, 2026 footprint, the counterfactual, diesel at three prices, twelve maintenance months, twenty knockouts) changes every number on the page.
The page explains itself as you go. Each tab carries a "How to use this view" card: what the view shows, how to read its marks and colors, what every control and layer does, Try buttons that set the view up for you, and questions to click straight into the chat. Every control has hover help. The Guide button in the header opens an index of which view answers which question, a short glossary (delivered marginal cost, scarcity value, own-system against open-market, the gap), and a nine-stop tour that walks the tabs and rings the control it is talking about. And every number can be traced from where it sits: click a mill for its capacity, the filing or report behind it with the link, and a confidence grade; click a market for where its demand comes from; click any dotted figure for the method that produced it. What follows is the same tour in prose.
Flows
Every ton of board, mill to market, October 2026 footprint. Hover a mill for its utilization and scarcity value, a market for its delivered marginal cost and suppliers. The bars below give tons by distance band (truck under 450 miles, rail beyond) and the integration table compares the model's own-plant share with what each company discloses.
The legend doubles as a filter. Knock out a mill from the dropdown and the arcs redraw from the re-solved network; pick a maintenance month and the mills down that month hollow out.
Corrugators
The middle tier. Markets colored by how loaded their corrugators are, sheet flows from feeders to the markets without a machine, and a calculator that turns width, speed, hours and OEE into tons a year, so you can argue with the assumptions.
Throughput
Each piece of the chain in the units its operators use: tons a day at the mills, million square feet a day on the corrugators, truckloads and railcars on the lanes. Per calendar day or per year.
Will you switch?
For every market, the cheapest mill by delivered freight and the one behind it. Pick a producer and raise its open-market price $70; the call sheet lists each account that moves, the tons, and the mill that takes them. The gap column is the price of loyalty.
Prices and delivered cost
What a thousand square feet of board costs delivered in every market: mill prices from the published index, freight from the model, diesel from the EIA. Switch between an ECT spec and a Mullen spec, type your own prices, and see the freight share of delivered cost by market. The FRED series sit below.
Fiber loop
The network in reverse. Thirty million tons of OCC routed from where boxes are used to the recycled mills and the docks: 18 Mt to mills, 8.4 Mt exported, 3.8 Mt the model cannot place at today's mill appetite. The county value is the dual: what a ton of OCC is worth where it is collected.
Demand
Where the boxes are made. Census employment sets each state's share, the EPA registry places it within the state, and the plants themselves are on the map exactly as registered: no inferred owners. Status, plant type and owner appear only once the verification pass has checked a site, with its source; green is verified operating, red verified closed, gray not yet checked. The note on the card says plainly that the list is a superset and roughly a third of it is stale.
Backtest
The honest miss. The 2024 network with the mills that closed in 2025 and 2026 marked: all of them loaded, none of them on the idle list, and the surplus column that does better.
Method and data
The assumptions, the calibration table above, the error bars, and a source for every anchor. If a number on another tab looks wrong, this is where to check what it was built from.
Ask the network
The chat drawer answers from the model: it calls page functions (find a mill, find a market, the knockouts, a price shock, the scenario KPIs) and under every answer lists exactly which lookups it made, with the raw data one click away. An answer about the network with no lookup under it reads as unsupported, and says so.
That provenance line is my answer to the obvious question about putting a language model in front of a model: how do you know it is not making the numbers up? You do not trust it; you check it. The chat cannot browse, has no memory between questions, and sees nothing about the network except what the page functions return. The functions run in your browser, on the same JSON the map is drawn from, and the trail shows the call and the result. On the public page it runs through a small proxy that holds the key and meters the budget (section 8); on the claude.ai copy it runs on the viewer's own account. Either way there is no key in the page and nothing is sent to me.
8. How it is built
One repository, one static page, one solver, no server. The pipeline, top to bottom:
sources ──▶ ingest/ EPA FRS, Census CBP, EIA, FRED, BLS QCEW, EDGAR, Places, Claude verify
└─▶ data/*.csv mills_final.csv (90 mills, a source per row), plants_frs_box.csv, counties.csv, prices
model/netmodel.py demand aggregation (3,142 counties → 441 markets) · freight rule · the LP with
owner copies, grade split, premium and unmet arcs, export sinks · duals · prospects · OCC loop
model/export_json.py every scenario: 2 footprints, counterfactual, 3 diesel prices, 12 months, 20 knockouts,
5 price shocks, the calendar MILP, the backtest · ~3 minutes on a laptop
model/corrugators.py the sheet tier: counts → machines → capacity → the sheet trade as a transportation LP
model/plants_layer.py EPA + Census → measured demand weights, with the proxy error measured
site/build_site.py one HTML page + one 1.8 MB data file + a self-contained copy for claude.ai
.github/workflows/ refresh (scheduled), plants, cbp, places, verify, chat (on demand) · commits what changed
chat/ the Worker that holds the key for the public page's chat, its config and its tests
The solver.scipy.optimize.linprog with the HiGHS backend for the flow LPs; scipy.optimize.milp for the maintenance calendar. Nothing proprietary, nothing that needs a license.
The page. Plain HTML, d3 v7 from a CDN, an Albers USA basemap pre-projected to SVG paths so the map draws instantly, a light and a dark theme on the same tokens as this blog, and a layout that holds at phone width. No framework, no build step beyond the Python script that injects the data.
The AI pair. Claude did the research reads (the 10-K tables, the closure notices, the plant counts), wrote the model and the page with me, ran the calibration and wrote the notes that say where it is off. I set the problem, the assumptions, the industry sanity checks and the rule that nothing non-public goes in. Every figure in the data folder carries its URL, so the division of labor does not matter to a reader who wants to check a number: the check is the same either way.
The verification layer. The same model that wrote the code now reads the web for each plant through the API, with a strict output schema (operating, plant type, owner, shifts, evidence URL, confidence), a spend cap, and a ledger of what each site cost. It is an ingest script like the others, and its output is a CSV with a URL on every row, not an opinion.
The chat. Six page functions over the same data file (find a mill, find a market, the knockouts, a price shock, the scenario KPIs, and the EPA plant list exactly as registered, with status and owner only where verified), offered to the model as tools, with the provenance trail above. Where the model runs depends on which copy you open, which is the next subsection.
The chat proxy: how a static page talks to a model without a key
A page served from GitHub Pages is a file; anyone can read its source, so a key placed in it would be everyone's key within the hour. The claude.ai copy sidesteps this because the artifact runtime runs the chat on the viewer's own account. The public page needs a middleman, and the middleman is a Cloudflare Worker, about 120 lines, in chat/worker.js:
browser ──POST {messages, page digest}──▶ Worker (holds the key, free tier)
· origin must be justfetz.github.io
· 20 rounds a minute per visitor; 400 rounds a day for everyone (a KV counter)
· the system prompt and the six tool definitions are fixed server-side
· forwards to the Messages API with max_tokens 1,200 and low effort
browser ◀──content blocks: text, tool_use──┘
browser runs the tool_use against its own data, appends the tool_result, POSTs again (a round)
browser renders the answer and the trail of lookups
Five things follow from that shape. The key never leaves Cloudflare. The Worker cannot be used as a general chatbot, because it supplies the prompt and the tools and only accepts user turns, tool results and echoes of its own answers. The budget has three walls: the per-visitor limit, the daily cap, and the monthly spend limit in the Anthropic console. The lookups still run in the browser on the same JSON the map is drawn from, so the trail under each answer is the same on both copies. And it deploys from the repo: a workflow installs, creates the counter namespace, uploads the key as a Worker secret, publishes, writes the endpoint to a file the page build reads, and rebuilds the page. A round on Opus costs a few cents with the prompt cached, so the daily cap bounds the worst day at about ten dollars; one line in the config moves it to Sonnet for a tenth of that. The gate logic has a test with a stub model, so the limits are checked without spending anything.
9. Correctness: how the model and the AI parts are evaluated
Three different things can be wrong here, and each gets its own kind of check: the optimization can be solved wrong, the inputs can be off, and the language model can say something the data does not support.
The solve
Optimality is certified, not hoped for. HiGHS returns a status with every solve, and the export aborts on anything but optimal. For a linear program "optimal" is a proof: a primal solution and a dual solution with zero gap. The duals the app shows are that certificate.
Conservation is built in. Every market's demand constraint is an equality with an explicit unmet arc, so tons cannot vanish or appear; each scenario reports shipped, exported and unmet tons, and they must add to the demand that was asked for. Integrality comes free from the network structure, so no rounding step exists to introduce error.
Twelve-month and knockout scenarios are differences of two solves with the same inputs, so a bug in an input would cancel out of the comparisons and show up in the levels. The outage calendar is checked the same way: the assignment problem's estimate is re-solved month by month and the realized penalty ($30M) is reported next to the estimate ($18M), not instead of it.
The inputs
Calibration against published totals, the table in section 6: national capacity within 2%, closures within 4% of the trade press's count, company totals matched to IP's and PCA's own filings, shipments landing on production plus the import gap, integration shares near disclosures. These are checks, not fits, because the demand side was set from box shipments and trade rather than from mill output.
Measured error, not assumed error. The population proxy was scored against the EPA plant registry before it was replaced (0.45 RMS log error by state), and sensitivity re-solves give the spread of every headline figure. The capacity duals are the fragile ones, and the page says so.
A backtest that is allowed to fail. The 2025 closures are run against the 2024 footprint on their own tab; the model misses them, the surplus column does better, and the missing variable is named.
Reproducibility. One command rebuilds every number from the committed inputs; every refresh re-solves and commits, so the history is the audit trail; each figure in the data folder carries its URL.
The receipts
When someone says the numbers look wrong, the answer should be a page of receipts, not a defense. On 21 October 2026 the mill list was audited row by row against the 10-Ks, company releases and the trade press, and the audit is a script anyone can run: audit/recompute.py recomputes every headline number from the committed data, checks it against its public anchor, re-solves the network under perturbed assumptions and writes the receipts page.
It found real errors, and they are fixed in the open. Smurfit Westrock's Roanoke Rapids, North Carolina mill (322 kt of containerboard in SW's own 10-K table) and its La Tuque, Quebec mill (341 kt) were missing. Riceboro was counted in International Paper's 2024 total although DS Smith owned it until January 2025. Forney and Big Island carried start-up-era figures where the companies' own filings give higher ones. And International Paper's and PCA's per-mill numbers, many from 2007 to 2015 filings, summed to about 4% less than each company's disclosed total today, which is what a decade and a half of debottlenecking looks like; those rows are now scaled to the companies' own totals, with the factor written into each row.
With the corrections, US capacity lands 1.6% under AF&PA's 2024 figure, the closures the model carries add to 4.03 Mt against Fastmarkets' count of about 3.9 Mt, the 2026 operating rate comes out at 95% against the industry's reported 95%, and the 2024 production overshoot is the Canadian board the solver declines to buy: with demand and exports as assumed, every ton it does not buy from Canada it makes at a US mill. The corrections also moved a headline. A network with 1.3 Mt more capacity has more slack, so the costliest single outage is now Georgia-Pacific's Monticello at about $98M a year, and Mansfield, at $171M before, is $78M. The old number was wrong because the mill list was incomplete, and the page now says so.
Every number can be challenged the same way it was checked: open an issue with the row and a public source, and the next refresh re-solves every scenario and regenerates the receipts.
The language model
Two parts of the project run on a language model, and they need different kinds of proof. The first paid test is in: twenty EPA sites checked with web search cost $0.95 in total, about five cents a site; it called eighteen operating and two unknown, four corrugators, nine sheet plants, and caught a recycled-paperboard mill mis-coded as a box plant, with every confidence marked medium or low. That is the right kind of humility, and it is what the golden set will score.
The verification pass is a classifier with a search engine. The right question is not "is Claude smart" but "what is its accuracy per field against a labeled set, and what does it do when it does not know". The plan, part of which is in the repo under evals/:
A golden set from sources that cannot be wrong about themselves. The integrated producers publish their own plant lists; the 2025 and 2026 closures are in the press with dates. That is a few hundred sites with a known owner, type and status, and it costs an afternoon to assemble. Run the pass over them and you get precision and recall per field (operating, plant type, owner, shifts), a confusion matrix, and a calibration curve for the confidence field: does "high" mean 95% or 70%?
Two models, one question. A random 5% of every run is re-checked by a second model (evals/verify_audit.py); the disagreement rate is the drift alarm, and the disagreements are the review queue.
A fixed re-run set. The same fifty sites every month, so a change in the model, the prompt or the web shows up as a change in the answers before it shows up on the map.
Guardrails that are rules, not hopes. The output schema is strict and "unknown" is a legal answer. The pass can only remove a site from the demand layer, never add one, and only when it says "closed" with high confidence and an evidence URL; everything else is a flag. Large sites and low-confidence verdicts go to a human list. Every call is budget-capped and every site's cost is in a ledger. The pipeline that consumes the CSV is deterministic code; the model never touches the model.
The chat is a different animal: open questions, no fixed answer key, a reader who may take the number at face value. Its correctness rests on the tools, so the evals test the tools' use rather than the prose:
A generated golden set.evals/chat_golden.py writes a few dozen questions whose answers are computed from the data file itself: what the Mansfield outage costs, who supplies Chicago, what International Paper loses at +$70, the freight bill at $6.20 diesel, plus a handful of questions whose correct answer is "that is not public". evals/run_chat_eval.py asks them through the API with the same six tools implemented in Python and scores three things: does the expected number appear, did a lookup happen before any number was stated, and did the non-public questions get a refusal instead of an invention.
The provenance trail as a guardrail. Under every answer the drawer lists the lookups it made and the raw data they returned. A deterministic check in the page flags any answer that quotes dollars, tons or percentages about the network without a single lookup behind it. The reader does not have to trust the model; the page tells them when there is nothing to check.
What the chat cannot do by construction. No browsing, no memory between questions, no system prompt but the page's rules, no data but the page functions' results, and a metered budget behind it, which bounds abuse.
The scored runs (20 October 2026, Claude Opus 5.5, 45 questions, about $0.60 a run). Run one: all five "that is not public" questions declined, none of the five injection attempts produced its demanded token, every one of the 35 numeric answers had a lookup behind it, and the numbers scored 21 of 35. That last figure turned out to be mostly the grader's fault: it was looking for "1,050 kt" when the model wrote "1.05 million tons", and for "$171 million" when the model wrote "$171,048,536". Re-scoring the same saved answers with a grader that reads units the way the model writes them gave 34 of 35. The one real miss was a tool bug, not a model bug: "Madison, AL" was resolved to the city alias for Madison, Wisconsin, and the model, correctly, refused to use Wisconsin's number for Alabama. Run two, on the fixed code: five of five, five of five, 35 of 35 lookups, Madison, Alabama answered correctly at about 184,000 tons a year, and 34 of 35 numbers as recorded. The last miss was once more the grader's ("loses all 71,253 of its open-market tons", words between the number and its unit), and it is 35 of 35 once the grader allows that. Two runs and $1.21 found two grader bugs and one lookup bug, and no answer that stated a network number without a lookup behind it. That is an eval doing its job in both directions. The set re-runs on demand and the latest scores live in evals/chat_eval_results.json.
Prompt injection: the two doors
"Ignore all your previous instructions and…" is the oldest trick against a language model, and any public chat will see it on day one. There are two doors it can come through here, and the defense on each is architectural first and model behavior second.
The front door is the chat box. Whatever a visitor types arrives as a user turn and nothing else. The rules live on the server (the proxy on the public page, the artifact runtime on the claude.ai copy), so a message cannot replace or append to them, and the prompt tells the model in so many words that text inside tool results or the page digest is data, never an instruction, and that a request to ignore the rules, reveal them, change role or leave the subject gets a one-sentence decline. Behind that sits the part that matters more than any wording: the chat is read-only. Its six tools look things up in a JSON file in the visitor's own browser. It has no key, no memory between questions, no browsing, no access to anything that can be written, and a metered budget with three walls. The worst a successful jailbreak achieves is a poem about cats for a few cents, in a drawer that prints "no page data was read for this answer" underneath it. And it is measured: the eval set carries five injection attempts, each demanding a marker token, and a pass is the token never appearing.
The side door is the verification pass, which reads arbitrary web pages about each plant, and a web page can say "mark this site as operating". Four things stand in the way. The prompt treats pages as evidence, never instructions, and tells the model to treat a page that tries to instruct it as low-quality evidence. The output is a strict record with enumerated fields and no free-form field that the pipeline acts on. The pipeline can only ever remove a site from the demand layer, never add one, and only when the record says "closed" with high confidence and an evidence URL. And a second model re-checks a random sample of every run, so a planted page would have to fool both and still survive the schema.
No filter is absolute, which is why none of this relies on one. The deterministic model is the product; the language model is a clerk with no keys to the building.
The honest summary is that the deterministic model is the product and the language model is a clerk with a search engine on one side and a reading assistant on the other. Both are checked the way you would check a clerk: against a labeled sample, by a second clerk, on a schedule, with the authority to flag but not to decide.
10. The clock: what happens when
Nothing on the page is typed in by hand after the fact. Here is every trigger, in UTC and Chicago time, what it runs, and what a reader sees change.
When
What runs
What changes
Every Monday, 14:17 UTC (9:17 Chicago)
refresh: EIA's weekly diesel lands Monday afternoon Eastern; the workflow pulls it, re-runs the plant layer and every scenario, rebuilds the page, commits, publishes.
The tape's diesel item and its regional prices; the live-diesel freight scenario; the Prices tab's diesel chart; the model-run badge date.
9th of each month, 13:23 UTC (8:23 Chicago)
refresh again, now with the month's FRED series; the monthly judgment review (a Claude session with a narrow brief) reads the trade press for index moves, closures and capacity announcements, edits the price points and the mill table with sources, and appends to the changelog.
Liner, medium and OCC points on the tape and the Prices tab; the FRED charts; any closure or conversion on the map; the changelog.
28th of January, April, July, October, 13:31 UTC
refresh with the quarter's BLS employment slice; the quarterly review reads the AF&PA quarterly and the three earnings releases: production, exports, operating rate, outage guidance, mill status; demand totals are re-set.
Demand and export totals in every scenario; mill statuses; the maintenance calendar's assumptions.
Every day, 16:11 UTC (11:11 Chicago)
places: the Google Places search resumes where the daily quota stopped it and exits in seconds once the list is complete.
Nothing visible until the Places file is joined to the EPA list; then the named-plant layer and its stale-site flags.
Every push to the model or the site
ci (the invariants, the site build, the Worker tests) and refresh (a full re-solve and publish).
The build badge; the page, within about six minutes of a merged change.
March, after the 10-Ks
The yearly review re-reconciles the mill table to the new filings and capacity appendices.
Capacities, grades and integration shares; the calibration table.
June, and yearly
cbp and plants on demand: the new County Business Patterns and the EPA registry.
The demand weights by state; the plant list.
On demand
verify (Claude checks EPA sites with web search under a budget) and chat (deploys the proxy).
Closed sites leave the demand layer at the next refresh and the tape's plant line updates; the chat switches on.
On the day
A closure, a conversion, a price increase: the row changes, with its source, and the push re-solves everything.
The map, the knockouts, the call sheet, the tape's closure items.
Every run commits what it changed, so the history is the audit trail, and every run re-solves from the committed inputs, so there is no cache to go stale. The first scheduled run with the keys in place landed 10,207 weekly diesel observations, 716 FRED rows and a 610-county employment slice without anyone touching it; the first time two runs overlapped, one published a half-merged file and blanked the page for twenty minutes, so the publish step now refuses to ship anything that does not parse. That is the other half of "it refreshes itself": it also has to fail safely.
11. Dream big
The model is a public draft. Here is what it becomes with one more layer, in roughly the order the layers are available.
A verified plant list. Run the Claude pass across all 1,605 sites and the EPA superset becomes a list of operating plants with a plant type, an owner and a shift signal each. Corrugator operators are hard to hire; a plant posting for a third-shift corrugator crew is telling you its order book. That is the "corrugator shift" layer, and it costs about $160.
A named corrugator map. Places results plus the verification plus the permit records that list machine widths turn the statistical sheet tier into a real one: which markets are long sheets and which are short, by machine.
A cash-cost layer. Mill age, furnish, energy source and capital history are mostly scattered across public filings, permits and press. Put a cost-per-ton estimate on each mill and the closure backtest stops missing, because the model finally has the variable the closures shared.
Demand that moves. County Business Patterns by end-use industry (food, beverage, chemicals, furniture, e-commerce fulfillment) gives each market its own mix instead of a national one; QCEW and the FRED retail series let the demand map breathe quarterly rather than annually.
A sales engine. The "will you switch" tab already produces a list of accounts and gaps. With a named plant list it becomes a ranked list of plants, with the mill that could serve them cheaper and by how much, refreshed every Monday with diesel. A sales manager with that list and a phone is dangerous.
A buyer's counter. The same numbers run the other way: a box buyer with the delivered-cost contour and the scarcity map knows which mills need the volume and which do not, before the negotiation starts.
The fiber arbitrage. Regional OCC prices against the loop's county values show where recovered fiber is mispriced relative to where it is wanted; with Nine Dragons' US mills in the table, the export docks compete with them explicitly.
A scheduler. The calendar tab is a start. With real outage durations and per-machine grades it becomes the thing the planning groups actually run, on public data, in the open.
An analyst that cites. The chat with its provenance trail is a pattern, not a feature: any public model can be put in front of a reader this way, with every answer carrying the lookups it was built from.
None of that requires a single private number. All of it requires the public draft to exist first, which is why it is public.
12. What it is not
It does not know who ships to whom today. The flows are what a cost minimizer would do, and sales history differs from that everywhere.
It does not price a contract. The mill prices on the Prices tab are the index points that reached the press, and the index covers a sliver of consumption.
It does not replace a RISI subscription, an FBA membership or a planning group. It is the map those things are drawn on, with its error bars showing.
The sheet tier is allocated, the EPA list is a superset, and the capacity duals move when a demand assumption moves, because the network runs within about 5% of sold out.
The real risk of a public model is not that it is wrong; it is that someone treats a $5.20 median gap as a quote. The page is written against that, and so is this post.
13. Use it, break it, fix a row
The repository is MIT. NEEDS.md lists what the project still wants from outside and why; ROADMAP.md is the layer list above with the next step on each; REFRESH.md is the calendar; the notes folder has the calibration write-up, the market anchors with their links, and the changelog. The fastest contribution is a row: a mill capacity you know is wrong, a plant that closed, a closure the press missed. Open an issue with the source and it is in the next run.
Treat the app as a public draft of a map the industry already has in pieces. Every number carries its source. The ones that are wrong are the ones worth telling me about.
Sources
SEC EDGAR 10-K filings: International Paper, Smurfit Westrock, Packaging Corp of America, Graphic Packaging, Greif, Cascades; mill lists and capacity appendices.