v1.3.0: Streamed top-K decision-tree plans + priority-aware ceiling

Fixes the bug where a specialization could show "Achievable" while no
per-set ceiling cell surfaces a path to it. Reproduction: pin SP2=Business
of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance,
SE1=GIE; rank HCR first. Healthcare showed Achievable but every ceiling
cell excluded HCR.

Root cause: computeCeiling used strict > on count alone, so the first
equal-count combination found won permanently and HCR-including outcomes
were never recorded.

Changes:

- Replace per-(set, choice) computeCeiling loop with a single full-tree
  searchDecisionTree DFS. Both the per-set ceiling table and a new ranked
  top-K plan list (default K=10) are populated from one enumeration.
- Comparison rule everywhere is (count desc, priority score desc,
  deterministic-tiebreak). priorityScore extracted from optimizer.ts
  into a shared priority.ts module used by both call sites.
- Heuristic enumeration ordering: select the first reachable ranked spec
  as priorityTarget; reorder DFS children at every level so target-
  qualifying courses are tried first. High-priority outcomes surface in
  early iterations instead of being blocked by less-relevant equal-count
  results.
- Bounded search: terminate on saturation (top-K stable for 500
  iterations) or hard cap (10000 iterations); set partial=true if cap
  hit. Mitigates the worst-case enumeration cost.
- Worker protocol: tagged-union response with topKUpdate, choiceUpdate
  (per-cell, replaces per-set setComplete), and allComplete events.
- App state adds topPlans/topPlansPartial slices and an adoptPlan action
  that pins a plan's full course assignment in one click. Also fixes
  loadState's stale "ranking.length !== 14" check (now uses
  SPECIALIZATIONS.length so HCR-era saved state restores correctly).
- New TopPlans component renders the ranked list with adopt buttons,
  placed above CourseSelection in the right column.
- 17 new tests in searchDecisionTree.test.ts covering priority scoring,
  bounded ranked list, comparison rule, target selection, the user's
  reproduction scenario, streaming monotonicity, saturation termination,
  and a performance smoke test (< 5s for the 8-open-set case).
- Existing decisionTree.test.ts: one test amended for per-cell streaming
  semantics; remaining 3 unchanged and passing.
