Skip to content

Capacity bands, preferences, exclusions and locks

Everything here is optional. suggest(items, bins) with plain weights and no options already balances load. These are the knobs for when it needs to respect something more.

Every setting at a glance

There are fourteen things you can set, spread across five objects. Which function reads them matters, and is easy to get wrong.

Set onFieldDefaultRead by
Binmin0all three
BinmaxInfinityall three
Itemsplit1suggest, rebalance
Itemaffinities{}all three
Optionsweights.violation100all three
Optionsweights.spread1all three
Optionsweights.affinity2all three
Optionsweights.exclusion50all three
Optionsexclusions[]all three
OptionsmaxIterations10_000suggest, rebalance
OptionsonUnfit"forceLeastLoaded"suggest only
Assignmentlockedabsentrebalance only
AssignmentlockedBinIdsabsentrebalance only
Exclusionhardfalse (soft)all three

"All three" means suggest, rebalance and cost. The three rows in bold are the ones that silently do nothing if you set them on the wrong call: onUnfit is ignored by rebalance, and both locks are ignored by suggest.

split is the other partial case. cost validates it, so an invalid split still throws, but it plays no part in the score: cost divides an item's weight over however many bins the assignment you passed actually names, even if that is more than split allows. Only suggest and rebalance treat it as a ceiling.

For task-shaped answers rather than field-shaped ones, see Recipes.

Capacity bands

A bin takes min and max, and either may be omitted. A missing min is 0; a missing max is Infinity.

ts
{ id: "jane-doe", min: 6, max: 12 }  // between 6 and 12, inclusive
{ id: "john-doe", max: 12 }          // at most 12, no lower requirement
{ id: "mary-major", min: 6 }         // at least 6, no ceiling
{ id: "richard-roe" }                // unconstrained

The band is a soft constraint priced at the violation weight, not a hard guarantee. It is weighted 100 against a spread weight of 1, so the solver will distort evenness heavily to stay in band, but if the totals make the band impossible it produces the least-bad layout rather than throwing.

Check violations to find out which happened. It is the total distance out of band, summed across bins, so 0 means every bin is inside its band and anything higher needs loads read alongside it.

ts
const plan = suggest(sections, lecturers);
if (plan.violations > 0) {
  // The bands cannot all be met. Inspect plan.loads to see where.
}

An explicit min: -Infinity or max: Infinity is accepted and means unconstrained. A NaN bound, or min > max, is rejected with a TypeError.

A max is most useful when it already exists as a number somewhere in your system: a CI job timeout or the hours an agent has left today both drop straight in, and violations then answers whether the plan fits.

Affinities

An item's affinities map scores bins by preference: higher pulls harder, absent or 0 is neutral, negative discourages.

ts
{ id: "algorithms", weight: 3, affinities: { "jane-doe": 1, "john-doe": 0.5 } }

There is no fixed range. Any finite number works and the scale is yours: 1, 7, 1000 and 0.001 are all valid, and negatives push the item away. What matters is the size of a score relative to the spread term it competes with, not its absolute value.

For ranked preferences, rankToAffinity saves you inventing numbers. It returns 1 / rank for any finite rank >= 1, including non-integers, and throws a RangeError on 0, negatives, NaN and Infinity:

RankAffinity
11
20.5
30.333…
40.25
100.1
1.50.666…

The table stops at a handful of values for readability; nothing caps the rank.

ts
import { rankToAffinity } from "aequitas";

const prefs = ["jane-doe", "john-doe", "mary-major"];  // in order of preference
const affinities = Object.fromEntries(
  prefs.map((binId, i) => [binId, rankToAffinity(i + 1)]),
);
// → { "jane-doe": 1, "john-doe": 0.5, "mary-major": 0.3333333333333333 }

Affinity cannot forbid a pairing

There is no affinity value that makes a bin off-limits. A section whose only available lecturer scores -1000 still lands on them:

