v1.3.1: Exhaustive decision-tree search + UX refinements

The v1.3.0 saturation termination silently capped the search after only
the heuristic-favored part of the tree, leaving most per-set ceiling cells
stuck at "0 specs" and hiding genuinely-feasible 3-spec plans in
maximize-count mode. Replace with full exhaustive enumeration plus a
batch of UX refinements that emerged during testing.

Algorithm:

- Drop the saturation early-termination entirely. Search now runs the
  full open-set cartesian product to completion; the iteration cap is
  also removed so no scenario exits partial.
- Add mode-dependent DFS child ordering: priority-order keeps the
  priority-target-first heuristic; maximize-count orders children by
  descending count of qualifications for reachable specs (generalist
  courses tried first).
- Make the (count, priorityScore) comparator mode-aware: priority-order
  ranks by (priorityScore, count) so the user's top spec surfaces;
  maximize-count ranks by (count, priorityScore) so the highest count
  wins. The same rule drives both top-K position and per-cell ceiling
  selection (and the Recommended badge).
- Add an evaluated boolean to each ChoiceOutcome and set it on first
  leaf evaluation. Distinguishes "still searching" from "evaluated, no
  specs achieved" so the UI never shows misleading 0 specs for a cell
  the search hasn't reached yet.
- Throttled progress events (~100ms) carrying iterations / total leaf
  count, drive both the per-set spinner and the global progress bar.

UI:

- Top Plans header shows a horizontal progress bar with
  "iterations / total · NN%" while the search runs; collapses to
  "Search complete · N explored" on completion.
- Per-set spinner next to each elective set heading while any choice
  in that set is unevaluated.
- Per-cell pulsing dot + "searching" text for unevaluated cells.
- Replace the "(HCR, BNK, ...)" text labels on each course with
  color-coded SpecTag pills using a new fixed per-spec palette
  (app/src/data/specColors.ts). Same palette applied to the Top Plans
  achievement badges so the two views are visually consistent.
