API reference
Every export is named. There is no default export.
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()
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.
const plan = suggest(
[{ id: "algorithms", weight: 4 }, { id: "databases", weight: 3 }],
[{ id: "jane-doe", max: 12 }, { id: "john-doe", max: 12 }],
);rebalance()
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: trueis never moved by the solver, though its bin list is still cleaned: see the caveat below. - An assignment with
lockedBinIdskeeps those bins pinned while its other bins may be relocated, dropped, or added to. - Bin ids that no longer exist in
binsare ignored, so passing a stale layout after removing a bin is safe. - Items present in
itemsbut absent fromcurrentare greedily seeded onto bins that have room, then climbed. - Hard-exclusion pairs already present in
currentare 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:
// 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 droppedDuplicates 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()
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.
// 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); // → 517Lower 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()
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.
rank | Returns |
|---|---|
1 | 1 |
2 | 0.5 |
3 | 0.3333333333333333 |
4 | 0.25 |
10 | 0.1 |
1.5 | 0.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.
rankToAffinity(0);
// RangeError: rankToAffinity: rank must be a finite number >= 1, got 0Types
Item
interface Item {
id: string;
weight: number;
split?: number;
affinities?: Record<string, number>;
}| Field | Default | Meaning |
|---|---|---|
id | required | Unique within the call. Duplicates throw. |
weight | required | Capacity consumed. Must be finite. |
split | 1 | Maximum 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
interface Bin {
id: string;
min?: number;
max?: number;
}| Field | Default | Meaning |
|---|---|---|
id | required | Unique within the call. Duplicates throw. |
min | 0 | Lower capacity bound. |
max | Infinity | Upper capacity bound. |
The band is soft, priced at the violation weight. Explicit -Infinity / Infinity are accepted as unconstrained; NaN and min > max throw.
Assignment
interface Assignment {
itemId: string;
binIds: string[];
locked?: boolean;
lockedBinIds?: string[];
}| Field | Meaning |
|---|---|
itemId | The item this placement is for. |
binIds | Distinct bins the item occupies. Empty means unassigned. |
locked | true stops the solver reassigning the item. Only read by rebalance, and not quite verbatim. |
lockedBinIds | Pins 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
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
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
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
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
interface Result {
assignments: Assignment[];
loads: Record<string, number>;
cost: number;
violations: number;
unassigned: string[];
affinityScore: number;
}| Field | Meaning |
|---|---|
assignments | One per input item, in input order. |
loads | Total weight per bin. Every bin is present, including empty ones. |
cost | The scalar minimized. Often negative. |
violations | Total band overflow plus underflow. 0 means every bin is in band. |
unassigned | Ids of items with no bin at all. |
affinityScore | Sum 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:
| Cause | Message |
|---|---|
| Duplicate item id | aequitas: duplicate item id "a" |
| Duplicate bin id | aequitas: duplicate bin id "b" |
Non-finite weight | aequitas: item "a" has a non-finite weight (NaN) |
split not a positive integer | aequitas: item "a" has an invalid split (0); expected a positive integer |
NaN bound | aequitas: bin "b" has a NaN max |
| Inverted band | aequitas: bin "b" has min 9 > max 2 |
RangeError
Thrown only by rankToAffinity, when rank is not a finite number >= 1.
Edge cases
- Empty
itemsorbinsreturn a degenerateResult, never a throw. With no bins, every item lands inunassignedandloadsis{}. - Ties always break by id, and there is no
Math.randomanywhere, so identical input yields identical output. - Inputs are never mutated. Every call returns fresh objects.
- A stale bin id in
currentis ignored, and the item is placed into a real bin when that lowers cost.