school-workshop-assigner is a standalone JavaScript module (ESM, one runtime
dependency) that assigns students to elective workshops fairly, under real
constraints: per-workshop capacities, per-pair exclusions (“keep these two
apart”), and messy multi-class CSV input merged from one file per class. All the
solving runs client-side — the HiGHS mixed-integer
solver compiled to WebAssembly — so a teacher-facing app needs no backend.
Instead of maximizing a single weighted score (which an optimizer can game by sacrificing a few unlucky students), it optimizes in strict priority order: first the number of students who get their 1st choice, then 1st-or-2nd, then any of their three, and finally it spreads the leftover “no choice available” students evenly across classes rather than dumping them all on one.
I built it for Le Tableau Noir — where it powers the Classificateur screen — and released it as a reusable package.
Get the code
npm install school-workshop-assigner
import { assignStudentsToWorkshops } from "school-workshop-assigner";
const result = await assignStudentsToWorkshops({
workshops: [
{ name: "Theater", maxCapacity: 25 },
{ name: "Robotics", maxCapacity: 20 },
],
students: [
{ lastName: "Dupont", firstName: "Alice", className: "CM2-A", choice1: "Theater", choice2: "Robotics" },
{ lastName: "Martin", firstName: "Bob", className: "CM2-A", choice1: "Robotics" },
],
exclusions: [
{
studentA: { lastName: "Dupont", firstName: "Alice", className: "CM2-A" },
studentB: { lastName: "Martin", firstName: "Bob", className: "CM2-A" },
},
],
});
console.log(result.status, result.statistics.choiceDistribution);
console.log(result.byClassroom, result.byWorkshop);
The module does not parse CSV itself — it takes plain JS objects, however you
assembled them (typically one Papa.parse() per uploaded class file, then
concatenated). Names and workshop choices are matched case/accent/whitespace-
insensitively; twins are disambiguated by first name. Structural problems
(no workshops, capacity below headcount, a model above maxProblemSize) throw a
CoherenceError; everything else — unknown choice, exclusion referencing an
unknown student — comes back as a non-blocking warnings entry.
Live demo
This demo runs school-workshop-assigner bundled and served from this site —
the solver, the HiGHS WebAssembly glue and the .wasm binary are all
same-origin static files, no CDN and no build step at request time. On the
left, the input: the built-in Solvay 1927 / France 98 roster (or your own
CSV files); on the right, the assignment the solver returns. The roster is the
29 people in the 1927 Solvay Conference
photograph plus France’s 22-man 1998 football World Cup squad — two “classes”,
8 workshops, reproducible seeded choices (seed 1927), and a deliberately dense
set of exclusions (every pair of people whose family name shares its first
letter), so the NEEDS_CONFIRMATION → confirm flow actually triggers.
On memory. HiGHS is ~3.4 MB of WebAssembly. It is never in the initial page, never on the main thread, and never kept alive between runs: each Run spins up a fresh Web Worker, solves, renders, and the
finallyblock callsworker.terminate()— handing the whole WASM heap back to the browser. Leaving the page mid-solve kills the worker too. A 15-secondtimeLimitSecondscap keeps a pathological input from freezing the tab; terminating the worker is the hard backstop.
Run an assignment to see the result here.