Logistics · Simulation · OptimizationCJ AI Center, with CJ LogisticsTechnical write-up · October 2026 · 13 min read

Building SKU allocation and injection-order engines for a QPS with knapsack DP, simulation and Tabu Search

A walkthrough of the two engines behind a QPS — picking stations around a ring conveyor: one decides which station holds each SKU, the other decides the order in which boxes enter the ring. How co-ordering is measured, how an exact knapsack fills each station, how a discrete-event simulation scores an injection order and how Tabu Search improves it, with simplified code for each step.

Built withPythonFastAPIRedispandasSciPy sparsenumbaKnapsack DPDiscrete-event simulationTabu SearchDocker

In a QPS, picking stations sit around a ring conveyor. Boxes are injected at fixed intervals, travel the ring and stop at every station that holds one of their SKUs, where a worker picks into them. Two decisions dominate throughput. Where each SKU sits decides how many stations a box must visit and how evenly the work spreads. The order in which boxes enter decides whether stations queue up — and when a station's queue is full, the box is bypassed and has to go round again. Operations had relied on experience and manual judgment, and the two decisions run on different clocks: allocation once per batch, injection order again and again while the batch runs.

In this post we walk through the two engines we built. The allocation engine measures SKU co-ordering with a sparse matrix product, seeds each station with an anchor SKU and fills stations in rounds with an exact two-dimensional 0/1 knapsack DP, for several station counts. The reorder engine scores an injection order with a discrete-event simulation of the ring and improves it with random multi-start and Tabu Search, re-planning on a rolling horizon with released boxes fixed. Both run behind a FastAPI service that reads its inputs from a Redis cache and answers by callback. Deployed in regular operations at the Dongtan logistics centre, the system delivered a 20% productivity improvement.

Solution overview

Both engines live in one Python service with one endpoint per engine. A call is acknowledged at once with HTTP 202; the engine runs as a background task on a snapshot read from a Redis cache and posts a compressed JSON result back to the operating system. The allocation engine runs once per batch, the reorder engine repeatedly during operation, and both share one sequencing core: a ring-conveyor simulation with a search on top.

Architecture
1Intake

Async API over a cached snapshot

FastAPIRedis
2Prepare

Workloads and co-order affinity

pandasSciPy sparse
3Allocate

Anchors and knapsack rounds

knapsack DPnumba
4Simulate

The ring, event by event

discrete-event simulation
5Search

Multi-start, then Tabu Search

Tabu Searchnumba prange
6Re-plan

Rolling horizon in operation

fixed prefixbox-type runs
7Return

Compressed result by callback

gzip · base64Docker
The allocation engine runs steps 1–5 once per batch, for several station counts; the reorder engine runs steps 1, 2 and 4–6 repeatedly while the batch is on the floor. Both return through step 7.

The numbered steps in the diagram:

  1. A FastAPI endpoint per engine answers 202 and runs the work as a background task; masters, inventory, order lines and task status are read from a Redis cache.
  2. pandas builds order × SKU and order × zone workloads, and a SciPy sparse product XᵀX gives SKU co-order counts.
  3. A deterministic multi-step heuristic places every SKU on one station, with an exact two-dimensional 0/1 knapsack DP compiled with numba at its core.
  4. A discrete-event simulation of the ring scores an injection order by makespan and bypasses.
  5. Random starts seed the search; Tabu Search then evaluates sampled position swaps in parallel with numba prange.
  6. During operation released boxes stay fixed and the rest are re-sequenced batch by batch on a rolling horizon, then grouped into box-type runs.
  7. Results are serialized to JSON, gzip-compressed, base64-encoded and POSTed back; the service ships as a Docker image.

Technology stack

LayerTechnologyWhat it does here
APIPython · FastAPI background tasksImmediate 202; one endpoint per engine; callback with the result
StateRedis (read-only)Station and zone masters, inventory, order lines and task status at call time
Data preppandas · SciPy sparseOrder × SKU and order × zone workloads; co-order counts C = XᵀX
AllocationExact 2-D 0/1 knapsack DP · greedy and swap moves · numbaSKU → station under cell capacity, load balance and affinity
SequencingDiscrete-event simulation · random multi-start · Tabu Search (numba prange)Injection order minimizing makespan and bypasses
Re-planningRolling horizon with a fixed prefix · box-type groupingNew order for boxes not yet released
Deliverygzip + base64 JSON · Docker · CI with static analysisCompact callbacks and repeatable deployment

Step 1: Serve two engines behind an asynchronous API

Allocation runs when stations are set up for a batch; reordering runs again and again while the batch is on the floor. Both are endpoints of one FastAPI service. A call is acknowledged immediately with HTTP 202 and the engine runs as a background task. Its inputs — station and zone masters, inventory, order lines with SKU, quantity and box type, task status, batch definitions and station priorities — are read from a Redis cache that the service treats as read-only, so each call works on the operating system's current snapshot.

