feat(auto-assign): ROC-weighted ranking + 2-opt global swap pass (revives #146)#149
Merged
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Revives the auto-assign algorithm work from closed PR #146. Two-stage matcher dispatched per-school via a new
School.algoModeenum:hard_weekly_cap) apply as pre-filters before either stage runs and 2-opt swaps never violate them.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 viaalgoMode = '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 = CASCADEfor 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 andhard_weekly_capenforcement under WEIGHTED)npm run build—next buildclean, all pages prerenderKnown follow-ups (not blocking this PR)
prefer_consecutiveis excluded from Stage 2 by design; a swap can break a deliberately-consecutive pair. Documented inscoreCandidateForSlot. Revisit if it becomes a frequent regression.teacherDaySchedulemutated in place) is intentional but only one iteration order is asserted by tests today.algoModefrom the dashboard is a separate PR.🤖 Generated with Claude Code