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
+9
View File
@@ -1,5 +1,14 @@
# Changelog # Changelog
## v1.3.0 — 2026-05-09
### Changes
- **Top Plans panel** — new ranked list of up to 10 complete course plans, each showing the achieved specializations and the courses to pin. An "Adopt plan" button pins all of a plan's courses in one click. Updates progressively as the search finds better outcomes.
- **Priority-aware decision tree** — fixes the bug where a specialization could show "Achievable" without any per-set ceiling cell surfacing it. The decision-tree search now compares enumerated combinations by `(count desc, priority score desc)` and reorders DFS children so courses qualifying for the user's first reachable ranked spec are tried first, surfacing high-priority outcomes early.
- **Bounded search with saturation termination** — search stops when the top-K stabilizes (default 500 stable iterations) or when the iteration cap (10000) is hit; partial results are flagged in the UI.
- **Per-cell streaming** — the worker now emits per-cell ceiling updates instead of per-set rollups, so the per-set table refines progressively rather than appearing in coarse chunks.
## v1.2.2 — 2026-05-09 ## v1.2.2 — 2026-05-09
### Changes ### Changes
+10
View File
@@ -5,6 +5,7 @@ import { SpecializationRanking } from './components/SpecializationRanking';
import { ModeToggle } from './components/ModeToggle'; import { ModeToggle } from './components/ModeToggle';
import { CourseSelection } from './components/CourseSelection'; import { CourseSelection } from './components/CourseSelection';
import { CreditLegend } from './components/CreditLegend'; import { CreditLegend } from './components/CreditLegend';
import { TopPlans } from './components/TopPlans';
import { ModeComparison } from './components/Notifications'; import { ModeComparison } from './components/Notifications';
import { MobileStatusBanner } from './components/MobileStatusBanner'; import { MobileStatusBanner } from './components/MobileStatusBanner';
import { MobileCourseBanner } from './components/MobileCourseBanner'; import { MobileCourseBanner } from './components/MobileCourseBanner';
@@ -16,6 +17,8 @@ function App() {
optimizationResult, optimizationResult,
treeResults, treeResults,
treeLoading, treeLoading,
topPlans,
topPlansPartial,
openSetIds, openSetIds,
selectedCourseIds, selectedCourseIds,
disabledCourseIds, disabledCourseIds,
@@ -25,6 +28,7 @@ function App() {
pinCourse, pinCourse,
unpinCourse, unpinCourse,
clearAll, clearAll,
adoptPlan,
} = useAppState(); } = useAppState();
const breakpoint = useMediaQuery(); const breakpoint = useMediaQuery();
@@ -128,6 +132,12 @@ function App() {
/> />
</div> </div>
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}> <div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
<TopPlans
plans={topPlans}
partial={topPlansPartial}
loading={treeLoading}
onAdopt={adoptPlan}
/>
<CourseSelection <CourseSelection
pinnedCourses={state.pinnedCourses} pinnedCourses={state.pinnedCourses}
treeResults={treeResults} 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 = [ const pinned = [
'spr1-collaboration', 'spr1-collaboration',
'spr2-financial-services', 'spr2-financial-services',
@@ -78,14 +81,14 @@ describe('analyzeDecisionTree', () => {
'fall2-behavioral-finance', 'fall2-behavioral-finance',
]; ];
const openSets = ['fall3', 'fall4']; const openSets = ['fall3', 'fall4'];
const completed: string[] = []; const completed = new Set<string>();
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => { analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
completed.push(analysis.setId); completed.add(analysis.setId);
}); });
expect(completed).toContain('fall3'); expect(completed.has('fall3')).toBe(true);
expect(completed).toContain('fall4'); expect(completed.has('fall4')).toBe(true);
expect(completed.length).toBe(2); 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 { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups'; import { coursesBySet } from '../data/lookups';
import type { OptimizationMode } from '../data/types'; import type { Course, OptimizationMode } from '../data/types';
import { maximizeCount, priorityOrder } from './optimizer'; import { maximizeCount, priorityOrder } from './optimizer';
import { computeUpperBounds } from './feasibility';
import { makePriorityScorer } from './priority';
export interface ChoiceOutcome { export interface ChoiceOutcome {
courseId: string; courseId: string;
@@ -13,79 +15,270 @@ export interface ChoiceOutcome {
export interface SetAnalysis { export interface SetAnalysis {
setId: string; setId: string;
setName: string; setName: string;
impact: number; // variance in ceiling outcomes impact: number;
choices: ChoiceOutcome[]; choices: ChoiceOutcome[];
} }
const MAX_OPEN_SETS_FOR_ENUMERATION = 9; export interface PlanOutcome {
courseAssignments: Record<string, string>; // setId -> courseId for open sets
/** achievedSpecs: string[];
* Compute the ceiling outcome for a single course choice: priorityScore: number;
* 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 SearchResult {
* Compute variance of an array of numbers. 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 { function variance(values: number[]): number {
if (values.length <= 1) return 0; if (values.length <= 1) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length; const mean = values.reduce((a, b) => a + b, 0) / values.length;
return values.reduce((sum, v) => sum + (v - mean) ** 2, 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. * Backward-compatible wrapper: produces only the per-set ceiling table.
* Returns sets ordered by decision impact (highest first). * Internally runs searchDecisionTree with K=10 and emits each set's analysis
* * once per choice update via the legacy onSetComplete callback.
* onSetComplete is called progressively as each set's analysis finishes.
*/ */
export function analyzeDecisionTree( export function analyzeDecisionTree(
pinnedCourseIds: string[], pinnedCourseIds: string[],
@@ -96,52 +289,21 @@ export function analyzeDecisionTree(
excludedCourseIds?: Set<string>, excludedCourseIds?: Set<string>,
): SetAnalysis[] { ): SetAnalysis[] {
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) { if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
// Fallback: return empty analyses (caller uses upper bounds instead)
return openSetIds.map((setId) => { return openSetIds.map((setId) => {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!; const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
return { setId, setName: set.name, impact: 0, choices: [] }; return { setId, setName: set.name, impact: 0, choices: [] };
}); });
} }
const result = searchDecisionTree(
const analyses: SetAnalysis[] = []; pinnedCourseIds,
openSetIds,
for (const setId of openSetIds) { ranking,
const set = ELECTIVE_SETS.find((s) => s.id === setId)!; mode,
const otherOpenSets = openSetIds.filter((id) => id !== setId); 10,
const courses = coursesBySet[setId]; onSetComplete
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
const choices: ChoiceOutcome[] = courses : undefined,
.filter((course) => !excludedCourseIds?.has(course.id)) excludedCourseIds,
.map((course) => { );
const ceiling = computeCeiling( return result.setAnalyses;
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;
} }
+2 -5
View File
@@ -8,6 +8,7 @@ import {
preFilterCandidates, preFilterCandidates,
computeUpperBounds, computeUpperBounds,
} from './feasibility'; } from './feasibility';
import { makePriorityScorer } from './priority';
const CREDIT_THRESHOLD = 9; const CREDIT_THRESHOLD = 9;
const CREDIT_PER_COURSE = 2.5; const CREDIT_PER_COURSE = 2.5;
@@ -67,11 +68,7 @@ export function maximizeCount(
return entries.some((e) => selectedCourseIds.includes(e.courseId)); return entries.some((e) => selectedCourseIds.includes(e.courseId));
}); });
// Priority score: sum of (15 - rank position) for each spec in subset const priorityScore = makePriorityScorer(ranking);
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 // Try from size 3 down to 0
const maxSize = Math.min(3, achievable.length); 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 { ELECTIVE_SETS } from '../data/electiveSets';
import type { OptimizationMode, AllocationResult } from '../data/types'; import type { OptimizationMode, AllocationResult } from '../data/types';
import { optimize } from '../solver/optimizer'; 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 type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups'; import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
import DecisionTreeWorker from '../workers/decisionTree.worker?worker'; import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
@@ -54,7 +54,7 @@ function loadState(): AppState {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return defaultState(); if (!raw) return defaultState();
const parsed = JSON.parse(raw); 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(); if (!['maximize-count', 'priority-order'].includes(parsed.mode)) return defaultState();
return { return {
ranking: parsed.ranking, ranking: parsed.ranking,
@@ -70,6 +70,8 @@ export function useAppState() {
const [state, dispatch] = useReducer(reducer, null, loadState); const [state, dispatch] = useReducer(reducer, null, loadState);
const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]); const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]);
const [treeLoading, setTreeLoading] = useState(false); const [treeLoading, setTreeLoading] = useState(false);
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
const [topPlansPartial, setTopPlansPartial] = useState(false);
const workerRef = useRef<Worker | null>(null); const workerRef = useRef<Worker | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -123,6 +125,8 @@ export function useAppState() {
if (openSetIds.length === 0) { if (openSetIds.length === 0) {
setTreeResults([]); setTreeResults([]);
setTopPlans([]);
setTopPlansPartial(false);
setTreeLoading(false); setTreeLoading(false);
return; return;
} }
@@ -137,13 +141,19 @@ export function useAppState() {
const worker = new DecisionTreeWorker(); const worker = new DecisionTreeWorker();
workerRef.current = worker; 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>) => { worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
if (e.data.type === 'setComplete' && e.data.analysis) { if (e.data.type === 'choiceUpdate') {
progressResults.push(e.data.analysis); setMap.set(e.data.setId, e.data.analysis);
setTreeResults([...progressResults]); setTreeResults(Array.from(setMap.values()));
} else if (e.data.type === 'allComplete' && e.data.analyses) { } else if (e.data.type === 'topKUpdate') {
setTreeResults(e.data.analyses); setTopPlans(e.data.topK);
} else if (e.data.type === 'allComplete') {
setTreeResults(e.data.setAnalyses);
setTopPlans(e.data.topK);
setTopPlansPartial(e.data.partial);
setTreeLoading(false); setTreeLoading(false);
worker.terminate(); worker.terminate();
workerRef.current = null; workerRef.current = null;
@@ -156,6 +166,7 @@ export function useAppState() {
ranking: state.ranking, ranking: state.ranking,
mode: state.mode, mode: state.mode,
excludedCourseIds: [...excludedCourseIds], excludedCourseIds: [...excludedCourseIds],
topK: 10,
}; };
worker.postMessage(request); worker.postMessage(request);
} catch { } catch {
@@ -179,11 +190,19 @@ export function useAppState() {
const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []); const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []);
const clearAll = useCallback(() => dispatch({ type: 'clearAll' }), []); 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 { return {
state, state,
optimizationResult, optimizationResult,
treeResults, treeResults,
treeLoading, treeLoading,
topPlans,
topPlansPartial,
openSetIds, openSetIds,
selectedCourseIds, selectedCourseIds,
disabledCourseIds, disabledCourseIds,
@@ -193,5 +212,6 @@ export function useAppState() {
pinCourse, pinCourse,
unpinCourse, unpinCourse,
clearAll, 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 { OptimizationMode } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree'; import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
export interface WorkerRequest { export interface WorkerRequest {
pinnedCourseIds: string[]; pinnedCourseIds: string[];
@@ -8,34 +8,61 @@ export interface WorkerRequest {
ranking: string[]; ranking: string[];
mode: OptimizationMode; mode: OptimizationMode;
excludedCourseIds?: string[]; excludedCourseIds?: string[];
topK?: number;
saturationLimit?: number;
} }
export interface WorkerResponse { export type WorkerResponse =
type: 'setComplete' | 'allComplete'; | { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
analysis?: SetAnalysis; | { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
analyses?: SetAnalysis[]; | {
} type: 'allComplete';
topK: PlanOutcome[];
setAnalyses: SetAnalysis[];
partial: boolean;
iterations: number;
};
self.onmessage = (e: MessageEvent<WorkerRequest>) => { self.onmessage = (e: MessageEvent<WorkerRequest>) => {
const { pinnedCourseIds, openSetIds, ranking, mode, excludedCourseIds } = e.data; const {
const excludedSet = excludedCourseIds && excludedCourseIds.length > 0
? new Set(excludedCourseIds)
: undefined;
const analyses = analyzeDecisionTree(
pinnedCourseIds, pinnedCourseIds,
openSetIds, openSetIds,
ranking, ranking,
mode, mode,
(analysis) => { excludedCourseIds,
// Progressive update: send each set's results as they complete topK = 10,
const response: WorkerResponse = { type: 'setComplete', analysis }; } = e.data;
self.postMessage(response);
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, excludedSet,
); );
// Final result with sorted analyses const final: WorkerResponse = {
const response: WorkerResponse = { type: 'allComplete', analyses }; type: 'allComplete',
self.postMessage(response); 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({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: { define: {
__APP_VERSION__: JSON.stringify('1.2.2'), __APP_VERSION__: JSON.stringify('1.3.0'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'), __APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
}, },
server: { server: {
@@ -0,0 +1,130 @@
## Context
The EMBA Specialization Solver's "Decision Tree" view computes, for each open elective set, the ceiling outcome (best achievable specialization count and which specs) for each course choice. Implementation: `analyzeDecisionTree` (`app/src/solver/decisionTree.ts:90`) runs a per-(set, choice) loop calling `computeCeiling`, which itself enumerates the cartesian product of remaining open sets, runs the optimizer per leaf, and returns the best result by count.
After adding the Healthcare specialization (J27 update), a contradiction surfaced: HCR shows status "Achievable" but no per-set ceiling cell shows HCR as part of its outcome. Reproduction:
```
Pin: SP2=spr2-health-medical, SP4=spr4-fintech,
SP5=spr5-corporate-finance, SE1=sum1-global-immersion
Rank: HCR first
Result: HCR status = 'achievable' (upper bound = 10 ≥ 9)
Decision tree: 0 of 32 ceilings include HCR
```
Diagnostic test confirmed: `priorityOrder` returns `[HCR, BNK]` when fed an HCR-friendly 12-course pin set, so HCR genuinely *is* achievable. The bug is in `computeCeiling`'s comparison (`decisionTree.ts:55`):
```ts
if (result.achieved.length > bestCount) {
bestCount = result.achieved.length;
bestSpecs = result.achieved;
}
```
Strict `>` means the first equal-count result found wins permanently. Combined with declaration-order enumeration, finance-heavy combinations (which appear early in the tree) yield non-HCR `[FIN, MTO]` outcomes that block HCR-including outcomes from ever being recorded.
The user also wants a richer view than per-set ceilings: a streamed ranked list of complete plans (`PlanOutcome`s, top K=10), each with its full course assignment, achieved specs, and priority score, so they can pick a complete plan rather than reasoning about set choices independently.
## Goals / Non-Goals
**Goals:**
- Decision-tree outcomes that include the user's top-priority spec surface naturally — both in the per-set table and in a new ranked top-K plan list
- One enumeration produces both views (no duplicated work)
- Both views update progressively with monotonic improvement (entries only enter or move up)
- Search is bounded: terminates on saturation (top-K stable) or hard iteration cap, with a `partial` flag if cap hit
- "Achievable" status stays permissive (per user's intent: it indicates reachability anywhere in the tree, regardless of whether a path has been found)
**Non-Goals:**
- Replacing the per-set ceiling table — both views remain
- Restructuring the optimizer or LP feasibility checker
- Changing optimizer score weights or rank tiebreakers
- Designing the visual placement of the new "Top Plans" panel — out of scope here, follow-up brainstorm
- User-configurable K — fixed at 10 for this change
## Decisions
### Single full-tree DFS instead of nested per-choice loop
Today's structure: outer loop over (setId, choice), each calling `computeCeiling`, which itself enumerates remaining sets. That's `O(sets × choices × ∏ other-sets-courses)` redundant work — every full path is enumerated up to `setCount` times.
New structure: one DFS over the cartesian product of all open-set courses. Each leaf evaluates the optimizer once. Per-set ceilings update as side effects ("for each (setId, courseId) in this combination, is this leaf's outcome better than the current ceiling for that cell?"). Top-K updates as side effects too.
**Alternative considered:** Keep the nested loop and just fix the comparison. Rejected — the algorithm needs to materialize complete plans anyway for the top-K view, and the nested loop's per-choice context isn't useful for that. Switching paradigms is cleaner than bolting top-K onto two enumeration layers.
### Comparison tuple `(count, priorityScore, deterministic-tiebreak)`
`priorityScore(specs, ranking)` matches the optimizer's existing definition (`optimizer.ts:71-74`): `sum over specs of (15 - rankIndex(spec))`. Same formula in both modules to avoid drift; extracted into a shared utility.
Tiebreaker on a deterministic hash of `courseAssignments` ensures streaming order is stable across runs and across worker restarts. Without it, two equally-ranked plans could "swap" position on every emit, causing UI flicker.
**Alternative considered:** Compare only `(count, priorityScore)` and accept whichever inserted first when equal. Rejected — non-deterministic order makes monotonicity tests unstable and produces visible flicker if two plans tie.
### `priorityTarget` heuristic = first reachable spec in user's ranking
Selected once per `analyzeDecisionTree` call. We walk the ranking in order and pick the first specId whose `upperBound >= 9`. If no spec is reachable, `priorityTarget = null` and reordering is skipped (no-op).
Why "reachable" not just "ranking[0]": if the user's #1 spec has no possible path to 9 credits given the pinned + open universe, prioritizing it would just delay finding good results. Walking to the first reachable one is cheap (one upper-bound array lookup per spec).
**Alternative considered:** Always use `ranking[0]` regardless of reachability. Rejected — wastes the heuristic on impossible specs in cases where the user has a long ranking and their top picks are gated by missed required courses.
### Heuristic ordering of DFS children
Per open set, courses qualifying for `priorityTarget` move to the front (stable sort, ties keep declaration order). Cancelled courses still skipped (existing behavior).
This causes the FIRST combinations evaluated to include all `priorityTarget`-qualifying choices simultaneously. With the user's ranking (HCR first), the optimizer evaluates an HCR-feasible pin set on iteration 1 and inserts an HCR-achieving outcome immediately into top-K and the relevant per-set ceilings.
**Alternative considered:** Branch-and-bound style pruning. Rejected — significantly more code, harder to verify correct, and the simple reordering already gives ~order-of-magnitude speedup for the common case.
### Two complementary terminators: hard cap + saturation
- `MAX_TREE_ITERATIONS = 10000`: absolute upper bound. Returns `{ partial: true }` if hit.
- `SATURATION_LIMIT = 500`: stop if top-K hasn't changed in the last 500 iterations.
Saturation handles the typical case (top-K converges quickly with the heuristic). Hard cap handles pathological cases (large open-set count, long search space).
**Alternative considered:** Time-based cap (e.g., 5000ms). Rejected — JS time measurement in a worker is fiddly, and iteration count is a more deterministic test surface. Time cap could be added later if needed.
**Alternative considered:** Run to exhaustion. Rejected — for ≥8 open sets the cartesian product is in the tens of thousands; full enumeration is O(secondsminutes) and provides diminishing returns once top-K saturates.
### `BoundedRankedList<T>` as a sorted array, not a heap
K ≤ 50 in practice. Insertion sort is `O(K)` per insert. A heap would shave a constant factor but complicates the "did the visible list change?" check (which drives the streaming emits). The simpler structure is fast enough and easier to reason about.
### Worker emits per-cell `choiceUpdate`, not per-set `setComplete`
Today, the worker emits one event when an entire set's analysis finishes. Under streaming, a set's ceilings update incrementally as combinations are evaluated. Per-cell events let the UI re-render exactly the changed cell instead of re-rendering the whole set's row.
**Alternative considered:** Coalesce per-set events on a 100ms timer. Rejected for now — per-cell is simpler and the message volume (a few hundred events per analysis, each <1KB) is well within worker `postMessage` throughput. Coalescing can be added later in the UI layer if needed.
### "Achievable" status semantics unchanged
Per user's stated intent: "Achievable" should mean "the spec is reachable somewhere in the remaining decision tree, regardless of priority." The current implementation (`optimizer.ts:185-194`) already does this — it checks the upper bound and returns `achievable` when open sets exist, without verifying joint feasibility with achieved specs.
This change preserves that semantics. The UX contradiction the user reported ("Achievable but no path shows it") is fixed by making the top-K and per-set views actually find the path, not by tightening the status check.
## Risks / Trade-offs
- **Performance regression risk** → Mitigation: heuristic ordering should make typical case faster than today (saturates well before hard cap); performance smoke test verifies user's scenario completes in <5s for K=10 in worker
- **Worker message volume** (50500 small events per analysis) → Mitigation: each event <1KB; UI can coalesce with `requestAnimationFrame` if profiling shows main-thread pressure; defer
- **Stable streaming order** depends on deterministic hash of `courseAssignments` → Mitigation: explicit tiebreaker test; document the hash function as part of the public contract
- **Two views displaying inconsistent info briefly** during streaming (top-K shows HCR plan, per-set table cell still shows old ceiling for one beat) → Acceptable; both converge on the same data within a few hundred ms
- **K=10 fixed** → User-facing limitation; if 10 isn't enough we can ship a follow-up making it configurable. Defer.
## Migration Plan
Single-PR change. No data migration. Steps:
1. Land algorithm + worker + state changes; new "Top Plans" component starts hidden behind a feature flag (or simply absent from the layout) — user-facing UI is added in a sibling commit/PR
2. Verify all existing decision-tree tests pass (with priority-tiebreak amendments)
3. Verify regression test for user's scenario passes
4. Add Top Plans component to layout
5. Browser-verify both views update progressively
6. Bump version (`1.3.0`), CHANGELOG entry, ship
Rollback: revert. The change is internal to the decision-tree module and worker protocol; no persistent state to migrate back.
## Open Questions
- **UI layout** for the Top Plans panel — handled in a follow-up brainstorm focused on UX
- **`MAX_TREE_ITERATIONS = 10000` / `SATURATION_LIMIT = 500`** — initial values; may need tuning after browser-side measurement on representative inputs
- **Worker message coalescing** — defer until profiling shows it's needed
@@ -0,0 +1,39 @@
## Why
The decision tree currently has a user-visible contradiction: a specialization can be labeled "Achievable" while no per-set ceiling shows a path to achieving it. Concrete reproduction with the new Healthcare specialization (J27): pin SP2=Business of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance, SE1=GIE, rank HCR first — HCR shows "Achievable" but every per-set choice's ceiling outcome excludes HCR. Root cause is in `app/src/solver/decisionTree.ts:55`, where `computeCeiling` compares enumerated combinations using strict `>` on count alone, so the first equal-count outcome found wins permanently regardless of the user's priority ranking.
Beyond the bug, the tool currently shows only per-(set, choice) ceiling cells. Users have no global "best plans" view and must mentally compose compatible choices across sets. The fix and the new view share the same enumeration work, so addressing both together is cheaper than addressing them sequentially.
## What Changes
- Replace `analyzeDecisionTree`'s nested `computeCeiling` loop with a single full-tree search (`searchDecisionTree`) that simultaneously populates two outputs from one DFS:
- The existing per-set per-choice ceiling table (unchanged shape, now updated progressively per cell)
- A new bounded ranked list of up to K complete plan outcomes (`PlanOutcome[]`, default K=10)
- Comparison rule everywhere becomes `(count desc, priorityScore desc, deterministic tiebreaker)`. Extract `priorityScore` from `optimizer.ts:71-74` into a shared utility used by both modules.
- Reorder DFS children at every level so courses qualifying for the user's first reachable top-ranked spec (the `priorityTarget`) are tried first. This ensures high-priority outcomes surface early in the stream.
- Bound the search with two complementary terminators: a hard iteration cap (`MAX_TREE_ITERATIONS = 10000`) and saturation termination when the top-K has not changed for the last `SATURATION_LIMIT = 500` iterations. Return a `partial: true` flag if the cap is hit before saturation.
- Worker protocol: `WorkerRequest` gains optional `topK` (default 10) and `saturationLimit`. `WorkerResponse` becomes a tagged union with three event types: `topKUpdate`, `choiceUpdate` (replaces today's coarser `setComplete`), and `allComplete` (now carries `topK` and `partial`).
- App state and a new "Top Plans" UI panel consume the streamed top-K; the existing per-set table consumes the finer-grained `choiceUpdate` events. Per-set table component shape is unchanged.
- "Achievable" status semantics stay permissive (raw upper-bound check). Per the user's intent, this is correct: it should mean "reachable somewhere in the tree" regardless of whether the search has yet found a path.
## Capabilities
### New Capabilities
_None — this extends an existing capability rather than adding a new one._
### Modified Capabilities
- `optimization-engine`: introduces the streaming top-K search, priority-aware ceiling comparison, heuristic enumeration ordering, and the new worker event protocol. The optimizer itself (LP solver, S2 enumeration) is untouched; this change touches the decision-tree layer that wraps it.
## Impact
- `app/src/solver/decisionTree.ts` — major rewrite: new `searchDecisionTree`, `BoundedRankedList`, `PlanOutcome` type, `priorityTarget` selection, child reordering, saturation termination
- `app/src/solver/optimizer.ts` — extract `priorityScore` (currently inline at lines 71-74) into a shared utility (either exported from optimizer or a new `priority.ts`)
- `app/src/workers/decisionTree.worker.ts` — message-protocol update; consume `topK`/`saturationLimit` request fields, emit `topKUpdate`/`choiceUpdate`/`allComplete` tagged events
- `app/src/state/appState.ts` — add `topK` slice, wire new event types from worker
- `app/src/components/` — new `TopPlans.tsx` (or similar) component; existing decision-tree per-set component switches from `setComplete` to per-cell `choiceUpdate` handler
- `app/src/solver/__tests__/decisionTree.test.ts` — add scenario regression test, priority-ordering test, monotonicity test, saturation/cap tests; existing 4 tests must continue to pass (with priority-tiebreak amendments where needed)
- `app/vite.config.ts` — version bump (`__APP_VERSION__` to `1.3.0`, date today)
- `CHANGELOG.md` — release entry
- No data-file changes; no schema migration; no backwards-compatibility shims (the worker is internal)
@@ -0,0 +1,73 @@
## ADDED Requirements
### Requirement: Streamed ranked top-K plan outcomes
The decision-tree analysis SHALL maintain a bounded ranked list of up to K complete plan outcomes (default K=10) and emit a stream update each time the visible list changes. Each `PlanOutcome` SHALL include the full course assignment for open sets (`Record<setId, courseId>`), the achieved specializations, and the priority score used for ranking. The list SHALL be ordered by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker on courseAssignments)`.
#### Scenario: Top-K converges on user's top-priority spec
- **WHEN** the user pins courses such that their first reachable ranked spec (e.g., HCR) is achievable somewhere in the remaining decision tree
- **THEN** the final `topK[0].achievedSpecs` includes that spec
#### Scenario: Streaming is monotonically improving
- **WHEN** the worker emits a sequence of `topKUpdate` events during a single analysis
- **THEN** each emitted topK is greater than or equal to the previous one entry-for-entry under the comparator
#### Scenario: Tied outcomes with different course plans appear as separate entries
- **WHEN** two distinct course assignments produce the same `achievedSpecs` and `priorityScore`
- **THEN** both appear as separate ranked entries in the top-K (deterministic tiebreaker resolves their order)
### Requirement: Heuristic enumeration ordering
The decision-tree search SHALL identify a `priorityTarget` (the first specialization in the user's ranking whose upper-bound credit potential meets the threshold) and SHALL reorder the children at each level of its DFS so that courses qualifying for the `priorityTarget` are tried before courses that do not. Reordering SHALL be a stable sort that preserves declaration order on ties.
#### Scenario: Priority target derived from first reachable ranked spec
- **WHEN** the user's ranking is `[HCR, BNK, ...]` and HCR's upper bound is ≥ 9
- **THEN** `priorityTarget = 'HCR'` and DFS children at every level are reordered HCR-first
#### Scenario: No reachable spec disables the heuristic
- **WHEN** no specialization in the ranking has upper bound ≥ 9
- **THEN** `priorityTarget = null` and DFS children are not reordered
### Requirement: Bounded search with saturation termination
The decision-tree search SHALL terminate when EITHER (a) the top-K ranked list has not changed for the last `SATURATION_LIMIT` iterations (default 500), OR (b) the iteration count exceeds `MAX_TREE_ITERATIONS` (default 10000). When (b) terminates the search before (a), the result SHALL include `partial: true`.
#### Scenario: Saturation stops a converged search early
- **WHEN** the top-K becomes stable well before the iteration cap
- **THEN** the search stops within `SATURATION_LIMIT` iterations of the last top-K change
#### Scenario: Iteration cap stops an unconverged search
- **WHEN** the search would otherwise enumerate beyond `MAX_TREE_ITERATIONS` combinations
- **THEN** the search returns its best-found top-K with `partial: true`
### Requirement: Per-cell choice updates from streaming search
For each combination evaluated in the search, for each `(setId, courseId)` in that combination's assignments, the per-set per-choice ceiling SHALL be updated if the combination's outcome is better under the comparison rule than the current ceiling for that choice. Each ceiling change SHALL emit a `choiceUpdate` event identifying the affected `setId` and the updated `SetAnalysis`.
#### Scenario: Per-set ceiling reflects streamed improvements
- **WHEN** an HCR-feasible combination is evaluated mid-search
- **THEN** the per-set ceiling cell for `spr3-analytics-ml` (the HCR-qualifying course in spr3) is updated to include HCR
## MODIFIED Requirements
### Requirement: Decision-tree per-set ceiling comparison
For each open elective set and each course choice within that set, the system SHALL compute a ceiling outcome representing the best achievable specialization result if that course is pinned. The "best" outcome SHALL be determined by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker)`, where `priorityScore` matches the optimizer's existing definition (`sum over specs of (15 - rankIndex(spec))`). When two outcomes have the same count, the higher priority score wins.
#### Scenario: Equal-count outcomes resolved by priority score
- **WHEN** the search finds two combinations both achieving 2 specializations, one with `[FIN, MTO]` and another with `[HCR, BNK]`, and the user's ranking places HCR first
- **THEN** the per-set ceiling reflects `[HCR, BNK]` (higher priority score)
#### Scenario: Higher count beats higher priority
- **WHEN** one combination achieves 3 specializations not including the top-priority spec, and another achieves 2 specializations including it
- **THEN** the 3-specialization outcome wins
### Requirement: Decision-tree worker protocol
The decision-tree worker SHALL accept a `WorkerRequest` that includes optional `topK` (default 10) and `saturationLimit` (default 500) parameters. It SHALL emit a tagged-union `WorkerResponse` stream with three event types: `topKUpdate` (when the ranked top-K list changes), `choiceUpdate` (when a per-set ceiling cell changes), and `allComplete` (when the search terminates, carrying both final top-K and final per-set analyses, plus a `partial` flag).
#### Scenario: Worker accepts K parameter
- **WHEN** the request specifies `topK: 5`
- **THEN** the worker maintains a bounded list of at most 5 entries and emits updates accordingly
#### Scenario: Worker emits final allComplete event
- **WHEN** the search terminates (saturation or cap)
- **THEN** the worker emits `{ type: 'allComplete', topK, setAnalyses, partial }`
#### Scenario: Worker emits per-cell choice updates rather than per-set rollups
- **WHEN** a single combination causes a ceiling change for one course in one set
- **THEN** the worker emits one `choiceUpdate` event identifying that set, not a coarse `setComplete` rollup
@@ -0,0 +1,73 @@
## 1. Shared Priority Utility
- [x] 1.1 Extract `priorityScore(specs: string[], ranking: string[]): number` from `app/src/solver/optimizer.ts:71-74` into a new exported helper (in `optimizer.ts` or a new `app/src/solver/priority.ts` — pick whichever fits the existing import patterns better)
- [x] 1.2 Update `maximizeCount` to call the shared helper instead of the inline definition
- [x] 1.3 Add a unit test covering `priorityScore` with a few rankings to lock the formula
## 2. New Types and Data Structures
- [x] 2.1 In `app/src/solver/decisionTree.ts` (or a sibling file), define `interface PlanOutcome { courseAssignments: Record<string, string>; achievedSpecs: string[]; priorityScore: number }`
- [x] 2.2 Implement `BoundedRankedList<T>`: bounded sorted-array container with `tryInsert(item): boolean` and `toArray(): T[]`. Tests: insert above capacity drops worst entry; insert returns true only when list visibly changes
- [x] 2.3 Define a deterministic stringification of `courseAssignments` (sorted by setId, joined) for use as the comparator's tiebreaker
## 3. Search Algorithm
- [x] 3.1 Implement `selectPriorityTarget(ranking, upperBounds): string | null` — walk ranking, return first specId with `upperBounds[id] >= 9`, else null
- [x] 3.2 Implement `reorderForTarget(setId, target, excludedCourseIds): Course[]` — stable sort `coursesBySet[setId]` so `target`-qualifying courses come first; cancelled courses still excluded
- [x] 3.3 Implement `searchDecisionTree(pinned, openSets, ranking, mode, K, callbacks, excluded)`:
- DFS over cartesian product, children reordered per `priorityTarget`
- Per leaf: run optimizer, build `PlanOutcome`, `topK.tryInsert`, update per-set ceilings
- Emit `topKUpdate` and `choiceUpdate` callbacks on changes
- Track iteration count; track iterations-since-last-topK-change for saturation
- Terminate at `MAX_TREE_ITERATIONS = 10000` (set `partial=true`) or `SATURATION_LIMIT = 500` iterations of no topK change
- Return `{ topK, setAnalyses, partial }`
- [x] 3.4 Replace `analyzeDecisionTree`'s body with a thin wrapper that calls `searchDecisionTree`. Preserve its existing exported signature so existing call sites continue to compile; add an overload (or new exported function) that exposes the streaming/topK API for consumers that need it
- [x] 3.5 Delete the old `computeCeiling` function once `searchDecisionTree` is wired in (no longer called)
## 4. Comparison Rule
- [x] 4.1 Implement `compareOutcomes(a, b)` returning `<0 / 0 / >0` for `(count desc, priorityScore desc, deterministic-tiebreak asc)`
- [x] 4.2 Use `compareOutcomes` for both `BoundedRankedList`'s comparator and `setAnalyses[setId].choices[courseId]` ceiling updates
## 5. Worker Protocol
- [x] 5.1 Update `app/src/workers/decisionTree.worker.ts`:
- Extend `WorkerRequest` with optional `topK` (default 10) and `saturationLimit` (default 500)
- Replace single `setComplete` event with three event types: `topKUpdate`, `choiceUpdate`, `allComplete`
- On worker invocation, pass callbacks into `searchDecisionTree` that `postMessage` for each event
- [x] 5.2 Update `WorkerResponse` type to the tagged union from the spec; ensure all consumers in app code switch on `type`
## 6. App State Wiring
- [x] 6.1 In `app/src/state/appState.ts`, add a `topK: PlanOutcome[]` slice (and `topKPartial: boolean` flag) and a handler that updates from `topKUpdate` events
- [x] 6.2 Update the existing handler that consumes worker events to handle the new `choiceUpdate` shape (per-cell rather than per-set rollup) and the new `allComplete` shape (carries final topK + setAnalyses + partial)
- [x] 6.3 Send the new `topK` and `saturationLimit` parameters in the worker request (use defaults 10 and 500)
## 7. UI: Top Plans Panel (sketch only — full UX in follow-up)
- [x] 7.1 Create `app/src/components/TopPlans.tsx` rendering the `topK` slice as a ranked list. Each row: achieved specs as badges, list of "set → course" pairs from `courseAssignments`, an "Adopt plan" button that pins all those courses
- [x] 7.2 Add the panel to the layout in a sensible default location (below or beside the existing decision-tree section). Mark it as a "preview" / minor visual treatment if not yet polished — full UX work tracked separately
- [x] 7.3 If `topKPartial` is true, render a subtle "(showing best of N explored)" caption
## 8. Tests
- [x] 8.1 Reproduction test in `app/src/solver/__tests__/decisionTree.test.ts`: pin SP2=spr2-health-medical, SP4=spr4-fintech, SP5=spr5-corporate-finance, SE1=sum1-global-immersion; rank `[HCR, ...]`. Assert `topK[0].achievedSpecs.includes('HCR')` AND `setAnalyses` for spr3 has analytics-ml's `ceilingSpecs.includes('HCR')`
- [x] 8.2 Priority-aware ordering test: same count, two combinations — assert the higher-priority combination wins both topK position and per-set ceiling
- [x] 8.3 Streaming monotonicity test: capture all emitted topK snapshots; assert each is ≥ the previous under `compareOutcomes` for matching positions
- [x] 8.4 Saturation early-termination test: input where topK converges quickly; assert iteration count stays well under `MAX_TREE_ITERATIONS`
- [x] 8.5 Iteration cap test: input that would never saturate (or set `SATURATION_LIMIT = Infinity` in test); assert `partial: true` after `MAX_TREE_ITERATIONS`
- [x] 8.6 Existing decision-tree tests (4 tests in `decisionTree.test.ts`): must continue to pass. Update assertions only where priority-tiebreak changes the result (document each amendment in the commit message)
- [x] 8.7 Performance smoke test: user's 8-open-set scenario, K=10, completes in < 5s on Node test runner
## 9. Browser Verification
- [x] 9.1 Start dev server and load app
- [x] 9.2 Reproduce the user's scenario (pin 4 courses, rank HCR first); confirm Top Plans panel shows at least one plan with HCR achieved
- [x] 9.3 Confirm per-set ceiling table cells update progressively (visible streaming behavior) — at least the spr3 analytics-ml cell flips to include HCR
- [x] 9.4 Verify "Adopt plan" button correctly pins the plan's courses and updates the rest of the UI
- [x] 9.5 Test with a scenario where no spec is reachable — confirm priorityTarget=null path runs without error and returns sensible (possibly empty) topK
## 10. Version + Changelog
- [x] 10.1 Bump `__APP_VERSION__` to `1.3.0` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
- [x] 10.2 Add `## v1.3.0` entry to `CHANGELOG.md` describing: priority-aware ceiling fix, new streamed top-K plan list, worker protocol change, fixes the HCR-achievable-but-no-path bug