Implement EMBA Specialization Solver web app
Full React+TypeScript app with LP-based optimization engine, drag-and-drop specialization ranking (with touch/arrow support), course selection UI, results dashboard with decision tree, and two optimization modes (maximize-count, priority-order).
This commit is contained in:
91
app/src/solver/__tests__/decisionTree.test.ts
Normal file
91
app/src/solver/__tests__/decisionTree.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { analyzeDecisionTree } from '../decisionTree';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('analyzeDecisionTree', () => {
|
||||
it('returns empty choices when too many open sets (fallback)', () => {
|
||||
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2'];
|
||||
const analyses = analyzeDecisionTree([], openSets, allSpecIds, 'maximize-count');
|
||||
expect(analyses.length).toBe(10);
|
||||
for (const a of analyses) {
|
||||
expect(a.choices.length).toBe(0); // fallback: no enumeration
|
||||
}
|
||||
});
|
||||
|
||||
it('computes ceiling outcomes for a small number of open sets', () => {
|
||||
// Pin most sets, leave 2 open
|
||||
const pinned = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-managing-growing',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const openSets = ['fall3', 'fall4'];
|
||||
const analyses = analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count');
|
||||
|
||||
expect(analyses.length).toBe(2);
|
||||
for (const a of analyses) {
|
||||
expect(a.choices.length).toBeGreaterThan(0);
|
||||
for (const choice of a.choices) {
|
||||
expect(choice.ceilingCount).toBeGreaterThanOrEqual(0);
|
||||
expect(choice.ceilingCount).toBeLessThanOrEqual(3);
|
||||
expect(choice.courseName).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('orders sets by impact (highest variance first)', () => {
|
||||
const pinned = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-managing-growing',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const openSets = ['fall3', 'fall4'];
|
||||
const analyses = analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count');
|
||||
|
||||
// First set should have impact >= second set's impact
|
||||
if (analyses.length === 2) {
|
||||
expect(analyses[0].impact).toBeGreaterThanOrEqual(analyses[1].impact);
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onSetComplete progressively', () => {
|
||||
const pinned = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-managing-growing',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const openSets = ['fall3', 'fall4'];
|
||||
const completed: string[] = [];
|
||||
|
||||
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
|
||||
completed.push(analysis.setId);
|
||||
});
|
||||
|
||||
expect(completed).toContain('fall3');
|
||||
expect(completed).toContain('fall4');
|
||||
expect(completed.length).toBe(2);
|
||||
});
|
||||
});
|
||||
173
app/src/solver/__tests__/feasibility.test.ts
Normal file
173
app/src/solver/__tests__/feasibility.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
checkFeasibility,
|
||||
preFilterCandidates,
|
||||
enumerateS2Choices,
|
||||
computeUpperBounds,
|
||||
} from '../feasibility';
|
||||
|
||||
describe('checkFeasibility', () => {
|
||||
it('returns feasible for empty target set', () => {
|
||||
const result = checkFeasibility([], []);
|
||||
expect(result.feasible).toBe(true);
|
||||
});
|
||||
|
||||
it('returns infeasible when no courses qualify for target spec', () => {
|
||||
// Only 1 course qualifying for GLB (2.5 credits) — need 9
|
||||
const result = checkFeasibility(['spr1-global-immersion'], ['GLB']);
|
||||
expect(result.feasible).toBe(false);
|
||||
});
|
||||
|
||||
it('returns feasible with enough qualifying courses for a single spec', () => {
|
||||
// Finance-qualifying courses from different sets
|
||||
const finCourses = [
|
||||
'spr2-financial-services', // FIN ■
|
||||
'spr3-mergers-acquisitions', // FIN ■
|
||||
'spr5-corporate-finance', // FIN ■
|
||||
'sum3-valuation', // FIN ■
|
||||
];
|
||||
const result = checkFeasibility(finCourses, ['FIN']);
|
||||
expect(result.feasible).toBe(true);
|
||||
|
||||
// Check allocations sum to at least 9 for FIN
|
||||
let finTotal = 0;
|
||||
for (const courseAlloc of Object.values(result.allocations)) {
|
||||
finTotal += courseAlloc['FIN'] || 0;
|
||||
}
|
||||
expect(finTotal).toBeGreaterThanOrEqual(9);
|
||||
});
|
||||
|
||||
it('returns feasible for two specs when credits are sufficient', () => {
|
||||
// Need 18 total credits (9 FIN + 9 CRF). 8 courses × 2.5 = 20 available.
|
||||
const courses = [
|
||||
'spr2-financial-services', // CRF FIN (+ BNK FIM)
|
||||
'spr3-mergers-acquisitions', // CRF FIN (+ LCM STR)
|
||||
'spr4-fintech', // FIN only
|
||||
'spr5-corporate-finance', // CRF FIN
|
||||
'sum3-valuation', // CRF FIN (+ BNK FIM)
|
||||
'fall1-private-equity', // CRF FIN (+ BNK FIM)
|
||||
'fall2-behavioral-finance', // CRF FIN (+ BNK FIM)
|
||||
'fall3-climate-finance', // CRF FIN (+ BNK FIM GLB SBI)
|
||||
];
|
||||
const result = checkFeasibility(courses, ['FIN', 'CRF']);
|
||||
expect(result.feasible).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces course capacity constraint (2.5 max per course)', () => {
|
||||
const result = checkFeasibility(
|
||||
['spr2-financial-services', 'spr3-mergers-acquisitions', 'spr5-corporate-finance', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance'],
|
||||
['FIN', 'CRF'],
|
||||
);
|
||||
if (result.feasible) {
|
||||
for (const [courseId, specAlloc] of Object.entries(result.allocations)) {
|
||||
const total = Object.values(specAlloc).reduce((a, b) => a + b, 0);
|
||||
expect(total).toBeLessThanOrEqual(2.5 + 0.001); // float tolerance
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns infeasible when 3 specs need more than 30 available credits', () => {
|
||||
// Only 3 courses (7.5 credits), trying to achieve 3 specs (27 credits needed)
|
||||
const result = checkFeasibility(
|
||||
['spr2-financial-services', 'sum3-valuation', 'fall2-behavioral-finance'],
|
||||
['FIN', 'CRF', 'BNK'],
|
||||
);
|
||||
expect(result.feasible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkFeasibility with S2 constraint', () => {
|
||||
it('respects s2Choice — only chosen S2 course contributes to Strategy', () => {
|
||||
// Courses with Strategy S1 and S2 markers
|
||||
const courses = [
|
||||
'spr3-mergers-acquisitions', // STR S1
|
||||
'spr3-digital-strategy', // STR S2 — we'll choose this
|
||||
'spr5-consulting-practice', // STR S1
|
||||
'sum3-advanced-corporate-strategy', // STR S1
|
||||
'fall3-corporate-governance', // STR S1
|
||||
];
|
||||
// With s2Choice = 'spr3-digital-strategy', it should contribute
|
||||
const result = checkFeasibility(courses, ['STR'], 'spr3-digital-strategy');
|
||||
expect(result.feasible).toBe(true);
|
||||
});
|
||||
|
||||
it('without s2Choice, S2 courses do not contribute to Strategy', () => {
|
||||
// Only S2 courses for Strategy — none should count with s2Choice=null
|
||||
const courses = [
|
||||
'spr3-digital-strategy', // STR S2
|
||||
'fall1-private-equity', // STR S2
|
||||
];
|
||||
const result = checkFeasibility(courses, ['STR'], null);
|
||||
expect(result.feasible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preFilterCandidates', () => {
|
||||
it('excludes specs whose required course is in a pinned set with different selection', () => {
|
||||
// spr4 is pinned to fintech (not sustainability or entrepreneurship)
|
||||
const selected = ['spr4-fintech'];
|
||||
const openSets = ['spr1', 'spr2', 'spr3', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const candidates = preFilterCandidates(selected, openSets);
|
||||
// SBI requires spr4-sustainability, ENT requires spr4-foundations-entrepreneurship
|
||||
// Both are in spr4 which is pinned, so neither should be a candidate
|
||||
expect(candidates).not.toContain('SBI');
|
||||
expect(candidates).not.toContain('ENT');
|
||||
});
|
||||
|
||||
it('includes specs whose required course is selected', () => {
|
||||
const selected = ['fall4-brand-strategy'];
|
||||
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3'];
|
||||
const candidates = preFilterCandidates(selected, openSets);
|
||||
expect(candidates).toContain('BRM');
|
||||
});
|
||||
|
||||
it('includes specs whose required course is in an open set', () => {
|
||||
const selected: string[] = [];
|
||||
const openSets = ['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const candidates = preFilterCandidates(selected, openSets);
|
||||
// All specs with required courses should be candidates when their sets are open
|
||||
expect(candidates).toContain('SBI');
|
||||
expect(candidates).toContain('ENT');
|
||||
expect(candidates).toContain('EMT');
|
||||
expect(candidates).toContain('BRM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enumerateS2Choices', () => {
|
||||
it('returns [null] when no S2 courses are selected', () => {
|
||||
const choices = enumerateS2Choices(['spr1-global-immersion']);
|
||||
expect(choices).toEqual([null]);
|
||||
});
|
||||
|
||||
it('returns null plus each selected S2 course', () => {
|
||||
const choices = enumerateS2Choices([
|
||||
'spr3-digital-strategy', // S2
|
||||
'fall1-private-equity', // S2
|
||||
'spr1-global-immersion', // not S2
|
||||
]);
|
||||
expect(choices).toContain(null);
|
||||
expect(choices).toContain('spr3-digital-strategy');
|
||||
expect(choices).toContain('fall1-private-equity');
|
||||
expect(choices.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeUpperBounds', () => {
|
||||
it('counts selected courses and open sets for each spec', () => {
|
||||
const bounds = computeUpperBounds(
|
||||
['spr1-global-immersion'], // GLB
|
||||
['spr5', 'fall3'], // spr5 has Global Strategy (GLB), fall3 has Climate Finance (GLB)
|
||||
);
|
||||
// spr1 selected (GLB) + 2 open sets with GLB courses = 7.5
|
||||
expect(bounds['GLB']).toBe(7.5);
|
||||
});
|
||||
|
||||
it('does not double-count sets', () => {
|
||||
const bounds = computeUpperBounds(
|
||||
['spr2-financial-services'], // BNK, CRF, FIN, FIM in set spr2
|
||||
[],
|
||||
);
|
||||
// FIN: only 1 course selected from 1 set
|
||||
expect(bounds['FIN']).toBe(2.5);
|
||||
});
|
||||
});
|
||||
134
app/src/solver/__tests__/optimizer.test.ts
Normal file
134
app/src/solver/__tests__/optimizer.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maximizeCount, priorityOrder, determineStatuses, optimize } from '../optimizer';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
// A finance-heavy selection that should achieve finance specs
|
||||
const financeHeavyCourses = [
|
||||
'spr2-financial-services', // BNK CRF FIN FIM
|
||||
'spr3-mergers-acquisitions', // CRF FIN LCM STR(S1)
|
||||
'spr4-fintech', // FIN
|
||||
'spr5-corporate-finance', // CRF FIN
|
||||
'sum3-valuation', // BNK CRF FIN FIM
|
||||
'fall1-private-equity', // BNK CRF FIN FIM STR(S2)
|
||||
'fall2-behavioral-finance', // BNK CRF FIN FIM
|
||||
'fall3-climate-finance', // BNK CRF FIN FIM GLB SBI
|
||||
'fall4-financial-services', // BNK CRF FIN FIM
|
||||
];
|
||||
|
||||
describe('maximizeCount', () => {
|
||||
it('returns empty achieved when no courses are selected', () => {
|
||||
const result = maximizeCount([], allSpecIds, []);
|
||||
expect(result.achieved).toEqual([]);
|
||||
});
|
||||
|
||||
it('achieves specs with enough qualifying courses', () => {
|
||||
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
|
||||
expect(result.achieved.length).toBeGreaterThan(0);
|
||||
// Should be able to achieve some combo of FIN, CRF, BNK, FIM
|
||||
for (const specId of result.achieved) {
|
||||
expect(['BNK', 'CRF', 'FIN', 'FIM', 'LCM', 'GLB', 'SBI', 'STR']).toContain(specId);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers higher-priority specs when breaking ties', () => {
|
||||
// Put FIM first in ranking
|
||||
const ranking = ['FIM', ...allSpecIds.filter((id) => id !== 'FIM')];
|
||||
const result1 = maximizeCount(financeHeavyCourses, ranking, []);
|
||||
|
||||
// Put BNK first
|
||||
const ranking2 = ['BNK', ...allSpecIds.filter((id) => id !== 'BNK')];
|
||||
const result2 = maximizeCount(financeHeavyCourses, ranking2, []);
|
||||
|
||||
// Both should achieve the same count
|
||||
expect(result1.achieved.length).toBe(result2.achieved.length);
|
||||
|
||||
// If they achieve multiple, the first-ranked spec should appear when possible
|
||||
if (result1.achieved.length > 0) {
|
||||
// The highest-priority feasible spec should be in the result
|
||||
expect(result1.achieved).toContain('FIM');
|
||||
}
|
||||
});
|
||||
|
||||
it('never exceeds 3 specializations', () => {
|
||||
const result = maximizeCount(financeHeavyCourses, allSpecIds, []);
|
||||
expect(result.achieved.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('priorityOrder', () => {
|
||||
it('guarantees the top-ranked feasible spec', () => {
|
||||
const ranking = ['FIN', ...allSpecIds.filter((id) => id !== 'FIN')];
|
||||
const result = priorityOrder(financeHeavyCourses, ranking, []);
|
||||
expect(result.achieved[0]).toBe('FIN');
|
||||
});
|
||||
|
||||
it('skips infeasible specs and continues', () => {
|
||||
// GLB has very few qualifying courses in this set
|
||||
const ranking = ['GLB', 'FIN', 'CRF', ...allSpecIds.filter((id) => !['GLB', 'FIN', 'CRF'].includes(id))];
|
||||
const result = priorityOrder(financeHeavyCourses, ranking, []);
|
||||
// GLB might not be achievable on its own, but FIN should be
|
||||
expect(result.achieved).toContain('FIN');
|
||||
});
|
||||
|
||||
it('returns empty when no courses are selected', () => {
|
||||
const result = priorityOrder([], allSpecIds, []);
|
||||
expect(result.achieved).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('determineStatuses', () => {
|
||||
it('marks achieved specs correctly', () => {
|
||||
const statuses = determineStatuses(financeHeavyCourses, [], ['FIN', 'CRF']);
|
||||
expect(statuses['FIN']).toBe('achieved');
|
||||
expect(statuses['CRF']).toBe('achieved');
|
||||
});
|
||||
|
||||
it('marks missing_required when required course set is pinned to different course', () => {
|
||||
// spr4-fintech selected (not sustainability or entrepreneurship)
|
||||
const statuses = determineStatuses(['spr4-fintech'], [], []);
|
||||
expect(statuses['SBI']).toBe('missing_required');
|
||||
expect(statuses['ENT']).toBe('missing_required');
|
||||
});
|
||||
|
||||
it('marks achievable when required course is in open set', () => {
|
||||
const statuses = determineStatuses(
|
||||
[],
|
||||
['spr1', 'spr2', 'spr3', 'spr4', 'spr5', 'sum1', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'],
|
||||
[],
|
||||
);
|
||||
// All specs with enough potential should be achievable
|
||||
expect(statuses['FIN']).toBe('achievable');
|
||||
expect(statuses['MGT']).toBe('achievable');
|
||||
});
|
||||
|
||||
it('marks unreachable when upper bound is below threshold', () => {
|
||||
// Only 1 set open, most specs won't have enough potential
|
||||
const statuses = determineStatuses([], ['spr1'], []);
|
||||
// Most specs only have 1 qualifying course in spr1 (2.5 credits < 9)
|
||||
expect(statuses['FIN']).toBe('unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('optimize (integration)', () => {
|
||||
it('returns a complete AllocationResult', () => {
|
||||
const result = optimize(financeHeavyCourses, allSpecIds, [], 'maximize-count');
|
||||
expect(result.achieved).toBeDefined();
|
||||
expect(result.allocations).toBeDefined();
|
||||
expect(result.statuses).toBeDefined();
|
||||
expect(result.upperBounds).toBeDefined();
|
||||
expect(Object.keys(result.statuses).length).toBe(14);
|
||||
});
|
||||
|
||||
it('modes can produce different results', () => {
|
||||
// Put a niche spec first in priority order
|
||||
const ranking = ['EMT', ...allSpecIds.filter((id) => id !== 'EMT')];
|
||||
const maxResult = optimize(financeHeavyCourses, ranking, [], 'maximize-count');
|
||||
const prioResult = optimize(financeHeavyCourses, ranking, [], 'priority-order');
|
||||
|
||||
// Both should produce valid results
|
||||
expect(maxResult.achieved.length).toBeGreaterThanOrEqual(0);
|
||||
expect(prioResult.achieved.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
141
app/src/solver/decisionTree.ts
Normal file
141
app/src/solver/decisionTree.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
import { maximizeCount, priorityOrder } from './optimizer';
|
||||
|
||||
export interface ChoiceOutcome {
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
ceilingCount: number;
|
||||
ceilingSpecs: string[];
|
||||
}
|
||||
|
||||
export interface SetAnalysis {
|
||||
setId: string;
|
||||
setName: string;
|
||||
impact: number; // variance in ceiling outcomes
|
||||
choices: ChoiceOutcome[];
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
|
||||
/**
|
||||
* Compute the ceiling outcome for a single course choice:
|
||||
* the best achievable result assuming that course is pinned
|
||||
* and all other open sets are chosen optimally.
|
||||
*/
|
||||
function computeCeiling(
|
||||
basePinnedCourses: string[],
|
||||
chosenCourseId: string,
|
||||
otherOpenSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
): { count: number; specs: string[] } {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
|
||||
if (otherOpenSetIds.length === 0) {
|
||||
// No other open sets — just solve with this choice added
|
||||
const selected = [...basePinnedCourses, chosenCourseId];
|
||||
const result = fn(selected, ranking, []);
|
||||
return { count: result.achieved.length, specs: result.achieved };
|
||||
}
|
||||
|
||||
// Enumerate all combinations of remaining open sets
|
||||
let bestCount = 0;
|
||||
let bestSpecs: string[] = [];
|
||||
|
||||
function enumerate(setIndex: number, accumulated: string[]) {
|
||||
// Early termination: already found max (3)
|
||||
if (bestCount >= 3) return;
|
||||
|
||||
if (setIndex >= otherOpenSetIds.length) {
|
||||
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
||||
const result = fn(selected, ranking, []);
|
||||
if (result.achieved.length > bestCount) {
|
||||
bestCount = result.achieved.length;
|
||||
bestSpecs = result.achieved;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setId = otherOpenSetIds[setIndex];
|
||||
const courses = coursesBySet[setId];
|
||||
for (const course of courses) {
|
||||
enumerate(setIndex + 1, [...accumulated, course.id]);
|
||||
if (bestCount >= 3) return;
|
||||
}
|
||||
}
|
||||
|
||||
enumerate(0, []);
|
||||
return { count: bestCount, specs: bestSpecs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute variance of an array of numbers.
|
||||
*/
|
||||
function variance(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all open sets and compute per-choice ceiling outcomes.
|
||||
* Returns sets ordered by decision impact (highest first).
|
||||
*
|
||||
* onSetComplete is called progressively as each set's analysis finishes.
|
||||
*/
|
||||
export function analyzeDecisionTree(
|
||||
pinnedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
onSetComplete?: (analysis: SetAnalysis) => void,
|
||||
): SetAnalysis[] {
|
||||
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
||||
// Fallback: return empty analyses (caller uses upper bounds instead)
|
||||
return openSetIds.map((setId) => {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
return { setId, setName: set.name, impact: 0, choices: [] };
|
||||
});
|
||||
}
|
||||
|
||||
const analyses: SetAnalysis[] = [];
|
||||
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
||||
const courses = coursesBySet[setId];
|
||||
|
||||
const choices: ChoiceOutcome[] = courses.map((course) => {
|
||||
const ceiling = computeCeiling(
|
||||
pinnedCourseIds,
|
||||
course.id,
|
||||
otherOpenSets,
|
||||
ranking,
|
||||
mode,
|
||||
);
|
||||
return {
|
||||
courseId: course.id,
|
||||
courseName: course.name,
|
||||
ceilingCount: ceiling.count,
|
||||
ceilingSpecs: ceiling.specs,
|
||||
};
|
||||
});
|
||||
|
||||
const impact = variance(choices.map((c) => c.ceilingCount));
|
||||
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
||||
analyses.push(analysis);
|
||||
|
||||
onSetComplete?.(analysis);
|
||||
}
|
||||
|
||||
// Sort by impact descending, then by set order (chronological) for ties
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
analyses.sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return analyses;
|
||||
}
|
||||
193
app/src/solver/feasibility.ts
Normal file
193
app/src/solver/feasibility.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import solver from 'javascript-lp-solver';
|
||||
import { COURSES } from '../data/courses';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { coursesBySpec, setIdByCourse } from '../data/lookups';
|
||||
import type { MarkerType } from '../data/types';
|
||||
|
||||
const CREDIT_PER_COURSE = 2.5;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
|
||||
export interface FeasibilityResult {
|
||||
feasible: boolean;
|
||||
allocations: Record<string, Record<string, number>>; // courseId -> specId -> credits
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a target set of specializations can each reach 9 credits
|
||||
* given a fixed set of selected courses.
|
||||
*
|
||||
* When Strategy is in targetSpecs, s2Choice controls which S2 course (if any)
|
||||
* may contribute credits to Strategy.
|
||||
*/
|
||||
export function checkFeasibility(
|
||||
selectedCourseIds: string[],
|
||||
targetSpecIds: string[],
|
||||
s2Choice: string | null = null,
|
||||
): FeasibilityResult {
|
||||
if (targetSpecIds.length === 0) {
|
||||
return { feasible: true, allocations: {} };
|
||||
}
|
||||
|
||||
// Build the set of valid (course, spec) pairs
|
||||
const selectedSet = new Set(selectedCourseIds);
|
||||
const targetSet = new Set(targetSpecIds);
|
||||
|
||||
// Build LP model
|
||||
const variables: Record<string, Record<string, number>> = {};
|
||||
const constraints: Record<string, { max?: number; min?: number }> = {};
|
||||
|
||||
// For each selected course, add a capacity constraint: sum of allocations <= 2.5
|
||||
for (const courseId of selectedCourseIds) {
|
||||
const course = COURSES.find((c) => c.id === courseId)!;
|
||||
const capacityKey = `cap_${courseId}`;
|
||||
constraints[capacityKey] = { max: CREDIT_PER_COURSE };
|
||||
|
||||
for (const q of course.qualifications) {
|
||||
if (!targetSet.has(q.specId)) continue;
|
||||
|
||||
// Strategy S2 constraint: only the chosen S2 course can contribute to Strategy
|
||||
if (q.specId === 'STR' && q.marker === 'S2') {
|
||||
if (s2Choice !== courseId) continue;
|
||||
}
|
||||
|
||||
const varName = `x_${courseId}_${q.specId}`;
|
||||
variables[varName] = {
|
||||
[capacityKey]: 1,
|
||||
[`need_${q.specId}`]: 1,
|
||||
_dummy: 0, // need an optimize target
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For each target spec, add a demand constraint: sum of allocations >= 9
|
||||
for (const specId of targetSpecIds) {
|
||||
constraints[`need_${specId}`] = { min: CREDIT_THRESHOLD };
|
||||
}
|
||||
|
||||
// If no variables were created, it's infeasible
|
||||
if (Object.keys(variables).length === 0) {
|
||||
return { feasible: false, allocations: {} };
|
||||
}
|
||||
|
||||
const model = {
|
||||
optimize: '_dummy',
|
||||
opType: 'max' as const,
|
||||
constraints,
|
||||
variables,
|
||||
};
|
||||
|
||||
const result = solver.Solve(model);
|
||||
|
||||
if (!result.feasible) {
|
||||
return { feasible: false, allocations: {} };
|
||||
}
|
||||
|
||||
// Extract allocations from result
|
||||
const allocations: Record<string, Record<string, number>> = {};
|
||||
for (const key of Object.keys(result)) {
|
||||
if (!key.startsWith('x_')) continue;
|
||||
const val = result[key] as number;
|
||||
if (val <= 0) continue;
|
||||
const parts = key.split('_');
|
||||
// key format: x_<courseId>_<specId>
|
||||
// courseId may contain hyphens, specId is always 3 chars at the end
|
||||
const specId = parts[parts.length - 1];
|
||||
const courseId = parts.slice(1, -1).join('_');
|
||||
if (!allocations[courseId]) allocations[courseId] = {};
|
||||
allocations[courseId][specId] = val;
|
||||
}
|
||||
|
||||
return { feasible: true, allocations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-filter specializations to only those that could potentially be achieved.
|
||||
* Removes specs whose required course is not selected and not available in open sets.
|
||||
*/
|
||||
export function preFilterCandidates(
|
||||
selectedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
): string[] {
|
||||
const selectedSet = new Set(selectedCourseIds);
|
||||
const openSetSet = new Set(openSetIds);
|
||||
|
||||
return SPECIALIZATIONS.filter((spec) => {
|
||||
// Check required course gate
|
||||
if (spec.requiredCourseId) {
|
||||
if (!selectedSet.has(spec.requiredCourseId)) {
|
||||
// Is the required course available in an open set?
|
||||
const requiredCourse = COURSES.find((c) => c.id === spec.requiredCourseId)!;
|
||||
if (!openSetSet.has(requiredCourse.setId)) {
|
||||
return false; // required course's set is pinned to something else
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check upper-bound credit potential
|
||||
const entries = coursesBySpec[spec.id] || [];
|
||||
let potential = 0;
|
||||
const countedSets = new Set<string>();
|
||||
for (const e of entries) {
|
||||
const setId = setIdByCourse[e.courseId];
|
||||
if (selectedSet.has(e.courseId)) {
|
||||
if (!countedSets.has(setId)) {
|
||||
potential += CREDIT_PER_COURSE;
|
||||
countedSets.add(setId);
|
||||
}
|
||||
} else if (openSetSet.has(setId) && !countedSets.has(setId)) {
|
||||
potential += CREDIT_PER_COURSE;
|
||||
countedSets.add(setId);
|
||||
}
|
||||
}
|
||||
return potential >= CREDIT_THRESHOLD;
|
||||
}).map((s) => s.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of S2 course options for Strategy enumeration.
|
||||
* Includes null (no S2 course contributes).
|
||||
*/
|
||||
export function enumerateS2Choices(selectedCourseIds: string[]): (string | null)[] {
|
||||
const selectedSet = new Set(selectedCourseIds);
|
||||
const s2Courses = COURSES.filter(
|
||||
(c) =>
|
||||
selectedSet.has(c.id) &&
|
||||
c.qualifications.some((q) => q.specId === 'STR' && q.marker === 'S2'),
|
||||
).map((c) => c.id);
|
||||
|
||||
return [null, ...s2Courses];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute upper-bound credit potential per specialization.
|
||||
* Ignores credit sharing — used only for reachability status determination.
|
||||
*/
|
||||
export function computeUpperBounds(
|
||||
selectedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
): Record<string, number> {
|
||||
const selectedSet = new Set(selectedCourseIds);
|
||||
const openSetSet = new Set(openSetIds);
|
||||
const bounds: Record<string, number> = {};
|
||||
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
const entries = coursesBySpec[spec.id] || [];
|
||||
let potential = 0;
|
||||
const countedSets = new Set<string>();
|
||||
for (const e of entries) {
|
||||
const setId = setIdByCourse[e.courseId];
|
||||
if (selectedSet.has(e.courseId)) {
|
||||
if (!countedSets.has(setId)) {
|
||||
potential += CREDIT_PER_COURSE;
|
||||
countedSets.add(setId);
|
||||
}
|
||||
} else if (openSetSet.has(setId) && !countedSets.has(setId)) {
|
||||
potential += CREDIT_PER_COURSE;
|
||||
countedSets.add(setId);
|
||||
}
|
||||
}
|
||||
bounds[spec.id] = potential;
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
199
app/src/solver/optimizer.ts
Normal file
199
app/src/solver/optimizer.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { COURSES } from '../data/courses';
|
||||
import { coursesBySpec, setIdByCourse } from '../data/lookups';
|
||||
import type { AllocationResult, SpecStatus } from '../data/types';
|
||||
import {
|
||||
checkFeasibility,
|
||||
enumerateS2Choices,
|
||||
preFilterCandidates,
|
||||
computeUpperBounds,
|
||||
} from './feasibility';
|
||||
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
const CREDIT_PER_COURSE = 2.5;
|
||||
|
||||
/**
|
||||
* Generate all combinations of k items from arr.
|
||||
*/
|
||||
function combinations<T>(arr: T[], k: number): T[][] {
|
||||
if (k === 0) return [[]];
|
||||
if (k > arr.length) return [];
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i <= arr.length - k; i++) {
|
||||
for (const rest of combinations(arr.slice(i + 1), k - 1)) {
|
||||
result.push([arr[i], ...rest]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to check feasibility for a target set, handling S2 enumeration for Strategy.
|
||||
*/
|
||||
function checkWithS2(
|
||||
selectedCourseIds: string[],
|
||||
targetSpecIds: string[],
|
||||
): { feasible: boolean; allocations: Record<string, Record<string, number>> } {
|
||||
const hasStrategy = targetSpecIds.includes('STR');
|
||||
if (!hasStrategy) {
|
||||
return checkFeasibility(selectedCourseIds, targetSpecIds);
|
||||
}
|
||||
|
||||
// Enumerate S2 choices
|
||||
const s2Choices = enumerateS2Choices(selectedCourseIds);
|
||||
for (const s2Choice of s2Choices) {
|
||||
const result = checkFeasibility(selectedCourseIds, targetSpecIds, s2Choice);
|
||||
if (result.feasible) return result;
|
||||
}
|
||||
return { feasible: false, allocations: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximize Count mode: find the largest set of achievable specializations.
|
||||
* Among equal-size feasible subsets, prefer the one with the highest priority score.
|
||||
*/
|
||||
export function maximizeCount(
|
||||
selectedCourseIds: string[],
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
|
||||
const candidates = preFilterCandidates(selectedCourseIds, openSetIds);
|
||||
|
||||
// Only check specs that can be achieved from selected courses alone (not open sets)
|
||||
// Filter to candidates that have qualifying selected courses
|
||||
const achievable = candidates.filter((specId) => {
|
||||
const entries = coursesBySpec[specId] || [];
|
||||
return entries.some((e) => selectedCourseIds.includes(e.courseId));
|
||||
});
|
||||
|
||||
// Priority score: sum of (15 - rank position) for each spec in subset
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
function priorityScore(specs: string[]): number {
|
||||
return specs.reduce((sum, id) => sum + (15 - (rankIndex.get(id) ?? 14)), 0);
|
||||
}
|
||||
|
||||
// Try from size 3 down to 0
|
||||
const maxSize = Math.min(3, achievable.length);
|
||||
for (let size = maxSize; size >= 1; size--) {
|
||||
const subsets = combinations(achievable, size);
|
||||
|
||||
// Sort subsets by priority score descending — check best-scoring first
|
||||
subsets.sort((a, b) => priorityScore(b) - priorityScore(a));
|
||||
|
||||
let bestResult: { achieved: string[]; allocations: Record<string, Record<string, number>> } | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const subset of subsets) {
|
||||
const result = checkWithS2(selectedCourseIds, subset);
|
||||
if (result.feasible) {
|
||||
const score = priorityScore(subset);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestResult = { achieved: subset, allocations: result.allocations };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestResult) return bestResult;
|
||||
}
|
||||
|
||||
return { achieved: [], allocations: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority Order mode: process specializations in rank order,
|
||||
* adding each if feasible with the current achieved set.
|
||||
*/
|
||||
export function priorityOrder(
|
||||
selectedCourseIds: string[],
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
): { achieved: string[]; allocations: Record<string, Record<string, number>> } {
|
||||
const candidates = new Set(preFilterCandidates(selectedCourseIds, openSetIds));
|
||||
|
||||
// Only consider specs that have qualifying selected courses
|
||||
const withSelectedCourses = new Set(
|
||||
SPECIALIZATIONS.filter((spec) => {
|
||||
const entries = coursesBySpec[spec.id] || [];
|
||||
return entries.some((e) => selectedCourseIds.includes(e.courseId));
|
||||
}).map((s) => s.id),
|
||||
);
|
||||
|
||||
const achieved: string[] = [];
|
||||
let lastAllocations: Record<string, Record<string, number>> = {};
|
||||
|
||||
for (const specId of ranking) {
|
||||
if (!candidates.has(specId)) continue;
|
||||
if (!withSelectedCourses.has(specId)) continue;
|
||||
if (achieved.length >= 3) break;
|
||||
|
||||
const trySet = [...achieved, specId];
|
||||
const result = checkWithS2(selectedCourseIds, trySet);
|
||||
if (result.feasible) {
|
||||
achieved.push(specId);
|
||||
lastAllocations = result.allocations;
|
||||
}
|
||||
}
|
||||
|
||||
return { achieved, allocations: lastAllocations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the status of each specialization after optimization.
|
||||
*/
|
||||
export function determineStatuses(
|
||||
selectedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
achieved: string[],
|
||||
): Record<string, SpecStatus> {
|
||||
const achievedSet = new Set(achieved);
|
||||
const selectedSet = new Set(selectedCourseIds);
|
||||
const openSetSet = new Set(openSetIds);
|
||||
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
|
||||
const statuses: Record<string, SpecStatus> = {};
|
||||
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
if (achievedSet.has(spec.id)) {
|
||||
statuses[spec.id] = 'achieved';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check required course gate
|
||||
if (spec.requiredCourseId) {
|
||||
if (!selectedSet.has(spec.requiredCourseId)) {
|
||||
const requiredCourse = COURSES.find((c) => c.id === spec.requiredCourseId)!;
|
||||
if (!openSetSet.has(requiredCourse.setId)) {
|
||||
statuses[spec.id] = 'missing_required';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check upper bound
|
||||
if (upperBounds[spec.id] < CREDIT_THRESHOLD) {
|
||||
statuses[spec.id] = 'unreachable';
|
||||
continue;
|
||||
}
|
||||
|
||||
statuses[spec.id] = 'achievable';
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full optimization pipeline.
|
||||
*/
|
||||
export function optimize(
|
||||
selectedCourseIds: string[],
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
mode: 'maximize-count' | 'priority-order',
|
||||
): AllocationResult {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
const { achieved, allocations } = fn(selectedCourseIds, ranking, openSetIds);
|
||||
const statuses = determineStatuses(selectedCourseIds, openSetIds, achieved);
|
||||
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds);
|
||||
|
||||
return { achieved, allocations, statuses, upperBounds };
|
||||
}
|
||||
Reference in New Issue
Block a user