Skip to content

API reference

Every export is named. There is no default export.

ts
import { suggest, rebalance, cost, rankToAffinity } from "aequitas";
import type {
  Item,
  Bin,
  Assignment,
  Exclusion,
  Weights,
  Options,
  OnUnfit,
  Result,
} from "aequitas";

Four functions and eight types is the entire public surface.

Functions

suggest()

ts
function suggest(
  items: readonly Item[],
  bins: readonly Bin[],
  options?: Options,
): Result;

Builds an assignment from scratch: every item goes through the greedy seed, then the layout is hill-climbed toward the lowest cost.

Under the default onUnfit: "forceLeastLoaded" every item ends up on a bin, taking a band violation if nothing has room. Under onUnfit: "leave" an item that fits nowhere is left in unassigned instead.

Locks are ignored. locked and lockedBinIds live on Assignment, and suggest does not take assignments, so there is nothing for it to honour. Use rebalance() when you need pins respected.

Exclusions are honoured: hard pairs are never placed together, soft pairs cost the exclusion weight.

ts
const plan = suggest(
  [{ id: "algorithms", weight: 4 }, { id: "databases", weight: 3 }],
  [{ id: "jane-doe", max: 12 }, { id: "john-doe", max: 12 }],
);

rebalance()

ts
function rebalance(
  items: readonly Item[],
  bins: readonly Bin[],
  current: readonly Assignment[],
  options?: Options,
): Result;

Improves an existing assignment instead of starting over, which keeps churn down: placements that do not need to move stay put.

How current is interpreted:

  • An assignment with locked: true is never moved by the solver, though its bin list is still cleaned: see the caveat below.
  • An assignment with lockedBinIds keeps those bins pinned while its other bins may be relocated, dropped, or added to.
  • Bin ids that no longer exist in bins are ignored, so passing a stale layout after removing a bin is safe.
  • Items present in items but absent from current are greedily seeded onto bins that have room, then climbed.
  • Hard-exclusion pairs already present in current are broken up, except where a lock holds both bins.

onUnfit has no effect here

rebalance always behaves as though onUnfit: "leave" were set and never forces a bin past its max to place an item. Passing onUnfit: "forceLeastLoaded" is accepted and silently does nothing. See Configuration.

Seeding a missing item is not gated on lowering cost. It is placed wherever there is room, and since a drop move requires the item to hold two or more bins, a newly seeded single-bin item can never go back to unassigned. Placing it can therefore raise the total cost.

locked is not quite verbatim

A locked assignment is never moved, but the bin list you hand in is still normalized before it is used. Two things can change it:

ts
// split is 2, but the locked assignment names three bins.
rebalance([{ id: "x", weight: 12, split: 2 }], bins,
  [{ itemId: "x", binIds: ["a", "b", "c"], locked: true }]);
// → binIds ["a", "b"]     trimmed to split

// "ghost" is not in bins.
rebalance([{ id: "x", weight: 12, split: 2 }], bins,
  [{ itemId: "x", binIds: ["a", "ghost"], locked: true }]);
// → binIds ["a"]          stale bin dropped

Duplicates are collapsed the same way. So a lock guarantees the solver will not reassign the item, not that binIds comes back byte-identical. Pass a placement that is already valid for the current bins and split and the two are the same thing.

cost()

ts
function cost(
  items: readonly Item[],
  bins: readonly Bin[],
  assignments: readonly Assignment[],
  options?: Pick<Options, "weights" | "exclusions">,
): number;

Scores an arbitrary layout with the exact function the solver minimizes. Exposed for inspection and testing.

Note the narrower options type: only weights and exclusions apply, because maxIterations and onUnfit describe how to solve, and this function does not solve anything. Pass the same exclusions you solved with, or the soft-exclusion term will be missing and the number will not be comparable.

ts
// Everything dumped on one bin, for comparison.
const mine = sections.map((s) => ({ itemId: s.id, binIds: ["jane-doe"] }));

cost(sections, lecturers, plan.assignments);  // → 1
cost(sections, lecturers, mine);              // → 517

Lower is better, and the value is often negative because satisfied affinity is subtracted. It is only meaningful compared against another layout of the same input.

rankToAffinity()

ts
function rankToAffinity(rank: number): number;

Turns a 1-based preference rank into a descending affinity score, 1 / rank, so a preference list can be fed in without inventing scores.

rankReturns
11
20.5
30.3333333333333333
40.25
100.1
1.50.6666666666666666

Any finite rank >= 1 is accepted, including non-integers; there is no upper bound. Throws a RangeError if rank is not a finite number >= 1, which covers 0, negatives, NaN and Infinity.

