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:
@@ -5,6 +5,7 @@ import { SpecializationRanking } from './components/SpecializationRanking';
|
||||
import { ModeToggle } from './components/ModeToggle';
|
||||
import { CourseSelection } from './components/CourseSelection';
|
||||
import { CreditLegend } from './components/CreditLegend';
|
||||
import { TopPlans } from './components/TopPlans';
|
||||
import { ModeComparison } from './components/Notifications';
|
||||
import { MobileStatusBanner } from './components/MobileStatusBanner';
|
||||
import { MobileCourseBanner } from './components/MobileCourseBanner';
|
||||
@@ -16,6 +17,8 @@ function App() {
|
||||
optimizationResult,
|
||||
treeResults,
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
@@ -25,6 +28,7 @@ function App() {
|
||||
pinCourse,
|
||||
unpinCourse,
|
||||
clearAll,
|
||||
adoptPlan,
|
||||
} = useAppState();
|
||||
|
||||
const breakpoint = useMediaQuery();
|
||||
@@ -128,6 +132,12 @@ function App() {
|
||||
/>
|
||||
</div>
|
||||
<div ref={courseSectionRef} style={isMobile ? {} : { overflowY: 'auto', minHeight: 0 }}>
|
||||
<TopPlans
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
onAdopt={adoptPlan}
|
||||
/>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
treeResults={treeResults}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import type { PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
const setNameById: Record<string, string> = {};
|
||||
for (const s of ELECTIVE_SETS) setNameById[s.id] = s.name;
|
||||
|
||||
const specNameById: Record<string, string> = {};
|
||||
for (const s of SPECIALIZATIONS) specNameById[s.id] = s.name;
|
||||
|
||||
interface TopPlansProps {
|
||||
plans: PlanOutcome[];
|
||||
partial: boolean;
|
||||
loading: boolean;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
|
||||
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
|
||||
Top Plans
|
||||
{visible.length > 0 && (
|
||||
<span style={{ fontSize: '11px', color: '#888', fontWeight: 400, marginLeft: '6px' }}>
|
||||
ranked by specs achieved
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{partial && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e' }}>
|
||||
(showing best of search; result is partial)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
Searching for high-priority plans…
|
||||
</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
No plans yet achieve a specialization with the current pinned courses.
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visible.map((plan, i) => (
|
||||
<PlanRow key={i + ':' + plan.priorityScore} plan={plan} rank={i + 1} onAdopt={onAdopt} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanRow({
|
||||
plan,
|
||||
rank,
|
||||
onAdopt,
|
||||
}: {
|
||||
plan: PlanOutcome;
|
||||
rank: number;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
}) {
|
||||
const assignmentEntries = Object.entries(plan.courseAssignments).sort(
|
||||
([a], [b]) => {
|
||||
const order = ELECTIVE_SETS.map((s) => s.id);
|
||||
return order.indexOf(a) - order.indexOf(b);
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 10px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 600, minWidth: '20px' }}>
|
||||
#{rank}
|
||||
</span>
|
||||
{plan.achievedSpecs.map((specId) => (
|
||||
<span
|
||||
key={specId}
|
||||
title={specNameById[specId]}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #bbf7d0',
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
))}
|
||||
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
|
||||
score {plan.priorityScore}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onAdopt(plan.courseAssignments)}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '3px 10px',
|
||||
border: '1px solid #bfdbfe',
|
||||
background: '#eff6ff',
|
||||
color: '#2563eb',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Adopt plan
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#555', lineHeight: 1.5 }}>
|
||||
{assignmentEntries.map(([setId, courseId], i) => (
|
||||
<span key={setId}>
|
||||
{i > 0 && <span style={{ color: '#ccc' }}> · </span>}
|
||||
<span style={{ color: '#888' }}>{setNameById[setId]?.replace('Elective Set ', 'S')}: </span>
|
||||
<span>{courseById[courseId]?.name ?? courseId}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,7 +64,10 @@ describe('analyzeDecisionTree', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('calls onSetComplete progressively', () => {
|
||||
it('invokes onSetComplete (per-cell streaming) for every open set', () => {
|
||||
// After the streaming refactor, the legacy onSetComplete callback fires
|
||||
// per cell update rather than once per set. Assert that every open set's
|
||||
// analysis is delivered at least once.
|
||||
const pinned = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
@@ -78,14 +81,14 @@ describe('analyzeDecisionTree', () => {
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const openSets = ['fall3', 'fall4'];
|
||||
const completed: string[] = [];
|
||||
const completed = new Set<string>();
|
||||
|
||||
analyzeDecisionTree(pinned, openSets, allSpecIds, 'maximize-count', (analysis) => {
|
||||
completed.push(analysis.setId);
|
||||
completed.add(analysis.setId);
|
||||
});
|
||||
|
||||
expect(completed).toContain('fall3');
|
||||
expect(completed).toContain('fall4');
|
||||
expect(completed.length).toBe(2);
|
||||
expect(completed.has('fall3')).toBe(true);
|
||||
expect(completed.has('fall4')).toBe(true);
|
||||
expect(completed.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchDecisionTree,
|
||||
BoundedRankedList,
|
||||
compareOutcomes,
|
||||
selectPriorityTarget,
|
||||
reorderForTarget,
|
||||
MAX_TREE_ITERATIONS,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { ELECTIVE_SETS } from '../../data/electiveSets';
|
||||
import { priorityScore } from '../priority';
|
||||
|
||||
const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id));
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('priorityScore', () => {
|
||||
it('weights specs by their position in ranking', () => {
|
||||
const ranking = ['HCR', 'BNK', 'FIN'];
|
||||
expect(priorityScore(['HCR'], ranking)).toBe(15);
|
||||
expect(priorityScore(['BNK'], ranking)).toBe(14);
|
||||
expect(priorityScore(['FIN'], ranking)).toBe(13);
|
||||
expect(priorityScore(['HCR', 'BNK'], ranking)).toBe(29);
|
||||
expect(priorityScore([], ranking)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to lowest weight for unranked specs', () => {
|
||||
const ranking = ['HCR'];
|
||||
// FALLBACK_RANK = 14 → weight = 15 - 14 = 1
|
||||
expect(priorityScore(['STR'], ranking)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoundedRankedList', () => {
|
||||
const cmp = (a: number, b: number) => b - a; // sort descending
|
||||
|
||||
it('inserts items maintaining descending order', () => {
|
||||
const list = new BoundedRankedList<number>(5, cmp);
|
||||
expect(list.tryInsert(3)).toBe(true);
|
||||
expect(list.tryInsert(7)).toBe(true);
|
||||
expect(list.tryInsert(5)).toBe(true);
|
||||
expect(list.toArray()).toEqual([7, 5, 3]);
|
||||
});
|
||||
|
||||
it('drops worst entry when over capacity', () => {
|
||||
const list = new BoundedRankedList<number>(3, cmp);
|
||||
[10, 5, 8, 1, 12].forEach((v) => list.tryInsert(v));
|
||||
expect(list.toArray()).toEqual([12, 10, 8]);
|
||||
});
|
||||
|
||||
it('rejects items that cannot enter at capacity', () => {
|
||||
const list = new BoundedRankedList<number>(2, cmp);
|
||||
list.tryInsert(10);
|
||||
list.tryInsert(5);
|
||||
expect(list.tryInsert(1)).toBe(false);
|
||||
expect(list.toArray()).toEqual([10, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareOutcomes', () => {
|
||||
const make = (specs: string[], score: number, key: string): PlanOutcome => ({
|
||||
courseAssignments: { spr1: key },
|
||||
achievedSpecs: specs,
|
||||
priorityScore: score,
|
||||
});
|
||||
|
||||
it('higher count beats lower count regardless of priority', () => {
|
||||
const a = make(['BNK', 'FIN', 'CRF'], 5, 'a');
|
||||
const b = make(['HCR'], 100, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count → higher priority score wins', () => {
|
||||
const a = make(['HCR', 'BNK'], 29, 'a');
|
||||
const b = make(['FIN', 'MTO'], 22, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count and score → deterministic tiebreak by assignment key', () => {
|
||||
const a = make(['HCR'], 15, 'aaa');
|
||||
const b = make(['HCR'], 15, 'bbb');
|
||||
// a has lex-smaller key, so a wins (returns negative)
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectPriorityTarget / reorderForTarget', () => {
|
||||
it('returns first reachable spec in ranking', () => {
|
||||
const upper = { HCR: 5, BNK: 12, FIN: 20 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK', 'FIN'], upper)).toBe('BNK');
|
||||
});
|
||||
|
||||
it('returns null when no spec is reachable', () => {
|
||||
const upper = { HCR: 5, BNK: 7 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK'], upper)).toBe(null);
|
||||
});
|
||||
|
||||
it('reorders set children with target-qualifying first', () => {
|
||||
// spr3 contains analytics-ml (HCR), mergers-acq (CRF/FIN/LCM/STR-S1), etc.
|
||||
const reordered = reorderForTarget('spr3', 'HCR', cancelledIds);
|
||||
expect(reordered[0].id).toBe('spr3-analytics-ml');
|
||||
});
|
||||
|
||||
it('returns courses unchanged when target is null', () => {
|
||||
const original = reorderForTarget('spr3', null, cancelledIds);
|
||||
expect(original.map((c) => c.id)).toEqual([
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr3-digital-strategy',
|
||||
'spr3-managing-high-tech',
|
||||
'spr3-analytics-ml',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ELECTIVE_SETS
|
||||
.map((s) => s.id)
|
||||
.filter(
|
||||
(id) =>
|
||||
!PINNED.some(
|
||||
(p) => COURSES.find((c) => c.id === p)?.setId === id,
|
||||
),
|
||||
);
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
it('topK[0] achieves HCR when HCR is ranked first', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.topK.length).toBeGreaterThan(0);
|
||||
expect(result.topK[0].achievedSpecs).toContain('HCR');
|
||||
});
|
||||
|
||||
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3');
|
||||
const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml');
|
||||
expect(aml?.ceilingSpecs).toContain('HCR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — ordering and streaming', () => {
|
||||
it('streamed topK is monotonically improving', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
const snapshots: PlanOutcome[][] = [];
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
{
|
||||
onTopKUpdate: (topK) => {
|
||||
snapshots.push(topK);
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
|
||||
expect(snapshots.length).toBeGreaterThan(0);
|
||||
// Each snapshot's [0] is no worse than predecessor's [0]
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
const prev = snapshots[i - 1][0];
|
||||
const curr = snapshots[i][0];
|
||||
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — termination', () => {
|
||||
it('saturation stops the search when topK converges', () => {
|
||||
// Tiny scenario: should saturate within a few hundred iterations
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
allSpecIds,
|
||||
'maximize-count',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBeLessThan(MAX_TREE_ITERATIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke', () => {
|
||||
it('user scenario completes in < 5s for K=10', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const start = Date.now();
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
+267
-105
@@ -1,7 +1,9 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
import type { Course, OptimizationMode } from '../data/types';
|
||||
import { maximizeCount, priorityOrder } from './optimizer';
|
||||
import { computeUpperBounds } from './feasibility';
|
||||
import { makePriorityScorer } from './priority';
|
||||
|
||||
export interface ChoiceOutcome {
|
||||
courseId: string;
|
||||
@@ -13,79 +15,270 @@ export interface ChoiceOutcome {
|
||||
export interface SetAnalysis {
|
||||
setId: string;
|
||||
setName: string;
|
||||
impact: number; // variance in ceiling outcomes
|
||||
impact: number;
|
||||
choices: ChoiceOutcome[];
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
|
||||
/**
|
||||
* Compute the ceiling outcome for a single course choice:
|
||||
* the best achievable result assuming that course is pinned
|
||||
* and all other open sets are chosen optimally.
|
||||
*/
|
||||
function computeCeiling(
|
||||
basePinnedCourses: string[],
|
||||
chosenCourseId: string,
|
||||
otherOpenSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { count: number; specs: string[] } {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
|
||||
if (otherOpenSetIds.length === 0) {
|
||||
// No other open sets — just solve with this choice added
|
||||
const selected = [...basePinnedCourses, chosenCourseId];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
return { count: result.achieved.length, specs: result.achieved };
|
||||
}
|
||||
|
||||
// Enumerate all combinations of remaining open sets
|
||||
let bestCount = 0;
|
||||
let bestSpecs: string[] = [];
|
||||
|
||||
function enumerate(setIndex: number, accumulated: string[]) {
|
||||
// Early termination: already found max (3)
|
||||
if (bestCount >= 3) return;
|
||||
|
||||
if (setIndex >= otherOpenSetIds.length) {
|
||||
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
if (result.achieved.length > bestCount) {
|
||||
bestCount = result.achieved.length;
|
||||
bestSpecs = result.achieved;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setId = otherOpenSetIds[setIndex];
|
||||
const courses = coursesBySet[setId];
|
||||
for (const course of courses) {
|
||||
if (excludedCourseIds?.has(course.id)) continue;
|
||||
enumerate(setIndex + 1, [...accumulated, course.id]);
|
||||
if (bestCount >= 3) return;
|
||||
}
|
||||
}
|
||||
|
||||
enumerate(0, []);
|
||||
return { count: bestCount, specs: bestSpecs };
|
||||
export interface PlanOutcome {
|
||||
courseAssignments: Record<string, string>; // setId -> courseId for open sets
|
||||
achievedSpecs: string[];
|
||||
priorityScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute variance of an array of numbers.
|
||||
*/
|
||||
export interface SearchResult {
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
export const MAX_TREE_ITERATIONS = 10000;
|
||||
export const SATURATION_LIMIT = 500;
|
||||
|
||||
function variance(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
}
|
||||
|
||||
export function selectPriorityTarget(
|
||||
ranking: string[],
|
||||
upperBounds: Record<string, number>,
|
||||
): string | null {
|
||||
for (const specId of ranking) {
|
||||
if ((upperBounds[specId] ?? 0) >= CREDIT_THRESHOLD) return specId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function reorderForTarget(
|
||||
setId: string,
|
||||
target: string | null,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): Course[] {
|
||||
const courses = coursesBySet[setId].filter(
|
||||
(c) => !excludedCourseIds?.has(c.id),
|
||||
);
|
||||
if (!target) return courses;
|
||||
const qualifying: Course[] = [];
|
||||
const others: Course[] = [];
|
||||
for (const c of courses) {
|
||||
if (c.qualifications.some((q) => q.specId === target)) qualifying.push(c);
|
||||
else others.push(c);
|
||||
}
|
||||
return [...qualifying, ...others];
|
||||
}
|
||||
|
||||
export function assignmentKey(assignments: Record<string, string>): string {
|
||||
return Object.keys(assignments)
|
||||
.sort()
|
||||
.map((k) => `${k}:${assignments[k]}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export class BoundedRankedList<T> {
|
||||
private items: T[] = [];
|
||||
constructor(
|
||||
private capacity: number,
|
||||
private compare: (a: T, b: T) => number,
|
||||
) {}
|
||||
|
||||
tryInsert(item: T): boolean {
|
||||
let pos = 0;
|
||||
while (pos < this.items.length && this.compare(item, this.items[pos]) > 0) pos++;
|
||||
if (pos >= this.capacity) return false;
|
||||
this.items.splice(pos, 0, item);
|
||||
if (this.items.length > this.capacity) this.items.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
return [...this.items];
|
||||
}
|
||||
}
|
||||
|
||||
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) {
|
||||
return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
}
|
||||
if (a.priorityScore !== b.priorityScore) {
|
||||
return b.priorityScore - a.priorityScore;
|
||||
}
|
||||
return assignmentKey(a.courseAssignments).localeCompare(
|
||||
assignmentKey(b.courseAssignments),
|
||||
);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
count: number;
|
||||
score: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function compareCeiling(a: CeilingComparable, b: CeilingComparable): number {
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
return a.key.localeCompare(b.key);
|
||||
}
|
||||
|
||||
export function searchDecisionTree(
|
||||
pinnedCourseIds: string[],
|
||||
openSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
K: number,
|
||||
callbacks?: SearchCallbacks,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): SearchResult {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
const upperBounds = computeUpperBounds(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
excludedCourseIds,
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
// Initialize per-set analyses with empty ceilings
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
const orderedCoursesPerSet: Record<string, Course[]> = {};
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
impact: 0,
|
||||
choices: ordered.map((c) => ({
|
||||
courseId: c.id,
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Track ceiling key per choice for stable tiebreaks
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
|
||||
let iterations = 0;
|
||||
let iterationsSinceTopKChange = 0;
|
||||
let partial = false;
|
||||
let halted = false;
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): boolean {
|
||||
iterations++;
|
||||
if (iterations > MAX_TREE_ITERATIONS) {
|
||||
partial = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const courses: string[] = [];
|
||||
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
||||
const selected = [...pinnedCourseIds, ...courses];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
const score = scorer(result.achieved);
|
||||
const aKey = assignmentKey(accumulated);
|
||||
|
||||
const outcome: PlanOutcome = {
|
||||
courseAssignments: { ...accumulated },
|
||||
achievedSpecs: result.achieved,
|
||||
priorityScore: score,
|
||||
};
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
iterationsSinceTopKChange = 0;
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
} else {
|
||||
iterationsSinceTopKChange++;
|
||||
}
|
||||
|
||||
// Per-set ceiling updates
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = accumulated[setId];
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
score: scorer(choice.ceilingSpecs),
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: result.achieved.length,
|
||||
score,
|
||||
key: aKey,
|
||||
};
|
||||
if (compareCeiling(candidate, existing) < 0) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = result.achieved;
|
||||
choiceKey[currentKey] = aKey;
|
||||
// Recompute impact lazily for emit
|
||||
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
|
||||
const updated: SetAnalysis = {
|
||||
...analysis,
|
||||
impact,
|
||||
choices: analysis.choices.map((c) => ({ ...c })),
|
||||
};
|
||||
setAnalyses[setId].impact = impact;
|
||||
callbacks?.onChoiceUpdate?.(setId, updated);
|
||||
}
|
||||
}
|
||||
|
||||
if (iterationsSinceTopKChange >= SATURATION_LIMIT) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function dfs(setIdx: number, accumulated: Record<string, string>) {
|
||||
if (halted) return;
|
||||
if (setIdx >= openSetIds.length) {
|
||||
if (evaluateLeaf(accumulated)) halted = true;
|
||||
return;
|
||||
}
|
||||
const setId = openSetIds[setIdx];
|
||||
const courses = orderedCoursesPerSet[setId];
|
||||
for (const course of courses) {
|
||||
if (halted) return;
|
||||
accumulated[setId] = course.id;
|
||||
dfs(setIdx + 1, accumulated);
|
||||
}
|
||||
delete accumulated[setId];
|
||||
}
|
||||
|
||||
if (openSetIds.length > 0 && openSetIds.every((s) => orderedCoursesPerSet[s].length > 0)) {
|
||||
dfs(0, {});
|
||||
}
|
||||
|
||||
// Final impact recomputation + sort
|
||||
for (const a of Object.values(setAnalyses)) {
|
||||
a.impact = variance(a.choices.map((c) => c.ceilingCount));
|
||||
}
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
const sortedAnalyses = Object.values(setAnalyses).sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return {
|
||||
topK: topK.toArray(),
|
||||
setAnalyses: sortedAnalyses,
|
||||
partial,
|
||||
iterations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all open sets and compute per-choice ceiling outcomes.
|
||||
* Returns sets ordered by decision impact (highest first).
|
||||
*
|
||||
* onSetComplete is called progressively as each set's analysis finishes.
|
||||
* Backward-compatible wrapper: produces only the per-set ceiling table.
|
||||
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
|
||||
* once per choice update via the legacy onSetComplete callback.
|
||||
*/
|
||||
export function analyzeDecisionTree(
|
||||
pinnedCourseIds: string[],
|
||||
@@ -96,52 +289,21 @@ export function analyzeDecisionTree(
|
||||
excludedCourseIds?: Set<string>,
|
||||
): SetAnalysis[] {
|
||||
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
||||
// Fallback: return empty analyses (caller uses upper bounds instead)
|
||||
return openSetIds.map((setId) => {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
return { setId, setName: set.name, impact: 0, choices: [] };
|
||||
});
|
||||
}
|
||||
|
||||
const analyses: SetAnalysis[] = [];
|
||||
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
||||
const courses = coursesBySet[setId];
|
||||
|
||||
const choices: ChoiceOutcome[] = courses
|
||||
.filter((course) => !excludedCourseIds?.has(course.id))
|
||||
.map((course) => {
|
||||
const ceiling = computeCeiling(
|
||||
pinnedCourseIds,
|
||||
course.id,
|
||||
otherOpenSets,
|
||||
ranking,
|
||||
mode,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return {
|
||||
courseId: course.id,
|
||||
courseName: course.name,
|
||||
ceilingCount: ceiling.count,
|
||||
ceilingSpecs: ceiling.specs,
|
||||
};
|
||||
});
|
||||
|
||||
const impact = variance(choices.map((c) => c.ceilingCount));
|
||||
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
||||
analyses.push(analysis);
|
||||
|
||||
onSetComplete?.(analysis);
|
||||
}
|
||||
|
||||
// Sort by impact descending, then by set order (chronological) for ties
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
analyses.sort((a, b) => {
|
||||
if (b.impact !== a.impact) return b.impact - a.impact;
|
||||
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
|
||||
});
|
||||
|
||||
return analyses;
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
10,
|
||||
onSetComplete
|
||||
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
|
||||
: undefined,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return result.setAnalyses;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
preFilterCandidates,
|
||||
computeUpperBounds,
|
||||
} from './feasibility';
|
||||
import { makePriorityScorer } from './priority';
|
||||
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
const CREDIT_PER_COURSE = 2.5;
|
||||
@@ -67,11 +68,7 @@ export function maximizeCount(
|
||||
return entries.some((e) => selectedCourseIds.includes(e.courseId));
|
||||
});
|
||||
|
||||
// Priority score: sum of (15 - rank position) for each spec in subset
|
||||
const rankIndex = new Map(ranking.map((id, i) => [id, i]));
|
||||
function priorityScore(specs: string[]): number {
|
||||
return specs.reduce((sum, id) => sum + (15 - (rankIndex.get(id) ?? 14)), 0);
|
||||
}
|
||||
const priorityScore = makePriorityScorer(ranking);
|
||||
|
||||
// Try from size 3 down to 0
|
||||
const maxSize = Math.min(3, achievable.length);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import type { OptimizationMode, AllocationResult } from '../data/types';
|
||||
import { optimize } from '../solver/optimizer';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
||||
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
|
||||
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
||||
@@ -54,7 +54,7 @@ function loadState(): AppState {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultState();
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== 14) return defaultState();
|
||||
if (!Array.isArray(parsed.ranking) || parsed.ranking.length !== SPECIALIZATIONS.length) return defaultState();
|
||||
if (!['maximize-count', 'priority-order'].includes(parsed.mode)) return defaultState();
|
||||
return {
|
||||
ranking: parsed.ranking,
|
||||
@@ -70,6 +70,8 @@ export function useAppState() {
|
||||
const [state, dispatch] = useReducer(reducer, null, loadState);
|
||||
const [treeResults, setTreeResults] = useState<SetAnalysis[]>([]);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
|
||||
const [topPlansPartial, setTopPlansPartial] = useState(false);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -123,6 +125,8 @@ export function useAppState() {
|
||||
|
||||
if (openSetIds.length === 0) {
|
||||
setTreeResults([]);
|
||||
setTopPlans([]);
|
||||
setTopPlansPartial(false);
|
||||
setTreeLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -137,13 +141,19 @@ export function useAppState() {
|
||||
const worker = new DecisionTreeWorker();
|
||||
workerRef.current = worker;
|
||||
|
||||
const progressResults: SetAnalysis[] = [];
|
||||
// Per-cell streaming: keep a working map of setId -> analysis,
|
||||
// emit the full array on each change so consumers re-render.
|
||||
const setMap = new Map<string, SetAnalysis>();
|
||||
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
||||
if (e.data.type === 'setComplete' && e.data.analysis) {
|
||||
progressResults.push(e.data.analysis);
|
||||
setTreeResults([...progressResults]);
|
||||
} else if (e.data.type === 'allComplete' && e.data.analyses) {
|
||||
setTreeResults(e.data.analyses);
|
||||
if (e.data.type === 'choiceUpdate') {
|
||||
setMap.set(e.data.setId, e.data.analysis);
|
||||
setTreeResults(Array.from(setMap.values()));
|
||||
} else if (e.data.type === 'topKUpdate') {
|
||||
setTopPlans(e.data.topK);
|
||||
} else if (e.data.type === 'allComplete') {
|
||||
setTreeResults(e.data.setAnalyses);
|
||||
setTopPlans(e.data.topK);
|
||||
setTopPlansPartial(e.data.partial);
|
||||
setTreeLoading(false);
|
||||
worker.terminate();
|
||||
workerRef.current = null;
|
||||
@@ -156,6 +166,7 @@ export function useAppState() {
|
||||
ranking: state.ranking,
|
||||
mode: state.mode,
|
||||
excludedCourseIds: [...excludedCourseIds],
|
||||
topK: 10,
|
||||
};
|
||||
worker.postMessage(request);
|
||||
} catch {
|
||||
@@ -179,11 +190,19 @@ export function useAppState() {
|
||||
const unpinCourse = useCallback((setId: string) => dispatch({ type: 'unpinCourse', setId }), []);
|
||||
const clearAll = useCallback(() => dispatch({ type: 'clearAll' }), []);
|
||||
|
||||
const adoptPlan = useCallback((assignments: Record<string, string>) => {
|
||||
for (const [setId, courseId] of Object.entries(assignments)) {
|
||||
dispatch({ type: 'pinCourse', setId, courseId });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
optimizationResult,
|
||||
treeResults,
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
@@ -193,5 +212,6 @@ export function useAppState() {
|
||||
pinCourse,
|
||||
unpinCourse,
|
||||
clearAll,
|
||||
adoptPlan,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { analyzeDecisionTree } from '../solver/decisionTree';
|
||||
import { searchDecisionTree } from '../solver/decisionTree';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
export interface WorkerRequest {
|
||||
pinnedCourseIds: string[];
|
||||
@@ -8,34 +8,61 @@ export interface WorkerRequest {
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
excludedCourseIds?: string[];
|
||||
topK?: number;
|
||||
saturationLimit?: number;
|
||||
}
|
||||
|
||||
export interface WorkerResponse {
|
||||
type: 'setComplete' | 'allComplete';
|
||||
analysis?: SetAnalysis;
|
||||
analyses?: SetAnalysis[];
|
||||
}
|
||||
export type WorkerResponse =
|
||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||
| {
|
||||
type: 'allComplete';
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
const { pinnedCourseIds, openSetIds, ranking, mode, excludedCourseIds } = e.data;
|
||||
const excludedSet = excludedCourseIds && excludedCourseIds.length > 0
|
||||
? new Set(excludedCourseIds)
|
||||
: undefined;
|
||||
|
||||
const analyses = analyzeDecisionTree(
|
||||
const {
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
(analysis) => {
|
||||
// Progressive update: send each set's results as they complete
|
||||
const response: WorkerResponse = { type: 'setComplete', analysis };
|
||||
self.postMessage(response);
|
||||
excludedCourseIds,
|
||||
topK = 10,
|
||||
} = e.data;
|
||||
|
||||
const excludedSet =
|
||||
excludedCourseIds && excludedCourseIds.length > 0
|
||||
? new Set(excludedCourseIds)
|
||||
: undefined;
|
||||
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
topK,
|
||||
{
|
||||
onTopKUpdate: (topK, iterations) => {
|
||||
const msg: WorkerResponse = { type: 'topKUpdate', topK, iterations };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onChoiceUpdate: (setId, analysis) => {
|
||||
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
},
|
||||
excludedSet,
|
||||
);
|
||||
|
||||
// Final result with sorted analyses
|
||||
const response: WorkerResponse = { type: 'allComplete', analyses };
|
||||
self.postMessage(response);
|
||||
const final: WorkerResponse = {
|
||||
type: 'allComplete',
|
||||
topK: result.topK,
|
||||
setAnalyses: result.setAnalyses,
|
||||
partial: result.partial,
|
||||
iterations: result.iterations,
|
||||
};
|
||||
self.postMessage(final);
|
||||
};
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify('1.2.2'),
|
||||
__APP_VERSION__: JSON.stringify('1.3.0'),
|
||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||
},
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user