Skip to content

Recipes

Each recipe is a task, the code for it, and the measured result. Every number on this page came from running the snippet above it.

Most recipes share this starting point: three lecturers capped at 12 credit hours, five course sections weighted by their credit hours.

ts
import { suggest, rebalance, cost } from "aequitas";

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

const sections = [
  { id: "capstone-project", weight: 5 },
  { id: "algorithms", weight: 4 },
  { id: "databases", weight: 3 },
  { id: "networks", weight: 3 },
  { id: "database-lab", weight: 2 },
];

const plan = suggest(sections, lecturers);
plan.loads;  // → { "jane-doe": 5, "john-doe": 6, "mary-major": 6 }

How to pin a course the department already promised

Set locked: true on the assignment and pass the timetable to rebalance. The pinned item is never reassigned, even when moving it would lower the cost.

ts
const pinned = plan.assignments.map((a) =>
  a.itemId === "capstone-project" ? { ...a, binIds: ["mary-major"], locked: true } : a,
);

const revised = rebalance(sections, lecturers, pinned);
// capstone-project stays with mary-major; everything else reshuffles around it.

suggest ignores locks entirely, because it does not take assignments. Pins only mean something to rebalance.

How to pin one half of a co-taught course

Use lockedBinIds to hold some bins while leaving the rest free.

ts
{ itemId: "seminar-ethics", binIds: ["jane-doe", "john-doe"], lockedBinIds: ["jane-doe"] }

Jane is held. John may be replaced, dropped, or joined by a third lecturer up to the item's split. If both locked and lockedBinIds are set, locked wins.

How to stop a co-taught course losing a teacher

split: n is a maximum, not a quota. The solver drops below n whenever concentrating the weight lowers cost, which happens more often than you would guess.

A thesis seminar meant for three co-teachers, alongside a 12-credit course:

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

const items = [
  { id: "seminar-thesis", weight: 9, split: 3 },
  { id: "algorithms", weight: 12 },
];

suggest(items, lecturers, { maxIterations: 0 });  // seed:   3 co-teachers, cost 12
suggest(items, lecturers);                        // solved: 2 co-teachers, cost 7.5

The seed does give the seminar all three lecturers, putting Jane on 12 + 3 = 15 while the others carry 3 each: spread 12. Dropping Jane from the seminar re-splits 9 over two lecturers at 4.5 each, leaving her on 12 and cutting spread to 7.5. Lower cost wins, so Jane is dropped as a co-teacher.

To keep all three, pin them:

ts
const current = [
  {
    itemId: "seminar-thesis",
    binIds: ["jane-doe", "john-doe", "mary-major"],
    lockedBinIds: ["jane-doe", "john-doe", "mary-major"],
  },
  { itemId: "algorithms", binIds: ["jane-doe"] },
];

const held = rebalance(items, lecturers, current);
// seminar-thesis keeps all three, loads { jane 15, john 3, mary 3 }, cost 12

locked: true holds all three as well. Either way you are choosing a cost of 12 over 7.5, which is the point: the pin overrides the solver's judgement, and you should mean it.

Do not derive the pins from a solve

Pinning the binIds that suggest returned pins whatever survived the climb, which may already be fewer than split. If you need exactly n, construct the placement yourself.

How to add a section mid-planning

Add it to items and pass the timetable you have already published as current.

ts
const withNew = [...sections, { id: "seminar-ethics", weight: 6 }];

const revised = rebalance(withNew, lecturers, plan.assignments);
revised.loads;  // → { "jane-doe": 11, "john-doe": 6, "mary-major": 6 }

All five original sections stayed exactly where they were: zero existing courses moved. That is the reason to reach for rebalance rather than re-running suggest, which is free to rewrite the whole timetable and hand everyone a new syllabus.

The trade-off is that starting from the current state can settle in a different local optimum than solving fresh. Here Jane ends on 11 against 6 and 6. If you care more about the final shape than about churn, call suggest instead.

How to cancel a section

Leave it out of items. You do not need to strip it from current: entries for items that are no longer in items are ignored.

ts
const remaining = sections.filter((s) => s.id !== "capstone-project");

const revised = rebalance(remaining, lecturers, plan.assignments);
revised.loads;  // → { "jane-doe": 5, "john-doe": 4, "mary-major": 3 }
revised.assignments.some((a) => a.itemId === "capstone-project");  // → false

Freeing capacity lets the solver improve what is left, so other sections may move.

How to add a lecturer

Add them to bins. Existing placements stay in current, and the newcomer picks up work only where that lowers cost.

ts
const withRichard = [...lecturers, { id: "richard-roe", max: 12 }];

const revised = rebalance(sections, withRichard, plan.assignments);
revised.loads;  // → { "jane-doe": 5, "john-doe": 4, "mary-major": 5, "richard-roe": 3 }

How to cover a sabbatical

Drop them from bins and pass the current timetable. Ids in current that no longer name a real bin are ignored, so a stale layout is safe to hand over.

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

In this case only the departing lecturer's sections moved, and everyone else kept the courses they were already preparing. That is the usual outcome rather than a guarantee: the climb is free to move anything unpinned when doing so lowers cost, so lock what must not move. Full worked example in Getting started.

How to cut someone's maximum load

Change their max and rebalance. The new ceiling is respected on the way out.

ts
const reduced = lecturers.map((l) =>
  l.id === "mary-major" ? { ...l, max: 3 } : l,
);

