Engineering Blog

Teaching an Inventory Planner Some Common Sense

Ask someone for 1,800 cobblestone, and the sensible answer is one full shulker box plus 72 loose blocks. It's common sense, really. Teaching a system how to reach that answer, however, becomes a little more interesting.

Mine Ops runs industrial, commercial, infrastructure, and service operations in the Minecraft world of Colonia. The Repository, a large warehouse used for material storage, is the setting for the retrieval examples here. Our orchestration platform, Workman (short for Worker Manager), assigns material retrieval orders to automated workers. These workers navigate our facilities, withdraw supplies from containers, and deliver them where they're needed.

The original retrieval planner treated this as a lookup problem: find enough matching inventory, withdraw it, and send the worker out. Once storage included both loose stock and bulk packages, finding enough material was no longer the same as choosing a sensible load.

Loose stock and sealed packages

Minecraft normally lets you carry items in stacks of up to 64. A shulker box is a portable container that can hold 27 such stacks, carried in a single inventory slot.

Our planner treats loose stock as divisible. Need 17 items? Take 17. It treats a shulker as an atomic package: withdraw the whole box, including its contents, or leave it in storage. “Sealed” describes this handling policy; the game itself allows shulkers to be opened.

That policy produced the failure that started this work: requesting one anvil could withdraw an entire shulker full of anvils.

For a small request, pulling a full shulker is like moving an entire pallet because someone wanted a handful. The unused contents aren't destroyed, but the whole package leaves organized storage and travels with the order.

That gave us a simple-sounding requirement: prefer loose stock for small requests and shulkers for bulk ones.

Why a threshold wasn't enough

“Use loose items first” works beautifully for one anvil. It works less well for 15,000 cobblestone, which would take 235 loose stacks but fit in nine full shulkers, with some excess. Workers have limited inventory slots, so carrying capacity changes the decision.

A quantity threshold doesn't account for source layout, either. An order just below the line might be cheap to fill from one package, while an order above it might have enough loose stock together in one chest. Additionally, adding multiple different items to your order also adds complexity. The choice therefore depends on the load and the containers needed to assemble it.

Once it was clear that a simple quantity threshold was inadequate, we stopped looking for a universal cutoff. So instead of adding another rule, we priced the tradeoffs.

Giving common sense a price

A candidate plan describes one possible load: what to take, where to take it from, and how much inventory space it needs. Candidates must fit the worker's available capacity. Among those considered, the planner selects the one with the lowest weighted cost.

This is the same broad move used in cost-based database query optimization: compare ways to retrieve the requested data rather than commit to one access strategy. IBM's System R optimizer is an early example. Rather than assuming one retrieval method was always best, it compared alternative access paths and estimated their costs before choosing one. Our planner applies the same principle to physical inventory: generate several valid ways to fulfill a request, put a cost on each, and choose the cheapest.

What the score measures

Our policy assigns these weights:

Cost component Weight
Unfulfilled demand 1,000,000 per missing item
Source-container visits 100 per distinct container
Inventory space 80 per occupied slot
Shulker withdrawals 40 per box
Package underutilization 200 × unused fraction, per package
Overdelivery 1 per excess item

These are relative planning units, not money or measured seconds. A source visit means withdrawing from one particular storage container within the Repository, so several stacks taken from the same chest count as one visit. It is a coarse handling cost, not a calculation of walking distance.

Package underutilization is calculated separately for each physical package. For the 1,800-block example, the first shulker is fully useful, while the second contributes only 72 blocks and is therefore 95.83% underutilized. This remains true even if the inventory system reports both identical shulkers as multiple units in one source record. (This was actually an issue we realized while writing this article — before the fix, two boxes would aggregate in the calculation, diluting the waste penalty.)

The final score is the weighted sum. The large missing-item penalty strongly favors fulfilling the order, including using a shulker when loose stock can't satisfy it. It doesn't remove capacity constraints or guarantee that the search will find every feasible plan.

Where the preference changes

Consider a request for 865 cobblestone, with either option available from one source container. Loose stock takes 14 slots. A full shulker takes one slot but delivers 863 extra blocks.

Candidate Score calculation Total
865 loose blocks 100 + 14 × 80 1,220
One full shulker 100 + 80 + 40 + 200 × (863 / 1728) + 863 1,182.88

The shulker wins narrowly. That's a deliberate consequence of the policy: saving 13 slots is worth some excess delivery. For 256 blocks, loose stock from one container costs just 420 (100 + 4 × 80), and the package's excess makes it much less attractive.

There is still a crossover point for any fixed set of sources and weights. We simply don't hard-code that point as the definition of “bulk.” Change the source layout or the policy, and the preference can change with it.

Now the cost model could score a good candidate favorably—once candidate generation and search brought it into consideration. The planner still had to think of one.

When the best plan never gets considered

The Repository offers many ways to assemble the same quantity from its source containers: several stacks from one chest, smaller amounts from several others, a package, or a mixture. Exploring every combination becomes expensive quickly, so our bounded search stops after examining 50,000 partial plans, or search states.

That limit exposed the first failure. We requested exactly 1,728 cobblestone, with full shulkers available. The planner chose loose stacks anyway.

The score wasn't the problem. The search spent its budget exploring loose-stock combinations before reaching the useful package candidate. We'd built a system capable of recognizing the right answer, provided it happened to think of it.

