import { describe, it, expect } from 'vitest'; import { searchDecisionTree, BoundedRankedList, compareOutcomes, selectPriorityTarget, reorderForTarget, reorderByReachableQualCount, PROGRESS_THROTTLE_MS, type PlanOutcome, } from '../decisionTree'; import { computeUpperBounds } from '../feasibility'; import { SPECIALIZATIONS } from '../../data/specializations'; import { COURSES } from '../../data/courses'; import { ELECTIVE_SETS } from '../../data/electiveSets'; import { priorityScore } from '../priority'; const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id)); const allSpecIds = SPECIALIZATIONS.map((s) => s.id); describe('priorityScore', () => { it('weights specs by their position in ranking', () => { const ranking = ['HCR', 'BNK', 'FIN']; expect(priorityScore(['HCR'], ranking)).toBe(15); expect(priorityScore(['BNK'], ranking)).toBe(14); expect(priorityScore(['FIN'], ranking)).toBe(13); expect(priorityScore(['HCR', 'BNK'], ranking)).toBe(29); expect(priorityScore([], ranking)).toBe(0); }); it('falls back to lowest weight for unranked specs', () => { const ranking = ['HCR']; // FALLBACK_RANK = 14 → weight = 15 - 14 = 1 expect(priorityScore(['STR'], ranking)).toBe(1); }); }); describe('BoundedRankedList', () => { const cmp = (a: number, b: number) => b - a; // sort descending it('inserts items maintaining descending order', () => { const list = new BoundedRankedList(5, cmp); expect(list.tryInsert(3)).toBe(true); expect(list.tryInsert(7)).toBe(true); expect(list.tryInsert(5)).toBe(true); expect(list.toArray()).toEqual([7, 5, 3]); }); it('drops worst entry when over capacity', () => { const list = new BoundedRankedList(3, cmp); [10, 5, 8, 1, 12].forEach((v) => list.tryInsert(v)); expect(list.toArray()).toEqual([12, 10, 8]); }); it('rejects items that cannot enter at capacity', () => { const list = new BoundedRankedList(2, cmp); list.tryInsert(10); list.tryInsert(5); expect(list.tryInsert(1)).toBe(false); expect(list.toArray()).toEqual([10, 5]); }); }); describe('priorityRankWeight (lex compare)', () => { it('a single top-ranked spec outweighs any combination of lower-ranked specs', async () => { const { priorityRankWeight } = await import('../priority'); const ranking = ['HCR', 'BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR']; const justHCR = priorityRankWeight(['HCR'], ranking); const allOthers = priorityRankWeight(['BNK', 'BRM', 'CRF', 'EMT', 'ENT', 'FIN', 'FIM', 'GLB', 'LCM', 'MGT', 'MKT', 'MTO', 'SBI', 'STR'], ranking); expect(justHCR).toBeGreaterThan(allOthers); }); it('among plans containing the top spec, the next-ranked spec is the tiebreaker', async () => { const { priorityRankWeight } = await import('../priority'); const ranking = ['HCR', 'BNK', 'CRF']; const hcrBnk = priorityRankWeight(['HCR', 'BNK'], ranking); const hcrCrf = priorityRankWeight(['HCR', 'CRF'], ranking); expect(hcrBnk).toBeGreaterThan(hcrCrf); }); }); describe('compareOutcomes', () => { const make = (specs: string[], score: number, key: string): PlanOutcome => ({ courseAssignments: { spr1: key }, achievedSpecs: specs, priorityScore: score, }); it('higher count beats lower count regardless of priority', () => { const a = make(['BNK', 'FIN', 'CRF'], 5, 'a'); const b = make(['HCR'], 100, 'b'); expect(compareOutcomes(a, b)).toBeLessThan(0); }); it('equal count → higher priority score wins', () => { const a = make(['HCR', 'BNK'], 29, 'a'); const b = make(['FIN', 'MTO'], 22, 'b'); expect(compareOutcomes(a, b)).toBeLessThan(0); }); it('equal count and score → deterministic tiebreak by assignment key', () => { const a = make(['HCR'], 15, 'aaa'); const b = make(['HCR'], 15, 'bbb'); // a has lex-smaller key, so a wins (returns negative) expect(compareOutcomes(a, b)).toBeLessThan(0); }); }); describe('selectPriorityTarget / reorderForTarget', () => { it('returns first reachable spec in ranking', () => { const upper = { HCR: 5, BNK: 12, FIN: 20 }; expect(selectPriorityTarget(['HCR', 'BNK', 'FIN'], upper)).toBe('BNK'); }); it('returns null when no spec is reachable', () => { const upper = { HCR: 5, BNK: 7 }; expect(selectPriorityTarget(['HCR', 'BNK'], upper)).toBe(null); }); it('reorders set children with target-qualifying first', () => { // spr3 contains analytics-ml (HCR), mergers-acq (CRF/FIN/LCM/STR-S1), etc. const reordered = reorderForTarget('spr3', 'HCR', cancelledIds); expect(reordered[0].id).toBe('spr3-analytics-ml'); }); it('returns courses unchanged when target is null', () => { const original = reorderForTarget('spr3', null, cancelledIds); expect(original.map((c) => c.id)).toEqual([ 'spr3-mergers-acquisitions', 'spr3-digital-strategy', 'spr3-managing-high-tech', 'spr3-analytics-ml', ]); }); }); describe('searchDecisionTree — HCR reproduction scenario', () => { const PINNED = [ 'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion', ]; const OPEN_SETS = ELECTIVE_SETS .map((s) => s.id) .filter( (id) => !PINNED.some( (p) => COURSES.find((c) => c.id === p)?.setId === id, ), ); const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')]; it('topK[0] achieves HCR when HCR is ranked first', () => { const result = searchDecisionTree( PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds, ); expect(result.topK.length).toBeGreaterThan(0); expect(result.topK[0].achievedSpecs).toContain('HCR'); }, 30_000); it('per-set ceiling for spr3-analytics-ml includes HCR', () => { const result = searchDecisionTree( PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds, ); const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3'); const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml'); expect(aml?.ceilingSpecs).toContain('HCR'); }, 30_000); }); describe('searchDecisionTree — ordering and streaming', () => { it('streamed topK is monotonically improving', () => { const PINNED = [ 'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion', ]; const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4']; const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')]; const snapshots: PlanOutcome[][] = []; searchDecisionTree( PINNED, OPEN_SETS, RANKING, 'priority-order', 10, { onTopKUpdate: (topK) => { snapshots.push(topK); }, }, cancelledIds, ); expect(snapshots.length).toBeGreaterThan(0); // Each snapshot's [0] is no worse than predecessor's [0] for (let i = 1; i < snapshots.length; i++) { const prev = snapshots[i - 1][0]; const curr = snapshots[i][0]; expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0); } }, 30_000); }); describe('searchDecisionTree — exhaustive termination', () => { it('exhausts the cartesian product when within the cap', () => { // 2 open sets, ~16 leaves total const PINNED = [ 'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration', 'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance', ]; const OPEN_SETS = ['fall3', 'fall4']; const result = searchDecisionTree( PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, undefined, cancelledIds, ); expect(result.partial).toBe(false); expect(result.iterations).toBe(result.iterationsTotal); // Every cell in every set is evaluated after exhaustion for (const setAnalysis of result.setAnalyses) { for (const choice of setAnalysis.choices) { expect(choice.evaluated).toBe(true); } } }); }); describe('searchDecisionTree — mode-dependent ordering', () => { it('reorderByReachableQualCount puts generalist courses first when many specs are reachable', () => { // All sets open so most specs are reachable; gives the heuristic something to weight against const allSets = ELECTIVE_SETS.map((s) => s.id); const upper = computeUpperBounds([], allSets, cancelledIds); const reordered = reorderByReachableQualCount('fall3', upper, cancelledIds); // Climate Finance (BNK,CRF,FIN,FIM,GLB,SBI = 6 quals) should beat // Corporate Governance (LCM,MGT,SBI,STR-S1 = 4 quals) const climateIdx = reordered.findIndex((c) => c.id === 'fall3-climate-finance'); const corpGovIdx = reordered.findIndex((c) => c.id === 'fall3-corporate-governance'); expect(climateIdx).toBeLessThan(corpGovIdx); }); it('maximize-count first leaf uses the generalist-ordered choices', () => { // Capture the first leaf evaluated in maximize-count mode by inspecting // the first onChoiceUpdate event for each set const PINNED = [ 'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration', 'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance', ]; const OPEN_SETS = ['fall3', 'fall4']; const firstChoiceBySet: Record = {}; searchDecisionTree( PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, { onChoiceUpdate: (setId, analysis) => { if (!(setId in firstChoiceBySet)) { // The first cell flipped to evaluated within this update is the // course we're after — but the analysis sends the whole choices // array. Find the first evaluated course in declaration order. const firstEval = analysis.choices.find((c) => c.evaluated); if (firstEval) firstChoiceBySet[setId] = firstEval.courseId; } }, }, cancelledIds, ); // For fall3 in max-count mode: climate-finance (most generalist) should be the first expect(firstChoiceBySet['fall3']).toBe('fall3-climate-finance'); }); }); describe('searchDecisionTree — evaluated flag transitions', () => { it('cells start unevaluated and flip true after first leaf containing them', () => { const PINNED = [ 'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration', 'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance', ]; const OPEN_SETS = ['fall3', 'fall4']; const result = searchDecisionTree( PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, undefined, cancelledIds, ); for (const sa of result.setAnalyses) { for (const choice of sa.choices) { expect(choice.evaluated).toBe(true); } } }); }); describe('searchDecisionTree — progress events', () => { it('emits progress events throttled to PROGRESS_THROTTLE_MS', () => { const PINNED = [ 'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion', ]; const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4']; const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')]; const timestamps: number[] = []; searchDecisionTree( PINNED, OPEN_SETS, RANKING, 'priority-order', 10, { onProgress: () => timestamps.push(Date.now()) }, cancelledIds, ); // At least one progress emit (the final one always fires) expect(timestamps.length).toBeGreaterThan(0); // Consecutive emits respect the throttle (allow 5ms jitter) for (let i = 1; i < timestamps.length - 1; i++) { const delta = timestamps[i] - timestamps[i - 1]; expect(delta).toBeGreaterThanOrEqual(PROGRESS_THROTTLE_MS - 5); } }, 30_000); }); describe('searchDecisionTree — performance smoke (exhaustive)', () => { it('user scenario completes in under 60s for K=10', () => { const PINNED = [ 'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion', ]; const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4']; const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')]; const start = Date.now(); const result = searchDecisionTree( PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds, ); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(60_000); expect(result.partial).toBe(false); expect(result.iterations).toBe(result.iterationsTotal); }, 90_000); });