service/api.py
import base64, gzip, json
import httpx, redis
from fastapi import BackgroundTasks, FastAPI

app = FastAPI()
cache = redis.Redis(decode_responses=True)       # read-only snapshot source

def pack(result: dict) -> str:
    return base64.b64encode(gzip.compress(json.dumps(result).encode())).decode()

def run(engine, req):
    snap = read_snapshot(cache, req.scope)       # masters, stock, orders, task status
    result = engine(snap, seed=req.seed)         # fixed seed: same input, same answer
    httpx.post(req.callback_url, json={"job": req.job_id, "result": pack(result)},
               timeout=60)

@app.post("/jobs/allocation", status_code=202)
async def post_allocation(req: JobRequest, tasks: BackgroundTasks):
    tasks.add_task(run, allocate, req)           # once per batch
    return {"job": req.job_id}

@app.post("/jobs/reorder", status_code=202)
async def post_reorder(req: JobRequest, tasks: BackgroundTasks):
    tasks.add_task(run, resequence, req)         # repeatedly during operation
    return {"job": req.job_id}

Simplified. Paths, field names and the snapshot reader are generic; authentication and error handling are omitted.

Results are serialized to JSON, gzip-compressed and base64-encoded before the callback, which keeps long sequences small on the wire. Seeds are fixed, so the same snapshot produces the same answer. The service is containerized with Docker and goes through CI with static analysis.

Step 2: Measure which SKUs are ordered together

A box visits every station that holds one of its SKUs, so an allocation is good when SKUs ordered together sit together and when picking load spreads evenly. Both quantities come from the order lines. A binary order × SKU matrix X gives each SKU's load — the number of distinct orders containing it — and, in one sparse product, every pair's co-order count.

X ∈ {0,1}^(orders × SKUs)      X_os = 1 if order o contains SKU s
load_s  = Σ_o X_os               distinct orders containing s
C       = XᵀX                    C_ij = orders containing both i and j      keep C_ij ≥ 2
J_ij    = C_ij / (load_i + load_j − C_ij)
aff_ij  = J_ij · ln(1 + C_ij)

target load per station    T = Σ_s load_s / Z            Z = stations opened

Jaccard overlap alone favours rare pairs that happen to coincide; raw counts favour SKUs that are simply popular. Their product rewards pairs that are both consistent and frequent, and pairs seen only once are dropped as noise.

alloc/affinity.py
import numpy as np
import pandas as pd
from scipy import sparse

def affinity(lines: pd.DataFrame, min_count=2):
    """lines: one row per (order, sku). Returns the load of each SKU and a
    sparse affinity matrix aff_ij = Jaccard_ij * ln(1 + C_ij)."""
    o = lines["order"].astype("category").cat.codes.to_numpy()
    s = lines["sku"].astype("category").cat.codes.to_numpy()
    X = sparse.csr_matrix((np.ones(len(o)), (o, s)))
    X.data[:] = 1                                   # an order counts once per SKU
    C = (X.T @ X).tocoo()                           # co-order counts
    load = np.asarray(X.sum(axis=0)).ravel()        # distinct orders per SKU
    keep = (C.row != C.col) & (C.data >= min_count)
    i, j, c = C.row[keep], C.col[keep], C.data[keep]
    jaccard = c / (load[i] + load[j] - c)
    aff = sparse.csr_matrix((jaccard * np.log1p(c), (i, j)), shape=C.shape)
    return load, aff

Simplified. Column names are generic; zone workloads are built the same way.

Step 3: Allocate SKUs to stations with rounds of an exact knapsack

Each SKU goes to exactly one station. Instead of one monolithic model, the allocation is built in deterministic stages, each enforcing cell capacity and the pallet-zone rule:

for r in (0.2, 0.4, 0.6, 0.8, 1.0), for each station z in turn:

  max   Σ_s (load_s + α_r · aff(s, z)) · x_s           x_s ∈ {0,1}, unassigned SKUs only
  s.t.  Σ_s load_s · x_s ≤ r · T − load_z               load budget
        Σ_s x_s        ≤ r · cells_z − used_z            cell budget

  aff(s, z) = affinity of s to the SKUs station z already holds;  α_r grows as stations fill

For one station in one round, the knapsack picks the SKUs that maximize load plus α times their affinity to what the station already holds, within two budgets: the station's share of the target load and its share of cells. Loads are integer order counts, so a two-dimensional dynamic program solves each knapsack exactly, and numba compiles it.

alloc/knapsack.py
import numpy as np
from numba import njit