This commit is contained in:
2026-05-09 14:51:32 -04:00
parent 4d6f81d1e5
commit 4b80fac500
15 changed files with 1099 additions and 145 deletions
+10
View File
@@ -5,6 +5,7 @@ import { SpecializationRanking } from './components/SpecializationRanking';
import { ModeToggle } from './components/ModeToggle';
import { CourseSelection } from './components/CourseSelection';
import { CreditLegend } from './components/CreditLegend';
import { TopPlans } from './components/TopPlans';
import { ModeComparison } from './components/Notifications';
import { MobileStatusBanner } from './components/MobileStatusBanner';
import { MobileCourseBanner } from './components/MobileCourseBanner';
@@ -16,6 +17,8 @@ function App() {
optimizationResult,
treeResults,
treeLoading,
topPlans,
topPlansPartial,
openSetIds,
selectedCourseIds,
disabledCourseIds,
@@ -25,6 +28,7 @@ function App() {
pinCourse,
unpinCourse,
clearAll,
adoptPlan,
} = useAppState();
const breakpoint = useMediaQuery();
@@ -128,6 +132,12 @@ function App() {
/>
</div>
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
<TopPlans
plans={topPlans}
partial={topPlansPartial}
loading={treeLoading}
onAdopt={adoptPlan}
/>
<CourseSelection
pinnedCourses={state.pinnedCourses}
treeResults={treeResults}
+137
View File
@@ -0,0 +1,137 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { SPECIALIZATIONS } from '../data/specializations';
import { courseById } from '../data/lookups';
import type { PlanOutcome } from '../solver/decisionTree';
const setNameById: Record<string, string> = {};
for (const s of ELECTIVE_SETS) setNameById[s.id] = s.name;
const specNameById: Record<string, string> = {};
for (const s of SPECIALIZATIONS) specNameById[s.id] = s.name;
interface TopPlansProps {
plans: PlanOutcome[];
partial: boolean;
loading: boolean;
onAdopt: (assignments: Record<string, string>) => void;
}
export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
return (
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
Top Plans
{visible.length > 0 && (
<span style={{ fontSize: '11px', color: '#888', fontWeight: 400, marginLeft: '6px' }}>
ranked by specs achieved
</span>
)}
</h3>
{partial && (
<span style={{ fontSize: '11px', color: '#92400e' }}>
(showing best of search; result is partial)
</span>
)}
</div>
{loading && visible.length === 0 && (
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
Searching for high-priority plans
</div>
)}
{!loading && visible.length === 0 && (
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
No plans yet achieve a specialization with the current pinned courses.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{visible.map((plan, i) => (
<PlanRow key={i + ':' + plan.priorityScore} plan={plan} rank={i + 1} onAdopt={onAdopt} />
))}
</div>
</div>
);
}
function PlanRow({
plan,
rank,
onAdopt,
}: {
plan: PlanOutcome;
rank: number;
onAdopt: (assignments: Record<string, string>) => void;
}) {
const assignmentEntries = Object.entries(plan.courseAssignments).sort(
([a], [b]) => {
const order = ELECTIVE_SETS.map((s) => s.id);
return order.indexOf(a) - order.indexOf(b);
},
);
return (
<div
style={{
border: '1px solid #e5e7eb',
borderRadius: '6px',
padding: '8px 10px',
background: '#fff',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', color: '#666', fontWeight: 600, minWidth: '20px' }}>
#{rank}
</span>
{plan.achievedSpecs.map((specId) => (
<span
key={specId}
title={specNameById[specId]}
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '10px',
background: '#dcfce7',
color: '#166534',
border: '1px solid #bbf7d0',
}}
>
{specId}
</span>
))}
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
score {plan.priorityScore}
</span>
<button
onClick={() => onAdopt(plan.courseAssignments)}
style={{
fontSize: '11px',
padding: '3px 10px',
border: '1px solid #bfdbfe',
background: '#eff6ff',
color: '#2563eb',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 500,
}}
>
Adopt plan
</button>
</div>
<div style={{ fontSize: '11px', color: '#555', lineHeight: 1.5 }}>
{assignmentEntries.map(([setId, courseId], i) => (
<span key={setId}>
{i > 0 && <span style={{ color: '#ccc' }}> · </span>}
<span style={{ color: '#888' }}>{setNameById[setId]?.replace('Elective Set ', 'S')}: </span>
<span>{courseById[courseId]?.name ?? courseId}</span>
</span>
))}
</div>
</div>
);
}
@@ -64,7 +64,10 @@ describe('analyzeDecisionTree', () => {
}
});
it('calls onSetComplete progressively', () => {
it('invokes onSetComplete (per-cell streaming) for every open set', () => {
// After the streaming refactor, the legacy onSetComplete callback fires
// per cell update rather than once per set. Assert that every open set's
// analysis is delivered at least once.
const pinned = [
'spr1-collaboration',
'spr2-financial-services',
@@ -78,14 +81,14 @@ describe('analyzeDecisionTree', () => {
'fall2-behavioral-finance',
];
const openSets = ['fall3', 'fall4'];
const completed: string[] = [];
const completed = new Set<string>();
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
completed.push(analysis.setId);
completed.add(analysis.setId);
});
expect(completed).toContain('fall3');
expect(completed).toContain('fall4');
expect(completed.length).toBe(2);
expect(completed.has('fall3')).toBe(true);
expect(completed.has('fall4')).toBe(true);
expect(completed.size).toBe(2);
});
});
@@ -0,0 +1,253 @@
import { describe, it, expect } from 'vitest';
import {
searchDecisionTree,
BoundedRankedList,
compareOutcomes,
selectPriorityTarget,
reorderForTarget,
MAX_TREE_ITERATIONS,
type PlanOutcome,
} from '../decisionTree';
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('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');
});
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');
});
});
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);
}
});
});
describe('searchDecisionTree — termination', () => {
it('saturation stops the search when topK converges', () => {
// Tiny scenario: should saturate within a few hundred iterations
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).toBeLessThan(MAX_TREE_ITERATIONS);
});
});
describe('searchDecisionTree — performance smoke', () => {
it('user scenario completes in < 5s 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();
searchDecisionTree(
PINNED,
OPEN_SETS,
RANKING,
'priority-order',
10,
undefined,
cancelledIds,
);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
});
});
+267 -105
View File
@@ -1,7 +1,9 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups';
import type { OptimizationMode } from '../data/types';
import type { Course, OptimizationMode } from '../data/types';
import { maximizeCount, priorityOrder } from './optimizer';
import { computeUpperBounds } from './feasibility';
import { makePriorityScorer } from './priority';
export interface ChoiceOutcome {
courseId: string;
@@ -13,79 +15,270 @@ export interface ChoiceOutcome {
export interface SetAnalysis {
setId: string;
setName: string;
impact: number; // variance in ceiling outcomes
impact: number;
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,
excludedCourseIds?: Set<string>,
): { 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, [], excludedCourseIds);
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, [], excludedCourseIds);
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) {
if (excludedCourseIds?.has(course.id)) continue;
enumerate(setIndex + 1, [...accumulated, course.id]);
if (bestCount >= 3) return;
}
}
enumerate(0, []);
return { count: bestCount, specs: bestSpecs };
export interface PlanOutcome {
courseAssignments: Record<string, string>; // setId -> courseId for open sets
achievedSpecs: string[];
priorityScore: number;
}
/**
* Compute variance of an array of numbers.
*/
export interface SearchResult {
topK: PlanOutcome[];
setAnalyses: SetAnalysis[];
partial: boolean;
iterations: number;
}
export interface SearchCallbacks {
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
}
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
const CREDIT_THRESHOLD = 9;
export const MAX_TREE_ITERATIONS = 10000;
export const SATURATION_LIMIT = 500;
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;
}
export function selectPriorityTarget(
ranking: string[],
upperBounds: Record<string, number>,
): string | null {
for (const specId of ranking) {
if ((upperBounds[specId] ?? 0) >= CREDIT_THRESHOLD) return specId;
}
return null;
}
export function reorderForTarget(
setId: string,
target: string | null,
excludedCourseIds?: Set<string>,
): Course[] {
const courses = coursesBySet[setId].filter(
(c) => !excludedCourseIds?.has(c.id),
);
if (!target) return courses;
const qualifying: Course[] = [];
const others: Course[] = [];
for (const c of courses) {
if (c.qualifications.some((q) => q.specId === target)) qualifying.push(c);
else others.push(c);
}
return [...qualifying, ...others];
}
export function assignmentKey(assignments: Record<string, string>): string {
return Object.keys(assignments)
.sort()
.map((k) => `${k}:${assignments[k]}`)
.join('|');
}
export class BoundedRankedList<T> {
private items: T[] = [];
constructor(
private capacity: number,
private compare: (a: T, b: T) => number,
) {}
tryInsert(item: T): boolean {
let pos = 0;
while (pos < this.items.length && this.compare(item, this.items[pos]) > 0) pos++;
if (pos >= this.capacity) return false;
this.items.splice(pos, 0, item);
if (this.items.length > this.capacity) this.items.pop();
return true;
}
toArray(): T[] {
return [...this.items];
}
}
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
if (a.achievedSpecs.length !== b.achievedSpecs.length) {
return b.achievedSpecs.length - a.achievedSpecs.length;
}
if (a.priorityScore !== b.priorityScore) {
return b.priorityScore - a.priorityScore;
}
return assignmentKey(a.courseAssignments).localeCompare(
assignmentKey(b.courseAssignments),
);
}
interface CeilingComparable {
count: number;
score: number;
key: string;
}
function compareCeiling(a: CeilingComparable, b: CeilingComparable): number {
if (a.count !== b.count) return b.count - a.count;
if (a.score !== b.score) return b.score - a.score;
return a.key.localeCompare(b.key);
}
export function searchDecisionTree(
pinnedCourseIds: string[],
openSetIds: string[],
ranking: string[],
mode: OptimizationMode,
K: number,
callbacks?: SearchCallbacks,
excludedCourseIds?: Set<string>,
): SearchResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const scorer = makePriorityScorer(ranking);
const upperBounds = computeUpperBounds(
pinnedCourseIds,
openSetIds,
excludedCourseIds,
);
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
// Initialize per-set analyses with empty ceilings
const setAnalyses: Record<string, SetAnalysis> = {};
const orderedCoursesPerSet: Record<string, Course[]> = {};
for (const setId of openSetIds) {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
orderedCoursesPerSet[setId] = ordered;
setAnalyses[setId] = {
setId,
setName: set.name,
impact: 0,
choices: ordered.map((c) => ({
courseId: c.id,
courseName: c.name,
ceilingCount: 0,
ceilingSpecs: [],
})),
};
}
// Track ceiling key per choice for stable tiebreaks
const choiceKey: Record<string, string> = {};
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
let iterations = 0;
let iterationsSinceTopKChange = 0;
let partial = false;
let halted = false;
function evaluateLeaf(accumulated: Record<string, string>): boolean {
iterations++;
if (iterations > MAX_TREE_ITERATIONS) {
partial = true;
return true;
}
const courses: string[] = [];
for (const setId of openSetIds) courses.push(accumulated[setId]);
const selected = [...pinnedCourseIds, ...courses];
const result = fn(selected, ranking, [], excludedCourseIds);
const score = scorer(result.achieved);
const aKey = assignmentKey(accumulated);
const outcome: PlanOutcome = {
courseAssignments: { ...accumulated },
achievedSpecs: result.achieved,
priorityScore: score,
};
if (topK.tryInsert(outcome)) {
iterationsSinceTopKChange = 0;
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
} else {
iterationsSinceTopKChange++;
}
// Per-set ceiling updates
for (const setId of openSetIds) {
const courseId = accumulated[setId];
const analysis = setAnalyses[setId];
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
const currentKey = `${setId}:${courseId}`;
const existing: CeilingComparable = {
count: choice.ceilingCount,
score: scorer(choice.ceilingSpecs),
key: choiceKey[currentKey] ?? '',
};
const candidate: CeilingComparable = {
count: result.achieved.length,
score,
key: aKey,
};
if (compareCeiling(candidate, existing) < 0) {
choice.ceilingCount = candidate.count;
choice.ceilingSpecs = result.achieved;
choiceKey[currentKey] = aKey;
// Recompute impact lazily for emit
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
const updated: SetAnalysis = {
...analysis,
impact,
choices: analysis.choices.map((c) => ({ ...c })),
};
setAnalyses[setId].impact = impact;
callbacks?.onChoiceUpdate?.(setId, updated);
}
}
if (iterationsSinceTopKChange >= SATURATION_LIMIT) return true;
return false;
}
function dfs(setIdx: number, accumulated: Record<string, string>) {
if (halted) return;
if (setIdx >= openSetIds.length) {
if (evaluateLeaf(accumulated)) halted = true;
return;
}
const setId = openSetIds[setIdx];
const courses = orderedCoursesPerSet[setId];
for (const course of courses) {
if (halted) return;
accumulated[setId] = course.id;
dfs(setIdx + 1, accumulated);
}
delete accumulated[setId];
}
if (openSetIds.length > 0 && openSetIds.every((s) => orderedCoursesPerSet[s].length > 0)) {
dfs(0, {});
}
// Final impact recomputation + sort
for (const a of Object.values(setAnalyses)) {
a.impact = variance(a.choices.map((c) => c.ceilingCount));
}
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
const sortedAnalyses = Object.values(setAnalyses).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 {
topK: topK.toArray(),
setAnalyses: sortedAnalyses,
partial,
iterations,
};
}
/**
* 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.
* Backward-compatible wrapper: produces only the per-set ceiling table.
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
* once per choice update via the legacy onSetComplete callback.
*/
export function analyzeDecisionTree(
pinnedCourseIds: string[],
@@ -96,52 +289,21 @@ export function analyzeDecisionTree(
excludedCourseIds?: Set<string>,
): 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
.filter((course) => !excludedCourseIds?.has(course.id))
.map((course) => {
const ceiling = computeCeiling(
pinnedCourseIds,
course.id,
otherOpenSets,
ranking,
mode,
excludedCourseIds,
);
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;
const result = searchDecisionTree(
pinnedCourseIds,
openSetIds,
ranking,
mode,
10,
onSetComplete
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
: undefined,
excludedCourseIds,
);
return result.setAnalyses;
}
+2 -5
View File
@@ -8,6 +8,7 @@ import {
preFilterCandidates,
computeUpperBounds,
} from './feasibility';
import { makePriorityScorer } from './priority';
const CREDIT_THRESHOLD = 9;
const CREDIT_PER_COURSE = 2.5;
@@ -67,11 +68,7 @@ export function maximizeCount(
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);
}
const priorityScore = makePriorityScorer(ranking);
// Try from size 3 down to 0
const maxSize = Math.min(3, achievable.length);
+21
View File
@@ -0,0 +1,21 @@
import { SPECIALIZATIONS } from '../data/specializations';
const FALLBACK_RANK = SPECIALIZATIONS.length - 1;
const MAX_RANK_WEIGHT = SPECIALIZATIONS.length;
export function priorityScore(specs: string[], ranking: string[]): number {
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
return specs.reduce(
(sum, id) => sum + (MAX_RANK_WEIGHT - (rankIndex.get(id) ?? FALLBACK_RANK)),
0,
);
}
export function makePriorityScorer(ranking: string[]): (specs: string[]) => number {
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
return (specs) =>
specs.reduce(
(sum, id) => sum + (MAX_RANK_WEIGHT - (rankIndex.get(id) ?? FALLBACK_RANK)),
0,
);
}
+28 -8
View File
@@ -3,7 +3,7 @@ import { SPECIALIZATIONS } from '../data/specializations';
import { ELECTIVE_SETS } from '../data/electiveSets';
import type { OptimizationMode, AllocationResult } from '../data/types';
import { optimize } from '../solver/optimizer';
import type { SetAnalysis } from '../solver/decisionTree';
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
@@ -54,7 +54,7 @@ function loadState(): AppState {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== 14) return defaultState();
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== SPECIALIZATIONS.length) return defaultState();
if (!['maximize-count', 'priority-order'].includes(parsed.mode)) return defaultState();
return {
ranking: parsed.ranking,
@@ -70,6 +70,8 @@ export function useAppState() {
const [state, dispatch] = useReducer(reducer, null, loadState);
const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]);
const [treeLoading, setTreeLoading] = useState(false);
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
const [topPlansPartial, setTopPlansPartial] = useState(false);
const workerRef = useRef<Worker | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -123,6 +125,8 @@ export function useAppState() {
if (openSetIds.length === 0) {
setTreeResults([]);
setTopPlans([]);
setTopPlansPartial(false);
setTreeLoading(false);
return;
}
@@ -137,13 +141,19 @@ export function useAppState() {
const worker = new DecisionTreeWorker();
workerRef.current = worker;
const progressResults: SetAnalysis[] = [];
// Per-cell streaming: keep a working map of setId -> analysis,
// emit the full array on each change so consumers re-render.
const setMap = new Map<string, SetAnalysis>();
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
if (e.data.type === 'setComplete' && e.data.analysis) {
progressResults.push(e.data.analysis);
setTreeResults([...progressResults]);
} else if (e.data.type === 'allComplete' && e.data.analyses) {
setTreeResults(e.data.analyses);
if (e.data.type === 'choiceUpdate') {
setMap.set(e.data.setId, e.data.analysis);
setTreeResults(Array.from(setMap.values()));
} else if (e.data.type === 'topKUpdate') {
setTopPlans(e.data.topK);
} else if (e.data.type === 'allComplete') {
setTreeResults(e.data.setAnalyses);
setTopPlans(e.data.topK);
setTopPlansPartial(e.data.partial);
setTreeLoading(false);
worker.terminate();
workerRef.current = null;
@@ -156,6 +166,7 @@ export function useAppState() {
ranking: state.ranking,
mode: state.mode,
excludedCourseIds: [...excludedCourseIds],
topK: 10,
};
worker.postMessage(request);
} catch {
@@ -179,11 +190,19 @@ export function useAppState() {
const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []);
const clearAll = useCallback(() => dispatch({ type: 'clearAll' }), []);
const adoptPlan = useCallback((assignments: Record<string, string>) => {
for (const [setId, courseId] of Object.entries(assignments)) {
dispatch({ type: 'pinCourse', setId, courseId });
}
}, []);
return {
state,
optimizationResult,
treeResults,
treeLoading,
topPlans,
topPlansPartial,
openSetIds,
selectedCourseIds,
disabledCourseIds,
@@ -193,5 +212,6 @@ export function useAppState() {
pinCourse,
unpinCourse,
clearAll,
adoptPlan,
};
}
+47 -20
View File
@@ -1,6 +1,6 @@
import { analyzeDecisionTree } from '../solver/decisionTree';
import { searchDecisionTree } from '../solver/decisionTree';
import type { OptimizationMode } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
export interface WorkerRequest {
pinnedCourseIds: string[];
@@ -8,34 +8,61 @@ export interface WorkerRequest {
ranking: string[];
mode: OptimizationMode;
excludedCourseIds?: string[];
topK?: number;
saturationLimit?: number;
}
export interface WorkerResponse {
type: 'setComplete' | 'allComplete';
analysis?: SetAnalysis;
analyses?: SetAnalysis[];
}
export type WorkerResponse =
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
| {
type: 'allComplete';
topK: PlanOutcome[];
setAnalyses: SetAnalysis[];
partial: boolean;
iterations: number;
};
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
const { pinnedCourseIds, openSetIds, ranking, mode, excludedCourseIds } = e.data;
const excludedSet = excludedCourseIds && excludedCourseIds.length > 0
? new Set(excludedCourseIds)
: undefined;
const analyses = analyzeDecisionTree(
const {
pinnedCourseIds,
openSetIds,
ranking,
mode,
(analysis) => {
// Progressive update: send each set's results as they complete
const response: WorkerResponse = { type: 'setComplete', analysis };
self.postMessage(response);
excludedCourseIds,
topK = 10,
} = e.data;
const excludedSet =
excludedCourseIds && excludedCourseIds.length > 0
? new Set(excludedCourseIds)
: undefined;
const result = searchDecisionTree(
pinnedCourseIds,
openSetIds,
ranking,
mode,
topK,
{
onTopKUpdate: (topK, iterations) => {
const msg: WorkerResponse = { type: 'topKUpdate', topK, iterations };
self.postMessage(msg);
},
onChoiceUpdate: (setId, analysis) => {
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
self.postMessage(msg);
},
},
excludedSet,
);
// Final result with sorted analyses
const response: WorkerResponse = { type: 'allComplete', analyses };
self.postMessage(response);
const final: WorkerResponse = {
type: 'allComplete',
topK: result.topK,
setAnalyses: result.setAnalyses,
partial: result.partial,
iterations: result.iterations,
};
self.postMessage(final);
};
+1 -1
View File
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
define: {
__APP_VERSION__: JSON.stringify('1.2.2'),
__APP_VERSION__: JSON.stringify('1.3.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
},
server: {