<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://michelecampi.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://michelecampi.github.io/" rel="alternate" type="text/html" hreflang="en-US" /><updated>2026-05-04T18:14:03+00:00</updated><id>https://michelecampi.github.io/feed.xml</id><title type="html">Michele Campi</title><subtitle>Technical notes on mathematical optimization, MCP servers, and operations engineering — building decision-grade infrastructure for AI-native operations.</subtitle><author><name>Michele Campi</name><email>michele.campi@outlook.com</email></author><entry><title type="html">How fragile is your weekly plan? A risk-premium framework for mid-market manufacturers</title><link href="https://michelecampi.github.io/2026/05/04/risk-premium-mid-market-manufacturing.html" rel="alternate" type="text/html" title="How fragile is your weekly plan? A risk-premium framework for mid-market manufacturers" /><published>2026-05-04T00:00:00+00:00</published><updated>2026-05-04T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/05/04/risk-premium-mid-market-manufacturing</id><content type="html" xml:base="https://michelecampi.github.io/2026/05/04/risk-premium-mid-market-manufacturing.html"><![CDATA[<p><strong>TL;DR.</strong> A deterministic schedule promises a single number — say, 161 hours of plant time. That number assumes every task takes exactly as long as the planner wrote down. Real plants don’t behave that way. Using Monte Carlo with CVaR 95% on a real OR-Tools schedule for a mid-market Italian contract packager, I show that doubling the input volatility (±20% → ±35% on filling tasks) raises the weekly <em>risk premium</em> from <strong>4.2% to 7.2%</strong> — not the catastrophic explosion most planners fear. The plan is structurally robust. The framework reproducible. The calculation is a single API call. <em>2,300 words, 8 minutes.</em></p>

<hr />

<p>In the last article I walked through one synthetic week at <em>Lombarda Confezionamenti SRL</em>, a fictional contract packager in northern Italy. OR-Tools returned an optimal weekly schedule with a makespan of 161 quarter-hours — about 40 hours of plant time, distributed across six lines and 26 production tasks. Roughly 15% better than the manual baseline, with zero late orders.</p>

<p>But there was a hidden assumption underneath that 161-hour number, and it’s the assumption every deterministic schedule makes. It assumes that the duration of every task is exactly what the planner wrote in the spreadsheet. The crema viso filling lasts exactly 16 quarters. The shampoo run is exactly 28. The labelling on the body wash is exactly 14. No surprises. No variation.</p>

<p>In a real plant, of course, this is never true. Filling lines run a little faster on a good day and a little slower on a hard one. Validation sometimes takes an extra cycle when a new product comes in. Sanitization between formats is usually two hours but occasionally three. The week’s actual makespan is some distribution around 161, not the number itself.</p>

<p>The question every operations manager intuitively asks is: <strong>how robust is my plan?</strong> Most of them answer it with stories — “last March we had a bad week, took us until Friday night,” “the line 3 always runs late” — rather than numbers. Operations research can do better than that. It can quantify it.</p>

<h2 id="what-robust-actually-means-mathematically">What “robust” actually means, mathematically</h2>

<p>Two concepts from finance translate directly to scheduling under uncertainty: <strong>Monte Carlo simulation</strong> and <strong>Conditional Value at Risk (CVaR)</strong>.</p>

<p>The first is straightforward. Instead of assuming each task duration is a single number, we treat it as a probability distribution — say, triangular, with min/expected/max. We then ask the solver to evaluate the schedule against, say, 100 random samples drawn from those distributions. Each sample is a realistic “alternative week.” The output is no longer a single makespan but a distribution of makespans: a histogram of how the week could actually play out.</p>

<p>The second concept matters more. <strong>CVaR 95%</strong> answers the question: <em>across the worst 5% of weeks, what is the average outcome?</em> Not the absolute worst case (which is dominated by tail events that may never happen), but the expected outcome conditional on being in the bad tail. If CVaR 95% of your weekly makespan is 168 hours when the expected value is 161, you can plan around 168 with reasonable confidence — not around 161.</p>

<p>Reframed in language a CFO understands: <strong>the risk premium is the gap between the expected makespan and the CVaR 95%, expressed as a percentage of the expected.</strong> It is the cost, in hours, of buying protection against bad weeks. A risk premium of 4% says: <em>to be safe in 95% of weeks, you need to budget 4% more time than the optimal plan suggests</em>. That number is concrete. It can be defended in front of a board. It can be priced into contracts.</p>

<h2 id="scenario-1-a-normal-week-20-volatility-on-filling-tasks">Scenario 1: a normal week (±20% volatility on filling tasks)</h2>

<p>I ran the optimal schedule from the previous article through OptimEngine’s stochastic scheduling endpoint. The setup: 100 Monte Carlo scenarios, triangular distribution on the four filling task durations (the structural bottleneck of cosmetics packaging), ±20% variation around the planned mean. Filling, not mixing or labelling, because filling is what happens on the shared bottleneck line and is most exposed to viscosity and product-changeover variability.</p>

<p>The output:</p>

<ul>
  <li><strong>Mean makespan</strong>: 161.38 quarter-hours</li>
  <li><strong>CVaR 95%</strong>: 168.17 quarter-hours</li>
  <li><strong>Coefficient of variation</strong>: 2.5%</li>
  <li><strong>Range</strong> (min–max across 100 simulations): 152 to 169 quarters</li>
  <li><strong>Risk premium</strong>: <strong>4.2%</strong></li>
</ul>

<p>What does this say? The plan is structurally robust. Even when the four filling tasks vary by ±20% — which is a generous estimate of normal-week volatility for a medium-complexity cosmetics line — the worst 5% of weeks land at 168 hours instead of 161. To buy 95% reliability you pay 4.2% extra time. That’s the price of robustness.</p>

<p>Crucially, no week in the 100-simulation pool exploded. No catastrophic delay. No order missed. The schedule degrades gracefully — which is what we want, but rarely measure.</p>

<h2 id="why-is-the-plan-this-robust-the-structural-answer">Why is the plan this robust? The structural answer</h2>

<p>Not all schedules degrade gracefully. Some shatter. The reason this one doesn’t is structural, and worth explaining because it tells you when to expect different behavior.</p>

<p>The optimal plan from OR-Tools placed the four filling tasks across <strong>two parallel filling lines</strong>. When task A on line 1 runs 20% longer than expected, it doesn’t ripple through tasks B, C, D on line 2 — they’re on a separate machine. The week’s makespan is determined by the <em>slower</em> of the two parallel paths, not the sum.</p>

<p>In other words, parallelism absorbs variance. A schedule that piles all four filling tasks on one line would have a much larger risk premium for the same input volatility — possibly 8-10% instead of 4.2%, because every variance compounds.</p>

<p>This is the kind of insight that’s invisible without quantification. A planner looking at the schedule manually would say “looks fine.” A solver looking at the schedule under stochastic perturbation says “looks fine, <em>and here’s why</em>.” That’s what robustness analysis adds.</p>

<h2 id="scenario-2-a-difficult-week-35-volatility">Scenario 2: a difficult week (±35% volatility)</h2>

<p>Now I doubled the volatility. ±35% on the four filling tasks — what you might see during a product-mix change, a new SKU introduction, or post-sanitization commissioning. Same schedule, same constraints, same 100 Monte Carlo simulations.</p>

<ul>
  <li><strong>Mean makespan</strong>: 161.28 quarter-hours</li>
  <li><strong>CVaR 95%</strong>: 172.83 quarter-hours</li>
  <li><strong>Coefficient of variation</strong>: 4.1%</li>
  <li><strong>Range</strong> (min–max across 100 simulations): 144 to 173 quarters</li>
  <li><strong>Risk premium</strong>: <strong>7.2%</strong></li>
</ul>

<p>Here is the surprising finding. <strong>Volatility almost doubled, but the risk premium did not.</strong> It went from 4.2% to 7.2% — a 71% relative increase, but in absolute terms still a manageable buffer. The mean makespan barely moved (161.28 vs 161.38). The structural robustness held.</p>

<p>This is not luck. It’s the same parallelism story playing out. With two parallel lines, the worst-case outcome of the bottleneck path is bounded by the longer of two correlated random variables — whose expected maximum grows much more slowly than the underlying variance.</p>

<p>Compare this to a schedule that put all filling on one line: there, doubling input variance would roughly double the risk premium too, because the variances accumulate without offset. The plan would shatter.</p>

<h2 id="what-this-changes-for-the-planner-and-the-cfo">What this changes for the planner and the CFO</h2>

<p>For the planner, this is a tool to <strong>defend the optimal schedule against intuition</strong>. If a senior operations manager says “I don’t trust this plan, last March we had a terrible week” — the answer is no longer “trust me” or “it’s optimal.” The answer is “the framework projects a 4.2% risk premium under normal volatility, 7.2% under elevated volatility. Here are the numbers. Here is the assumption set. Here is what changes if you disagree with that assumption set.”</p>

<p>That’s a much harder conversation for the senior manager to win on intuition alone, because the framework is reproducible and falsifiable. If they disagree with the volatility input, they can change it. If they disagree with the parallelism modeling, they can override it. What they can’t do is say “your plan is fragile” without engaging with the math.</p>

<p>For the CFO, the framing is different but equally concrete. The risk premium is a <strong>cost</strong>. It’s the cost of buying delivery reliability. If the firm prices contracts on the deterministic plan (161 hours) and then absorbs the 4.2% slippage internally, that slippage shows up as overtime, expedited freight, or compressed margin on rush jobs. If the firm prices contracts on the CVaR plan (168 hours) and the week ends up at 161, that’s bonus margin. Either way, the number is real and the conversation is honest. Without measurement, the firm pays the premium without knowing it exists.</p>

<h2 id="when-this-analysis-does-not-apply">When this analysis does NOT apply</h2>

<p>Three caveats matter for honest framing.</p>

<p><strong>First</strong>, this analysis assumed parallelism in the bottleneck. If your plant has a single filling line, the framework still works but the risk premium will be larger and grow faster with volatility. The structural protection comes from the schedule’s topology, not from the framework itself.</p>

<p><strong>Second</strong>, only filling task durations were perturbed. In a real plant other parameters vary too — yields, setup times, downtime events. A complete robustness analysis would add stochastic distributions for those too. The ones we used are the dominant ones for cosmetics filling, but in another industry (precision machining, food processing, semiconductor assembly) the dominant variance source might be elsewhere.</p>

<p><strong>Third</strong>, the analysis is conditional on the schedule produced by OR-Tools being itself near-optimal. If the input scheduling is poor, no risk analysis on top of it will save it. Robustness analysis is a layer over solid optimization, not a substitute for it.</p>

<h2 id="the-framework-in-five-steps">The framework, in five steps</h2>

<p>This is the operational synthesis. Any mid-market manufacturer with weekly scheduling decisions can apply this:</p>

<ol>
  <li><strong>Compute the deterministic optimum</strong> with OR-Tools or equivalent, using mean durations.</li>
  <li><strong>Identify the dominant variance sources</strong> — usually 2-4 task types where empirical historical variation is largest.</li>
  <li><strong>Define triangular distributions</strong> around each (min, expected, max) based on either historical data or expert estimate.</li>
  <li><strong>Run 100 Monte Carlo simulations</strong> with the same scheduler, and extract the makespan distribution. Compute CVaR 95% and the risk premium.</li>
  <li><strong>Defend the schedule with the risk premium</strong>, not the deterministic number. Price contracts, allocate buffer time, and have a real conversation with stakeholders.</li>