We added deterministic seed candidates: plans constructed directly before the bounded search, so basic strategies would be evaluated even if further exploration ran out of time. Loose-first and package-first gave it two obvious places to start.

Problem solved — for about five minutes.

The 1,800 cobblestone problem

Our next test requested:

Material retrieval request
fetch repo cobblestone:1800

One full shulker contains 1,728 blocks, leaving the 1,800-block request exactly 72 short. The package-first candidate therefore selected a second full shulker. That fulfilled the order, but delivered 3,456 blocks: a surplus of 1,656. Loose stock alone went to the other extreme, occupying 29 inventory slots.

The useful answer sat between them: one shulker plus the missing 72 loose blocks. In the test snapshot, the loose-only load drew from three containers, both shulkers were in one container, and the hybrid drew from that package container plus one loose-stock container. The alternatives scored as follows:

Candidate Delivered Slots Source visits Score
Loose stock only 1,800 29 3 2,620
Two full shulkers 3,456 2 1 2,187.67
One shulker plus 72 loose 1,800 3 2 480

The hybrid load contains one full shulker, one stack of 64, and eight more loose blocks. It delivers exactly the requested quantity, uses three slots, and visits two containers. With no waste, excess, or missing items, its score is 2 × 100 + 3 × 80 + 40 = 480.

We expanded the deterministic candidates to try some number of packages, then complete the remaining demand from loose stock—an implementation strategy we call package prefixes. This gives the planner intermediate options instead of making “use packages” an all-or-nothing strategy.

The resulting candidate selection looks like this:

The seeds establish a useful baseline before search spends its budget on other combinations. They don't prove global optimality or cover every possible package combination. They ensure that the search limit can't prevent these deliberately constructed options from being scored.

The 1,800-block test needed both pieces: a score that rewarded the hybrid, and candidate generation that actually produced it.

When execution disagrees with the plan

Once the planner selected the hybrid reliably, the same request exposed another problem. One loose source was expected to provide 64 blocks. The worker obtained only 56, leaving the load eight short.

A shortened, sanitized execution event captures the discrepancy. Here, revision identifies this planning pass, sourceKey identifies the source storage container, and unrelated withdrawals have been omitted from actualPlan:

Execution event
{
  "type": "bot.fetch.plan_executed",
  "revision": 1,
  "actualPlan": [
    {
      "sourceKey": "source-container-17",
      "plannedUnits": 64,
      "actualUnits": 56
    }
  ],
  "delivered": {
    "cobblestone": 1792
  }
}

This tells us where the expected and actual quantities diverged. It doesn't, by itself, tell us why. Inventory may have changed after the snapshot was taken, the snapshot may have been inaccurate, or the transfer may have obtained less than expected.

Originally, the worker returned with 1,792 blocks and made another delivery trip for the remaining eight. We needed reconciliation: compare what the plan expected with what execution actually obtained, then use the difference to decide what happens next.

A short source is excluded for the rest of the task so the worker doesn't keep trusting the same inaccurate expectation. If other stock is available and fits, collection continues before the worker returns to the player. Capacity or unavailable stock can still prevent completion in one trip.

The plan is a proposal based on a snapshot. Once execution supplies better evidence, the remaining work needs a new planning revision: remaining demand is recalculated and a new plan is selected before collection continues.

Logging the decision, not just the delivery

“1,800 blocks arrived in two trips” wasn't enough information to debug this. We needed to distinguish a poorly scored plan, a good candidate the search never reached, and a withdrawal that fell short.

Workman's task debugger records two events: bot.fetch.plan_evaluated and bot.fetch.plan_executed. Together they preserve the request, source snapshots, capacity, policy weights, candidate scores, search budget usage, selected plan, and actual withdrawals across planning revisions.

That gives us specific questions to ask:

Question Evidence to inspect
Why did loose stock win? Candidate score components
Was a better package plan considered? Evaluated alternatives and source snapshot
Did search run out of budget? Search states examined and truncation flag
Where did the load become short? Planned versus actual withdrawals
What did the worker do about it? Subsequent planning revisions

The distinction mattered during the 1,800-block test. Candidate generation needed a hybrid option; execution needed to recover from an eight-block shortfall. Adjusting cost weights alone wouldn't have fixed either problem.

What we took away

The useful lessons were small enough to carry into other planners:

  • Price the tradeoffs explicitly instead of guessing a universal threshold.
  • Construct important candidate shapes before spending a bounded search budget.
  • Include mixed strategies when the system can legally combine them.
  • Replan from actual results when execution disagrees with the snapshot.
  • Record alternatives and outcomes so unexpected decisions can be explained.

Moving forward

While our current cost model solves the immediate retrieval headaches, we are treating it as an operational baseline rather than the end of the road. As warehouse operations scale, a few clear improvements lie ahead. Right now, a container visit costs the same whether a chest is right next to you or across the entire building, so incorporating actual travel distance into our scoring is an obvious next step. Such improvements are also important in worker selection for fleet-routable tasks: it would, for instance be inappropriate to have a worker from another facility commute to ours to fulfill a task because all closer workers were occupied at assignment time. We also want to better calibrate our scoring weights against real-world execution data—moving from hand-tuned parameters toward a more formal mathematical model as our warehouse layouts grow in complexity.

Mine Ops is always working to improve its operations and build better systems for its customers. There will always be another problem to solve and another improvement to make. But for now, when we ask for one anvil, we receive one anvil.