@njit(cache=True)
def knapsack_2d(value, weight, L, K):
    """Exact 0/1 knapsack with two budgets: sum of weight <= L, count <= K.
    value = load + alpha * affinity to the station; weight = integer load."""
    n = len(value)
    best = np.zeros((L + 1, K + 1))
    take = np.zeros((n, L + 1, K + 1), dtype=np.bool_)
    for i in range(n):
        w = weight[i]
        for l in range(L, w - 1, -1):              # backwards: each SKU once
            for k in range(K, 0, -1):
                cand = best[l - w, k - 1] + value[i]
                if cand > best[l, k]:
                    best[l, k] = cand
                    take[i, l, k] = True
    pick = np.zeros(n, dtype=np.bool_)
    l, k = L, K
    for i in range(n - 1, -1, -1):                 # walk the choices back
        if take[i, l, k]:
            pick[i] = True
            l -= weight[i]
            k -= 1
    return pick

Simplified. Budgets are the round's load and cell allowances for one station.

Why rounds? Filling one station to 100% first would let it take the best SKUs and leave the last station with leftovers. Raising every station's allowance together lets all of them compete for good SKUs.

The whole build is repeated for several station counts, so the floor sees alternatives — open one more station or not — each with its predicted duration and man-hours from the simulation in the next step.

Step 4: Score an injection order with a discrete-event simulation

The injection order is a permutation of boxes on fixed release slots. Its quality depends on interactions no simple proxy captures — queues, a single worker per station, bypasses when a queue is full — so the objective is a simulation. A box arriving at a station with nothing to pick moves on after a hop. If it has picks and the queue has room, it waits for the worker, whose service time is proportional to the number of distinct SKUs to pick. If the queue is full, it is bypassed and must come round again.

decision    π = order of boxes on fixed injection slots (slot k at time k·Δ)
service     t_pick(b, s) = τ · (distinct SKUs of box b at station s)
movement    t_hop per station;  one worker and a FIFO queue of length q per station
bypass      queue full on arrival  →  the box recirculates and tries again next lap

objective   min_π   makespan(π) + bypasses(π)
sequence/simulate.py
import heapq

def simulate(order, stops, n_st, slot, hop, per_sku, qlen):
    """Ring conveyor, event by event. stops[b] = {station: distinct SKUs}.
    One worker and a finite FIFO queue per station. Returns (makespan, bypasses)."""
    free_at = [0.0] * n_st                      # when each worker is next free
    queued = [[] for _ in range(n_st)]          # finish times of boxes at a station
    done = {b: set() for b in order}
    events = [(k * slot, b, 0) for k, b in enumerate(order)]   # (time, box, station)
    heapq.heapify(events)
    makespan, bypasses = 0.0, 0
    while events:
        t, b, s = heapq.heappop(events)
        if s in stops[b] and s not in done[b]:
            queued[s] = [f for f in queued[s] if f > t]
            if len(queued[s]) < qlen:           # join the queue, get picked
                free_at[s] = max(t, free_at[s]) + per_sku * stops[b][s]
                queued[s].append(free_at[s])
                done[b].add(s)
                t = free_at[s]
                makespan = max(makespan, t)
            else:
                bypasses += 1                   # queue full: come round again
        if len(done[b]) < len(stops[b]):
            heapq.heappush(events, (t + hop, b, (s + 1) % n_st))
    return makespan, bypasses

Simplified. In production the simulation is compiled with numba.

In the live model the parameters are ten seconds per SKU, three seconds per hop and five queue slots per station.

Step 5: Search the order with multi-start and Tabu Search

The search is deliberately plain, because every candidate costs a full simulation. Many random permutations are simulated first and the best is kept, stopping early once new starts stop helping. Tabu Search then samples position-pair swaps around the current order, simulates each one and moves to the best even if it is worse; a short tabu tenure forbids undoing recent swaps, which lets the search walk out of local minima.

sequence/tabu.py
import numpy as np

def search_order(boxes, score, evaluate, rng, starts, patience, iters, neigh, tenure):
    """score(order) and evaluate(order, moves) run the simulation and
    return makespan + bypasses."""
    best, best_val, stale = None, np.inf, 0
    for _ in range(starts):                            # random multi-start
        cand = rng.permutation(boxes)
        val = score(cand)
        if val < best_val:
            best, best_val, stale = cand, val, 0
        elif (stale := stale + 1) >= patience:         # early stop
            break
    cur, tabu = best.copy(), {}
    for it in range(iters):                            # Tabu Search
        pairs = {tuple(sorted(rng.choice(len(cur), 2, replace=False)))
                 for _ in range(neigh)}
        moves = np.array([m for m in pairs if tabu.get(m, -1) < it])
        if len(moves) == 0:
            continue
        vals = evaluate(cur, moves)                    # parallel kernel below
        k = int(np.argmin(vals))
        i, j = moves[k]
        cur[[i, j]] = cur[[j, i]]                      # take it, even if worse
        tabu[(i, j)] = it + tenure
        if vals[k] < best_val:
            best, best_val = cur.copy(), vals[k]
    return best, best_val

