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 * softExclusionPairsThe four terms:
bandViolationssums, per bin, how far its load sits belowminplus how far it rises abovemax. A bin inside its band contributes nothing. This is the same number reported asviolations.maxLoad - minLoadis the load range across all bins, which is the evenness term. Perfectly even is0.totalAffinitySatisfiedis 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.softExclusionPairscounts 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:
| Weight | Default | Meaning |
|---|---|---|
violation | 100 | Capacity comes first |
exclusion | 50 | A soft exclusion sits just under capacity |
spread | 1 | Then evenness |
affinity | 2 | Preferences 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.
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:
| Step | Section | Credits | Lands on | Why |
|---|---|---|---|---|
| 1 | capstone-project | 5 | jane-doe | She requested it and has room |
| 2 | algorithms | 4 | jane-doe | Also her first choice, 5 + 4 <= 12 |
| 3 | databases | 3 | jane-doe | Fills her exactly to the cap, 9 + 3 = 12 |
| 4 | networks | 3 | john-doe | Jane has no room; john-doe and mary-major both sit at 0, so the lower id wins |
| 5 | database-lab | 2 | mary-major | Jane still full; mary-major is now the least loaded |
Which leaves a timetable that honours requests and is badly lopsided:
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:
| Iteration | Move | Loads | Spread | Affinity | Cost |
|---|---|---|---|---|---|
| 0 (seed) | none | jane 12, john 3, mary 2 | 10 | 3 | 4 |
| 1 | capstone-project jane → mary | jane 7, john 3, mary 7 | 4 | 2 | 0 |
| 2 | database-lab mary → john | jane 7, john 5, mary 5 | 2 | 2 | -2 |
| 3 | none improves, stop | jane 7, john 5, mary 5 | 2 | 2 | -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.