Files
emba-course-solver/app/src/solver/__tests__/searchDecisionTree.test.ts
T
Bill b282709476 v1.3.3: Lex priority comparator + warm-cache cap + score display
The v1.3.1 comparator used a sum-of-weights priorityScore. With weights
15..1 across 15 specs, three lower-priority specs (BNK+BRM+CRF, sum 39)
could outrank a single top-priority spec (HCR alone, sum 15). In
priority-order mode this surfaced lower-priority plans above the user's
top spec — the opposite of intent.

Fix: replace sum-of-weights with a lexicographic rank weight. Each spec
encodes as a bit, top-ranked spec = highest bit. So [HCR] = 16384 beats
[BNK,BRM,CRF,EMT,ENT,FIN,FIM,GLB,LCM,MGT,MKT,MTO,SBI,STR] = 16383. A plan
containing a higher-ranked spec ALWAYS outranks any plan that doesn't,
regardless of how many lower-ranked specs the latter contains. Lower
specs only act as tiebreakers among plans that all contain the same
higher-ranked spec.

Both modes use lex weight as the priority key; modes still differ in
ordering:
  priority-order: (rankWeight desc, count desc, key asc)
  maximize-count: (count desc, rankWeight desc, key asc)

Score display changes from the legacy sum (e.g. "score 29") to the lex
weight in compact form (e.g. "score 24.6k"). Hover for full integer.
The display now actually corresponds to ranking order.

Other:

- Cache cap (500k leaves) now retains existing entries instead of
  clearing on overflow. New entries past the cap are dropped; the
  cached subset stays available as a warm starting point.
- Two new lex-weight tests in searchDecisionTree.test.ts:
  - single top-ranked spec outweighs all 14 others combined
  - tiebreaker is the next-ranked spec
- All 84 tests pass; cached leaves stay valid across the comparator
  change since achievedSpecs (the input to lex compare) is unchanged.

Files: solver/priority.ts (new functions), solver/decisionTree.ts
(comparators take ranking), components/{TopPlans,CourseSelection}.tsx
(score display + Recommended badge), state/appState.ts (cache-cap
behavior), vite.config.ts, CHANGELOG.md.
2026-05-09 16:51:54 -04:00

365 lines
13 KiB
TypeScript

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<number>(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<number>(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<number>(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<string, string> = {};
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);
});