</ol>

<p>What stays constant across industries, plant sizes, and product categories is the framework — and the fact that without measuring, the conversation about plan robustness is the same one production managers have been having for fifty years: based on intuition, on bad weeks they remember, on stories.</p>

<p>Operations research doesn’t replace that intuition. It quantifies it.</p>

<hr />

<p><em>The analysis used OptimEngine’s stochastic scheduling endpoint with 100 Monte Carlo scenarios. The two volatility profiles (±20% and ±35% on filling tasks, triangular distribution) were chosen to represent a normal week and a difficult week respectively. The full schedule, parameter distributions, and the resulting risk metrics are reproducible — the input was a single JSON request to a public endpoint, the output is what the solver returned.</em></p>

<p><em>If you’re applying this framework to your own operation and want a second pair of eyes on the setup or the assumptions, the contact is on the profile.</em></p>]]></content><author><name>Michele Campi</name></author><category term="or-tools" /><category term="scheduling" /><category term="manufacturing" /><category term="optimization" /><category term="risk-management" /><category term="monte-carlo" /><category term="cvar" /><summary type="html"><![CDATA[The optimal weekly plan from last article promised a makespan of 161 quarter-hours. But that number assumes durations are exactly what the planner says they are. What happens when reality varies by 20%? By 35%? Monte Carlo with CVaR turns the question 'is my plan robust?' into a quantitative one — and reveals that the right metric isn't fragility, it's the risk premium you pay for protection.]]></summary></entry><entry><title type="html">What an OR-Tools solver finds in a week of contract packaging — and what the planner usually misses</title><link href="https://michelecampi.github.io/2026/04/29/or-tools-week-contract-packaging.html" rel="alternate" type="text/html" title="What an OR-Tools solver finds in a week of contract packaging — and what the planner usually misses" /><published>2026-04-29T00:00:00+00:00</published><updated>2026-04-29T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/04/29/or-tools-week-contract-packaging</id><content type="html" xml:base="https://michelecampi.github.io/2026/04/29/or-tools-week-contract-packaging.html"><![CDATA[<p>Most arguments in favor of optimization software focus on the obvious benefit: a solver finds a better schedule than a human planner, faster. That part is true and uninteresting. The interesting part is what the solver shows you about your own operation that you couldn’t see before.</p>

<p>This article walks through one synthetic but realistic week at <em>Lombarda Confezionamenti SRL</em>, a fictional contract packager in northern Italy. The company is fictional; the operational pattern is one I’ve seen repeat across European mid-market contract packagers in seven years of operations work. The numerical input is deliberately ordinary. The numerical output — the schedule, the metrics, the bottleneck analysis — is what OptimEngine actually returned when I fed the input into the solver. No invented numbers.</p>

<p>The takeaway isn’t “automate scheduling.” The takeaway is closer to: <em>your manual planner is doing the visible part of the job correctly, but the spreadsheet hides what the solver makes obvious — that four of your six lines are running half-empty most of the time.</em></p>

<h2 id="the-setup">The setup</h2>

<p>Lombarda Confezionamenti is a contract packager for personal care brands: shampoos, body washes, creams, lotions, fragrances. €18M revenue, ~85 employees, two-shift operation (16 hours per day, five days per week). Six production lines, each specialized in different parts of the packaging flow:</p>

<ul>
  <li><strong>L1</strong> — Heavy filling line (200-500ml bottles)</li>
  <li><strong>L2</strong> — Medium filling line (50-150ml jars and tubes)</li>
  <li><strong>L3</strong> — Automatic cartoning line</li>
  <li><strong>L4</strong> — Labelling line</li>
  <li><strong>L5</strong> — Bundling and multipack line</li>
  <li><strong>L6</strong> — QC station with batch release</li>
</ul>

<p>Format changeovers on these lines aren’t trivial. On the heavy filling line, a switch between products requires roughly two hours of cleaning, machine adjustment, and validation. On the medium filling line, it’s around 90 minutes. On cartoning and labelling, an hour each. Bundling is the cheapest at 30 minutes. QC has no setup. These are real numbers from this kind of plant.</p>

<p>The week in question has eight orders from five different customers. Each order requires a different sequence of operations, depending on the product. Here’s the load:</p>

<table>
  <thead>
    <tr>
      <th>Order</th>
      <th>Customer</th>
      <th>Product</th>
      <th>Sequence</th>
      <th>Volume</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>J1</td>
      <td>Customer A</td>
      <td>Face cream 50ml</td>
      <td>filling → cartoning → labelling → QC</td>
      <td>8,000 units (rush, 48h deadline)</td>
    </tr>
    <tr>
      <td>J2</td>
      <td>Customer B</td>
      <td>Shampoo 250ml</td>
      <td>filling → labelling → QC</td>
      <td>12,000 units</td>
    </tr>
    <tr>
      <td>J3</td>
      <td>Customer C</td>
      <td>Detergent 500ml</td>
      <td>filling → labelling → bundling → QC</td>
      <td>6,000 units</td>
    </tr>
    <tr>
      <td>J4</td>
      <td>Customer A</td>
      <td>Body wash 200ml</td>
      <td>filling → labelling → QC</td>
      <td>10,000 units</td>
    </tr>
    <tr>
      <td>J5</td>
      <td>Customer D</td>
      <td>Body lotion 150ml</td>
      <td>filling → cartoning → QC</td>
      <td>4,000 units</td>
    </tr>
    <tr>
      <td>J6</td>
      <td>Customer B</td>
      <td>Conditioner 250ml</td>
      <td>filling → labelling → bundling → QC</td>
      <td>8,000 units</td>
    </tr>
    <tr>
      <td>J7</td>
      <td>Customer E</td>
      <td>Perfume 100ml</td>
      <td>cartoning → QC</td>
      <td>3,000 units (no filling)</td>
    </tr>
    <tr>
      <td>J8</td>
      <td>Customer C</td>
      <td>Liquid soap 300ml</td>
      <td>filling → labelling → QC</td>
      <td>7,000 units</td>
    </tr>
  </tbody>
</table>

<p>Total processing time across all operations, ignoring setups: roughly 79 hours of machine work distributed across six lines. With perfect parallelization and zero setup, the absolute lower bound on makespan would be around 13 hours. Reality is much further from that.</p>

<h2 id="what-the-experienced-planner-does">What the experienced planner does</h2>

<p>On Monday morning at 7am, the production manager opens the Excel file. He has done this for fifteen years. He reads the rush order from Customer A, marks it as priority one. Customer A is a strategic account — they also have order J4 — so he pencils in J4 second. Then he groups the remaining orders by customer to minimize “context switching” mentally: Customer B (J2 and J6 together), Customer C (J3 and J8 together), then D and E.</p>

<p>Within each block, he assigns tasks to lines using rules he doesn’t articulate but follows consistently:</p>

<ul>
  <li>Long fillings on L1 because that’s the heavy line</li>
  <li>Small fillings on L2</li>
  <li>One job at a time on the bottleneck line, mostly — this is the heuristic he trusts most</li>
  <li>Setup planned at the start of each job, never overlapped with anything</li>
</ul>

<p>He blocks out the week. The schedule he produces, when I trace it through the same constraints I gave the solver, terminates around <strong>190 quarter-hours of makespan</strong>. That’s about 47.5 hours of plant time, which means he closes the week sometime late Wednesday or early Thursday — roughly three working days, given the two-shift schedule.</p>

<p>This is a defensible, professional schedule. The rush is delivered on time. No customer is forgotten. Setup costs are managed. He’s been doing this competently for fifteen years.</p>

<h2 id="what-optimengine-does">What OptimEngine does</h2>

<p>I fed the same inputs — eight jobs with their task sequences, six lines with their setup times, the priority ranking, the rush deadline — into OptimEngine’s CP-SAT scheduler.</p>

<p>The solver returned a status of <code class="language-plaintext highlighter-rouge">optimal</code> in <strong>10 milliseconds</strong>.</p>

<p>The makespan it found: <strong>161 quarter-hours</strong>. That’s 40.25 hours, or roughly 2.5 working days at two shifts. About <strong>15% better than the manual schedule</strong>.</p>

<p>Zero orders late. The rush J1 finishes at quarter-hour 57, which is 7 quarters before its 64-quarter deadline — comfortable margin without overcommitting capacity to the rush.</p>

<p>The solver’s gain over the manual baseline is not coming from any single brilliant move. It’s coming from many small parallelizations the human eye doesn’t easily see. At time zero, three things start in parallel: J6 begins filling on L1, J1 begins filling on L2, J7 begins cartoning on L3. The manual planner usually starts L1 first and then thinks about L2 once L1 is “moving.” The solver doesn’t think; it just maps the constraint graph and moves everything that can move.</p>

<p>Format changeovers on L1 are also handled aggressively. Five different products run on L1 across the week: J6 → J2 → J3 → J4 → J8. Each transition costs eight quarter-hours of setup. The solver sequences them in the order that doesn’t force any other line to wait. The manual planner often runs setups during the night shift “to keep the day shift productive,” which sounds smart but actually adds idle time elsewhere.</p>

<h2 id="the-interesting-finding-average-machine-utilization-is-313">The interesting finding: average machine utilization is 31.3%</h2>

<p>Here’s where the article would normally end with “and that’s why you should buy optimization software.” But the solver returns more than a schedule. It returns metrics. And one of them is uncomfortable:</p>

<p><strong>Average machine utilization across the week: 31.3%</strong>.</p>

<p>Let me break that down by line:</p>

<table>
  <thead>
    <tr>
      <th>Line</th>
      <th>Utilization</th>
      <th>Tasks</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>L1 (heavy filling)</td>
      <td>69.6%</td>
      <td>5</td>
      <td>Bottleneck</td>
    </tr>
    <tr>
      <td>L2 (medium filling)</td>
      <td>17.4%</td>
      <td>2</td>
      <td>Underused</td>
    </tr>
    <tr>
      <td>L3 (cartoning)</td>
      <td>18.6%</td>
      <td>3</td>
      <td>Underused</td>
    </tr>
    <tr>
      <td>L4 (labelling)</td>
      <td>50.3%</td>
      <td>6</td>
      <td>Secondary bottleneck</td>
    </tr>
    <tr>
      <td>L5 (bundling)</td>
      <td>11.2%</td>
      <td>2</td>
      <td>Severely underused</td>
    </tr>
    <tr>
      <td>L6 (QC)</td>
      <td>20.5%</td>
      <td>8</td>
      <td>Underused</td>
    </tr>
  </tbody>
</table>

<p>L1 is running roughly 70% of the available time. L4 is at half capacity. The other four lines are sitting idle most of the week. This is true <em>under the optimal schedule</em> — there’s no sequencing improvement that would change this picture. The reason these lines are underused is structural: the order mix this week happens not to need much medium filling, much cartoning, much bundling, or much QC throughput.</p>

<p>The manual planner can’t see this. He sees that the week “got done.” He sees that the rush was delivered on time. He doesn’t see that L2, L3, L5 sat idle for over 30 hours each. They were never on his dashboard because they weren’t constraining his completion date. The bottleneck has all the visibility; the slack has none.</p>

<p>For a plant manager, this is the most actionable insight in the entire schedule. It’s not “your planner could be better.” It’s “this week, you have roughly 100 hours of free capacity on four lines that nobody is selling.” Those four lines have an industrial cost — depreciation, energy in standby mode, maintenance contracts, operator availability — that runs whether they’re packaging product or not.</p>

<p>For a CFO, the question becomes: <em>what additional orders, with what setup profile, would absorb the slack on L2, L3, L5, and L6 without overloading L1?</em> That’s a commercial question, not a scheduling question. But the scheduling output is what makes the question even visible.</p>

<h2 id="what-this-case-shows-and-what-it-doesnt">What this case shows, and what it doesn’t</h2>

<p>I want to be careful with what this analysis proves and what it doesn’t.</p>

<p>It does prove that, on a realistic week of contract packaging, an OR-Tools solver finds a schedule about 15% shorter than what an experienced human planner would produce in 30 minutes of paper-and-spreadsheet work. That gain is real and consistent across most of the FJSP scheduling problems I’ve tested. It comes from parallelism that humans don’t naturally compute.</p>

<p>It also shows that the solver surfaces structural information — line utilization, bottleneck analysis, idle capacity — that doesn’t appear on the planner’s Monday-morning whiteboard. This information is more valuable than the 15% scheduling gain in most plants I’ve worked with, because it points at commercial decisions, not just operational ones.</p>

<p>What the analysis does <em>not</em> prove is that automating scheduling alone fixes anything. The 15% gain only matters if the plant can absorb it: if the order book grows, if the planner uses the time saved on something else, if the customer accepts faster delivery. Plenty of plants would just see the 15% as a softer week and do nothing differently. That’s not a software problem; that’s a management problem.</p>

<p>It also doesn’t prove that this particular plant should immediately invest in optimization software. The 15% scheduling gain at this volume is worth, very roughly, €40-60K per year of recovered capacity at typical mid-market industrial costs. That’s not life-changing, and it has to be weighed against the cost of integration, training, and the change management work of asking a fifteen-year-veteran planner to trust a black box.</p>

<p>Where I see optimization tools actually pay off in mid-market is two situations:</p>

<p><strong>First</strong>, when the plant has a real growth ceiling that could be lifted. If management is looking at L1 utilization and thinking “we should buy a second heavy filling line,” but L2, L3, L5 are at 17%, the better question is whether sales mix can be rebalanced before capex. The solver makes that question quantitative.</p>

<p><strong>Second</strong>, when the plant has visible service problems — rushes accepted at high cost, deadlines slipping under load, last-minute customer changes producing chaos. The same solver run with robust optimization extensions can quantify how brittle the current schedule is to disruption, and how much slack would buy how much resilience. That’s a different article.</p>

<h2 id="a-note-on-whats-underneath">A note on what’s underneath</h2>

<p>The solver used in this analysis is OptimEngine, built on Google OR-Tools CP-SAT. The math is mature: constraint programming applied to flexible job-shop scheduling is a well-developed field with decades of research behind it. What’s new in 2026 is mostly the accessibility — the same kind of math that enterprise APS vendors charge mid-market companies €100-300K per year to access can now be packaged as a service that fits the operational and financial profile of an Italian PMI.</p>

<p>The endpoint that returned the schedule for this case study is the same one I exposed publicly through MCP and x402 payment infrastructure earlier this month, and that I’ve been writing about in the rest of this blog. If you’re a mid-market manufacturer or contract packager wondering whether your week looks like Lombarda Confezionamenti’s, the question I’d start with is the one this case ends on: not “is my planner producing the optimal schedule?” but “what does my plant’s average utilization actually look like, and how much of my weekly idleness is structural versus something I could sell into?”</p>

<p>That answer doesn’t come out of a spreadsheet. It comes out of a model.</p>

<hr />

<p><em>Built with OptimEngine v9.0.0 on Google OR-Tools CP-SAT. The schedule and metrics presented here are the actual solver output for the input described, not retrospective approximations. The company name and customer specifics are synthetic; the operational pattern is drawn from years inside European mid-market contract manufacturing.</em></p>]]></content><author><name>Michele Campi</name></author><category term="or-tools" /><category term="scheduling" /><category term="manufacturing" /><category term="optimization" /><category term="contract-packaging" /><summary type="html"><![CDATA[A synthetic but realistic case study on a mid-market contract packager: eight customer orders, six production lines, sequence-dependent setup times. The expert manual schedule lands around 190 quarter-hours of makespan. OptimEngine returns the proven optimum in ten milliseconds: 161 quarters. The interesting finding isn't the 15% gain — it's what the solver shows about hidden capacity that the manual planner can't see.]]></summary></entry><entry><title type="html">Three Production Scheduling Failures I’ve Seen, and the Math That Would Have Caught Them</title><link href="https://michelecampi.github.io/2026/04/26/three-scheduling-failures-and-the-math-that-would-have-caught-them.html" rel="alternate" type="text/html" title="Three Production Scheduling Failures I’ve Seen, and the Math That Would Have Caught Them" /><published>2026-04-26T00:00:00+00:00</published><updated>2026-04-26T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/04/26/three-scheduling-failures-and-the-math-that-would-have-caught-them</id><content type="html" xml:base="https://michelecampi.github.io/2026/04/26/three-scheduling-failures-and-the-math-that-would-have-caught-them.html"><![CDATA[<p>In seven years of operations controlling inside mid-market manufacturers, I watched the same scheduling failures repeat across plants, across product lines, across teams. Different people, different machines, identical patterns.</p>

<p>The interesting thing about chronic scheduling failures isn’t that they happen. It’s that everyone in the plant knows they happen, and almost nobody knows why. Management explains them away with operational folklore — <em>“that machine is always troubled,” “Friday afternoons are bad,” “this client is impossible.”</em> Engineers blame planners. Planners blame the ERP. The ERP blames the master data. Nothing changes.</p>

<p>What changed for me, slowly, was realizing that almost every chronic scheduling failure I’d seen had the same shape. There was a visible symptom that everyone agreed on. There was a hidden cause that nobody was tracking. There was a management reaction that addressed the symptom and not the cause. And there was, quietly waiting in textbooks nobody read, a piece of operations research math that would have solved it.</p>

<p>Three of those failures, anonymized but real, with the math that would have caught them.</p>

<hr />

<h2 id="failure-1--the-oee-that-wouldnt-move">Failure 1 — The OEE that wouldn’t move</h2>

<p>Every contract manufacturer I worked with had at least one line where OEE was stubbornly stuck somewhere between 60% and 70%, far below the 85% management kept asking for. Production stoppages were frequent. Deadlines slipped every month, sometimes by hours, sometimes by days.</p>

<p>The dashboard showed the symptoms in red. The reaction was always the same: overtime shifts to recover, occasionally a maintenance contractor brought in to “fix the machine.”</p>

<p>Neither solved anything, because neither addressed what was actually happening.</p>

<p><strong>The hidden cause was double.</strong> First, micro-stoppages under five minutes weren’t being tracked at all. The MES rounded everything below that threshold to zero. When we eventually instrumented the line and counted them properly, those untracked micro-stops were <em>roughly half</em> of the total downtime. The line wasn’t failing dramatically. It was failing constantly, in tiny invisible bursts, and the cumulative effect was catastrophic.</p>

<p>Second, the preventive maintenance plan existed only on paper. Maintenance was scheduled every 200 hours in the manual, but in practice intervention happened reactively, after a failure had already caused a production stop. The plan was theoretical; the operation was firefighting.</p>

<p><strong>What the math would have done.</strong> This is exactly the situation stochastic optimization was built for. Instead of treating the line’s OEE as a deterministic input — <em>“machine X runs at 75% efficiency”</em> — you model micro-stops as a stochastic variable with the empirical distribution you observe. The Monte Carlo simulation generates thousands of possible production days, accounting for the real shape of the failure distribution, not its average.</p>

<p>The output isn’t a number. It’s a Conditional Value at Risk: <em>given the way this line actually behaves, what’s the worst 5% of production days going to look like?</em> That single metric — CVaR 95% — would have ended the conversation about averages. Management would have seen, in numbers, that the line was vulnerable to compounding micro-stops in a way no average could capture.</p>

<p>For maintenance, the same engine answers a different question: when should preventive intervention happen to maximize expected uptime, given the empirical distribution of failure intervals? Not “every 200 hours” because that’s what the manual says. The optimal interval, computed from data, often turns out to be very different.</p>

<p>The total infrastructure required to do this exists in OR-Tools and a Monte Carlo wrapper. It costs nothing in licenses. What it costs is recognizing that the problem isn’t the machine — it’s the model of the machine that everyone is using to make decisions.</p>

<hr />

<h2 id="failure-2--the-format-change-that-took-twice-as-long">Failure 2 — The format change that took twice as long</h2>

<p>Theoretical changeover time: two hours. Actual changeover time: four hours. Every single time, on every line, for years.</p>

<p>This wasn’t a technical mystery. Everyone knew the SMED methodology existed. Some of the plants had even sent operators to training courses on it. The problem was that the management response to the gap between theoretical and actual time was always the same kind of response: structural and physical.</p>

<p>I saw companies dedicate specific lines to specific product families, sacrificing flexibility, to avoid the cross-format change problem entirely. I saw machine vendors brought back in to redesign accessibility on equipment that was perfectly accessible already. I saw line layouts modified, costing hundreds of thousands of euros, to shorten transport distances during changeovers.</p>

<p>What I never saw was anyone treating the changeover as what it actually is: <strong>a scheduling and parallelization problem disguised as a hardware problem.</strong></p>

<p><strong>The hidden cause</strong> was the framing itself. Management saw long changeovers as a sign that the machine was the bottleneck, when actually the bottleneck was the sequence in which changeover activities were performed and the fact that they were almost always serialized when they didn’t need to be. Sanitization and tool retrieval and quality validation and operator briefing — these can all happen in parallel, with the right planning. Almost none of them did, in practice.</p>

<p><strong>The math that would have caught it.</strong> Changeover sequencing is a classical Constraint Programming problem. CP-SAT solves it in milliseconds for problems up to a few hundred activities. You define each sub-activity (sanitize, retrieve tools, validate quality, brief operator, swap die, calibrate, run-in), the resource each consumes (operator A, operator B, the machine itself, the QC technician), and the precedence constraints (you can’t run-in before calibration). The solver finds the minimum total time given these constraints, automatically discovering which activities can run in parallel and which must wait.</p>

<p>For a typical contract manufacturer running roughly five format changes per month per machine, with two hours of avoidable extra time per change and an industrial cost of line idleness in the €80–€120 per hour range, the financial impact of leaving this unsolved tends to land somewhere between €40K and €60K per year per plant. Not catastrophic. Not invisible either. The kind of number that, once quantified, makes the conversation about “should we invest in optimization” finally productive.</p>

<p>The other piece of math — sequence-dependent setup times — addresses what comes before. Two consecutive products with similar fragrances need a brief sanitization. Two products with very different formulas need a deep cleaning that takes triple the time. Treating all sanitizations as uniform in the schedule is one of the most expensive simplifications I’ve seen in consumer goods manufacturing. CP-SAT handles asymmetric setup times natively. Spreadsheets don’t.</p>

<hr />

<h2 id="failure-3--the-customer-who-could-move-anything">Failure 3 — The customer who could move anything</h2>

<p>The third pattern is the one that’s most specific to contract manufacturing, and I think the least discussed publicly. It has to do with the fact that contract manufacturers don’t really plan their own production. Their customers do.</p>

<p>In every plant I worked with, somewhere around 15% of orders were modified in the seven days before production. Quantities changed. Formulas were tweaked. Delivery dates moved up because of a marketing campaign nobody had mentioned earlier. Sometimes the customer would call on Thursday asking to bring forward a batch scheduled for the following Monday, and the answer was always yes, because the customer was big and the relationship mattered.</p>

<p><strong>The hidden cause was triple, and worth unpacking carefully.</strong></p>

<p>Large customers knew they could move deadlines and they did so systematically. There was an asymmetry of leverage that nobody had quantified, so nobody had pushed back on. Smaller customers anticipated batches for marketing reasons without understanding the impact on line saturation — they weren’t being malicious, they simply lacked the information that would have made them think twice. And internally, <em>nobody calculated the real cost of a last-minute change</em>. The commercial team accepted modifications because to them, it was free. The cost showed up later, in overtime hours and missed deadlines on other clients’ orders, and by then it was diffused enough that you couldn’t trace it back to the original “yes.”</p>

<p><strong>The management response</strong> was almost always to schedule a planning-commercial meeting where rules of engagement were established for accepting last-minute changes. Those rules then proceeded to be ignored within a month. I don’t say this with cynicism — the rules were genuinely well-designed, in some cases. They just couldn’t survive contact with the reality that the commercial team’s incentive structure rewarded saying yes and the planning team had no quantitative argument for saying no.</p>

<p><strong>The math is two pieces.</strong> First, robust optimization under demand uncertainty. Instead of producing a single deterministic schedule and reacting to changes one by one, you produce a schedule that’s robust against the typical pattern of last-minute modifications. The solver knows that 15% of orders will change, and it finds the schedule that minimizes worst-case impact across plausible modification scenarios. The schedule itself becomes more resilient — built-in slack on the right resources, batch ordering that survives reordering, buffer placement informed by which customers historically modify and which don’t.</p>

<p>Second, and this matters more than the first: <strong>automated cost-of-change quantification</strong>. When the customer calls on Thursday asking to bring a Monday batch forward, the planner shouldn’t have to estimate the impact in their head. The system should respond with a number: <em>“This change costs us roughly €X in disrupted capacity and pushes three other deliveries by Y hours. Are we accepting this, and what’s the customer paying for it?”</em> The cost stops being invisible. The conversation shifts.</p>

<p>This is exactly what sensitivity analysis on top of a scheduling solver does. Perturb one parameter (the deadline of order #347), re-solve, observe the delta in the objective function. The technology to do this in real time is mature. What’s missing is almost never the technology. It’s the recognition that the planning team needs to bring numbers to the meeting, not opinions.</p>

<hr />

<h2 id="what-ties-these-three-failures-together">What ties these three failures together</h2>

<p>I want to be careful not to oversimplify, because each of these problems is real and complicated and deserves more than a paragraph of treatment. But there’s a pattern worth naming.</p>

<p>In all three cases, the planning team was operating with a model of reality that was missing something. Micro-stops were missing from the OEE model. Parallelizable activities were missing from the changeover model. Customer modification probability was missing from the demand model. Each missing piece led to decisions that looked reasonable on the dashboard and were quietly destructive in production.</p>

<p>Operations research math doesn’t fix these failures by being magical. It fixes them by forcing you to write down the model — explicitly, in code — and then revealing the parts of reality the model is ignoring. The discipline isn’t the optimization. It’s the modeling.</p>

<p>The tools to do this kind of modeling are mature, free, and well-documented. OR-Tools handles all three of the math problems I described. Python or any language with a CP-SAT binding is sufficient. The hard part isn’t the technology.</p>

<p>The hard part is having someone in the organization who has spent enough time on the production floor to know which parts of reality the spreadsheet is hiding, and enough time with the math to know which solver to point at the problem.</p>

<p>That intersection is rare. It’s also where most of the value is.</p>

<hr />

<p><em>I build production optimization tooling and consult with European mid-market manufacturers who want to move past spreadsheet-based planning. If any of these patterns sound familiar, the contact links are in the site header.</em></p>]]></content><author><name>Michele Campi</name></author><category term="scheduling" /><category term="or-tools" /><category term="manufacturing" /><category term="optimization" /><summary type="html"><![CDATA[Three chronic scheduling failures I watched repeat across mid-market manufacturers in seven years of operations controlling. Each had the same shape: a visible symptom in the dashboards, a hidden cause nobody was tracking, a management reaction that addressed the symptom, and an operations research method that would have caught the cause. Notes for plant managers and operations directors who suspect their planning is leaving money on the floor.]]></summary></entry><entry><title type="html">Why your AI assistant can’t actually plan your factory</title><link href="https://michelecampi.github.io/2026/04/25/why-ai-assistants-cant-plan-your-factory.html" rel="alternate" type="text/html" title="Why your AI assistant can’t actually plan your factory" /><published>2026-04-25T00:00:00+00:00</published><updated>2026-04-25T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/04/25/why-ai-assistants-cant-plan-your-factory</id><content type="html" xml:base="https://michelecampi.github.io/2026/04/25/why-ai-assistants-cant-plan-your-factory.html"><![CDATA[<p>Last week I sat down with a synthetic but realistic problem. <strong>MetallbauTech GmbH</strong>, a 45-person precision manufacturer in the Stuttgart area, has fifteen CNC machines and six automotive orders to deliver this week. Mix of brake calipers for BMW, transmission gears for Porsche (with a yield-rate constraint, because precision), Audi steering parts, a Tesla rush order due in 15 hours, MAN truck components, and a standard machining batch.</p>

<p>A Monday-morning question for the production manager: what’s the optimal weekly plan?</p>

<p>I wanted to test two things, both relevant to anyone running an SME factory in 2026:</p>

<ol>
  <li><strong>Can a mathematical solver actually do better than spreadsheets and intuition?</strong> (Spoiler: yes, dramatically.)</li>
  <li><strong>Can a generic AI assistant — like ChatGPT or Claude — do the same job?</strong> (Spoiler: no, and not for the reasons you’d expect.)</li>
</ol>

<p>This post walks through the actual tests, with real outputs and real timings. If you run a factory with 10–50 machines and you’re tired of Excel-Gantt week planning, the patterns here will be familiar. If you’re evaluating “AI for manufacturing” tools, you’ll see exactly where the line is between marketing and reality.</p>

<h2 id="the-problem-in-plain-numbers">The problem, in plain numbers</h2>

<p>Here’s MetallbauTech’s week:</p>

<ul>
  <li><strong>15 machines</strong>: 8 CNC milling stations (two of them on night shift, available 80h instead of 45h), 4 CNC lathes, 2 precision grinders, 1 quality measurement station</li>
  <li><strong>6 orders</strong>: priorities 3 to 10, due dates ranging 15h to 42h, total 19 production tasks across all orders</li>
  <li><strong>Constraints</strong>: each task can only run on certain machines (you can’t do precision grinding on a lathe), Porsche gears require a machine with ≥98% yield rate, the QC station bottlenecks at the end</li>
  <li><strong>Objective</strong>: minimize total tardiness — the sum of how late each order is past its due date</li>
</ul>

<p>A human production manager — and I’ve watched several do this — typically takes half a day to lay out a plan like this on a whiteboard or in a spreadsheet, and the result is rarely optimal. They prioritize by gut, parallelize where they remember they can, and accept that some orders slip.</p>

<p>This is exactly the kind of problem <strong>Flexible Job Shop Scheduling (FJSP)</strong> was designed to solve mathematically. Google’s OR-Tools CP-SAT solver, wrapped in a service layer I built called <strong>OptimEngine</strong>, eats this for breakfast.</p>

<h2 id="test-1-deterministic-schedule">Test 1: deterministic schedule</h2>

<p>I wrote the scenario as JSON — 6 jobs with their tasks, 15 machines with their availability and yield rates, the optimization objective — and POSTed it to OptimEngine’s <code class="language-plaintext highlighter-rouge">/schedule-robust</code> endpoint. This is a composite endpoint that, given a scheduling problem, returns the optimal plan.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST https://optim-core-gateway-production.up.railway.app/schedule-robust <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"X-Core-Key: ***"</span> <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> @metallbautech-scenario.json
</code></pre></div></div>

<p>The response came back in <strong>1.28 seconds</strong>. Solver status: <code class="language-plaintext highlighter-rouge">optimal</code> (not heuristic — provably optimal under the constraints). Result:</p>

<ul>
  <li><strong>Makespan: 18 hours.</strong> The entire week’s production fits in 18 hours of wall-clock time.</li>
  <li><strong>6 of 6 orders on time.</strong> Zero tardiness. Including the Tesla URGENT order (due in 15h), which the solver completed in 5h flat.</li>
  <li><strong>Plan structure</strong>: Tesla used CNC-M-06 for rough milling and CNC-M-01 for finishing — not the “obvious” CNC-M-01 first. The solver figured out that running Tesla and BMW in parallel on different machines was faster than serializing them. Porsche correctly went to GRIND-01 (yield 0.99, satisfying the ≥0.98 constraint).</li>
</ul>

<p>The schedule wasn’t intuitive. A production manager working manually would likely have started Tesla on the “best” machine and serialized everything else around it. The solver found a parallel plan where Tesla finishes in 5h on the second-tier mill while the first-tier mill handles BMW concurrently.</p>

<p>This alone is the value proposition: <strong>a solver finds non-obvious optimal plans in seconds</strong>. But there’s a catch, and it’s the reason most schedule outputs don’t survive contact with reality.</p>

<h2 id="test-2-what-happens-when-reality-bites">Test 2: what happens when reality bites</h2>

<p>The 18-hour plan above assumes all task durations are exact. They aren’t. In a real precision shop, the gear-cutting operation on Porsche transmissions might take 5 hours on a good day, 7 nominally, 10 if a tool needs replacement. The Tesla rough milling might run 1.5h to 4h depending on material variability and operator experience.</p>

<p>If you give your production manager an “18 hour optimal plan” and don’t tell them the underlying assumptions, they’ll commit to it. Then on Wednesday, when Porsche’s gear-cutting runs over, the cascading delays push Tesla off its 15h deadline. By Thursday afternoon, you’re calling the customer.</p>

<p>OptimEngine’s <code class="language-plaintext highlighter-rouge">/schedule-robust</code> endpoint accepts an optional <code class="language-plaintext highlighter-rouge">stochastic_parameters</code> array — a way to declare which parameters are uncertain and what distribution they follow. I added three:</p>

<ul>
  <li>Tesla rough-milling: triangular distribution, min 1.5h, mode 2.5h, max 4h</li>
  <li>Porsche gear-cutting: triangular, min 5h, mode 7h, max 10h</li>
  <li>Porsche grinding: triangular, min 3h, mode 5h, max 8h</li>
</ul>

<p>Re-ran the call. Total time: <strong>1.83 seconds</strong> (147ms scheduling + 793ms running 30 Monte Carlo scenarios). The response now contained three strategies:</p>

<p><strong>Strategy A — Nominal Optimistic</strong>: same 18-hour plan as before. Risk level: high.</p>

<p><strong>Strategy B — CVaR-Protected</strong>: makespan 28 hours under the 95% CVaR worst case. Expected value 23 hours. Feasibility rate 100%. The recommendation field reads:</p>

<blockquote>
  <p>“High variability: CV=15.5%. The makespan ranges from 18.0 to 29.0. Risk-aware planning strongly recommended. The gap between expected value (23.0) and CVaR 95% (28.0) is 21.7%. This is the ‘risk premium’ — what you pay for 95% protection.”</p>
</blockquote>

<p><strong>Strategy C — Balanced</strong>: middle ground. Use the nominal schedule but add buffer time on the parameters identified as uncertain.</p>

<p>This is the part that matters for a CEO making delivery commitments. The nominal plan said 18 hours. The probabilistic analysis says: <strong>the realistic expected outcome is 23 hours, and there’s a 5% chance you blow past 28 hours</strong>. If you committed to the customer based on Strategy A, you’re now exposed.</p>

<p>The cost of running this analysis is <strong>0.79 seconds of computation</strong>. The cost of not running it is the phone call to BMW on Thursday.</p>

<h2 id="and-what-about-asking-chatgpt-or-claude">And what about asking ChatGPT or Claude?</h2>

<p>Here’s where it gets interesting. While testing OptimEngine, I asked Claude (in a separate conversation, no MCP integration) to plan the same MetallbauTech week using natural language. The query was straightforward:</p>

<blockquote>
  <p>“I work at MetallbauTech GmbH, a German manufacturer with 15 CNC machines. I have 6 automotive orders this week (BMW brake calipers, Porsche transmission gears, Audi steering, standard batch, Tesla motor mounts URGENT due in 15h, MAN truck parts). Each order has multiple machining stages. Can you build me an optimized weekly schedule that minimizes total tardiness?”</p>
</blockquote>

<p>Claude responded thoughtfully. It flagged its assumptions explicitly (synthetic operation data, generic machine pool), built an FJSP model, then ran CP-SAT in its sandbox. <strong>It produced a schedule in 0.06 seconds.</strong> It even generated a Gantt chart.</p>

<p>Impressive. Until I asked the follow-up: “Did you call the OptimEngine MCP server I have configured, or did you run CP-SAT locally?”</p>

<p>The honest answer:</p>

<blockquote>
  <p>“I ran CP-SAT locally in my sandbox. I pip install’d ortools and called the Python solver directly — I never called your OptimEngine MCP server. […] My local model is a reasonable FJSP with weighted tardiness, but I have no guarantee it matches OptimEngine’s actual /optimize_schedule contract — field names, objective options, how alternatives are encoded, whether setup_times and availability_windows are passed the way I’d assume from memory. The solution is mathematically valid for the model I built; it is not a verified OptimEngine output.”</p>
</blockquote>

<p>This is the line that matters. <strong>A generic AI assistant can improvise CP-SAT for a small problem and produce something plausible. But “plausible” is not “production-grade.”</strong></p>

<p>Here’s what the AI’s local reconstruction was missing, and what your factory will care about:</p>

<ol>
  <li>
    <p><strong>Reproducibility.</strong> The AI’s solver runs in a sandbox that’s recreated each session. No two runs are identical environments. Production scheduling needs runs that are byte-identical given the same inputs, for audit and rollback.</p>
  </li>
  <li>
    <p><strong>Custom logic.</strong> OptimEngine’s v9 solver has nine years of hand-tuning for manufacturing scenarios — sequence-dependent setup times, multi-window availability, yield-rate filtering, four optimization objectives, four uncertainty modes. The AI improvised “a reasonable FJSP” — generic, but missing every detail that makes a real shop’s plans actually executable.</p>
  </li>
  <li>
    <p><strong>Performance at scale.</strong> AI sandboxes run for one conversation. They don’t run 24/7 with SLAs, can’t be called by automated agents at 1000 requests per hour, don’t return responses in &lt;2 seconds under load. Your factory’s MES integration needs an actual API endpoint with uptime guarantees, not a chat tab.</p>
  </li>
  <li>
    <p><strong>Specialization beyond scheduling.</strong> OptimEngine exposes ten composite endpoints — not just <code class="language-plaintext highlighter-rouge">/schedule-robust</code>, but <code class="language-plaintext highlighter-rouge">/risk-analysis</code>, <code class="language-plaintext highlighter-rouge">/full-intel</code>, <code class="language-plaintext highlighter-rouge">/pack-resources</code>, <code class="language-plaintext highlighter-rouge">/forecast-basic</code>, etc. Each is pre-orchestrated for a specific class of decision. A generic AI would have to invent the orchestration each time, with no memory of last week’s decisions, no understanding of your specific shop’s bottlenecks.</p>
  </li>
  <li>
    <p><strong>Auditability.</strong> When the auditor asks why you made a specific scheduling choice in February, “because I asked Claude” doesn’t pass ISO 9001. “Because OptimEngine <code class="language-plaintext highlighter-rouge">/schedule-robust</code> v9.0.2 returned this strategy with these parameters and these inputs, logged with timestamp, signature, and CVaR analysis” does.</p>
  </li>
</ol>

<p>The AI assistant was honest about this. Most won’t be. The current generation of “AI for X” tools either avoids these questions or hides behind vague capability claims. <strong>Your AI assistant can sketch a Gantt chart. It cannot run your factory.</strong></p>

<h2 id="what-this-means-if-youre-an-sme-manufacturer">What this means if you’re an SME manufacturer</h2>

<p>If you have 10–50 machines and you’re juggling multi-client orders weekly, three things follow from this:</p>

<p><strong>One: a real solver is now within reach.</strong> You don’t need an SAP/Siemens enterprise contract anymore. OptimEngine is an HTTP API — anyone with a backend developer can integrate it in a day. Send your jobs and machines, get back an optimal plan with risk analysis. That’s the whole story.</p>

<p><strong>Two: probabilistic planning is a competitive advantage.</strong> Every competitor still using deterministic Excel plans is silently exposed to the variance you’re now measuring. The 21.7% risk premium between nominal and CVaR-95 is the gap they don’t see. You will.</p>

<p><strong>Three: AI assistants are not the answer for production decisions, but they’re a great front-end.</strong> A natural-language interface that converts a CTO’s question — <em>“can we squeeze in one more order this week?”</em> — into an OptimEngine call, runs the solver, and returns the answer in business terms is exactly the right architecture. The AI handles the conversation; the solver handles the math.</p>

<h2 id="try-it">Try it</h2>

<p>If your factory’s weekly planning sounds like the MetallbauTech scenario above, OptimEngine’s <code class="language-plaintext highlighter-rouge">/schedule-robust</code> endpoint is live. The full request schema is in the <a href="https://optim-core-gateway-production.up.railway.app/">public OptimEngine documentation</a> (open to inspection — no signup needed to read).</p>

<p>The 6-job, 15-machine, 19-task scenario from this article runs in under 2 seconds and returns three strategies. Your real shop floor — likely 30–80 jobs across 10–50 machines — runs in 5–30 seconds.</p>

<p>If you’re an SME manufacturer in Italy, Germany, or Europe more broadly, and you’d like to discuss a pilot integration — connecting OptimEngine to your existing MES, ERP, or planning workflow — reach out. I work with manufacturing operations specifically, with a controlling background in contract manufacturing before transitioning to engineering.</p>

<p>The math is solved. The integration is the part that matters.</p>

<hr />

<p><em>OptimEngine is a mathematical optimization service built on Google OR-Tools CP-SAT (v9.0.0), exposing 11 solver capabilities including FJSP scheduling, CVRPTW routing, bin packing, Pareto multi-objective analysis, Monte Carlo risk simulation with CVaR metrics, parametric sensitivity analysis, and prescriptive intelligence. The engine is currently deployed for industrial use cases and accessible via REST and (forthcoming, OAuth-gated) MCP protocols.</em></p>]]></content><author><name>Michele Campi</name></author><category term="optimization" /><category term="manufacturing" /><category term="industry-4-0" /><category term="or-tools" /><category term="ai-agents" /><category term="scheduling" /><summary type="html"><![CDATA[I gave a synthetic but realistic factory scheduling problem — fifteen machines, six automotive orders, real constraints — to a frontier AI assistant and to a constraint-programming solver. The assistant produced confident answers that were measurably suboptimal. The solver produced the proven optimum in milliseconds. The gap is structural, not incremental, and it has direct cost implications for any manufacturer considering AI-powered planning tools.]]></summary></entry><entry><title type="html">Exposing a math solver as Circle Nanopayments: what I learned forking arc-nanopayments</title><link href="https://michelecampi.github.io/2026/04/24/exposing-math-solver-circle-nanopayments.html" rel="alternate" type="text/html" title="Exposing a math solver as Circle Nanopayments: what I learned forking arc-nanopayments" /><published>2026-04-24T00:00:00+00:00</published><updated>2026-04-24T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/04/24/exposing-math-solver-circle-nanopayments</id><content type="html" xml:base="https://michelecampi.github.io/2026/04/24/exposing-math-solver-circle-nanopayments.html"><![CDATA[<p>I spent the last three weeks wiring a constraint-programming solver into Circle’s Nanopayments stack on Arc testnet. The result is <a href="https://github.com/MicheleCampi/optim-arc-v3"><code class="language-plaintext highlighter-rouge">optim-arc-v3</code></a>, a Next.js gateway that exposes ten optimization endpoints — scheduling, routing, Pareto frontiers, stochastic CVaR analysis — each behind a <code class="language-plaintext highlighter-rouge">402 Payment Required</code> response that accepts gasless USDC micropayments.</p>

<p>It’s live at <a href="https://optim-arc-v3.vercel.app">optim-arc-v3.vercel.app</a>. You can hit any endpoint with <code class="language-plaintext highlighter-rouge">curl</code> right now and get back a valid x402 v2 payment challenge.</p>

<p>This post is about how I got there. Not the Circle marketing pitch — the actual friction, the choices I didn’t expect to face, and the patterns I’d reach for next time.</p>

<h2 id="why-this-why-now">Why this, why now</h2>

<p>In March 2026 Circle published a blog post on Nanopayments. I was already tracking them for stablecoin infrastructure reasons, but that post clicked something. Sub-cent, gasless USDC payments batched off-chain and settled on-chain periodically — this wasn’t another “blockchain primitive in search of a use case.” It was plumbing for a thing I’d been thinking about for months: <strong>markets of calculations and decisions</strong>.</p>

<p>Here’s what I mean. I run OptimEngine, a mathematical optimization service built on Google OR-Tools. It solves things like factory scheduling, logistics routing, resource packing — problems that LLMs cannot solve, because they require constraint satisfaction and provable optimality, not pattern matching. My hunch is that agentic systems in 2026-2027 will increasingly need to call into specialized solvers for decisions that matter. Not every problem is a text generation problem.</p>

<p>But for an AI agent to <em>pay</em> my solver for each call — autonomously, without human approval loops, at sub-cent granularity — the payment layer has to disappear. Traditional APIs with credit card billing and monthly invoices don’t work when the caller is a machine making ten thousand decisions per hour. That’s the gap Nanopayments targets.</p>

<p>So the plan crystallized: make OptimEngine speak x402 natively on Arc, Circle’s payment-native chain. An agent sees a <code class="language-plaintext highlighter-rouge">402</code>, signs an EIP-3009 authorization off-chain, retries, gets the solver response. Zero gas. Settlement batched by Circle in the background. For the agent, it’s just an HTTP call with a payment header.</p>

<h2 id="the-decision-fork-dont-build-from-scratch">The decision: fork, don’t build from scratch</h2>

<p>I already had a working gateway on Arc testnet — an Express app I’d hand-rolled months earlier, implementing a custom x402 flow against the same network. One transaction had even been processed through it. Extending that code was the obvious path.</p>

<p>I chose the opposite: fork Circle’s own <a href="https://github.com/circlefin/arc-nanopayments"><code class="language-plaintext highlighter-rouge">arc-nanopayments</code></a> sample and adapt it.</p>

<p>The reasoning, which I worked through in iterative discussion with an AI assistant (a workflow I’ve come to rely on for significant architectural choices): Circle’s sample is the reference implementation. It’s the code their DevRel team points to, built on their own SDKs, validated against their own infrastructure. Starting from something they promote means inheriting their assumptions about batching, signature verification, and settlement — assumptions I’d otherwise have to reverse-engineer.</p>

<p>The trade-off was real. Their sample is Next.js + Supabase + Tailwind, while my existing gateway was Express + ethers. Different stack. Different deployment story. Adopting it meant throwing away a working codebase.</p>

<p>I estimated the fork would be “90% compatible” with what I already had. That was optimistic. When I actually walked through the code, it was closer to 50-60%. The payment flow patterns matched at a high level, but every implementation detail — how the <code class="language-plaintext highlighter-rouge">payment-required</code> header is structured, how signatures are verified, how settlement is recorded — was different enough to require new code.</p>

<p>What saved the approach wasn’t the estimate being accurate. It was the ability to course-correct quickly. The principle that emerged, which I’ll keep reusing: <strong>what matters is moving quickly from misalignment to alignment</strong>. An imprecise initial estimate isn’t a failure mode if the process catches it early and adjusts.</p>

<h2 id="the-implementation-thin-proxy-hoc-pattern">The implementation: thin proxy, HOC pattern</h2>

<p>The core insight, once I’d read Circle’s sample carefully, is that the x402/Nanopayments flow is encapsulated in a single Higher-Order Function: <code class="language-plaintext highlighter-rouge">withGateway</code>. You write a normal Next.js route handler that returns JSON, then wrap it:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">NextRequest</span><span class="p">,</span> <span class="nx">NextResponse</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">next/server</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">withGateway</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">@/lib/x402</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">CORE_GATEWAY_URL</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CORE_GATEWAY_URL</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">CORE_GATEWAY_KEY</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CORE_GATEWAY_KEY</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">handler</span> <span class="o">=</span> <span class="k">async</span> <span class="p">(</span><span class="nx">req</span><span class="p">:</span> <span class="nx">NextRequest</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">body</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">req</span><span class="p">.</span><span class="nx">json</span><span class="p">();</span>

  <span class="kd">const</span> <span class="nx">coreRes</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">CORE_GATEWAY_URL</span><span class="p">}</span><span class="s2">/pack-resources`</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span>
      <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span><span class="p">,</span>
      <span class="dl">"</span><span class="s2">X-Core-Key</span><span class="dl">"</span><span class="p">:</span> <span class="nx">CORE_GATEWAY_KEY</span><span class="p">,</span>
    <span class="p">},</span>
    <span class="na">body</span><span class="p">:</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">body</span><span class="p">),</span>
  <span class="p">});</span>

  <span class="kd">const</span> <span class="nx">responseBody</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">coreRes</span><span class="p">.</span><span class="nx">text</span><span class="p">();</span>
  <span class="k">return</span> <span class="k">new</span> <span class="nx">NextResponse</span><span class="p">(</span><span class="nx">responseBody</span><span class="p">,</span> <span class="p">{</span>
    <span class="na">status</span><span class="p">:</span> <span class="nx">coreRes</span><span class="p">.</span><span class="nx">status</span><span class="p">,</span>
    <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">Content-Type</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">application/json</span><span class="dl">"</span> <span class="p">},</span>
  <span class="p">});</span>