ts
rankToAffinity(0);
// RangeError: rankToAffinity: rank must be a finite number >= 1, got 0

Types

Item

ts
interface Item {
  id: string;
  weight: number;
  split?: number;
  affinities?: Record<string, number>;
}
FieldDefaultMeaning
idrequiredUnique within the call. Duplicates throw.
weightrequiredCapacity consumed. Must be finite.
split1Maximum distinct bins to spread across. Positive integer.
affinities{}Bin id to preference score. Any finite number; higher pulls harder, 0 or absent is neutral, negative discourages. Not validated.

An item's weight divides equally over the bins it actually occupies, so { weight: 6, split: 2 } contributes 3 to each of two bins. Affinity is credited once per bin it lands on.

Bin

ts
interface Bin {
  id: string;
  min?: number;
  max?: number;
}
FieldDefaultMeaning
idrequiredUnique within the call. Duplicates throw.
min0Lower capacity bound.
maxInfinityUpper capacity bound.

The band is soft, priced at the violation weight. Explicit -Infinity / Infinity are accepted as unconstrained; NaN and min > max throw.

Assignment

ts
interface Assignment {
  itemId: string;
  binIds: string[];
  locked?: boolean;
  lockedBinIds?: string[];
}
FieldMeaning
itemIdThe item this placement is for.
binIdsDistinct bins the item occupies. Empty means unassigned.
lockedtrue stops the solver reassigning the item. Only read by rebalance, and not quite verbatim.
lockedBinIdsPins just these bins; the rest stay free. Only read by rebalance.

locked: true wins over lockedBinIds when both are set. Ids in lockedBinIds that the item does not currently hold, or that name a bin no longer in bins, are ignored.

On the way out, binIds holds between 1 and the item's split bins, or is empty for an unassigned item.

Exclusion

ts
interface Exclusion {
  bins: readonly [string, string];
  hard?: boolean;
}

Two bins that should not both appear on the same item. hard: true forbids the pairing structurally; otherwise it is discouraged by the exclusion weight.

Only meaningful for items with split > 1, since a single-bin item cannot pair anything. Pairs naming an unknown bin, or a bin with itself, are ignored. A pair listed as both hard and soft is treated as hard only.

Weights

ts
interface Weights {
  violation?: number;  // default 100
  spread?: number;     // default 1
  affinity?: number;   // default 2
  exclusion?: number;  // default 50
}

Relative importance of the four cost terms. exclusion affects soft exclusions only; hard ones are structural and ignore it entirely.

Options

ts
interface Options {
  weights?: Weights;
  maxIterations?: number;              // default 10_000
  onUnfit?: OnUnfit;                   // default "forceLeastLoaded"
  exclusions?: readonly Exclusion[];
}

maxIterations caps hill-climb moves and is a runaway guard rather than a tuning knob. onUnfit is read by suggest only.

OnUnfit

ts
type OnUnfit = "leave" | "forceLeastLoaded";

What happens to an item that fits in no bin during the greedy seed. "forceLeastLoaded" (default) places it anyway and takes the band violation; "leave" leaves it in unassigned.

Result

ts
interface Result {
  assignments: Assignment[];
  loads: Record<string, number>;
  cost: number;
  violations: number;
  unassigned: string[];
  affinityScore: number;
}
FieldMeaning
assignmentsOne per input item, in input order.
loadsTotal weight per bin. Every bin is present, including empty ones.
costThe scalar minimized. Often negative.
violationsTotal band overflow plus underflow. 0 means every bin is in band.
unassignedIds of items with no bin at all.
affinityScoreSum of satisfied affinity scores.

A partially placed split item, on fewer bins than its split, is not listed in unassigned. Only items with an entirely empty binIds are.

Errors

TypeError

Thrown by suggest, rebalance and cost for input that would make the result silently wrong:

CauseMessage
Duplicate item idaequitas: duplicate item id "a"
Duplicate bin idaequitas: duplicate bin id "b"
Non-finite weightaequitas: item "a" has a non-finite weight (NaN)
split not a positive integeraequitas: item "a" has an invalid split (0); expected a positive integer
NaN boundaequitas: bin "b" has a NaN max
Inverted bandaequitas: bin "b" has min 9 > max 2

RangeError

Thrown only by rankToAffinity, when rank is not a finite number >= 1.

Edge cases

  • Empty items or bins return a degenerate Result, never a throw. With no bins, every item lands in unassigned and loads is {}.
  • Ties always break by id, and there is no Math.random anywhere, so identical input yields identical output.
  • Inputs are never mutated. Every call returns fresh objects.
  • A stale bin id in current is ignored, and the item is placed into a real bin when that lowers cost.

Released under the MIT License.