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:
2026-02-28 20:43:00 -05:00
parent e62afa631b
commit 9e00901179
43 changed files with 10098 additions and 0 deletions

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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;
}

View 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
View 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 };
}