Skip to content

Install and your first balanced assignment

Install

sh
npm install aequitas
ts
import { suggest, rebalance, cost, rankToAffinity } from "aequitas";
js
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:

ConceptIn this example
BinA lecturer
Bin's min / maxTheir minimum and maximum teaching load
ItemA course section
Item's weightIts credit hours
Item's affinitiesWhich lecturers requested it
Item's splitHow many lecturers co-teach it
exclusionsTwo lecturers who will not co-teach
lockedA 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.

ts
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:

ts
plan.loads;         // → { "jane-doe": 9, "john-doe": 9, "mary-major": 9 }
plan.violations;    // → 0
plan.unassigned;    // → []
plan.affinityScore; // → 9
plan.cost;          // → -18

Everyone lands on exactly 9 hours, inside the band, and every section went to a lecturer who ranked it first:

SectionLecturer
algorithmsjane-doe
databasesjane-doe
compilersjane-doe
networksjohn-doe
operating-systemsjohn-doe
graphicsjohn-doe
calculusmary-major
statisticsmary-major
securitymary-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.

ts
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.

ts
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; // → 0

Here 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:

ts
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:

ts
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. exclusions are 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.
  • onUnfit applies to suggest only. rebalance never 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.

Released under the MIT License.