ts
suggest(
  [{ id: "algorithms", weight: 4, affinities: { "jane-doe": -1000 } }],
  [{ id: "jane-doe", max: 12 }],
).assignments[0].binIds;  // → ["jane-doe"]

If a pairing must be impossible, leave that bin out of the array you pass for that item, or filter the list per item before calling. exclusions will not do it either: those are pairs of bins, not item-to-bin rules.

Affinity values are not validated

Unlike weight and the capacity bounds, affinity scores are never checked. A NaN affinity does not throw; it propagates straight into cost:

ts
suggest([{ id: "algorithms", weight: 4, affinities: { "jane-doe": NaN } }], lecturers).cost;
// → NaN

Worse, it silently disables the hill-climb, because every NaN < bestCost comparison is false, so no move ever registers as an improvement and you get the raw greedy seed back. Infinity behaves similarly, giving a cost of -Infinity. Validate affinity scores on your side if they come from user input or a database.

Splitting an item across bins

split: n spreads one item over up to n distinct bins, dividing its weight equally over the bins it actually occupies. A weight: 8, split: 2 item puts 4 on each of two bins.

ts
{ id: "seminar-ethics", weight: 8, split: 2 }  // co-taught: 4 credit hours each

split is a target, not a guarantee, in both directions:

  • If fewer than n bins are available or have room, the item takes as many as it can and its weight divides over those. Nothing is lost: a split: 2 item that only fits one bin carries the full 8 there.
  • The solver may also drop back below n when concentrating the weight lowers cost. split: 2 means "up to 2", not "exactly 2".

Affinity is credited once per bin the item lands on, so a split item satisfying two preferences contributes both.

Exclusions

options.exclusions lists pairs of bins that should not both appear on the same item. This only bites for items with split > 1, since a single-bin item cannot pair anything.

Generically this is anti-affinity: the constraint you reach for when two bins share a failure domain and a split item should not put both of its copies there.

Here, where the bins are lecturers and an item can be co-taught, it is how you say "these two will not teach together":

ts
const exclusions = [
  { bins: ["john-doe", "mary-major"], hard: true },  // never together
  { bins: ["jane-doe", "richard-roe"] },             // discouraged (soft)
];

Four co-taught seminars, each 8 credit hours split across two lecturers, over three lecturers with a generous cap:

ts
const lecturers = [
  { id: "jane-doe", max: 20 },
  { id: "john-doe", max: 20 },
  { id: "mary-major", max: 20 },
];

const seminars = [
  { id: "seminar-ethics", weight: 8, split: 2 },
  { id: "seminar-research", weight: 8, split: 2 },
  { id: "seminar-thesis", weight: 8, split: 2 },
  { id: "seminar-capstone", weight: 8, split: 2 },
];

Left alone, the solver pairs John and Mary on seminar-research. Adding the hard exclusion removes that pairing entirely:

SeminarNo exclusionjohn-doe + mary-major excluded
seminar-ethicsjane + maryjane + mary
seminar-researchjohn + maryjane + john
seminar-thesisjane + johnjane + mary
seminar-capstonejane + johnjohn alone
ts
suggest(seminars, lecturers, {
  exclusions: [{ bins: ["john-doe", "mary-major"], hard: true }],
});
// loads { "jane-doe": 12, "john-doe": 12, "mary-major": 8 }, violations 0

Note what the constraint cost: seminar-capstone ends up taught solo by John.

With only three lecturers and one pair forbidden, the only legal pairings left are Jane with John and Jane with Mary, so every co-taught seminar has to include Jane. She has the capacity for all four (4 × 4 = 16 against her cap of 20), but taking them all would leave her on 16 while John and Mary sat on 8 each, a spread of 8. Handing seminar-capstone to John alone gives 12 / 12 / 8 instead, a spread of 4. The solver takes the more even layout and drops a co-teacher to get it.

So split: 2 is a maximum, not a promise, and this is one of the ways it ends up below: not because the constraint made co-teaching impossible, but because the evenness term preferred the alternative.