- "Top outcome if picked ↓" caption above the right side of each open
  elective set so the spec tags are clearly identified as decision-tree
  outcomes (not the course's own qualifications).
- Recommended badge moved inline next to the course name (instead of
  on a separate row below) to keep button heights stable.

Tests:

- Replace the saturation early-termination test with an exhaustion test
  asserting every cell ends with evaluated: true and partial: false.
- Add mode-dependent ordering test (max-count visits Climate Finance
  before Corporate Governance in fall3).
- Add evaluated-flag transition test.
- Add throttled progress-event test (>= ~100ms between consecutive
  emits).
- Performance smoke updated to a 60s budget for the exhaustive
  user-scenario search; 8-open-set typical case completes in ~7s.

Files: solver/decisionTree.ts, solver/priority.ts (already shipped),
data/specColors.ts (new), components/{TopPlans,CourseSelection}.tsx,
state/appState.ts, workers/decisionTree.worker.ts,
__tests__/searchDecisionTree.test.ts, vite.config.ts, CHANGELOG.md,
openspec/changes/decision-tree-exhaustive-search/* (full change spec).
This commit is contained in:
2026-05-09 15:47:56 -04:00
parent 4b80fac500
commit cb49123930
16 changed files with 780 additions and 110 deletions
+4
View File
@@ -19,6 +19,7 @@ function App() {
treeLoading,
topPlans,
topPlansPartial,
searchProgress,
openSetIds,
selectedCourseIds,
disabledCourseIds,
@@ -136,6 +137,7 @@ function App() {
plans={topPlans}
partial={topPlansPartial}
loading={treeLoading}
progress={searchProgress}
onAdopt={adoptPlan}
/>
<CourseSelection
@@ -143,6 +145,8 @@ function App() {
treeResults={treeResults}
treeLoading={treeLoading}
disabledCourseIds={disabledCourseIds}
ranking={state.ranking}
mode={state.mode}
onPin={pinCourse}
onUnpin={unpinCourse}
onClearAll={clearAll}
+114 -26
View File
@@ -1,13 +1,33 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { ELECTIVE_SETS } from '../data/electiveSets';
import { SPECIALIZATIONS } from '../data/specializations';
import { coursesBySet } from '../data/lookups';
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
import { courseById } from '../data/lookups';
import { useMediaQuery } from '../hooks/useMediaQuery';
import type { Term } from '../data/types';
import { makePriorityScorer } from '../solver/priority';
import { specColor } from '../data/specColors';
import type { OptimizationMode, Term } from '../data/types';
import type { SetAnalysis } from '../solver/decisionTree';
function SpecTag({ specId }: { specId: string }) {
const c = specColor(specId);
return (
<span
title={specNameById[specId] ?? specId}
style={{
display: 'inline-block',
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
padding: '1px 5px', borderRadius: '3px',
background: c.bg, color: c.fg, border: `1px solid ${c.border}`,
whiteSpace: 'nowrap', lineHeight: '1.3',
}}
>
{specId}
</span>
);
}
// Reverse map: courseId → specialization names that require it
const requiredForSpec: Record<string, string[]> = {};
for (const spec of SPECIALIZATIONS) {
@@ -179,6 +199,8 @@ interface CourseSelectionProps {
treeResults: SetAnalysis[];
treeLoading: boolean;
disabledCourseIds: Set<string>;
ranking: string[];
mode: OptimizationMode;
onPin: (setId: string, courseId: string) => void;
onUnpin: (setId: string) => void;
onClearAll: () => void;
@@ -191,6 +213,8 @@ function ElectiveSet({
analysis,
loading,
disabledCourseIds,
scorer,
mode,
onPin,
onUnpin,
openPopoverId,
@@ -205,6 +229,8 @@ function ElectiveSet({
analysis?: SetAnalysis;
loading: boolean;
disabledCourseIds: Set<string>;
scorer: (specs: string[]) => number;
mode: OptimizationMode;
onPin: (courseId: string) => void;
onUnpin: () => void;
openPopoverId: string | null;
@@ -223,6 +249,30 @@ function ElectiveSet({
);
const hasHighImpact = analysis && analysis.impact > 0;
// Determine the recommended choice. Mode-dependent comparison matches the
// top-K comparator: priority-order ranks by (score, count); max-count by (count, score).
let recommendedCourseId: string | null = null;
if (analysis && analysis.choices.length > 0) {
let best: { id: string; count: number; score: number } | null = null;
for (const ch of analysis.choices) {
if (!ch.evaluated) continue;
const score = scorer(ch.ceilingSpecs);
const isBetter =
!best ||
(mode === 'priority-order'
? score > best.score || (score === best.score && ch.ceilingCount > best.count)
: ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score));
if (isBetter) {
best = { id: ch.courseId, count: ch.ceilingCount, score };
}
}
if (best && (best.count > 0 || best.score > 0)) recommendedCourseId = best.id;
}
// Per-set still-searching indicator: search in progress AND at least one cell unevaluated
const setSearching =
loading && !!analysis && analysis.choices.some((c) => !c.evaluated);
return (
<div
style={{
@@ -236,12 +286,27 @@ function ElectiveSet({
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
{setName}
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>{setName}</span>
{!isPinned && setSearching && (
<span style={{
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
border: '2px solid #cbd5e1', borderTopColor: '#6366f1',
animation: 'spin 0.8s linear infinite',
}} aria-label="searching" />
)}
{!isPinned && hasHighImpact && (
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
)}
</h4>
{!isPinned && (
<span style={{
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
textTransform: 'uppercase',
}}>
top outcome if picked
</span>
)}
{isPinned && (
<button
onClick={onUnpin}
@@ -282,6 +347,8 @@ function ElectiveSet({
const ceiling = ceilingMap.get(course.id);
const reqFor = requiredForSpec[course.id];
const showSkeleton = loading && !analysis;
const cellSearching = !!ceiling && !ceiling.evaluated;
const isRecommended = recommendedCourseId === course.id;
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
return (
<button
@@ -319,6 +386,20 @@ function ElectiveSet({
(Already selected)
</span>
)}
{isRecommended && !isUnavailable && (
<span
title="Best outcome among the choices in this set"
style={{
fontSize: '10px', color: '#15803d', fontWeight: 600,
display: 'inline-flex', alignItems: 'center', gap: '2px',
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
border: '1px solid #bbf7d0', lineHeight: 1.2,
}}
>
<span aria-hidden="true"></span>
<span>Recommended</span>
</span>
)}
{!isUnavailable && hasInfo && (
<span
role="button"
@@ -371,28 +452,28 @@ function ElectiveSet({
</span>
)}
</span>
{!isUnavailable && showSkeleton ? (
<span
style={{
display: 'inline-block',
width: '60px',
height: '14px',
borderRadius: '3px',
background: 'linear-gradient(90deg, #e5e7eb 25%, #f0f0f0 50%, #e5e7eb 75%)',
backgroundSize: '200% 100%',
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
}}
/>
) : !isUnavailable && ceiling ? (
{!isUnavailable && (showSkeleton || cellSearching) ? (
<span style={{
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
display: 'inline-flex', alignItems: 'center', gap: '4px',
}}>
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
{ceiling.ceilingSpecs.length > 0 && (
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
({ceiling.ceilingSpecs.join(', ')})
<span style={{
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
}} />
searching
</span>
) : !isUnavailable && ceiling && ceiling.evaluated ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: '4px',
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
}}>
{ceiling.ceilingSpecs.length === 0 ? (
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
no specs
</span>
) : (
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
)}
</span>
) : null}
@@ -411,9 +492,14 @@ function ElectiveSet({
);
}
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
const skeletonStyle = `
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
@keyframes cell-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }
`;
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
const hasPinned = Object.keys(pinnedCourses).length > 0;
@@ -493,6 +579,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
analysis={treeBySet.get(set.id)}
loading={treeLoading}
disabledCourseIds={disabledCourseIds}
scorer={scorer}
mode={mode}
onPin={(courseId) => onPin(set.id, courseId)}
onUnpin={() => onUnpin(set.id)}
openPopoverId={popover?.courseId ?? null}
+66 -22
View File
@@ -1,6 +1,7 @@
import { ELECTIVE_SETS } from '../data/electiveSets';
import { SPECIALIZATIONS } from '../data/specializations';
import { courseById } from '../data/lookups';
import { specColor } from '../data/specColors';
import type { PlanOutcome } from '../solver/decisionTree';
const setNameById: Record<string, string> = {};
@@ -13,15 +14,31 @@ interface TopPlansProps {
plans: PlanOutcome[];
partial: boolean;
loading: boolean;
progress: { iterations: number; iterationsTotal: number } | null;
onAdopt: (assignments: Record<string, string>) => void;
}
export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
function formatNum(n: number): string {
return n.toLocaleString();
}
export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlansProps) {
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
const pct = progress && progress.iterationsTotal > 0
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
: 0;
let staticText: string | null = null;
if (!loading && partial && progress) {
staticText = `Search incomplete · cap hit at ${formatNum(progress.iterations)}`;
} else if (!loading && progress) {
staticText = `Search complete · ${formatNum(progress.iterations)} explored`;
}
return (
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px', gap: '8px', flexWrap: 'wrap' }}>
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
Top Plans
{visible.length > 0 && (
@@ -30,12 +47,36 @@ export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
</span>
)}
</h3>
{partial && (
<span style={{ fontSize: '11px', color: '#92400e' }}>
(showing best of search; result is partial)
{staticText && (
<span style={{ fontSize: '11px', color: partial ? '#92400e' : '#888' }}>
{staticText}
</span>
)}
</div>
{loading && progress && (
<div style={{ marginBottom: '8px' }}>
<div style={{
position: 'relative', height: '6px', background: '#e5e7eb',
borderRadius: '3px', overflow: 'hidden',
}}>
<div style={{
position: 'absolute', top: 0, left: 0, height: '100%',
width: `${pct}%`, background: '#3b82f6',
transition: 'width 150ms ease-out',
}} />
</div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: '10px', color: '#888', marginTop: '2px',
}}>
<span>Searching</span>
<span>
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
{' · '}{Math.round(pct)}%
</span>
</div>
</div>
)}
{loading && visible.length === 0 && (
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
Searching for high-priority plans
@@ -87,23 +128,26 @@ function PlanRow({
<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>
))}
{plan.achievedSpecs.map((specId) => {
const c = specColor(specId);
return (
<span
key={specId}
title={specNameById[specId]}
style={{
fontSize: '11px',
fontWeight: 700,
padding: '2px 8px',
borderRadius: '10px',
background: c.bg,
color: c.fg,
border: `1px solid ${c.border}`,
}}
>
{specId}
</span>
);
})}
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
score {plan.priorityScore}
</span>
+29
View File
@@ -0,0 +1,29 @@
export interface SpecColor {
bg: string;
fg: string;
border: string;
}
export const SPEC_COLORS: Record<string, SpecColor> = {
BNK: { bg: '#dbeafe', fg: '#1d4ed8', border: '#bfdbfe' },
BRM: { bg: '#fce7f3', fg: '#be185d', border: '#fbcfe8' },
CRF: { bg: '#cffafe', fg: '#0e7490', border: '#a5f3fc' },
EMT: { bg: '#ffedd5', fg: '#c2410c', border: '#fed7aa' },
ENT: { bg: '#fef9c3', fg: '#a16207', border: '#fef08a' },
FIN: { bg: '#e0e7ff', fg: '#3730a3', border: '#c7d2fe' },
FIM: { bg: '#ccfbf1', fg: '#0f766e', border: '#99f6e4' },
GLB: { bg: '#ecfccb', fg: '#4d7c0f', border: '#d9f99d' },
HCR: { bg: '#d1fae5', fg: '#047857', border: '#a7f3d0' },
LCM: { bg: '#ede9fe', fg: '#6d28d9', border: '#ddd6fe' },
MGT: { bg: '#f3e8ff', fg: '#7e22ce', border: '#e9d5ff' },
MKT: { bg: '#ffe4e6', fg: '#be123c', border: '#fecdd3' },
MTO: { bg: '#fed7aa', fg: '#9a3412', border: '#fdba74' },
SBI: { bg: '#bbf7d0', fg: '#166534', border: '#86efac' },
STR: { bg: '#e0e7ff', fg: '#4338ca', border: '#a5b4fc' },
};
const FALLBACK: SpecColor = { bg: '#f3f4f6', fg: '#374151', border: '#e5e7eb' };
export function specColor(specId: string): SpecColor {
return SPEC_COLORS[specId] ?? FALLBACK;
}
@@ -5,9 +5,11 @@ import {
compareOutcomes,
selectPriorityTarget,
reorderForTarget,
MAX_TREE_ITERATIONS,
reorderByReachableQualCount,
PROGRESS_THROTTLE_MS,
type PlanOutcome,
} from '../decisionTree';
import { computeUpperBounds } from '../feasibility';
import { SPECIALIZATIONS } from '../../data/specializations';
import { COURSES } from '../../data/courses';
import { ELECTIVE_SETS } from '../../data/electiveSets';
@@ -143,7 +145,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
);
expect(result.topK.length).toBeGreaterThan(0);
expect(result.topK[0].achievedSpecs).toContain('HCR');
});
}, 30_000);
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
const result = searchDecisionTree(
@@ -158,7 +160,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3');
const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml');
expect(aml?.ceilingSpecs).toContain('HCR');
});
}, 30_000);
});
describe('searchDecisionTree — ordering and streaming', () => {
@@ -194,12 +196,12 @@ describe('searchDecisionTree — ordering and streaming', () => {
const curr = snapshots[i][0];
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
}
});
}, 30_000);
});
describe('searchDecisionTree — termination', () => {
it('saturation stops the search when topK converges', () => {
// Tiny scenario: should saturate within a few hundred iterations
describe('searchDecisionTree — exhaustive termination', () => {
it('exhausts the cartesian product when within the cap', () => {
// 2 open sets, ~16 leaves total
const PINNED = [
'spr1-collaboration',
'spr2-financial-services',
@@ -223,31 +225,122 @@ describe('searchDecisionTree — termination', () => {
cancelledIds,
);
expect(result.partial).toBe(false);
expect(result.iterations).toBeLessThan(MAX_TREE_ITERATIONS);
expect(result.iterations).toBe(result.iterationsTotal);
// Every cell in every set is evaluated after exhaustion
for (const setAnalysis of result.setAnalyses) {
for (const choice of setAnalysis.choices) {
expect(choice.evaluated).toBe(true);
}
}
});
});
describe('searchDecisionTree — performance smoke', () => {
it('user scenario completes in < 5s for K=10', () => {
describe('searchDecisionTree — mode-dependent ordering', () => {
it('reorderByReachableQualCount puts generalist courses first when many specs are reachable', () => {
// All sets open so most specs are reachable; gives the heuristic something to weight against
const allSets = ELECTIVE_SETS.map((s) => s.id);
const upper = computeUpperBounds([], allSets, cancelledIds);
const reordered = reorderByReachableQualCount('fall3', upper, cancelledIds);
// Climate Finance (BNK,CRF,FIN,FIM,GLB,SBI = 6 quals) should beat
// Corporate Governance (LCM,MGT,SBI,STR-S1 = 4 quals)
const climateIdx = reordered.findIndex((c) => c.id === 'fall3-climate-finance');
const corpGovIdx = reordered.findIndex((c) => c.id === 'fall3-corporate-governance');
expect(climateIdx).toBeLessThan(corpGovIdx);
});
it('maximize-count first leaf uses the generalist-ordered choices', () => {
// Capture the first leaf evaluated in maximize-count mode by inspecting
// the first onChoiceUpdate event for each set
const PINNED = [
'spr2-health-medical',
'spr1-collaboration',
'spr2-financial-services',
'spr3-mergers-acquisitions',
'spr4-fintech',
'spr5-corporate-finance',
'sum1-global-immersion',
'sum1-collaboration',
'sum2-innovation-design',
'sum3-valuation',
'fall1-private-equity',
'fall2-behavioral-finance',
];
const OPEN_SETS = ['fall3', 'fall4'];
const firstChoiceBySet: Record<string, string> = {};
searchDecisionTree(
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10,
{
onChoiceUpdate: (setId, analysis) => {
if (!(setId in firstChoiceBySet)) {
// The first cell flipped to evaluated within this update is the
// course we're after — but the analysis sends the whole choices
// array. Find the first evaluated course in declaration order.
const firstEval = analysis.choices.find((c) => c.evaluated);
if (firstEval) firstChoiceBySet[setId] = firstEval.courseId;
}
},
},
cancelledIds,
);
// For fall3 in max-count mode: climate-finance (most generalist) should be the first
expect(firstChoiceBySet['fall3']).toBe('fall3-climate-finance');
});
});
describe('searchDecisionTree — evaluated flag transitions', () => {
it('cells start unevaluated and flip true after first leaf containing them', () => {
const PINNED = [
'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions',
'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration',
'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance',
];
const OPEN_SETS = ['fall3', 'fall4'];
const result = searchDecisionTree(
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, undefined, cancelledIds,
);
for (const sa of result.setAnalyses) {
for (const choice of sa.choices) {
expect(choice.evaluated).toBe(true);
}
}
});
});
describe('searchDecisionTree — progress events', () => {
it('emits progress events throttled to PROGRESS_THROTTLE_MS', () => {
const PINNED = [
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
];
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
const timestamps: number[] = [];
searchDecisionTree(
PINNED, OPEN_SETS, RANKING, 'priority-order', 10,
{ onProgress: () => timestamps.push(Date.now()) },
cancelledIds,
);
// At least one progress emit (the final one always fires)
expect(timestamps.length).toBeGreaterThan(0);
// Consecutive emits respect the throttle (allow 5ms jitter)
for (let i = 1; i < timestamps.length - 1; i++) {
const delta = timestamps[i] - timestamps[i - 1];
expect(delta).toBeGreaterThanOrEqual(PROGRESS_THROTTLE_MS - 5);
}
}, 30_000);
});
describe('searchDecisionTree — performance smoke (exhaustive)', () => {
it('user scenario completes in under 60s for K=10', () => {
const PINNED = [
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
];
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
const start = Date.now();
searchDecisionTree(
PINNED,
OPEN_SETS,
RANKING,
'priority-order',
10,
undefined,
cancelledIds,
const result = searchDecisionTree(
PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds,
);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
});
expect(elapsed).toBeLessThan(60_000);
expect(result.partial).toBe(false);
expect(result.iterations).toBe(result.iterationsTotal);
}, 90_000);
});
+110 -39
View File
@@ -10,6 +10,7 @@ export interface ChoiceOutcome {
courseName: string;
ceilingCount: number;
ceilingSpecs: string[];
evaluated: boolean;
}
export interface SetAnalysis {
@@ -30,17 +31,18 @@ export interface SearchResult {
setAnalyses: SetAnalysis[];
partial: boolean;
iterations: number;
iterationsTotal: number;
}
export interface SearchCallbacks {
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
onProgress?: (iterations: number, iterationsTotal: number) => void;
}
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
const CREDIT_THRESHOLD = 9;
export const MAX_TREE_ITERATIONS = 10000;
export const SATURATION_LIMIT = 500;
export const PROGRESS_THROTTLE_MS = 100;
function variance(values: number[]): number {
if (values.length <= 1) return 0;
@@ -76,6 +78,35 @@ export function reorderForTarget(
return [...qualifying, ...others];
}
/**
* Reorder a set's courses so those qualifying for the most reachable specs
* (upperBound >= 9) come first. Stable sort: ties keep declaration order.
* Used by maximize-count mode to surface generalist courses early.
*/
export function reorderByReachableQualCount(
setId: string,
upperBounds: Record<string, number>,
excludedCourseIds?: Set<string>,
): Course[] {
const courses = coursesBySet[setId].filter(
(c) => !excludedCourseIds?.has(c.id),
);
// Decorate-sort-undecorate for stability
return courses
.map((course, idx) => ({
course,
idx,
score: course.qualifications.filter(
(q) => (upperBounds[q.specId] ?? 0) >= CREDIT_THRESHOLD,
).length,
}))
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.idx - b.idx;
})
.map((x) => x.course);
}
export function assignmentKey(assignments: Record<string, string>): string {
return Object.keys(assignments)
.sort()
@@ -104,16 +135,32 @@ export class BoundedRankedList<T> {
}
}
/**
* Comparator for plan outcomes. Mode-dependent ordering:
* - priority-order mode: (priorityScore desc, count desc, key asc)
* - maximize-count mode: (count desc, priorityScore desc, key asc)
* Returns negative if a is better, positive if b is better.
*/
export function makeOutcomeComparator(
mode: OptimizationMode,
): (a: PlanOutcome, b: PlanOutcome) => number {
return (a, b) => {
if (mode === 'priority-order') {
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
} else {
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),
);
};
}
/** Default count-first comparator, retained for backward compatibility with tests. */
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),
);
return makeOutcomeComparator('maximize-count')(a, b);
}
interface CeilingComparable {
@@ -122,10 +169,19 @@ interface CeilingComparable {
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);
function makeCeilingComparator(
mode: OptimizationMode,
): (a: CeilingComparable, b: CeilingComparable) => number {
return (a, b) => {
if (mode === 'priority-order') {
if (a.score !== b.score) return b.score - a.score;
if (a.count !== b.count) return b.count - a.count;
} else {
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(
@@ -146,13 +202,18 @@ export function searchDecisionTree(
);
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
// Initialize per-set analyses with empty ceilings
// Initialize per-set analyses with unevaluated cells, ordered by mode
const setAnalyses: Record<string, SetAnalysis> = {};
const orderedCoursesPerSet: Record<string, Course[]> = {};
let iterationsTotal = 1;
for (const setId of openSetIds) {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
const ordered =
mode === 'maximize-count'
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
orderedCoursesPerSet[setId] = ordered;
iterationsTotal *= ordered.length || 1;
setAnalyses[setId] = {
setId,
setName: set.name,
@@ -162,24 +223,30 @@ export function searchDecisionTree(
courseName: c.name,
ceilingCount: 0,
ceilingSpecs: [],
evaluated: false,
})),
};
}
// Track ceiling key per choice for stable tiebreaks
const choiceKey: Record<string, string> = {};
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
const outcomeComparator = makeOutcomeComparator(mode);
const ceilingComparator = makeCeilingComparator(mode);
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
let iterations = 0;
let iterationsSinceTopKChange = 0;
let partial = false;
let halted = false;
const partial = false;
let lastProgressEmit = 0;
function evaluateLeaf(accumulated: Record<string, string>): boolean {
iterations++;
if (iterations > MAX_TREE_ITERATIONS) {
partial = true;
return true;
function emitProgress() {
if (!callbacks?.onProgress) return;
const now = Date.now();
if (now - lastProgressEmit >= PROGRESS_THROTTLE_MS) {
lastProgressEmit = now;
callbacks.onProgress(iterations, iterationsTotal);
}
}
function evaluateLeaf(accumulated: Record<string, string>): void {
iterations++;
const courses: string[] = [];
for (const setId of openSetIds) courses.push(accumulated[setId]);
@@ -195,17 +262,15 @@ export function searchDecisionTree(
};
if (topK.tryInsert(outcome)) {
iterationsSinceTopKChange = 0;
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
} else {
iterationsSinceTopKChange++;
}
// Per-set ceiling updates
// Per-set ceiling + evaluated-flag updates
for (const setId of openSetIds) {
const courseId = accumulated[setId];
const analysis = setAnalyses[setId];
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
const wasEvaluated = choice.evaluated;
const currentKey = `${setId}:${courseId}`;
const existing: CeilingComparable = {
count: choice.ceilingCount,
@@ -217,36 +282,38 @@ export function searchDecisionTree(
score,
key: aKey,
};
if (compareCeiling(candidate, existing) < 0) {
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
if (ceilingImproved) {
choice.ceilingCount = candidate.count;
choice.ceilingSpecs = result.achieved;
choiceKey[currentKey] = aKey;
// Recompute impact lazily for emit
}
// Mark evaluated regardless of improvement
choice.evaluated = true;
if (!wasEvaluated || ceilingImproved) {
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
analysis.impact = impact;
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;
emitProgress();
}
function dfs(setIdx: number, accumulated: Record<string, string>) {
if (halted) return;
if (setIdx >= openSetIds.length) {
if (evaluateLeaf(accumulated)) halted = true;
evaluateLeaf(accumulated);
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);
}
@@ -257,6 +324,9 @@ export function searchDecisionTree(
dfs(0, {});
}
// Final progress emit so consumers see the completion count
if (callbacks?.onProgress) callbacks.onProgress(iterations, iterationsTotal);
// Final impact recomputation + sort
for (const a of Object.values(setAnalyses)) {
a.impact = variance(a.choices.map((c) => c.ceilingCount));
@@ -272,6 +342,7 @@ export function searchDecisionTree(
setAnalyses: sortedAnalyses,
partial,
iterations,
iterationsTotal,
};
}
+7
View File
@@ -72,6 +72,7 @@ export function useAppState() {
const [treeLoading, setTreeLoading] = useState(false);
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
const [topPlansPartial, setTopPlansPartial] = useState(false);
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
const workerRef = useRef<Worker | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
@@ -127,11 +128,13 @@ export function useAppState() {
setTreeResults([]);
setTopPlans([]);
setTopPlansPartial(false);
setSearchProgress(null);
setTreeLoading(false);
return;
}
setTreeLoading(true);
setSearchProgress(null);
debounceRef.current = setTimeout(() => {
// Terminate previous worker if still running
@@ -150,10 +153,13 @@ export function useAppState() {
setTreeResults(Array.from(setMap.values()));
} else if (e.data.type === 'topKUpdate') {
setTopPlans(e.data.topK);
} else if (e.data.type === 'progress') {
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
} else if (e.data.type === 'allComplete') {
setTreeResults(e.data.setAnalyses);
setTopPlans(e.data.topK);
setTopPlansPartial(e.data.partial);
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
setTreeLoading(false);
worker.terminate();
workerRef.current = null;
@@ -203,6 +209,7 @@ export function useAppState() {
treeLoading,
topPlans,
topPlansPartial,
searchProgress,
openSetIds,
selectedCourseIds,
disabledCourseIds,
+7
View File
@@ -15,12 +15,14 @@ export interface WorkerRequest {
export type WorkerResponse =
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
| { type: 'progress'; iterations: number; iterationsTotal: number }
| {
type: 'allComplete';
topK: PlanOutcome[];
setAnalyses: SetAnalysis[];
partial: boolean;
iterations: number;
iterationsTotal: number;
};
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
@@ -53,6 +55,10 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
self.postMessage(msg);
},
onProgress: (iterations, iterationsTotal) => {
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
self.postMessage(msg);
},
},
excludedSet,
);
@@ -63,6 +69,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
setAnalyses: result.setAnalyses,
partial: result.partial,
iterations: result.iterations,
iterationsTotal: result.iterationsTotal,
};
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.3.0'),
__APP_VERSION__: JSON.stringify('1.3.1'),
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
},
server: {