<span class="p">};</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">POST</span> <span class="o">=</span> <span class="nx">withGateway</span><span class="p">(</span><span class="nx">handler</span><span class="p">,</span> <span class="dl">"</span><span class="s2">$0.25</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">/api/solve/pack-resources</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p>That’s it. The handler is a thin proxy to my existing orchestration layer (the OptimEngine Core Gateway, running separately). It doesn’t know anything about x402, Nanopayments, Arc, or USDC. Payment verification, settlement, and event logging to Supabase all happen inside <code class="language-plaintext highlighter-rouge">withGateway</code>.</p>

<p>When a client hits this route without a payment header, the response is <code class="language-plaintext highlighter-rouge">HTTP 402 Payment Required</code> with a base64-encoded <code class="language-plaintext highlighter-rouge">payment-required</code> header. Decoded, the JSON looks like this (shortened):</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"x402Version"</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="p">,</span><span class="w">
  </span><span class="nl">"accepts"</span><span class="p">:</span><span class="w"> </span><span class="p">[{</span><span class="w">
    </span><span class="nl">"scheme"</span><span class="p">:</span><span class="w"> </span><span class="s2">"exact"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"network"</span><span class="p">:</span><span class="w"> </span><span class="s2">"eip155:5042002"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"asset"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0x3600000000000000000000000000000000000000"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"amount"</span><span class="p">:</span><span class="w"> </span><span class="s2">"250000"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"payTo"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0x389D73e1cAC5e4D4a7BB3C6c4Cf35aB36bF00712"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"extra"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"GatewayWalletBatched"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"verifyingContract"</span><span class="p">:</span><span class="w"> </span><span class="s2">"0x0077777d7EBA4688BDeF3E311b846F25870A19B9"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">network</code> field identifies Arc testnet in CAIP-2 format. The <code class="language-plaintext highlighter-rouge">amount</code> is in USDC atomic units (6 decimals, so 250000 = $0.25). The <code class="language-plaintext highlighter-rouge">extra.name: "GatewayWalletBatched"</code> is the flag that tells a compatible client to use Circle’s batched settlement path via the Gateway Wallet contract at <code class="language-plaintext highlighter-rouge">0x0077777d7EBA...</code> — this is what makes the payment gasless and nanopayment-scale.</p>

<p>A client that understands this responds by signing an EIP-3009 authorization off-chain, base64-encoding it into a <code class="language-plaintext highlighter-rouge">payment-signature</code> header, and retrying. The seller’s <code class="language-plaintext highlighter-rouge">withGateway</code> wrapper forwards the signature to Circle’s <code class="language-plaintext highlighter-rouge">BatchFacilitatorClient.verify()</code> and <code class="language-plaintext highlighter-rouge">BatchFacilitatorClient.settle()</code>, records the payment event in Supabase, then calls my handler and streams the response back.</p>

<p>I did this pattern once for <code class="language-plaintext highlighter-rouge">/api/solve/pack-resources</code>, then generated the remaining nine endpoints with a shell script that templates the same ~30-line proxy. Each endpoint has a different price tier and proxies to a different route on my Core Gateway, but the middleware logic is identical.</p>

<h2 id="the-fork-minimally">The fork, minimally</h2>

<p>The upstream sample includes a dashboard UI, a LangChain-powered agent buyer, and four demo endpoints that return things like motivational quotes and toy JSON datasets. I kept none of it.</p>

<p>Removed:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">agent.mts</code> and the LangChain/DeepAgents/OpenAI dependencies. I’m building a seller, not a buyer. The sample’s buyer agent is instructive but it’s infrastructure I don’t need to own.</li>
  <li><code class="language-plaintext highlighter-rouge">app/dashboard/</code> and the related React components. If I ever want a seller dashboard, I’ll build one tailored to OptimEngine metrics. The sample’s dashboard was for demonstrating Circle’s balance and withdrawal APIs, which aren’t the story I want to tell.</li>
  <li>The four demo paywalled routes.</li>
</ul>

<p>Kept:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">lib/x402.ts</code> — the core middleware. Circle’s implementation is well-structured, uses the official SDK, and handles edge cases I’d otherwise forget. Apache-2.0 licensed.</li>
  <li><code class="language-plaintext highlighter-rouge">supabase/migrations/</code> — the schema for <code class="language-plaintext highlighter-rouge">payment_events</code> and <code class="language-plaintext highlighter-rouge">withdrawals</code> tables.</li>
  <li>The <code class="language-plaintext highlighter-rouge">app/api/gateway/balance</code> and <code class="language-plaintext highlighter-rouge">app/api/gateway/withdraw</code> endpoints. I don’t use them today, but leaving them costs nothing and saves work later.</li>
</ul>

<p>The result was a clean install of 189 packages (down from 327 in the upstream sample), zero vulnerabilities, TypeScript passing with no errors. First commit on April 23, production deploy on Vercel two days later.</p>

<h2 id="deploy-vercel-and-one-thing-id-redo">Deploy: Vercel, and one thing I’d redo</h2>

<p>Vercel was the right choice for me, even though my other infrastructure is on Railway. Next.js 16 with Turbopack is Vercel-native — zero configuration, push to <code class="language-plaintext highlighter-rouge">main</code>, live in ninety seconds. The free tier’s 10-second function timeout is a real limit on my heaviest endpoint (a composite analysis that can take seven seconds of solver time), but it’s a constraint I’ll manage rather than architect around for now.</p>

<p>The one thing I’d do differently: iterate on the Vercel setup more carefully. I accidentally created two parallel Vercel projects pointing to the same GitHub repo — one where I’d configured environment variables correctly, one left empty. Builds on the empty project kept failing with <code class="language-plaintext highlighter-rouge">supabaseUrl is required</code> errors that looked identical to missing configuration. It took a screenshot comparison to realize I was looking at two different projects in my dashboard. The lesson wasn’t technical. It was: when an error repeats after you think you’ve fixed it, check that you’re fixing the right instance.</p>

<p>Once sorted, the smoke test from my terminal was clean: all ten endpoints returning <code class="language-plaintext highlighter-rouge">HTTP 402</code> with valid payload, response times between 370 and 730 milliseconds, first call warm-up aside.</p>

<h2 id="two-things-id-want-you-to-take-from-this">Two things I’d want you to take from this</h2>

<p><strong>First, on the practical side: don’t be afraid to fork enterprise code.</strong> Circle’s sample isn’t sacred. It’s Apache-2.0 licensed, it’s designed to be adapted, and the maintainers at Circle would rather see external builders fork it into real applications than admire it untouched. Strip what you don’t need, keep what you do, credit the upstream in your README, and move on. The sample is a starting point, not a template to preserve.</p>

<p><strong>Second, on the broader bet: x402 + Nanopayments is foundational infrastructure for agent economies.</strong> Not because it’s fashionable, but because the alternative — traditional payment rails, human-approved transactions, monthly billing cycles — doesn’t scale to the kind of economic activity that autonomous agents will generate. If an agent wants to call my solver ten thousand times to evaluate a decision space, the payment layer has to be invisible and nearly free. That’s Nanopayments. On Arc, with USDC, through Circle’s Gateway, it works.</p>

<p>The piece that convinced me this was worth three weeks of effort wasn’t the technology itself. It was the realization that <strong>markets of calculations and decisions</strong> — where agents pay specialized services for mathematical work at micropayment scale — become economically viable once the payment friction collapses. Solvers, predictors, validators, oracles: all of them become addressable by autonomous clients who can pay per call without accounting overhead. The solver is just the first primitive I happened to have ready.</p>

<h2 id="try-it-or-reach-out">Try it, or reach out</h2>

<p>The endpoint is live. A single command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-i</span> <span class="nt">-X</span> POST https://optim-arc-v3.vercel.app/api/solve/pack-resources <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{}'</span>
</code></pre></div></div>

<p>You’ll get back a <code class="language-plaintext highlighter-rouge">402</code> with a decodable <code class="language-plaintext highlighter-rouge">payment-required</code> header. Everything to implement a client is in Circle’s <a href="https://developers.circle.com/gateway/nanopayments">Nanopayments documentation</a>.</p>

<p>If you’re building an agent that needs to make optimization decisions — scheduling, routing, portfolio construction, resource allocation — and you want to integrate OptimEngine as a solver in your pipeline, reach out. The solver is production-grade, the payment rail is now Circle-native, and I’m interested in hearing what kinds of problems you’re trying to make agent-solvable.</p>

<hr />

<p><em>Built on top of <a href="https://github.com/circlefin/arc-nanopayments">circlefin/arc-nanopayments</a> (Apache-2.0). Thanks to the Circle team for the sample and to Arc network for making sub-cent USDC payments practical on testnet. The OptimEngine Core Gateway is private infrastructure that orchestrates Google OR-Tools CP-SAT solvers behind this payment layer.</em></p>]]></content><author><name>Michele Campi</name></author><category term="x402" /><category term="circle" /><category term="nanopayments" /><category term="agent-economy" /><category term="nextjs" /><category term="or-tools" /><summary type="html"><![CDATA[Why autonomous AI agents will start buying optimization decisions the way SaaS platforms today buy API calls — and what it means for the next generation of B2B integrations. Three weeks at the intersection of operations research and machine-to-machine commerce, with the technical detail of forking Circle's reference implementation.]]></summary></entry><entry><title type="html">How I exposed OR-Tools as a production MCP server</title><link href="https://michelecampi.github.io/2026/04/21/how-i-exposed-or-tools-as-mcp.html" rel="alternate" type="text/html" title="How I exposed OR-Tools as a production MCP server" /><published>2026-04-21T00:00:00+00:00</published><updated>2026-04-21T00:00:00+00:00</updated><id>https://michelecampi.github.io/2026/04/21/how-i-exposed-or-tools-as-mcp</id><content type="html" xml:base="https://michelecampi.github.io/2026/04/21/how-i-exposed-or-tools-as-mcp.html"><![CDATA[<p>In April 2026, the tooling gap is starting to show.</p>

<p>AI agents can generate text, call APIs, navigate browsers, write and execute code. They can do a lot. What they can’t do reliably is decide how to schedule ten manufacturing jobs across three machines so that lateness is minimized. Ask a language model to do it and you’ll watch it reason for forty-five seconds, then return a plausible-looking sequence that’s noticeably worse than optimal. Or worse, confidently wrong.</p>

<p>OR-Tools CP-SAT, Google’s open-source constraint solver, returns the provably optimal schedule for the same problem in under fifty milliseconds. No hallucination. No “let me reason about this step by step.” Just a deterministic answer with a mathematical guarantee.</p>

<p>So I spent the last two months wrapping it as a production MCP server that autonomous agents can call. This is how it went, what I chose, what I’d do differently, and the bugs I hit — all documented in a git log that I later had to clean up.</p>

<h2 id="the-problem-i-came-from">The problem I came from</h2>

<p>I’m not a “tech guy” by origin. I’ve spent seven years as an operations controller in mid-market manufacturing in northern Italy — the kind of work where you watch production managers rebuild weekly schedules by hand every Monday morning because the MES system doesn’t do real scheduling, it only tracks events after the fact.</p>

<p>The scheduling problems I saw were always the same shape. Five to fifteen machines. Ten to twenty active jobs with different customer deadlines and setup dependencies. A plant manager with an Excel sheet and twenty years of gut feeling. When something went wrong — a line didn’t start, a machine broke down — the replan was done by eye, because formally recalculating would take hours. The cost of a late order translates into penalties, lost customer goodwill, and sometimes lost customers.</p>

<p>Commercial alternatives existed but weren’t accessible. Enterprise MES and APS vendors start in the low six figures per year in license fees, often multiples more once you include integration and consulting. For a small-to-medium manufacturer in Emilia or Lombardy doing ten to thirty million in revenue, those numbers don’t pencil out.</p>

<p>Before OptimEngine, I built smaller things: an order forecast scheduler for my controller work (Gantt visualization, multi-job parallelism, economics tracking), then a routing demo. Neither went anywhere, but they taught me the pattern: <strong>the math that enterprise software sells for six figures runs on open-source solvers that anyone can use, if they know how</strong>.</p>

<p>Then MCP landed. Anthropic released the spec, and I started noticing that agents building on top of frontier LLMs could now <em>discover</em> tools via a standardized protocol, not hard-coded integrations. The question answered itself: what if I took a world-class solver and made it an MCP tool that any autonomous agent could call without knowing anything about operations research?</p>

<p>That’s what OptimEngine is now.</p>

<h2 id="why-mcp-not-just-a-rest-api">Why MCP, not just a REST API</h2>

<p>The first architectural decision was whether to build this as a conventional REST service or as an MCP server. I ended up doing both. Here’s the reasoning.</p>

<p>REST APIs are universal. They work from anywhere. But they have one structural limitation in the agent era: <strong>the agent has to know you exist before it can call you</strong>. Every REST integration requires someone — a developer, a prompt engineer — to manually wire the API into the agent’s context. You are a service that needs to be introduced.</p>

<p>MCP flips that. An MCP server exposes a tool manifest: a machine-readable description of what it does, what inputs it takes, and what outputs it returns. Agents can discover MCP servers through registries like Smithery, read the manifest, and decide on the fly whether to invoke the tool. No human integration step.</p>

<p>For a solver service, this matters. If the target user is “every AI agent that might ever need to solve a scheduling problem,” the ratio of developers-who-will-write-custom-integrations to agents-that-will-find-me-via-MCP-and-call-me tips hard toward MCP over a three-year horizon. MCP is where the audience is going.</p>

<p>But REST still earns its place. My own use cases — a Next.js SaaS for Italian SMEs called PMI Scheduler, server-to-server workflows, Stripe-based billing — all speak REST natively. Forcing them through MCP would be silly.</p>

<p>The solution is dual-stack. Same FastAPI backend, same OR-Tools core. REST for traditional integrations, MCP for agent-native consumers. Routes split by transport, logic shared by the solver layer underneath.</p>

<h2 id="architecture">Architecture</h2>

<p>Here’s the shape of OptimEngine at a high level:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>REST client             MCP client
(Next.js proxy,        (Claude Desktop,
 cURL, Python SDK)      Cursor, autonomous agent)
       |                    |
       v                    v
  FastAPI app          MCP transport
  (api/server.py)      (/mcp SSE +
                        /mcp/v2 Streamable HTTP)
       |                    |
       +---------+----------+
                 v
         Solver dispatch
         (solver/, routing/, packing/,
          pareto/, robust/, stochastic/)
                 |
                 v
         OR-Tools CP-SAT
                 |
                 v
            JSON response
</code></pre></div></div>

<p>The application is FastAPI. I went with it because it’s what I already knew from previous projects — Python async, auto-generated OpenAPI docs, dependency injection that fits naturally with middleware. Flask would have worked; Starlette alone might have been lighter. I didn’t benchmark the alternatives, and I don’t regret it.</p>

<p>Under the FastAPI layer sits a modular solver tree. Eight domains: scheduling, routing, packing, pareto frontier, prescriptive analytics, robust optimization, sensitivity analysis, stochastic scheduling. That breadth emerged incrementally. I started with scheduling, iterated with AI pair-programming, kept adding domains as I understood the space better.</p>

<p>In retrospect, this is both the strength and the weakness of the project. Scheduling and routing are the most mature and most used. Pareto and sensitivity are production-grade. The robust and stochastic modules exist but haven’t been stress-tested by real traffic. If I were starting again, I’d build depth in one domain first and expand only after the first one had paying users. Shipping eight domains in two months looks impressive but makes each one thinner than it could be.</p>

<p>The solver choice — CP-SAT specifically — was deliberate. OR-Tools offers multiple solvers (linear programming, mixed-integer, constraint programming), but CP-SAT is the sweet spot for the problems I care about: combinatorial scheduling with hard constraints, vehicle routing with capacity windows, bin packing with rules. It’s open source, it’s been battle-tested by Google internally, and it handles disjunctive constraints natively — something MIP solvers tend to struggle with. I didn’t run head-to-head benchmarks against Gurobi or CPLEX. I chose CP-SAT on the recommendation that it fits this problem class, and it has.</p>

<h2 id="dual-mcp-transport-sse-and-streamable-http">Dual MCP transport: SSE and Streamable HTTP</h2>

<p>MCP supports multiple transports. The older one is Server-Sent Events (SSE): long-lived HTTP connections where the server streams tool invocations to the client. The newer one is Streamable HTTP, which moves toward a more conventional request/response model with first-class support for auth.</p>

<p>OptimEngine deploys both.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">### Open, rate-limited, no auth — for demos and compat
</span><span class="n">mcp</span><span class="p">.</span><span class="n">mount_sse</span><span class="p">(</span><span class="n">mount_path</span><span class="o">=</span><span class="s">"/mcp"</span><span class="p">)</span>

<span class="c1">### Streamable HTTP + OAuth 2.1 — for production agents
</span><span class="k">if</span> <span class="n">_SCALEKIT_CONFIGURED</span><span class="p">:</span>
    <span class="n">mcp</span><span class="p">.</span><span class="n">mount_http</span><span class="p">(</span><span class="n">mount_path</span><span class="o">=</span><span class="s">"/mcp/v2"</span><span class="p">)</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">/mcp</code> endpoint is open, rate-limited at ten tool calls per hour per IP via a custom middleware. Good enough for Claude Desktop, Cursor, and anyone kicking the tires on the service. No authentication, no billing, no friction.</p>

<p>The <code class="language-plaintext highlighter-rouge">/mcp/v2</code> endpoint requires a valid OAuth 2.1 bearer token, validated against a ScaleKit JWKS endpoint. This is the tier where production agents with real traffic live.</p>

<p><strong>A gotcha I spent time on</strong>: the <code class="language-plaintext highlighter-rouge">mcp</code> Python library’s <code class="language-plaintext highlighter-rouge">mount_sse()</code> method defaults to mounting at <code class="language-plaintext highlighter-rouge">/sse</code>, not <code class="language-plaintext highlighter-rouge">/mcp</code>. The older <code class="language-plaintext highlighter-rouge">mount()</code> method used <code class="language-plaintext highlighter-rouge">/mcp</code> as its default. If you’re upgrading from an earlier version and don’t pass <code class="language-plaintext highlighter-rouge">mount_path="/mcp"</code> explicitly, every existing client breaks silently — the SSE handshake succeeds at the wrong path, and nothing in the logs makes this obvious. The fix is one argument, but the debugging time to find it is real. I learned this the hard way during post-deploy verification.</p>

<p><strong>A second gotcha, related to auth</strong>: the <code class="language-plaintext highlighter-rouge">/.well-known/oauth-protected-resource</code> endpoint — the standard OAuth 2.1 discovery path that MCP clients call before authentication — was being blocked by my API key middleware because it didn’t match any of the public paths. Clients would get a 403 during discovery, fail to initialize OAuth, and give up. The fix is to add <code class="language-plaintext highlighter-rouge">/.well-known</code> to the middleware’s bypass list. Trivial in hindsight, but it cost a deploy cycle to figure out.</p>

<p>If you’re building your first MCP server: start with SSE, keep it open, add auth and the streamable transport only when you have a concrete reason. My mistake early on was adding OAuth before I had any user who needed it.</p>

<h2 id="authentication-without-building-an-oauth-server">Authentication without building an OAuth server</h2>

<p>Implementing OAuth 2.1 from scratch is something nobody sensible does anymore. The RFCs are dense, the attack surface is large, and the table stakes for correctness are high. The pragmatic choice is to delegate to an identity provider.</p>

<p>I used ScaleKit. The tier is free for development, it supports OAuth 2.1 natively, it handles Dynamic Client Registration — which MCP discovery platforms like Smithery require — and the dashboard is usable. Auth0 would have worked too, Clerk probably, plenty of others.</p>

<p>The integration is lean: four environment variables, a JWT validator using PyJWT directly, and a FastAPI dependency that checks the bearer token on every <code class="language-plaintext highlighter-rouge">/mcp/v2</code> request.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">from</span> <span class="nn">jwt</span> <span class="kn">import</span> <span class="n">PyJWKClient</span><span class="p">,</span> <span class="n">decode</span> <span class="k">as</span> <span class="n">jwt_decode</span>

<span class="n">_JWKS_CLIENT</span> <span class="o">=</span> <span class="n">PyJWKClient</span><span class="p">(</span>
    <span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">_SCALEKIT_ENV_URL</span><span class="si">}</span><span class="s">/.well-known/jwks.json"</span><span class="p">,</span>
    <span class="n">cache_keys</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
    <span class="n">lifespan</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>
<span class="p">)</span>

<span class="k">async</span> <span class="k">def</span> <span class="nf">validate_mcp_bearer</span><span class="p">(</span><span class="n">request</span><span class="p">:</span> <span class="n">Request</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
    <span class="n">auth</span> <span class="o">=</span> <span class="n">request</span><span class="p">.</span><span class="n">headers</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"Authorization"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">auth</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="s">"Bearer "</span><span class="p">):</span>
        <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="s">"Missing bearer token"</span><span class="p">)</span>
    <span class="n">token</span> <span class="o">=</span> <span class="n">auth</span><span class="p">[</span><span class="mi">7</span><span class="p">:]</span>
    <span class="n">signing_key</span> <span class="o">=</span> <span class="n">_JWKS_CLIENT</span><span class="p">.</span><span class="n">get_signing_key_from_jwt</span><span class="p">(</span><span class="n">token</span><span class="p">).</span><span class="n">key</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="n">claims</span> <span class="o">=</span> <span class="n">jwt_decode</span><span class="p">(</span>
            <span class="n">token</span><span class="p">,</span>
            <span class="n">signing_key</span><span class="p">,</span>
            <span class="n">algorithms</span><span class="o">=</span><span class="p">[</span><span class="s">"RS256"</span><span class="p">],</span>
            <span class="n">audience</span><span class="o">=</span><span class="n">_SCALEKIT_RESOURCE_ID</span><span class="p">,</span>
        <span class="p">)</span>
    <span class="k">except</span> <span class="nb">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span><span class="mi">401</span><span class="p">,</span> <span class="sa">f</span><span class="s">"Invalid token: </span><span class="si">{</span><span class="n">e</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">claims</span>
</code></pre></div></div>

<p><strong>A nontrivial gotcha here</strong>: the official <code class="language-plaintext highlighter-rouge">scalekit-sdk-python</code> package depends on protobuf 5.x. OR-Tools, the entire reason this service exists, depends on protobuf 6.33+. The two cannot coexist in the same Python environment. I wasted a deploy figuring this out before switching to PyJWT directly against the JWKS endpoint. Public-key validation is the only thing I needed from the SDK anyway — no need for the full client library.</p>

<p>This is a recurring pattern in modern Python backends: SDKs from auth providers pull heavy dependency trees that conflict with whatever scientific or ML libraries you’re also using. When it happens, drop the SDK and call the provider’s raw HTTP endpoints. OAuth 2.1 with JWKS is simple enough to do in thirty lines of code.</p>

<h2 id="the-smithery-issuer-match-saga">The Smithery issuer match saga</h2>

<p>The last piece — making OptimEngine discoverable on Smithery, the de-facto MCP directory — turned out to be the hardest. It’s worth describing because it illustrates how young this ecosystem still is.</p>

<p>Smithery tries to connect to an MCP server, complete the OAuth handshake, and introspect its tools. When it couldn’t scan OptimEngine, I went looking for the reason, and the reason was an RFC 8414 compliance issue between how ScaleKit advertises itself and how Smithery expects authorization servers to be described.</p>

<p>The short version: ScaleKit’s resource-scoped OAuth metadata correctly supports DCR (what Smithery needs), but its <code class="language-plaintext highlighter-rouge">issuer</code> claim points to the base environment URL rather than the resource-scoped URL. Smithery, strictly following RFC 8414, validates that <code class="language-plaintext highlighter-rouge">issuer</code> equals the <code class="language-plaintext highlighter-rouge">authorization_servers</code> URL declared in <code class="language-plaintext highlighter-rouge">/.well-known/oauth-protected-resource</code>. The mismatch fails the handshake.</p>

<p>I tried three fixes over a single Saturday evening, in three successive branches.</p>

<ol>
  <li>
    <p><strong>Use the base URL as authorization server.</strong> This matches the issuer, but the base URL doesn’t expose DCR — Smithery still fails, now with a different error: “does not support dynamic client registration.”</p>
  </li>
  <li>
    <p><strong>Use the resource-scoped URL.</strong> This has DCR but mismatched issuer. Back to square one.</p>
  </li>
  <li>
    <p><strong>Serve the metadata myself.</strong> Implement <code class="language-plaintext highlighter-rouge">/.well-known/oauth-authorization-server</code> as a proxy endpoint: fetch ScaleKit’s real metadata, override the <code class="language-plaintext highlighter-rouge">issuer</code> field to match my own <code class="language-plaintext highlighter-rouge">BASE_URL</code>, return the rewritten document. All the OAuth flows still terminate at ScaleKit (authorize, token, register, jwks) — I only rewrite the one field Smithery validates. Works. RFC-compliant. Both compliance and DCR satisfied.</p>
  </li>
</ol>

<p>Even after this, Smithery’s MCP scanner was still returning 401 during the handshake for unrelated reasons. Rather than keep debugging, I implemented a <code class="language-plaintext highlighter-rouge">/.well-known/mcp/server-card.json</code> endpoint — a static JSON document describing my tools — which Smithery docs explicitly support as a fallback for servers whose dynamic discovery fails. Paste the tool manifest, done, scanned.</p>

<p><strong>The lesson, if there is one</strong>: in a young protocol ecosystem, working around integration quirks is sometimes faster than fixing them. Both OAuth spec compliance and Smithery’s scanner are reasonable in isolation, but they didn’t meet cleanly at the time. A metadata proxy plus a static fallback cost a few hours. Solving it “properly” at the ScaleKit layer would have required either them changing their issuer convention or me running my own OAuth provider — not an acceptable trade.</p>

<h2 id="what-id-do-differently">What I’d do differently</h2>

<p>Writing this up forces a kind of retrospective honesty. A few things I’d change if I started again:</p>

<p><strong>I’d build one domain deep before eight domains wide.</strong> Eight optimization verticals in two months is impressive on paper. In practice, scheduling and routing are the only ones with real traffic; the others are options I left on the table. If the first use case is SME manufacturing, then the first version should do scheduling with deep configuration (shift windows, setup times, quality yield) and nothing else. Breadth was a hedge that slowed everything down.</p>

<p><strong>I’d wait on OAuth.</strong> I built the OAuth 2.1 integration before I had a single paying customer who needed it. The open <code class="language-plaintext highlighter-rouge">/mcp</code> tier, with rate limiting, would have covered all real usage for the first ninety days. Adding auth earlier meant debugging ScaleKit-Smithery integration when I should have been writing articles or talking to users.</p>

<p><strong>I’d use one branch per problem, not per attempt.</strong> The Smithery saga left orphan branches in my repo — each representing one attempted fix. Iterating on a single branch with <code class="language-plaintext highlighter-rouge">git commit --amend</code> or <code class="language-plaintext highlighter-rouge">git rebase -i</code> produces the same outcome with a cleaner history. The reason I didn’t is psychological: new branches felt like “resets” during stressful debugging. Useful insight, next time.</p>

<p><strong>I’d start writing the same day I started the product.</strong> The technical work is only half of what gets a developer tool adopted. Without an article trail describing decisions and lessons, nobody finds the work. I’m writing this article two months late. If you’re shipping something similar, start writing about it from commit one.</p>

<h2 id="try-it-yourself">Try it yourself</h2>

<p>OptimEngine is live. For Claude Desktop or any MCP client, add this to your config:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"mcpServers"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"optimengine"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
      </span><span class="nl">"transport"</span><span class="p">:</span><span class="w"> </span><span class="s2">"sse"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://optim-engine-production.up.railway.app/mcp"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Restart your client and the solver tools should appear in the tool list, ready to be called for scheduling, routing, packing and the other optimization domains. Free tier: ten calls per hour per IP, no authentication.</p>

<p>REST endpoints exist for server-to-server integration, but they’re behind an API key — this article focuses on the MCP side, which is the open surface. Production tier with OAuth and higher limits is billed via x402 on Base — I’ll cover the monetization layer in a separate article.</p>

<p>Source on GitHub at <a href="https://github.com/MicheleCampi/optim-engine">github.com/MicheleCampi/optim-engine</a>. If you’re working on something similar — wrapping a scientific or domain-specific tool as an agent-accessible service — I’d genuinely like to hear about it. Reach out on X at <a href="https://twitter.com/MicheleC54474">@MicheleC54474</a>.</p>]]></content><author><name>Michele Campi</name></author><category term="mcp" /><category term="ortools" /><category term="oauth" /><category term="fastapi" /><summary type="html"><![CDATA[How operations research methods solve scheduling problems that ERP and APS vendors charge mid-six-figure annual licenses to address. Written from seven years inside mid-market manufacturing in northern Italy. Technical postmortem on wrapping Google's OR-Tools as an MCP server — for readers who want the architecture and the integration trade-offs.]]></summary></entry></feed>