The total loads happen to be identical with and without the exclusion here, so in this case the rule cost nothing in evenness, only in co-teaching.

Hard versus soft

A hard pair is prevented structurally. The seed never creates it, no move that would create it is ever generated, and rebalance breaks one that a current layout already has. It is not priced into the cost at all.

A soft pair is merely discouraged, costing the exclusion weight (default 50) per pairing. It survives only when every alternative is worse.

The difference shows up under pressure. Suppose John and Mary each owe a minimum of 5 credit hours, and the only thing left to assign is a single 10-credit seminar that can be co-taught. The only way both reach their minimum is if the two of them teach it together, which is exactly the pairing being discouraged:

ts
const lecturers = [{ id: "john-doe", min: 5 }, { id: "mary-major", min: 5 }];
const seminars = [{ id: "seminar-ethics", weight: 10, split: 2 }];
ExclusionTaught byLoadsViolationsCost
none["john-doe", "mary-major"]john 5, mary 500
soft["john-doe", "mary-major"]john 5, mary 5050
hard["john-doe"]john 10, mary 05510

The soft pair yields. Pairing them is the only way to meet both minimums, so it happens and the timetable simply carries the 50-point penalty: a preference that lost.

The hard pair refuses. John takes the whole seminar alone, Mary teaches nothing and finishes 5 hours under her minimum, and the solver accepts that band violation rather than create the forbidden pairing.

That is the choice in one table. Use hard for a rule that must hold even when it hurts the timetable, and soft for a strong preference that should give way rather than leave someone with no work.

Pairs naming an unknown bin, or a bin with itself, are ignored. Listing a pair as both hard and soft leaves it hard only, so it is never also charged the soft weight.

Locks

Locks only affect rebalance. suggest builds from scratch and ignores them entirely.

There are two grains:

ts
{ itemId: "algorithms", binIds: ["jane-doe"], locked: true }
// The whole placement is pinned. The solver never reassigns it.

{ itemId: "seminar-ethics", binIds: ["jane-doe", "john-doe"], lockedBinIds: ["jane-doe"] }
// jane-doe is pinned. john-doe may still be relocated, dropped, or joined by another bin.

locked: true wins if both are set.

A partial lock is the useful one when a split item has one placement you have committed to and one you have not. Jane is capped at 6 credit hours, so dropping John would put the seminar's full 8 hours on her and breach her band. The solver will not choose that, though nothing forbids it: bands are soft, so it would simply cost 2 hours of violation. Meanwhile the labs have all piled onto John:

ts
const lecturers = [
  { id: "jane-doe", max: 6 },
  { id: "john-doe", max: 10 },
  { id: "mary-major", max: 10 },
  { id: "richard-roe", max: 10 },
];

const sections = [
  { id: "seminar-ethics", weight: 8, split: 2 },
  { id: "algorithms-lab", weight: 6 },
  { id: "databases-lab", weight: 6 },
  { id: "networks-lab", weight: 6 },
];

const current = [
  { itemId: "seminar-ethics", binIds: ["jane-doe", "john-doe"], lockedBinIds: ["jane-doe"] },
  { itemId: "algorithms-lab", binIds: ["john-doe"] },
  { itemId: "databases-lab", binIds: ["john-doe"] },
  { itemId: "networks-lab", binIds: ["john-doe"] },
];

const fixed = rebalance(sections, lecturers, current);

John starts on 22 hours against a cap of 10: his half of the seminar plus all three labs. After rebalancing:

ItemLands on
seminar-ethicsjane-doe, john-doe (with jane-doe pinned)
algorithms-labmary-major
databases-labrichard-roe
networks-labjohn-doe
ts
fixed.loads;      // → { "jane-doe": 4, "john-doe": 10, "mary-major": 6, "richard-roe": 6 }
fixed.violations; // → 0
fixed.cost;       // → 6

Jane keeps her half of the seminar, the overload resolves by moving two labs off John, and cost falls from 1222 to 6.