Simplified. Aspiration rules and budgets are omitted.

Swap evaluation is the hot loop, so it is a numba kernel that simulates the sampled swaps in parallel with prange.

sequence/kernels.py
import numpy as np
from numba import njit, prange

@njit(parallel=True, cache=True)
def evaluate_swaps(order, moves, stops, params):
    """One full ring simulation per sampled swap, spread over threads."""
    out = np.empty(len(moves))
    for k in prange(len(moves)):
        cand = order.copy()
        i, j = moves[k, 0], moves[k, 1]
        cand[i], cand[j] = order[j], order[i]
        makespan, bypasses = simulate_nb(cand, stops, params)
        out[k] = makespan + bypasses
    return out

Simplified. simulate_nb is the Step 4 simulation over NumPy arrays; evaluate in the search is this kernel with the batch data bound.

The order handed to the floor is not the permutation itself but the order in which boxes first start picking when the best permutation is simulated. Two engineering details mattered in production: numba kernels are warmed up on the first call so compile time is not paid inside a request, and a thread-safe parallel layer (TBB) was needed once several requests could run concurrently.

Step 6: Re-plan during operation on a rolling horizon

During operation the floor changes, so the reorder engine re-sequences what has not been released yet. Released boxes form a fixed prefix; the remaining boxes are re-sequenced batch by batch, each batch searched with everything before it fixed. Calling the engine again never moves a box that is already on its way.

sequence/reorder.py
def reorder(released, batches, search_batch):
    """Re-sequence what has not been released. Released boxes keep their
    slots; each batch is searched with everything before it fixed."""
    fixed = list(released)
    for batch in batches:                               # batch definitions, in order
        tail = search_batch(prefix=fixed, boxes=batch)  # simulation + Tabu
        fixed += runs_by_box_type(tail)
    return fixed[len(released):]


def runs_by_box_type(order):
    """Contiguous runs per box type, in order of first appearance; the
    searched order is kept within a type. Not re-simulated."""
    first = {}
    for k, box in enumerate(order):
        first.setdefault(box.box_type, k)
    return sorted(order, key=lambda box: first[box.box_type])

Simplified. search_batch runs Steps 4–5 for one batch behind the fixed prefix.

A final post-process groups boxes of the same box type into contiguous runs, because frequent alternation between box types slows injection on the floor. It is not re-simulated, so it can give back a little of the makespan the search found — a deliberate trade for a sequence the floor can work with.

Try the live model

The live model below runs a re-implementation of both engines on a generated batch: the allocation fixes where SKUs sit, and two rings replay the simulation of the same batch under a first-come and an optimized injection order.

Solved on a Python server for a generated batch of 40 boxes and 30 SKUs: the allocation (compared with a round-robin layout on stations visited and load balance), the simulation and a reduced-budget Tabu Search run on every request, and both rings replay the server's simulation log. Switch the station count to compare scenarios; “New batch” solves a fresh one. If the server cannot be reached, a stored example is shown. Open the live model on its own page ↗

Results

+20%productivity improvement at the Dongtan logistics centre
Deployedinto regular field operations
Rolloutplanned to a second logistics centre

The system demonstrated a 20% productivity improvement at the Dongtan logistics centre and was deployed into regular field operations. The model design was adjusted around field requirements — response speed, stability, operational constraints and integration feasibility — and outputs were validated against actual productivity, not only against the simulator.

Alongside the engines, an LLM-agent-based AI Floor Supervisor interprets real-time operational conditions, monitors workload and bottleneck signals, and recommends task allocation and priority adjustments to field managers; it is outside the scope of this post. The result supported a planned rollout to a second centre and the wider logistics AI roadmap, including cart picking optimization.

Lessons learned

Conclusion

A ring-conveyor picking system has two levers on two clocks. Measuring co-ordering with a sparse product, filling stations with an exact knapsack in rounds, scoring injection orders with a discrete-event simulation and improving them with Tabu Search gave two engines the operation calls in its own rhythm — and a 20% productivity improvement at the Dongtan centre.

The pattern — exact optimization where the structure is clean, simulation plus search where interactions dominate, and an asynchronous service that fits the operating system's cycle — is what moved the work from offline analysis into regular operations.

Limitations

About the demo and confidentiality

The orders, SKUs, stations and box types in the embedded model are generated from a seed. No equipment ID, zone code, cache key, endpoint, port, batch volume or production setting appears in this post, and the AI Floor Supervisor's internals are not described; code is simplified and written for illustration.

Taehee Lee · Data Scientist / Applied AI Scientist, CJ AI CenterProblem definition, engine design, field validation. Demo re-implemented on synthetic data for this site.