v1.3.2: Leaf cache for instant pin/unpin + TopPlans block UX
Decision-tree leaf outcomes are now cached on the main thread keyed by
their full 12-course assignment. Pin operations filter the cache and
re-derive top-K + per-set ceilings instantly with no worker spawn. Unpin
operations show the cached subset immediately and stream improvements as
a background worker fills in the missing leaves. Cache survives pin,
unpin, and adopt-plan; only ranking or mode changes invalidate it.
Solver / worker:
- searchDecisionTree accepts skipKeys (Set<string>) and pinnedAssignments
(Record<setId,courseId>). Leaves are emitted with their full 12-set
assignment so cache keys are stable across pin/unpin operations.
- evaluateLeaf short-circuits when the leaf's assignmentKey is in
skipKeys: increments iterations + emits progress, but skips the
optimizer call and all callbacks. Keeps progress percentage honest
(counts whole tree, not just delta).
- New deriveFromLeaves pure helper produces {topK, setAnalyses} from a
leaf collection; used by the main-thread cache filter and gives a
reusable derivation primitive for tests.
- Worker request gains skipKeys and pinnedAssignments fields. Worker
response gains a leafEvaluated event so the main thread can populate
its cache as the search streams.
App state:
- leafCacheRef holds Map<assignmentKey, PlanOutcome> scoped to the
current (ranking, mode) pair. The search effect now: invalidates on
ranking/mode change; computes the orderedCourses + expectedTotal;
filters the cache against the current pinned/excluded state; calls
deriveFromLeaves to render immediately; spawns the worker only when
filtered.length < expectedTotal, passing skipKeys.
- Cache cap of 500,000 leaves with full clear on overflow. Bounds
worst-case memory at ~150 MB.
UI (TopPlans):
- Course blocks in the per-plan row are now interactive buttons. Click
pins (or unpins, if the course is currently pinned) the course in
that set. Pinned blocks render in a selected blue color.
- Each plan row now shows the FULL 12-set sequence including pinned
courses (interleaved with the search's recommended choices for the
remaining open sets) so the displayed plan is always complete.
- Spec qualification tags removed from per-block display (kept the
set-label + course-name treatment for clarity).
Tests:
- New app/src/solver/__tests__/leafCache.test.ts with 4 tests:
skipKeys parity (second-pass run with skipKeys evaluates zero
leaves), deriveFromLeaves parity (matches a fresh search), cache
filter on pinned assignments, cache filter on excluded courses.
- All 78 prior tests continue to pass; 82 total.
Browser-verified: pin click on a Top Plans block from the cached
8-open-set scenario completes instantly with no spinner; unpin restores
the original cached subset (also instant when the prior space was
already cached); mode toggle correctly invalidates and re-runs the
search.
This commit is contained in:
@@ -138,7 +138,10 @@ function App() {
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
progress={searchProgress}
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
onAdopt={adoptPlan}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
/>
|
||||
<CourseSelection
|
||||
pinnedCourses={state.pinnedCourses}
|
||||
|
||||
@@ -15,14 +15,17 @@ interface TopPlansProps {
|
||||
partial: boolean;
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
}
|
||||
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlansProps) {
|
||||
export function TopPlans({ plans, partial, loading, progress, pinnedCourses, onAdopt, onPin, onUnpin }: TopPlansProps) {
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
|
||||
const pct = progress && progress.iterationsTotal > 0
|
||||
@@ -89,7 +92,15 @@ export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlan
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{visible.map((plan, i) => (
|
||||
<PlanRow key={i + ':' + plan.priorityScore} plan={plan} rank={i + 1} onAdopt={onAdopt} />
|
||||
<PlanRow
|
||||
key={i + ':' + plan.priorityScore}
|
||||
plan={plan}
|
||||
rank={i + 1}
|
||||
pinnedCourses={pinnedCourses}
|
||||
onAdopt={onAdopt}
|
||||
onPin={onPin}
|
||||
onUnpin={onUnpin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,18 +110,28 @@ export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlan
|
||||
function PlanRow({
|
||||
plan,
|
||||
rank,
|
||||
pinnedCourses,
|
||||
onAdopt,
|
||||
onPin,
|
||||
onUnpin,
|
||||
}: {
|
||||
plan: PlanOutcome;
|
||||
rank: number;
|
||||
pinnedCourses: Record<string, string | null>;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: 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);
|
||||
},
|
||||
);
|
||||
// Combine the plan's open-set assignments with the user's currently-pinned
|
||||
// courses so the row shows the full sequence across all 12 sets.
|
||||
const assignmentEntries: [string, string][] = ELECTIVE_SETS
|
||||
.map((s) => {
|
||||
const pinned = pinnedCourses[s.id];
|
||||
const planned = plan.courseAssignments[s.id];
|
||||
const courseId = pinned ?? planned;
|
||||
return courseId ? ([s.id, courseId] as [string, string]) : null;
|
||||
})
|
||||
.filter((e): e is [string, string] => e !== null);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -167,14 +188,75 @@ function PlanRow({
|
||||
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 style={{
|
||||
display: 'flex', flexWrap: 'wrap', gap: '4px',
|
||||
}}>
|
||||
{assignmentEntries.map(([setId, courseId]) => {
|
||||
const course = courseById[courseId];
|
||||
const label = setNameById[setId]?.replace('Elective Set ', 'S').replace('Spring ', 'Sp').replace('Summer ', 'Su').replace('Fall ', 'F').replace(' ', '') ?? setId;
|
||||
const isPinned = pinnedCourses[setId] === courseId;
|
||||
return (
|
||||
<button
|
||||
key={setId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isPinned) onUnpin(setId);
|
||||
else onPin(setId, courseId);
|
||||
}}
|
||||
title={
|
||||
isPinned
|
||||
? `Unpin "${course?.name ?? courseId}" from ${setNameById[setId]}`
|
||||
: `Pin "${course?.name ?? courseId}" in ${setNameById[setId]}`
|
||||
}
|
||||
style={{
|
||||
flex: '1 1 110px', minWidth: '90px', maxWidth: '180px',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
padding: '4px 6px', gap: '2px',
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #e5e7eb',
|
||||
borderRadius: '4px',
|
||||
background: isPinned ? '#dbeafe' : '#f9fafb',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
transition: 'background 150ms, border-color 150ms',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isPinned) {
|
||||
e.currentTarget.style.background = '#bfdbfe';
|
||||
} else {
|
||||
e.currentTarget.style.background = '#eff6ff';
|
||||
e.currentTarget.style.borderColor = '#bfdbfe';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isPinned) {
|
||||
e.currentTarget.style.background = '#dbeafe';
|
||||
} else {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontSize: '9px', fontWeight: 700,
|
||||
color: isPinned ? '#1e40af' : '#94a3b8',
|
||||
letterSpacing: '0.3px', textTransform: 'uppercase',
|
||||
}}>
|
||||
{label}{isPinned && ' · pinned'}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: isPinned ? '#1e3a8a' : '#374151',
|
||||
fontWeight: isPinned ? 600 : 500,
|
||||
lineHeight: 1.25,
|
||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{course?.name ?? courseId}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchDecisionTree,
|
||||
deriveFromLeaves,
|
||||
assignmentKey,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
|
||||
const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id));
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('leaf cache: skipKeys parity', () => {
|
||||
it('two-pass run with skipKeys produces identical final result', () => {
|
||||
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 = ['fall3', 'fall4'];
|
||||
const pinnedAssignments: Record<string, string> = {};
|
||||
for (const id of PINNED) {
|
||||
const c = COURSES.find((x) => x.id === id)!;
|
||||
pinnedAssignments[c.setId] = id;
|
||||
}
|
||||
|
||||
// Pass 1: full run, capture all leaves
|
||||
const leaves: PlanOutcome[] = [];
|
||||
const r1 = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: (l) => leaves.push(l) },
|
||||
cancelledIds, undefined, pinnedAssignments,
|
||||
);
|
||||
|
||||
// Pass 2: skip every leaf the first run produced
|
||||
const skipKeys = new Set(leaves.map((l) => assignmentKey(l.courseAssignments)));
|
||||
let evaluatedCount = 0;
|
||||
const r2 = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: () => { evaluatedCount++; } },
|
||||
cancelledIds, skipKeys, pinnedAssignments,
|
||||
);
|
||||
|
||||
// r2 should have visited all leaves but evaluated none
|
||||
expect(r2.iterations).toBe(r1.iterations);
|
||||
expect(r2.iterationsTotal).toBe(r1.iterationsTotal);
|
||||
expect(evaluatedCount).toBe(0);
|
||||
// r2's topK is empty since nothing was evaluated; this is expected
|
||||
// (cache provides the data on the main thread)
|
||||
expect(r2.topK.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveFromLeaves parity', () => {
|
||||
it('matches a fresh search when given the same leaves', () => {
|
||||
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 = ['fall3', 'fall4'];
|
||||
const pinnedAssignments: Record<string, string> = {};
|
||||
for (const id of PINNED) {
|
||||
const c = COURSES.find((x) => x.id === id)!;
|
||||
pinnedAssignments[c.setId] = id;
|
||||
}
|
||||
|
||||
const leaves: PlanOutcome[] = [];
|
||||
const search = searchDecisionTree(
|
||||
PINNED, OPEN, allSpecIds, 'maximize-count', 10,
|
||||
{ onLeafEvaluated: (l) => leaves.push(l) },
|
||||
cancelledIds, undefined, pinnedAssignments,
|
||||
);
|
||||
|
||||
const derived = deriveFromLeaves(leaves, 10, 'maximize-count', allSpecIds, OPEN, cancelledIds);
|
||||
|
||||
// Top-K matches in length and outcomes
|
||||
expect(derived.topK.length).toBe(search.topK.length);
|
||||
for (let i = 0; i < search.topK.length; i++) {
|
||||
expect(derived.topK[i].achievedSpecs).toEqual(search.topK[i].achievedSpecs);
|
||||
expect(derived.topK[i].priorityScore).toBe(search.topK[i].priorityScore);
|
||||
}
|
||||
|
||||
// Per-set analyses match
|
||||
for (const setAnalysis of search.setAnalyses) {
|
||||
const dSet = derived.setAnalyses.find((s) => s.setId === setAnalysis.setId)!;
|
||||
for (const choice of setAnalysis.choices) {
|
||||
const dChoice = dSet.choices.find((c) => c.courseId === choice.courseId)!;
|
||||
expect(dChoice.ceilingCount).toBe(choice.ceilingCount);
|
||||
expect(dChoice.ceilingSpecs).toEqual(choice.ceilingSpecs);
|
||||
expect(dChoice.evaluated).toBe(choice.evaluated);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache filter semantics', () => {
|
||||
it('filtering retains only leaves matching pinned assignments', () => {
|
||||
// Build a small synthetic set of leaves
|
||||
const leaves: PlanOutcome[] = [
|
||||
{ courseAssignments: { spr3: 'spr3-mergers-acquisitions', fall3: 'fall3-climate-finance' }, achievedSpecs: ['BNK'], priorityScore: 14 },
|
||||
{ courseAssignments: { spr3: 'spr3-analytics-ml', fall3: 'fall3-climate-finance' }, achievedSpecs: ['HCR'], priorityScore: 15 },
|
||||
{ courseAssignments: { spr3: 'spr3-mergers-acquisitions', fall3: 'fall3-corporate-governance' }, achievedSpecs: ['LCM'], priorityScore: 6 },
|
||||
];
|
||||
// Filter for spr3 = analytics-ml
|
||||
const pinned = { spr3: 'spr3-analytics-ml' };
|
||||
const filtered = leaves.filter((l) =>
|
||||
Object.entries(pinned).every(([s, c]) => l.courseAssignments[s] === c),
|
||||
);
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].achievedSpecs).toEqual(['HCR']);
|
||||
});
|
||||
|
||||
it('filtering drops leaves containing excluded courses', () => {
|
||||
const leaves: PlanOutcome[] = [
|
||||
{ courseAssignments: { spr1: 'spr1-global-immersion', sum1: 'sum1-global-immersion' }, achievedSpecs: [], priorityScore: 0 },
|
||||
{ courseAssignments: { spr1: 'spr1-collaboration', sum1: 'sum1-high-stakes' }, achievedSpecs: ['LCM'], priorityScore: 6 },
|
||||
];
|
||||
const excluded = new Set(['sum1-global-immersion']);
|
||||
const filtered = leaves.filter((l) =>
|
||||
!Object.values(l.courseAssignments).some((cid) => excluded.has(cid)),
|
||||
);
|
||||
expect(filtered.length).toBe(1);
|
||||
expect(filtered[0].achievedSpecs).toEqual(['LCM']);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,7 @@ export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||
onLeafEvaluated?: (leaf: PlanOutcome) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
@@ -192,6 +193,8 @@ export function searchDecisionTree(
|
||||
K: number,
|
||||
callbacks?: SearchCallbacks,
|
||||
excludedCourseIds?: Set<string>,
|
||||
skipKeys?: Set<string>,
|
||||
pinnedAssignments?: Record<string, string>,
|
||||
): SearchResult {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
@@ -201,6 +204,9 @@ export function searchDecisionTree(
|
||||
excludedCourseIds,
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
// Pinned assignments (setId -> courseId) for any pinned sets — included in
|
||||
// the leaf's full courseAssignments so cache keys are stable across pin/unpin.
|
||||
const pinnedMap = pinnedAssignments ?? {};
|
||||
|
||||
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
@@ -248,19 +254,29 @@ export function searchDecisionTree(
|
||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||
iterations++;
|
||||
|
||||
// Build the full 12-set assignment so cache keys remain stable across
|
||||
// pin/unpin operations.
|
||||
const fullAssignment: Record<string, string> = { ...pinnedMap, ...accumulated };
|
||||
const aKey = assignmentKey(fullAssignment);
|
||||
if (skipKeys?.has(aKey)) {
|
||||
emitProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
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 },
|
||||
courseAssignments: fullAssignment,
|
||||
achievedSpecs: result.achieved,
|
||||
priorityScore: score,
|
||||
};
|
||||
|
||||
callbacks?.onLeafEvaluated?.(outcome);
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
}
|
||||
@@ -346,6 +362,90 @@ export function searchDecisionTree(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation of {topK, setAnalyses} from a collection of leaf outcomes.
|
||||
* Used by the main thread when filtering the leaf cache, and reusable
|
||||
* elsewhere as needed. Does NOT run any optimizer calls — leaves carry
|
||||
* their own pre-computed achievedSpecs/priorityScore.
|
||||
*/
|
||||
export function deriveFromLeaves(
|
||||
leaves: Iterable<PlanOutcome>,
|
||||
K: number,
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered =
|
||||
mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
impact: 0,
|
||||
choices: ordered.map((c) => ({
|
||||
courseId: c.id,
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
evaluated: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const choiceKey: Record<string, string> = {};
|
||||
const ceilingComparator = makeCeilingComparator(mode);
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
|
||||
for (const leaf of leaves) {
|
||||
topK.tryInsert(leaf);
|
||||
const aKey = assignmentKey(leaf.courseAssignments);
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = leaf.courseAssignments[setId];
|
||||
if (!courseId) continue;
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId);
|
||||
if (!choice) continue;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
score: scorer(choice.ceilingSpecs),
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: leaf.achievedSpecs.length,
|
||||
score: leaf.priorityScore,
|
||||
key: aKey,
|
||||
};
|
||||
if (ceilingComparator(candidate, existing) < 0) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = leaf.achievedSpecs;
|
||||
choiceKey[currentKey] = aKey;
|
||||
}
|
||||
choice.evaluated = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper: produces only the per-set ceiling table.
|
||||
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
|
||||
|
||||
+116
-7
@@ -3,11 +3,27 @@ import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import type { OptimizationMode, AllocationResult } from '../data/types';
|
||||
import { optimize } from '../solver/optimizer';
|
||||
import {
|
||||
assignmentKey,
|
||||
deriveFromLeaves,
|
||||
reorderByReachableQualCount,
|
||||
reorderForTarget,
|
||||
selectPriorityTarget,
|
||||
} from '../solver/decisionTree';
|
||||
import { computeUpperBounds } from '../solver/feasibility';
|
||||
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';
|
||||
|
||||
const LEAF_CACHE_CAP = 500_000;
|
||||
|
||||
interface LeafCache {
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
leaves: Map<string, PlanOutcome>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'emba-solver-state';
|
||||
|
||||
export interface AppState {
|
||||
@@ -75,6 +91,11 @@ export function useAppState() {
|
||||
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const leafCacheRef = useRef<LeafCache>({
|
||||
ranking: state.ranking,
|
||||
mode: state.mode,
|
||||
leaves: new Map(),
|
||||
});
|
||||
|
||||
// Persist to localStorage
|
||||
useEffect(() => {
|
||||
@@ -120,7 +141,16 @@ export function useAppState() {
|
||||
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
|
||||
);
|
||||
|
||||
// Web Worker decision tree (debounced)
|
||||
// Pinned assignments map (setId -> courseId) for the cache + worker
|
||||
const pinnedAssignments = useMemo(() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [setId, courseId] of Object.entries(state.pinnedCourses)) {
|
||||
if (courseId) out[setId] = courseId;
|
||||
}
|
||||
return out;
|
||||
}, [state.pinnedCourses]);
|
||||
|
||||
// Web Worker decision tree (debounced) — with leaf cache short-circuit
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
@@ -133,20 +163,91 @@ export function useAppState() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate cache if ranking or mode has changed
|
||||
const cache = leafCacheRef.current;
|
||||
const sameRanking =
|
||||
cache.ranking.length === state.ranking.length &&
|
||||
cache.ranking.every((r, i) => r === state.ranking[i]);
|
||||
if (!sameRanking || cache.mode !== state.mode) {
|
||||
cache.ranking = state.ranking;
|
||||
cache.mode = state.mode;
|
||||
cache.leaves.clear();
|
||||
}
|
||||
|
||||
// Compute the orderedCourses per set + expectedTotal (mirrors searchDecisionTree)
|
||||
const upperBounds = computeUpperBounds(selectedCourseIds, openSetIds, excludedCourseIds);
|
||||
const priorityTarget = selectPriorityTarget(state.ranking, upperBounds);
|
||||
const orderedCoursesPerSet: Record<string, ReturnType<typeof reorderForTarget>> = {};
|
||||
let expectedTotal = 1;
|
||||
for (const setId of openSetIds) {
|
||||
const ordered =
|
||||
state.mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
expectedTotal *= ordered.length || 1;
|
||||
}
|
||||
|
||||
// Filter cache to leaves matching the current pinned + excluded state
|
||||
const filtered: PlanOutcome[] = [];
|
||||
for (const leaf of cache.leaves.values()) {
|
||||
// Every pinned set's assignment must match
|
||||
let ok = true;
|
||||
for (const [pinSet, pinCourse] of Object.entries(pinnedAssignments)) {
|
||||
if (leaf.courseAssignments[pinSet] !== pinCourse) { ok = false; break; }
|
||||
}
|
||||
if (!ok) continue;
|
||||
// No excluded courses may appear in the leaf's assignments
|
||||
for (const courseId of Object.values(leaf.courseAssignments)) {
|
||||
if (excludedCourseIds.has(courseId)) { ok = false; break; }
|
||||
}
|
||||
if (!ok) continue;
|
||||
// Each open-set assignment in the leaf must be one of the currently-orderedCoursesPerSet entries
|
||||
for (const setId of openSetIds) {
|
||||
const v = leaf.courseAssignments[setId];
|
||||
if (!v || !orderedCoursesPerSet[setId].some((c) => c.id === v)) { ok = false; break; }
|
||||
}
|
||||
if (ok) filtered.push(leaf);
|
||||
}
|
||||
|
||||
// Derive UI state from filtered cache and render immediately
|
||||
const { topK: cachedTopK, setAnalyses: cachedAnalyses } = deriveFromLeaves(
|
||||
filtered,
|
||||
10,
|
||||
state.mode,
|
||||
state.ranking,
|
||||
openSetIds,
|
||||
excludedCourseIds,
|
||||
);
|
||||
setTreeResults(cachedAnalyses);
|
||||
setTopPlans(cachedTopK);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress({ iterations: filtered.length, iterationsTotal: expectedTotal });
|
||||
|
||||
// Full cache hit — no worker needed
|
||||
if (filtered.length >= expectedTotal) {
|
||||
setTreeLoading(false);
|
||||
// Make sure any in-flight worker is shut down
|
||||
if (workerRef.current) {
|
||||
workerRef.current.terminate();
|
||||
workerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Partial hit — spawn worker to fill in the missing leaves
|
||||
setTreeLoading(true);
|
||||
setSearchProgress(null);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
// Terminate previous worker if still running
|
||||
if (workerRef.current) workerRef.current.terminate();
|
||||
|
||||
try {
|
||||
const worker = new DecisionTreeWorker();
|
||||
workerRef.current = worker;
|
||||
|
||||
// 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>();
|
||||
for (const a of cachedAnalyses) setMap.set(a.setId, a);
|
||||
|
||||
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
||||
if (e.data.type === 'choiceUpdate') {
|
||||
setMap.set(e.data.setId, e.data.analysis);
|
||||
@@ -155,6 +256,13 @@ export function useAppState() {
|
||||
setTopPlans(e.data.topK);
|
||||
} else if (e.data.type === 'progress') {
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
} else if (e.data.type === 'leafEvaluated') {
|
||||
const cache = leafCacheRef.current;
|
||||
const key = assignmentKey(e.data.leaf.courseAssignments);
|
||||
cache.leaves.set(key, e.data.leaf);
|
||||
if (cache.leaves.size > LEAF_CACHE_CAP) {
|
||||
cache.leaves.clear();
|
||||
}
|
||||
} else if (e.data.type === 'allComplete') {
|
||||
setTreeResults(e.data.setAnalyses);
|
||||
setTopPlans(e.data.topK);
|
||||
@@ -168,15 +276,16 @@ export function useAppState() {
|
||||
|
||||
const request: WorkerRequest = {
|
||||
pinnedCourseIds: selectedCourseIds,
|
||||
pinnedAssignments,
|
||||
openSetIds,
|
||||
ranking: state.ranking,
|
||||
mode: state.mode,
|
||||
excludedCourseIds: [...excludedCourseIds],
|
||||
topK: 10,
|
||||
skipKeys: filtered.map((l) => assignmentKey(l.courseAssignments)),
|
||||
};
|
||||
worker.postMessage(request);
|
||||
} catch {
|
||||
// Web Worker not available (e.g., test env) — skip
|
||||
setTreeLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
@@ -188,7 +297,7 @@ export function useAppState() {
|
||||
workerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds]);
|
||||
}, [selectedCourseIds, openSetIds, state.ranking, state.mode, excludedCourseIds, pinnedAssignments]);
|
||||
|
||||
const reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
|
||||
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
|
||||
|
||||
@@ -4,18 +4,21 @@ import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
export interface WorkerRequest {
|
||||
pinnedCourseIds: string[];
|
||||
pinnedAssignments?: Record<string, string>;
|
||||
openSetIds: string[];
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
excludedCourseIds?: string[];
|
||||
topK?: number;
|
||||
saturationLimit?: number;
|
||||
skipKeys?: string[];
|
||||
}
|
||||
|
||||
export type WorkerResponse =
|
||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
||||
| { type: 'leafEvaluated'; leaf: PlanOutcome }
|
||||
| {
|
||||
type: 'allComplete';
|
||||
topK: PlanOutcome[];
|
||||
@@ -33,12 +36,16 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
mode,
|
||||
excludedCourseIds,
|
||||
topK = 10,
|
||||
skipKeys,
|
||||
pinnedAssignments,
|
||||
} = e.data;
|
||||
|
||||
const excludedSet =
|
||||
excludedCourseIds && excludedCourseIds.length > 0
|
||||
? new Set(excludedCourseIds)
|
||||
: undefined;
|
||||
const skipSet =
|
||||
skipKeys && skipKeys.length > 0 ? new Set(skipKeys) : undefined;
|
||||
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
@@ -59,8 +66,14 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onLeafEvaluated: (leaf) => {
|
||||
const msg: WorkerResponse = { type: 'leafEvaluated', leaf };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
},
|
||||
excludedSet,
|
||||
skipSet,
|
||||
pinnedAssignments,
|
||||
);
|
||||
|
||||
const final: WorkerResponse = {
|
||||
|
||||
Reference in New Issue
Block a user