const revised = rebalance(sections, reduced, plan.assignments);
revised.loads;      // → { "jane-doe": 8, "john-doe": 6, "mary-major": 3 }
revised.violations; // → 0

Mary was carrying 6 against a new cap of 3, so a section moves off her and the timetable is back in band.

How to stop one lecturer teaching one course

There is no option for this. Affinities are soft, and even -1000 still allows the pairing. Filter the bin list for that item instead:

ts
const eligible = lecturers.filter((l) => l.id !== "jane-doe");

suggest([{ id: "algorithms", weight: 4 }], lecturers).assignments[0].binIds; // → ["jane-doe"]
suggest([{ id: "algorithms", weight: 4 }], eligible).assignments[0].binIds;  // → ["john-doe"]

When eligibility differs per course you have to solve per group, or solve the unrestricted sections first and rebalance the restricted ones against the result. exclusions will not do it: those are pairs of bins, not item-to-bin rules.

How to stop two lecturers co-teaching

Mark them as a hard exclusion of one another. This works on items with split > 1, since a single-bin item cannot pair anything.

ts
const plan = suggest(seminars, lecturers, {
  exclusions: [{ bins: ["john-doe", "mary-major"], hard: true }],
});

No seminar will ever list both of them. Be aware it can cost you a co-teacher elsewhere: with few enough lecturers, the solver would rather teach a seminar solo than break the rule. Full example in Configuration.

How to turn a ranked request list into affinities

rankToAffinity maps a 1-based rank to 1 / rank, so you can feed an ordered list of who wanted the course straight in.

ts
import { rankToAffinity } from "aequitas";

const requested = ["jane-doe", "john-doe", "mary-major"];  // most keen first
const affinities = Object.fromEntries(
  requested.map((binId, i) => [binId, rankToAffinity(i + 1)]),
);
// → { "jane-doe": 1, "john-doe": 0.5, "mary-major": 0.3333333333333333 }

How to let requests outrank evenness

Raise the affinity weight. Two 10-credit sections that both requested Jane, and two lecturers with room for either arrangement:

ts
const pair = [{ id: "jane-doe", max: 100 }, { id: "john-doe", max: 100 }];
const requested = [
  { id: "algorithms", weight: 10, affinities: { "jane-doe": 1 } },
  { id: "databases", weight: 10, affinities: { "jane-doe": 1 } },
];

suggest(requested, pair);
// → { "jane-doe": 10, "john-doe": 10 }   1 request met

suggest(requested, pair, { weights: { affinity: 50 } });
// → { "jane-doe": 20, "john-doe": 0 }    2 requests met

Setting spread: 0 reaches the same place from the other direction, by making evenness worthless rather than preference expensive. Full table in Configuration.

How to check whether the load bands were satisfiable

Read violations. It is 0 when every bin is inside its band, and otherwise the total distance out of band summed over all bins.

ts
const bands = [
  { id: "jane-doe", min: 10, max: 12 },
  { id: "john-doe", min: 10, max: 12 },
];
const tooLittle = [{ id: "algorithms", weight: 5 }];

const out = suggest(tooLittle, bands);
out.violations;  // → 15
out.loads;       // → { "jane-doe": 5, "john-doe": 0 }

There are only 5 credit hours to hand out and the two minimums ask for 20, so the bands cannot be met. The 15 is (10 - 5) + (10 - 0). Bands are soft, so this returns a best-effort layout rather than throwing.

Because violations is a sum, it does not say which bin is out. Read loads alongside it.

The number is more useful than a pass/fail flag, because its units are your units. In CI it comes out as seconds of overshoot past a job timeout, and on a support queue as minutes of work with nowhere to go, which is a figure you can hand to someone.

How to compare two timetables

cost() scores any layout with the function the solver minimizes. Lower is better.

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

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

Pass the same exclusions you solved with, or the soft-exclusion term is missing and the two numbers are not comparable. cost() accepts only weights and exclusions, since maxIterations and onUnfit describe solving rather than scoring.

How to see what the hill-climb bought

maxIterations: 0 stops right after the greedy seed. Diff it against a full run.

ts
const seeded = suggest(sections, lecturers, { maxIterations: 0 });
const climbed = suggest(sections, lecturers);

seeded.cost - climbed.cost;  // the gain

On the five sections above the gain is 0, because the seed was already optimal. On the popular-lecturer example, where every section requests the same person, the seed scores 4 and the climbed layout -2, a gain of 6.

How to handle a section nobody has room for

Choose with onUnfit, which suggest reads and rebalance ignores.

ts
const oneLecturer = [{ id: "jane-doe", max: 5 }];
const tooBig = [{ id: "capstone-project", weight: 10 }];

suggest(tooBig, oneLecturer, { onUnfit: "forceLeastLoaded" }).unassigned;
// → []                     (default; violations is 5 instead)

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

"forceLeastLoaded" assigns it anyway and reports a band violation; "leave" reports it in unassigned so you can escalate it to a human. rebalance always behaves as "leave".

How to make a timetable reproducible

Nothing to configure. Every tie breaks by id and there is no Math.random in the package, so the same input always produces identical output. That makes a layout safe to snapshot-test:

ts
expect(suggest(sections, lecturers).assignments).toMatchSnapshot();

Inputs are never mutated either, so you can reuse the same arrays across calls.

Released under the MIT License.