Ids in lockedBinIds that the item does not currently hold, or that name a bin no longer in bins, are ignored rather than rejected.

A lock stops reassignment, not normalization

Even with locked: true, the binIds you pass are cleaned before use: duplicates are collapsed, bins that are no longer in bins are dropped, and the list is trimmed to the item's split. The solver will not move a locked item, but it does not promise to echo your array back verbatim. See locked is not quite verbatim.

Weights

options.weights overrides any of the four cost coefficients. Anything omitted keeps its default.

ts
suggest(items, bins, { weights: { affinity: 50 } });

Raising affinity is the common adjustment: it buys preference satisfaction at the price of evenness. Two 10-credit sections that both requested Jane, over two lecturers with room for either arrangement:

ts
const lecturers = [{ id: "jane-doe", max: 100 }, { id: "john-doe", max: 100 }];
const sections = [
  { id: "algorithms", weight: 10, affinities: { "jane-doe": 1 } },
  { id: "databases", weight: 10, affinities: { "jane-doe": 1 } },
];
WeightsLoadsRequests metCost
defaultsjane 10, john 101-2
{ affinity: 50 }jane 20, john 02-80
{ spread: 0 }jane 20, john 02-4

At the default affinity: 2, splitting the two sections evenly wins and only one request is honoured. At 50, both land on Jane and both requests are honoured, at the price of leaving John with nothing. Setting spread: 0 reaches the same layout from the other direction, by making evenness worthless rather than making preference expensive.

exclusion only affects soft exclusions. Hard ones are enforced structurally and setting exclusion: 0 will not relax them.

onUnfit (suggest only)

onUnfit decides what happens to an item that fits in no bin during the greedy seed:

  • "forceLeastLoaded" (default) drops it into the least-loaded bin anyway, taking a band violation.
  • "leave" leaves it unassigned, so it shows up in unassigned.
ts
const lecturers = [{ id: "jane-doe", max: 5 }];
const sections = [{ id: "capstone-project", weight: 10 }];

suggest(sections, lecturers, { onUnfit: "leave" }).unassigned;
// → ["capstone-project"]

suggest(sections, lecturers, { onUnfit: "forceLeastLoaded" }).unassigned;
// → []   assigned anyway, and violations is 5 instead

rebalance ignores this option

rebalance never forces an overflow. It always behaves as though onUnfit: "leave" were set, whatever you pass:

ts
rebalance(sections, lecturers, [], { onUnfit: "leave" }).unassigned;
// → ["capstone-project"]

rebalance(sections, lecturers, [], { onUnfit: "forceLeastLoaded" }).unassigned;
// → ["capstone-project"]   the option changed nothing

The option is part of the shared Options type, so passing it to rebalance is neither a type error nor a runtime error. It simply has no effect. The underlying behaviour is intentional: seeding a rebalance stays conservative and will not push a bin past its max to place a newcomer. What is unfortunate is that the option is accepted rather than rejected.

Note that a missing item is still seeded by rebalance, and it is not gated on lowering cost. It is placed onto any bin with room, and because a drop move requires the item to hold two or more bins, a newly seeded single-bin item can never return to unassigned. Placing it can therefore raise the total cost.

maxIterations

A safety cap on hill-climb steps, default 10_000. This is a runaway guard, not a tuning knob: the climb normally converges in a handful of steps and stops on its own when no move improves.

Setting it to 0 is genuinely useful, though, because it stops right after the greedy seed. Comparing that against a full run tells you what the climb bought on your data:

ts
const seeded = suggest(items, bins, { maxIterations: 0 });
const climbed = suggest(items, bins);
seeded.cost - climbed.cost;  // how much the hill-climb gained

Validation

Input that would make the result silently wrong throws a TypeError. Empty items or bins are always valid and return a degenerate result instead.

RejectedMessage
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

Duplicate ids are rejected rather than tolerated because internal state is keyed by id, so duplicates would collapse into one entry and undercount load.

Released under the MIT License.