Skip to content

How the solver works

There is one algorithm and nothing to choose between. Every call runs the same two phases: a greedy seed builds a complete layout, then a hill-climb improves it one move at a time until no single move helps.

Both phases are steered by the same number.

The cost function

Everything reduces to one scalar the solver tries to make as small as possible:

cost = violation  * bandViolations
     + spread     * (maxLoad - minLoad)
     - affinity   * totalAffinitySatisfied
     + exclusion  * softExclusionPairs

The four terms:

  • bandViolations sums, per bin, how far its load sits below min plus how far it rises above max. A bin inside its band contributes nothing. This is the same number reported as violations.
  • maxLoad - minLoad is the load range across all bins, which is the evenness term. Perfectly even is 0.
  • totalAffinitySatisfied is the sum of each placed item's affinity for the bins it landed on, counted once per bin. It is subtracted, so more satisfied preference means lower cost.
  • softExclusionPairs counts soft-excluded bin pairs that ended up sharing an item. Hard exclusions never appear here because they are prevented structurally.

The default weights encode the priority order:

WeightDefaultMeaning
violation100Capacity comes first
exclusion50A soft exclusion sits just under capacity
spread1Then evenness
affinity2Preferences act as a tiebreaker

affinity being 2 against spread of 1 looks like preferences outrank evenness, but they are measured in different units. Spread is in weight units and can reach into the hundreds, while a satisfied rank-1 affinity contributes 1.0. In practice capacity dominates, evenness decides the shape, and affinity settles ties. See Configuration for changing that.

Because affinity is subtracted, cost is often negative, and a negative cost is not an error. It only means something compared against another layout of the same input.

Phase 1: the greedy seed

Items are sorted by weight descending, ties broken by id, then each claims up to split bins one at a time. Heaviest first matters: a big section placed late tends to have nowhere good left to go.

For each claim, the best bin is the one with the highest affinity among bins the item does not already hold and that has room (load + share <= max), with ties broken by least current load, then by bin id. If nothing has room, the item is forced onto the least-loaded bin still available to it, which still excludes bins it already holds and any that would create a hard-exclusion pair. Under onUnfit: "leave" it is left short instead.

Watch that play out on the case every timetable runs into: one popular lecturer. Every section here lists Jane as its first choice, and all three lecturers are capped at 12 credit hours.

ts
const lecturers = [
  { id: "jane-doe", max: 12 },
  { id: "john-doe", max: 12 },
  { id: "mary-major", max: 12 },
];

const sections = [
  { id: "capstone-project", weight: 5, affinities: { "jane-doe": rankToAffinity(1) } },
  { id: "algorithms", weight: 4, affinities: { "jane-doe": rankToAffinity(1) } },
  { id: "databases", weight: 3, affinities: { "jane-doe": rankToAffinity(1) } },
  { id: "networks", weight: 3, affinities: { "jane-doe": rankToAffinity(1) } },
  { id: "database-lab", weight: 2, affinities: { "jane-doe": rankToAffinity(1) } },
];

The seed, step by step:

StepSectionCreditsLands onWhy
1capstone-project5jane-doeShe requested it and has room
2algorithms4jane-doeAlso her first choice, 5 + 4 <= 12
3databases3jane-doeFills her exactly to the cap, 9 + 3 = 12
4networks3john-doeJane has no room; john-doe and mary-major both sit at 0, so the lower id wins
5database-lab2mary-majorJane still full; mary-major is now the least loaded

Which leaves a timetable that honours requests and is badly lopsided:

ts
suggest(sections, lecturers, { maxIterations: 0 }).loads;
// → { "jane-doe": 12, "john-doe": 3, "mary-major": 2 }

Jane is pinned at her cap while Mary teaches 2 credit hours. Spread is 12 - 2 = 10, three requests are satisfied, so cost = 1 * 10 - 2 * 3 = 4.

Seeing the seed on its own

maxIterations: 0 stops after the seed and skips climbing entirely, which is how the numbers above were produced. It is a useful way to see what climbing bought you on your own data.

Phase 2: the hill-climb

The climb repeatedly finds the single reassignment that lowers cost the most and applies it, stopping when nothing improves or maxIterations is reached. It is steepest-descent, not a random walk: every candidate move is scored before one is chosen.

Continuing the same five sections:

IterationMoveLoadsSpreadAffinityCost
0 (seed)nonejane 12, john 3, mary 21034
1capstone-project jane → maryjane 7, john 3, mary 7420
2database-lab mary → johnjane 7, john 5, mary 522-2
3none improves, stopjane 7, john 5, mary 522-2

The first move is the interesting one. Taking capstone-project off Jane overrides a request she made, dropping affinity from 3 to 2 and pushing cost up by 2. It happens anyway because it cuts spread from 10 to 4, worth 6. Net gain 4, so the move wins.

That is the trade the weights exist to arbitrate, and it is the behaviour you want: one lecturer does not get to absorb the whole timetable just because they asked first. Requests are real but cheap; evenness is what the solver is mostly buying.

What counts as a move

Three kinds, all operating on one item at a time:

  • Relocate shifts one of the item's shares from a bin it holds to one it does not. The number of bins is unchanged, so the per-bin share stays the same.
  • Add takes on one more bin, allowed only while the item holds fewer than its split. The weight re-splits, so every bin already held sheds a little. In timetable terms, a section gains a co-teacher.
  • Drop gives a bin back, allowed only while at least one remains. The weight re-splits over the survivors, each taking a bit more. Worth it when concentrating load fits a band better than spreading it, and it means a co-taught section can end up taught solo.

Moves that would create a hard-exclusion pair, or that would move or drop a bin pinned by lockedBinIds, are never generated at all. They are not scored and rejected; they never enter the candidate set.

A consequence worth knowing: since drop needs the item to hold at least two bins, an item sitting on exactly one bin can never be moved back to unassigned. Once something is placed, it stays placed.

Determinism

Every tie, in both phases, is broken by id. There is no Math.random anywhere in the package. The same input therefore always produces byte-identical output, which means you can snapshot-test a layout and diff two runs meaningfully.

Inputs are never mutated. Every call returns fresh objects.

Where it stops

Hill-climbing finds a local optimum: a layout where no single move improves things. That is not the same as a proven global optimum, and aequitas does not claim to find one.

There is a reason for that. Distributing weighted items into capacity-bounded bins is NP-hard in general, and in two separate ways. Deciding whether the items fit inside the capacities at all is bin packing. Minimizing the load range across a fixed set of bins is multiway number partitioning. Both are NP-hard, and this solver is asked to do both at once. Layering preferences on top puts it near the generalized assignment problem, which is also NP-hard.

Note that the plain assignment problem, matching items to bins one-to-one, is the easy relative: it has a polynomial algorithm. That is not this problem. Once one bin can hold many items and capacities bind, the easy version is gone.

So no fast algorithm returns a proven optimum, and every practical library either runs a heuristic or hands the model to an exact solver and waits.

aequitas takes the first route. The distinction is real but usually academic for this class of workload: a layout where no single reassignment helps is, in practice, even and in-band. What it will not do is escape a valley that needs two simultaneously bad moves to climb out of. If you need provable optimality, model the problem as an integer linear program and use a real ILP solver.

The gap is often zero anyway. The monorepo CI case computes the lower bound for a real input, max(total / bins, heaviest item), and the heuristic lands exactly on it. Work that bound out before concluding a layout is poor: the plain average is frequently unreachable, so the floor is the number worth comparing against.

maxIterations (default 10_000) is a safety cap, not a tuning knob. The climb normally converges in far fewer steps: the example above took two.

Released under the MIT License.