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 finally block calls worker.terminate() — handing the whole WASM heap back to the browser. Leaving the page mid-solve kills the worker too. A 15-second timeLimitSeconds cap keeps a pathological input from freezing the tab; terminating the worker is the hard backstop.