Install and your first balanced assignment
Install
npm install aequitasimport { suggest, rebalance, cost, rankToAffinity } from "aequitas";const { suggest, rebalance, cost, rankToAffinity } = require("aequitas");Node.js 22 or newer. No runtime dependencies, and nothing Node-specific is imported, so the same code runs in a browser.
The two nouns
Everything in aequitas is items going into bins.
An item is a weighted thing to place. A bin is a destination with an optional capacity band, min and max.
These docs use university timetabling as the running example, because its constraints are the kind you can say out loud:
| Concept | In this example |
|---|---|
| Bin | A lecturer |
Bin's min / max | Their minimum and maximum teaching load |
| Item | A course section |
Item's weight | Its credit hours |
Item's affinities | Which lecturers requested it |
Item's split | How many lecturers co-teach it |
exclusions | Two lecturers who will not co-teach |
locked | A course the department already promised to someone |
The library knows none of that. Swap in workers and tasks, staff and shifts, or nodes and shards, and nothing changes. If a worked example in one of those domains would land better than a timetable, the use cases do the same mapping for CI agents, test shards, database shards and a support queue.
Your first plan
Lecturers have a credit-hour band. Sections weigh their credit hours. Each section carries ranked lecturer requests, converted to affinity scores with rankToAffinity, where rank 1 is the lecturer who wanted it most.
import { suggest, rankToAffinity } from "aequitas";
const lecturers = [
{ id: "jane-doe", min: 6, max: 12 },
{ id: "john-doe", min: 6, max: 12 },
{ id: "mary-major", min: 6, max: 12 },
];
const sections = [
{ id: "algorithms", weight: 3, affinities: { "jane-doe": rankToAffinity(1), "john-doe": rankToAffinity(2) } },
{ id: "databases", weight: 3, affinities: { "jane-doe": rankToAffinity(1), "mary-major": rankToAffinity(2) } },
{ id: "networks", weight: 3, affinities: { "john-doe": rankToAffinity(1) } },
{ id: "operating-systems", weight: 3, affinities: { "john-doe": rankToAffinity(1), "mary-major": rankToAffinity(2) } },
{ id: "calculus", weight: 3, affinities: { "mary-major": rankToAffinity(1) } },
{ id: "statistics", weight: 3, affinities: { "mary-major": rankToAffinity(1), "jane-doe": rankToAffinity(2) } },
{ id: "compilers", weight: 3, affinities: { "jane-doe": rankToAffinity(1) } },
{ id: "graphics", weight: 3, affinities: { "john-doe": rankToAffinity(1) } },
{ id: "security", weight: 3, affinities: { "mary-major": rankToAffinity(1) } },
];
const plan = suggest(sections, lecturers);Nine sections of 3 credit hours is 27 hours over three lecturers, and every band is 6 to 12. The result:
plan.loads; // → { "jane-doe": 9, "john-doe": 9, "mary-major": 9 }
plan.violations; // → 0
plan.unassigned; // → []
plan.affinityScore; // → 9
plan.cost; // → -18Everyone lands on exactly 9 hours, inside the band, and every section went to a lecturer who ranked it first:
| Section | Lecturer |
|---|---|
algorithms | jane-doe |
databases | jane-doe |
compilers | jane-doe |
networks | john-doe |
operating-systems | john-doe |
graphics | john-doe |
calculus | mary-major |
statistics | mary-major |
security | mary-major |
Nine sections each satisfying a rank-1 request is 9 × 1.0 = 9, which is the affinityScore. Do not expect that every term: requests are a tiebreaker, and they lose to capacity whenever the two disagree.
Reading the result
Every call returns the same Result shape.
interface Result {
assignments: Assignment[]; // one per input item, in input order
loads: Record<string, number>; // total weight per bin, every bin present
cost: number; // the score the solver minimized
violations: number; // band overflow + underflow; 0 means all in-band
unassigned: string[]; // ids of items that got no bin at all
affinityScore: number; // sum of satisfied affinity scores
}The two fields worth checking on every run are violations and unassigned.
violations: 0 means every bin is inside its band. Anything above zero is the total distance out of band, summed across bins, so violations: 5 could be one bin 5 over its max or two bins 2 and 3 under their min. It does not say which, so read loads when it is non-zero. In the timetable above, that would read as one lecturer 5 hours over their maximum, or two of them 2 and 3 hours short.
unassigned lists items that landed nowhere. In suggest under the default onUnfit: "forceLeastLoaded", this stays empty as long as there is at least one bin, because an item that fits nowhere is pushed into the least-loaded bin anyway and shows up as a band violation instead. Under onUnfit: "leave", and in rebalance regardless, it is where unplaceable items go.
cost is the single number being minimized, and it is frequently negative, because satisfied affinity is subtracted. Do not read it as a quality percentage. It is only meaningful compared against another layout of the same input, which is what cost() is for.
Pinning a decision
Say the department head has already promised algorithms to Jane. Set locked: true on that assignment and hand the whole timetable to rebalance, which reshuffles everything else around it.
import { rebalance } from "aequitas";
const pinned = plan.assignments.map((a) =>
a.itemId === "algorithms" ? { ...a, binIds: ["jane-doe"], locked: true } : a,
);
const revised = rebalance(sections, lecturers, pinned);
revised.assignments.find((a) => a.itemId === "algorithms");
// → { itemId: "algorithms", binIds: ["jane-doe"], locked: true }
revised.loads; // → { "jane-doe": 9, "john-doe": 9, "mary-major": 9 }
revised.violations; // → 0Here the pin costs nothing, because Jane was already taking algorithms. The point is that the guarantee holds regardless: a locked: true assignment is never reassigned, even when moving it would lower the cost.
Rebalancing after the world changes
rebalance earns its keep when the inputs shift under a timetable you have already published. A lecturer going on sabbatical is the clearest case.
Eight sections of 3 credit hours over four lecturers:
const lecturers = [
{ id: "jane-doe", max: 12 },
{ id: "john-doe", max: 12 },
{ id: "mary-major", max: 12 },
{ id: "richard-roe", max: 12 },
];
const sections = [
{ id: "algorithms", weight: 3 }, { id: "databases", weight: 3 },
{ id: "networks", weight: 3 }, { id: "operating-systems", weight: 3 },
{ id: "calculus", weight: 3 }, { id: "statistics", weight: 3 },
{ id: "compilers", weight: 3 }, { id: "graphics", weight: 3 },
];
const plan = suggest(sections, lecturers);
plan.loads; // → { "jane-doe": 6, "john-doe": 6, "mary-major": 6, "richard-roe": 6 }Two sections each, six hours each, dead even. Now Richard takes sabbatical. Drop him from the lecturer list and pass the timetable you are already running as current:
const remaining = lecturers.filter((l) => l.id !== "richard-roe");
const revised = rebalance(sections, remaining, plan.assignments);
revised.loads; // → { "jane-doe": 9, "john-doe": 9, "mary-major": 6 }
revised.violations; // → 0
revised.unassigned; // → []Richard's two sections, databases and statistics, are the only two that move. Every other lecturer keeps the exact courses they were already preparing.
Note the shape of the answer: 9 / 9 / 6, not a perfectly even 8 / 8 / 8. Eight three-credit sections cannot split evenly across three people, and the closest reachable split is three, three and two sections. The solver returns the best layout the inputs allow, not the one the arithmetic wishes for.
Why pass the old timetable at all
suggest would also produce a valid answer here. The difference is churn: rebalance starts from what you already have, so lecturers who did not need to move keep their courses. Every needless move is a person re-preparing a syllabus.
What this does not do
Worth knowing before you build on it.
- It is not an exact solver. The hill-climb finds a strong local optimum, not a proven global one. For everyday balancing it lands even, in-band layouts. If you need provable optimality, you want an ILP solver.
- There is no time dimension. No dates, ordering, durations, or dependencies between items. It answers which bin gets which item, and nothing about when. For a timetable that means it assigns courses to lecturers, not to timeslots or rooms.
- You cannot forbid an item from a bin. Affinities are soft scores, so a large negative affinity discourages a pairing without preventing it.
exclusionsare pairs of bins that should not share an item, which is a different constraint. Filter the bin list yourself when a pairing must be impossible. - No item-to-item constraints. There is no way to say two items must land on the same bin, or must not: "these two sections need the same lecturer" is not expressible.
onUnfitapplies tosuggestonly.rebalancenever forces an overflow. See Configuration.- It is synchronous and stateless. No I/O, no persistence, no async. You load the data and store the result.