Skip to content

feat(auto-assign): ROC-weighted ranking + 2-opt global swap pass (revives #146)#149

Merged
guangshinhaha merged 2 commits into
mainfrom
algo-roc
May 4, 2026
Merged

feat(auto-assign): ROC-weighted ranking + 2-opt global swap pass (revives #146)#149
guangshinhaha merged 2 commits into
mainfrom
algo-roc

Conversation

@guangshinhaha
Copy link
Copy Markdown
Collaborator

Summary

Revives the auto-assign algorithm work from closed PR #146. Two-stage matcher dispatched per-school via a new School.algoMode enum:

  • Stage 1 — ROC ranking. Each enabled rule scores candidates in [0,1]; weights are derived as Rank-Order Centroid from the admin's existing drag-to-reorder ordering, so admins keep tuning order without juggling sliders. No mid-pipeline elimination — a teacher slightly heavier on workload can still win the slot if they're the only subject + class match.
  • Stage 2 — 2-opt global swap pass. Pairwise swap trial across slot proposals; swap if it strictly improves total weighted score and respects hard constraints. Catches cross-slot trades like "the only Math + 3A teacher should not get burned on an earlier less-critical slot."
  • Hard constraints (sick / busy / already-assigned / hard_weekly_cap) apply as pre-filters before either stage runs and 2-opt swaps never violate them.
  • Settings UI (AlgorithmTab.tsx) splits hard constraints into locked cards above the soft-rule reorder list.

Legacy CASCADE (filter pipeline, eliminations are permanent) is preserved as opt-in via algoMode = 'CASCADE'.

Why

PR #146's description undersold the change ("lexicographic ranking") and the branch was closed without merging. On revival, code review surfaced two real blockers in the original commit — both fixed in commit 2 of this PR. See the second commit message for full rationale on each fix.

Why CASCADE is the rollout default

Migration defaults algoMode = CASCADE for every existing and new school. Schools opt in to WEIGHTED via a DB flip. After stakeholder soak we'll flip the default in a follow-up.

Test plan

  • npm test — 438 unit tests pass (incl. 3 new tests for ROC differentiator, 2-opt soft-skip semantics, 3-proposal sweep order)
  • npm run test:api — 322 integration tests pass (incl. 2 new tests for multi-tenant CASCADE/WEIGHTED isolation and hard_weekly_cap enforcement under WEIGHTED)
  • npm run buildnext build clean, all pages prerender
  • After merge: confirm Vercel deploy on the merge commit is green before opening the next PR (per CLAUDE.md deployment-verification rule)

Known follow-ups (not blocking this PR)

  • prefer_consecutive is excluded from Stage 2 by design; a swap can break a deliberately-consecutive pair. Documented in scoreCandidateForSlot. Revisit if it becomes a frequent regression.
  • Stage 2 score-state drift across iterations (teacherDaySchedule mutated in place) is intentional but only one iteration order is asserted by tests today.
  • Settings UI to flip a school's algoMode from the dashboard is a separate PR.

🤖 Generated with Claude Code

claude and others added 2 commits May 4, 2026 10:34
Replaces the per-slot cascading-filter algorithm as the default for
internal teacher matching. The new WEIGHTED mode runs in two stages:

  Stage 1 — Rank-Order Centroid (ROC) weighted sum across enabled rules.
  Weights are derived automatically from the admin's drag-to-reorder
  rule order (Barron 1992); no manual tuning. For 6 rules the weights
  are roughly 41% / 24% / 16% / 10% / 6% / 3% — rule #1 dominates but
  isn't absolute, so a candidate strong on multiple lower-rank rules
  can beat one only strong on rule #1. Fixes the small-pool collapse
  problem where strict lex / cascade always pick on rule #1.

  Stage 2 — 2-opt pairwise swap pass over the Stage-1 proposals.
  Catches cross-slot teacher trades — e.g. a teacher who is the only
  Math + 3A match shouldn't get "used up" on a less critical earlier
  slot. Each swap must strictly improve total weighted score and
  satisfy hard constraints (no double-booking, no busy-period
  conflicts, no self-cover). Deterministic, sub-100ms for n ≤ 20.

CASCADE mode (the legacy strict-priority filter) stays available as
an opt-in via School.algoMode = 'CASCADE'. Hard constraints (no
self-cover, no double-book, no assign while sick, hard weekly cap)
are surfaced in the Settings → Algorithm page as locked, uneditable
cards above the editable soft-rule list.
B1 — 2-opt was a no-op for the headline use case. `scoreCandidateForSlot`
called `rule.score(teacher, ctx, [teacher])`. The soft-skip branch in
`match_subject` / `prefer_same_class` (`if (!pool.some(...)) return 1`)
fired for every (teacher, slot) pair because the single-element pool
never matched, so swap deltas were always 0 and the cross-slot trades
the algorithm exists to find never fired. Pass the full teacher pool
through `runTwoOptImprovement` instead.

B2 — `pickedRuleId` reported the dominant ROC contributor, not the
differentiator. With heavy front-loaded weights, a tied rule #1 still
won attribution even when both candidates scored equally on it. Switch
to `argmax weights[i] * (score(picked) - score(runnerUp))` so the
deciding rule is the one that actually separated the pick from the
runner-up. Fixes nonsensical "Lower balance_workload" alternative
strings as a side effect.

C5 — flip rollout default to CASCADE. Schema and migration now default
new schools to the legacy filter; WEIGHTED is opt-in per-tenant via DB
flip until a settings UI ships.

C7 — `AlgorithmTab.handleReset` hardcoded `enabled: true` for every
rule and wiped `config` blocks. Now reads `defaultEnabled` from the
registry and preserves existing config (the workload-window settings
admins set explicitly).

C3 — explicit caveat in `scoreCandidateForSlot` docstring that
`prefer_consecutive` is excluded from Stage 2 because the prior-period
teacher is null; a swap can break a deliberately-consecutive pair.

Tests: Q4 multi-tenant CASCADE/WEIGHTED isolation, Q5 hard_weekly_cap
exclusion in WEIGHTED mode, C4 3-proposal forward-sweep ordering.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 4, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
relief-teacher-planning Ready Ready Preview, Comment May 4, 2026 3:28am

@guangshinhaha guangshinhaha merged commit 9b6ac9b into main May 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants