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:
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.3.2 — 2026-05-09
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- **Leaf cache for instant pin/unpin** — 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 the 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. The cache persists across pin, unpin, and adopt-plan operations.
|
||||||
|
- **Cache invalidation** — the cache is cleared only when the active mode or the specialization ranking changes. Pin/unpin alone never invalidates.
|
||||||
|
- **`skipKeys` worker contract** — workers now accept a list of cached assignment keys and skip the optimizer call for any leaf already in the cache, while still counting iterations toward the global progress percentage.
|
||||||
|
- **`leafEvaluated` worker event** — workers stream individual leaf outcomes to the main thread for cache population as the search progresses.
|
||||||
|
- **`deriveFromLeaves` shared helper** — pure function that produces the top-K and per-set ceilings from a leaf collection; used by both the main-thread cache filter and the worker's final emission for parity.
|
||||||
|
- **500,000-leaf soft cap** — the cache is cleared if it grows beyond 500k entries, bounding worst-case memory at ~150 MB. Typical sessions stay well below.
|
||||||
|
|
||||||
## v1.3.1 — 2026-05-09
|
## v1.3.1 — 2026-05-09
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ function App() {
|
|||||||
partial={topPlansPartial}
|
partial={topPlansPartial}
|
||||||
loading={treeLoading}
|
loading={treeLoading}
|
||||||
progress={searchProgress}
|
progress={searchProgress}
|
||||||
|
pinnedCourses={state.pinnedCourses}
|
||||||
onAdopt={adoptPlan}
|
onAdopt={adoptPlan}
|
||||||
|
onPin={pinCourse}
|
||||||
|
onUnpin={unpinCourse}
|
||||||
/>
|
/>
|
||||||
<CourseSelection
|
<CourseSelection
|
||||||
pinnedCourses={state.pinnedCourses}
|
pinnedCourses={state.pinnedCourses}
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ interface TopPlansProps {
|
|||||||
partial: boolean;
|
partial: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
progress: { iterations: number; iterationsTotal: number } | null;
|
progress: { iterations: number; iterationsTotal: number } | null;
|
||||||
|
pinnedCourses: Record<string, string | null>;
|
||||||
onAdopt: (assignments: Record<string, string>) => void;
|
onAdopt: (assignments: Record<string, string>) => void;
|
||||||
|
onPin: (setId: string, courseId: string) => void;
|
||||||
|
onUnpin: (setId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNum(n: number): string {
|
function formatNum(n: number): string {
|
||||||
return n.toLocaleString();
|
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 visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||||
|
|
||||||
const pct = progress && progress.iterationsTotal > 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' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
{visible.map((plan, i) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,18 +110,28 @@ export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlan
|
|||||||
function PlanRow({
|
function PlanRow({
|
||||||
plan,
|
plan,
|
||||||
rank,
|
rank,
|
||||||
|
pinnedCourses,
|
||||||
onAdopt,
|
onAdopt,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
}: {
|
}: {
|
||||||
plan: PlanOutcome;
|
plan: PlanOutcome;
|
||||||
rank: number;
|
rank: number;
|
||||||
|
pinnedCourses: Record<string, string | null>;
|
||||||
onAdopt: (assignments: Record<string, string>) => void;
|
onAdopt: (assignments: Record<string, string>) => void;
|
||||||
|
onPin: (setId: string, courseId: string) => void;
|
||||||
|
onUnpin: (setId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const assignmentEntries = Object.entries(plan.courseAssignments).sort(
|
// Combine the plan's open-set assignments with the user's currently-pinned
|
||||||
([a], [b]) => {
|
// courses so the row shows the full sequence across all 12 sets.
|
||||||
const order = ELECTIVE_SETS.map((s) => s.id);
|
const assignmentEntries: [string, string][] = ELECTIVE_SETS
|
||||||
return order.indexOf(a) - order.indexOf(b);
|
.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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -167,14 +188,75 @@ function PlanRow({
|
|||||||
Adopt plan
|
Adopt plan
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: '#555', lineHeight: 1.5 }}>
|
<div style={{
|
||||||
{assignmentEntries.map(([setId, courseId], i) => (
|
display: 'flex', flexWrap: 'wrap', gap: '4px',
|
||||||
<span key={setId}>
|
}}>
|
||||||
{i > 0 && <span style={{ color: '#ccc' }}> · </span>}
|
{assignmentEntries.map(([setId, courseId]) => {
|
||||||
<span style={{ color: '#888' }}>{setNameById[setId]?.replace('Elective Set ', 'S')}: </span>
|
const course = courseById[courseId];
|
||||||
<span>{courseById[courseId]?.name ?? courseId}</span>
|
const label = setNameById[setId]?.replace('Elective Set ', 'S').replace('Spring ', 'Sp').replace('Summer ', 'Su').replace('Fall ', 'F').replace(' ', '') ?? setId;
|
||||||
</span>
|
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>
|
||||||
</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;
|
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||||
|
onLeafEvaluated?: (leaf: PlanOutcome) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||||
@@ -192,6 +193,8 @@ export function searchDecisionTree(
|
|||||||
K: number,
|
K: number,
|
||||||
callbacks?: SearchCallbacks,
|
callbacks?: SearchCallbacks,
|
||||||
excludedCourseIds?: Set<string>,
|
excludedCourseIds?: Set<string>,
|
||||||
|
skipKeys?: Set<string>,
|
||||||
|
pinnedAssignments?: Record<string, string>,
|
||||||
): SearchResult {
|
): SearchResult {
|
||||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||||
const scorer = makePriorityScorer(ranking);
|
const scorer = makePriorityScorer(ranking);
|
||||||
@@ -201,6 +204,9 @@ export function searchDecisionTree(
|
|||||||
excludedCourseIds,
|
excludedCourseIds,
|
||||||
);
|
);
|
||||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
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
|
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||||
@@ -248,19 +254,29 @@ export function searchDecisionTree(
|
|||||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||||
iterations++;
|
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[] = [];
|
const courses: string[] = [];
|
||||||
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
||||||
const selected = [...pinnedCourseIds, ...courses];
|
const selected = [...pinnedCourseIds, ...courses];
|
||||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||||
const score = scorer(result.achieved);
|
const score = scorer(result.achieved);
|
||||||
const aKey = assignmentKey(accumulated);
|
|
||||||
|
|
||||||
const outcome: PlanOutcome = {
|
const outcome: PlanOutcome = {
|
||||||
courseAssignments: { ...accumulated },
|
courseAssignments: fullAssignment,
|
||||||
achievedSpecs: result.achieved,
|
achievedSpecs: result.achieved,
|
||||||
priorityScore: score,
|
priorityScore: score,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
callbacks?.onLeafEvaluated?.(outcome);
|
||||||
|
|
||||||
if (topK.tryInsert(outcome)) {
|
if (topK.tryInsert(outcome)) {
|
||||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
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.
|
* Backward-compatible wrapper: produces only the per-set ceiling table.
|
||||||
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
|
* 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 { ELECTIVE_SETS } from '../data/electiveSets';
|
||||||
import type { OptimizationMode, AllocationResult } from '../data/types';
|
import type { OptimizationMode, AllocationResult } from '../data/types';
|
||||||
import { optimize } from '../solver/optimizer';
|
import { optimize } from '../solver/optimizer';
|
||||||
|
import {
|
||||||
|
assignmentKey,
|
||||||
|
deriveFromLeaves,
|
||||||
|
reorderByReachableQualCount,
|
||||||
|
reorderForTarget,
|
||||||
|
selectPriorityTarget,
|
||||||
|
} from '../solver/decisionTree';
|
||||||
|
import { computeUpperBounds } from '../solver/feasibility';
|
||||||
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
||||||
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
import type { WorkerRequest, WorkerResponse } from '../workers/decisionTree.worker';
|
||||||
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
|
import { cancelledCourseIds, courseIdsByName, courseById } from '../data/lookups';
|
||||||
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
import DecisionTreeWorker from '../workers/decisionTree.worker?worker';
|
||||||
|
|
||||||
|
const LEAF_CACHE_CAP = 500_000;
|
||||||
|
|
||||||
|
interface LeafCache {
|
||||||
|
ranking: string[];
|
||||||
|
mode: OptimizationMode;
|
||||||
|
leaves: Map<string, PlanOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'emba-solver-state';
|
const STORAGE_KEY = 'emba-solver-state';
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
@@ -75,6 +91,11 @@ export function useAppState() {
|
|||||||
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
||||||
const workerRef = useRef<Worker | null>(null);
|
const workerRef = useRef<Worker | null>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const leafCacheRef = useRef<LeafCache>({
|
||||||
|
ranking: state.ranking,
|
||||||
|
mode: state.mode,
|
||||||
|
leaves: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
// Persist to localStorage
|
// Persist to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,7 +141,16 @@ export function useAppState() {
|
|||||||
[selectedCourseIds, state.ranking, openSetIds, state.mode, excludedCourseIds],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
@@ -133,20 +163,91 @@ export function useAppState() {
|
|||||||
return;
|
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);
|
setTreeLoading(true);
|
||||||
setSearchProgress(null);
|
|
||||||
|
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
// Terminate previous worker if still running
|
|
||||||
if (workerRef.current) workerRef.current.terminate();
|
if (workerRef.current) workerRef.current.terminate();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const worker = new DecisionTreeWorker();
|
const worker = new DecisionTreeWorker();
|
||||||
workerRef.current = worker;
|
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>();
|
const setMap = new Map<string, SetAnalysis>();
|
||||||
|
for (const a of cachedAnalyses) setMap.set(a.setId, a);
|
||||||
|
|
||||||
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
worker.onmessage = (e: MessageEvent<WorkerResponse>) => {
|
||||||
if (e.data.type === 'choiceUpdate') {
|
if (e.data.type === 'choiceUpdate') {
|
||||||
setMap.set(e.data.setId, e.data.analysis);
|
setMap.set(e.data.setId, e.data.analysis);
|
||||||
@@ -155,6 +256,13 @@ export function useAppState() {
|
|||||||
setTopPlans(e.data.topK);
|
setTopPlans(e.data.topK);
|
||||||
} else if (e.data.type === 'progress') {
|
} else if (e.data.type === 'progress') {
|
||||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
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') {
|
} else if (e.data.type === 'allComplete') {
|
||||||
setTreeResults(e.data.setAnalyses);
|
setTreeResults(e.data.setAnalyses);
|
||||||
setTopPlans(e.data.topK);
|
setTopPlans(e.data.topK);
|
||||||
@@ -168,15 +276,16 @@ export function useAppState() {
|
|||||||
|
|
||||||
const request: WorkerRequest = {
|
const request: WorkerRequest = {
|
||||||
pinnedCourseIds: selectedCourseIds,
|
pinnedCourseIds: selectedCourseIds,
|
||||||
|
pinnedAssignments,
|
||||||
openSetIds,
|
openSetIds,
|
||||||
ranking: state.ranking,
|
ranking: state.ranking,
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
excludedCourseIds: [...excludedCourseIds],
|
excludedCourseIds: [...excludedCourseIds],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
|
skipKeys: filtered.map((l) => assignmentKey(l.courseAssignments)),
|
||||||
};
|
};
|
||||||
worker.postMessage(request);
|
worker.postMessage(request);
|
||||||
} catch {
|
} catch {
|
||||||
// Web Worker not available (e.g., test env) — skip
|
|
||||||
setTreeLoading(false);
|
setTreeLoading(false);
|
||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
@@ -188,7 +297,7 @@ export function useAppState() {
|
|||||||
workerRef.current = null;
|
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 reorder = useCallback((ranking: string[]) => dispatch({ type: 'reorder', ranking }), []);
|
||||||
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
|
const setMode = useCallback((mode: OptimizationMode) => dispatch({ type: 'setMode', mode }), []);
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree';
|
|||||||
|
|
||||||
export interface WorkerRequest {
|
export interface WorkerRequest {
|
||||||
pinnedCourseIds: string[];
|
pinnedCourseIds: string[];
|
||||||
|
pinnedAssignments?: Record<string, string>;
|
||||||
openSetIds: string[];
|
openSetIds: string[];
|
||||||
ranking: string[];
|
ranking: string[];
|
||||||
mode: OptimizationMode;
|
mode: OptimizationMode;
|
||||||
excludedCourseIds?: string[];
|
excludedCourseIds?: string[];
|
||||||
topK?: number;
|
topK?: number;
|
||||||
saturationLimit?: number;
|
saturationLimit?: number;
|
||||||
|
skipKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkerResponse =
|
export type WorkerResponse =
|
||||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||||
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
||||||
|
| { type: 'leafEvaluated'; leaf: PlanOutcome }
|
||||||
| {
|
| {
|
||||||
type: 'allComplete';
|
type: 'allComplete';
|
||||||
topK: PlanOutcome[];
|
topK: PlanOutcome[];
|
||||||
@@ -33,12 +36,16 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
|||||||
mode,
|
mode,
|
||||||
excludedCourseIds,
|
excludedCourseIds,
|
||||||
topK = 10,
|
topK = 10,
|
||||||
|
skipKeys,
|
||||||
|
pinnedAssignments,
|
||||||
} = e.data;
|
} = e.data;
|
||||||
|
|
||||||
const excludedSet =
|
const excludedSet =
|
||||||
excludedCourseIds && excludedCourseIds.length > 0
|
excludedCourseIds && excludedCourseIds.length > 0
|
||||||
? new Set(excludedCourseIds)
|
? new Set(excludedCourseIds)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const skipSet =
|
||||||
|
skipKeys && skipKeys.length > 0 ? new Set(skipKeys) : undefined;
|
||||||
|
|
||||||
const result = searchDecisionTree(
|
const result = searchDecisionTree(
|
||||||
pinnedCourseIds,
|
pinnedCourseIds,
|
||||||
@@ -59,8 +66,14 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
|||||||
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
||||||
self.postMessage(msg);
|
self.postMessage(msg);
|
||||||
},
|
},
|
||||||
|
onLeafEvaluated: (leaf) => {
|
||||||
|
const msg: WorkerResponse = { type: 'leafEvaluated', leaf };
|
||||||
|
self.postMessage(msg);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
excludedSet,
|
excludedSet,
|
||||||
|
skipSet,
|
||||||
|
pinnedAssignments,
|
||||||
);
|
);
|
||||||
|
|
||||||
const final: WorkerResponse = {
|
const final: WorkerResponse = {
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify('1.3.1'),
|
__APP_VERSION__: JSON.stringify('1.3.2'),
|
||||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-05-09
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# decision-tree-leaf-cache
|
||||||
|
|
||||||
|
Cache decision-tree leaf outcomes (achievedSpecs + priorityScore) keyed by assignment, so pin/unpin operations re-derive top-K and per-set ceilings instantly without re-running the worker. Cache invalidates on ranking or mode change, or when size exceeds 500k entries. Worker accepts a skipKeys set to avoid recomputing cached leaves on partial-hit re-runs (unpin).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
v1.3.1 ships exhaustive decision-tree search. Per-cell ceilings and top-K plans are correct, but every pin/unpin click triggers a full re-search (~7s for an 8-open-set scenario). The compute is wasteful: leaf outcomes are pure functions of `(courseAssignments, ranking, mode)`. A leaf evaluated once stays correct as long as ranking and mode don't change.
|
||||||
|
|
||||||
|
Today the search effect in `useAppState` (`appState.ts:124-191`) lists `[selectedCourseIds, openSetIds, ranking, mode, excludedCourseIds]` as deps. Any change kills the running worker and starts a fresh search after a 300ms debounce.
|
||||||
|
|
||||||
|
This change adds an in-memory cache of leaf outcomes keyed by the existing `assignmentKey`. Pin operations become 100% cache hits (no worker). Unpin operations get a partial hit (cached subset rendered immediately, missing leaves computed in the background).
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Pin clicks produce instant top-K + per-cell updates with no spinner
|
||||||
|
- Unpin clicks render the cached subset immediately as a lower bound, then improve as the worker streams new leaves
|
||||||
|
- "Adopt plan" (which fires multiple pin actions in quick succession) feels instant
|
||||||
|
- Cache memory is bounded (~80 MB worst case at the 500k cap)
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Persisting the cache across page reloads (no localStorage; recompute on first use after reload)
|
||||||
|
- Caching across ranking or mode changes
|
||||||
|
- Re-architecting the optimizer or worker protocol beyond the new `skipKeys` field
|
||||||
|
- A "tree of caches" keyed by ranking — keep one cache, invalidate on ranking change
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### Cache key = `assignmentKey` only; cache scope = `(ranking, mode)`
|
||||||
|
|
||||||
|
`assignmentKey` already exists (`decisionTree.ts:79-84`) and is the deterministic stringification used by the comparator's tiebreaker. Reusing it avoids a parallel hashing scheme. Cache scope is bound to the current `(ranking, mode)` pair: when either changes, the cache is wiped and rebuilt from scratch on the next search.
|
||||||
|
|
||||||
|
**Alternative considered:** Keep multiple caches, one per `(ranking, mode)` pair the user has visited. Rejected — extra complexity for a workflow where ranking/mode changes are infrequent compared to pin clicks. If profiling later shows ranking-toggle becoming a hot path, can add then.
|
||||||
|
|
||||||
|
### Immediate render on partial hit (unpin), then stream improvements
|
||||||
|
|
||||||
|
When the user unpins a set, the new openSetIds product is larger than the cached subset. Today's UX would block on a fresh worker. Instead:
|
||||||
|
|
||||||
|
1. Filter the cache against the new pinned + excluded state. Derive top-K and per-set ceilings from the filtered leaves. Render immediately.
|
||||||
|
2. Compute missing leaves count. If non-zero, spawn the worker with `skipKeys = cache.keys()`. The worker DFS visits every leaf in the new search space but skips the optimizer call for keys already cached.
|
||||||
|
3. As the worker streams new leaves (existing `topKUpdate` / `choiceUpdate` events plus a final `allComplete`), insert each into the cache and re-derive top-K/ceilings via the existing streaming path.
|
||||||
|
|
||||||
|
The cached subset is a strict lower bound on the true result: top-K from cache is a valid (possibly incomplete) subset of the true top-K; per-set ceilings from cache are ≤ the true ceilings. Streaming improvements are monotonically non-decreasing under the existing comparator.
|
||||||
|
|
||||||
|
**Alternative considered:** Show a spinner and wait for the search to complete before rendering. Rejected — would feel like a regression after pin became instant. The streaming UI already handles monotonic improvement gracefully.
|
||||||
|
|
||||||
|
### Worker contract: `skipKeys: string[]`
|
||||||
|
|
||||||
|
The simplest extension. Main thread serializes `cache.keys()` to an array; worker reconstructs the Set on the receiving end. For 65k cached keys × ~30 chars each = ~2 MB transfer per worker spawn. structured-cloned in tens of milliseconds. Acceptable.
|
||||||
|
|
||||||
|
**Alternative considered:** Persistent worker that holds the cache internally. Rejected — adds lifecycle complexity (when to terminate, how to abort in-flight work, race conditions on cache writes). The fresh-worker model already exists and the transfer cost is bearable.
|
||||||
|
|
||||||
|
### `evaluateLeaf` short-circuit semantics
|
||||||
|
|
||||||
|
When a leaf's key is in `skipKeys`:
|
||||||
|
- Increment `iterations` (so progress reflects total leaves visited, not just newly-computed)
|
||||||
|
- Skip the optimizer call, skip `topK.tryInsert`, skip `choiceUpdate` emit
|
||||||
|
- Skip `evaluated` flag updates (those cells were already marked from the cached results on the main thread)
|
||||||
|
- Still emit `progress` events at the throttled rate
|
||||||
|
|
||||||
|
Rationale: the user-visible "iterations explored" should reflect tree size, not just delta. Otherwise an unpin would show "Searching… 100,000/200,000" jumping from 100k mid-search, which is confusing.
|
||||||
|
|
||||||
|
**Alternative considered:** Don't increment iterations for skipped leaves. Rejected — the percentage in the progress bar would be misleading.
|
||||||
|
|
||||||
|
### `deriveFromLeaves` extracted as a pure helper
|
||||||
|
|
||||||
|
To produce top-K and per-set ceilings from a leaf collection on the main thread, factor out the relevant logic from `searchDecisionTree`. The helper takes `(leaves, K, mode, ranking, openSetIds, excludedCourseIds)` and returns `{ topK: PlanOutcome[], setAnalyses: SetAnalysis[] }`.
|
||||||
|
|
||||||
|
The worker uses the same helper at `allComplete` to produce its final emission. The main thread uses it for the immediate-render path.
|
||||||
|
|
||||||
|
**Alternative considered:** Maintain top-K and per-set ceilings incrementally in the cache structure itself. Rejected — too coupled; recomputing from leaves is O(cache.size) which is fast (linear scan + small comparator work).
|
||||||
|
|
||||||
|
### Cache cap = 500k leaves with full clear on overflow
|
||||||
|
|
||||||
|
500k × ~300 bytes ≈ 150 MB. Comfortable on desktop, snug on mobile. Tripping the cap requires extreme exploration paths (≥10 open sets + repeated cycles); typical sessions stay under 100k.
|
||||||
|
|
||||||
|
When the cap fires, clear the entire cache. Simplest possible policy. Subsequent searches behave as v1.3.1 (full recompute).
|
||||||
|
|
||||||
|
**Alternative considered:** LRU eviction. Rejected for v1 — adds bookkeeping overhead per insert; benefit is marginal given cap is rarely hit. Add later if profiling shows churn.
|
||||||
|
|
||||||
|
### Cache invalidation events
|
||||||
|
|
||||||
|
| Event | Cache action |
|
||||||
|
|---|---|
|
||||||
|
| Pin a course | Keep cache; filter |
|
||||||
|
| Unpin a course | Keep cache; filter (partial hit) |
|
||||||
|
| Adopt plan | Keep cache; filter |
|
||||||
|
| Ranking re-order | Clear cache; re-search from scratch |
|
||||||
|
| Mode toggle | Clear cache; re-search from scratch |
|
||||||
|
| Cancellation toggle (data file edit) | Clear cache (data version changed) |
|
||||||
|
| Cache size exceeds 500k | Clear cache |
|
||||||
|
|
||||||
|
The ranking/mode/cancellation changes are uncommon compared to pin clicks, so the simplification is worth it.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Memory footprint** → Mitigation: 500k cap clears the cache when exceeded; typical sessions stay well under
|
||||||
|
- **Worker transfer of `skipKeys`** → ~2 MB per worker spawn at 65k cached keys; acceptable, measure and revisit if it becomes painful at scale
|
||||||
|
- **Cached subset can briefly disagree with worker's final result** during streaming → Existing streaming UI handles monotonic improvement; the only visible effect is some cells starting at a lower count and improving as the worker fills in
|
||||||
|
- **Iteration counter semantics** during skip-mode runs → Counts total leaves visited (cached + new). Decision documented above; clear in the proposal scenarios so users understand "Searching… 50,000/200,000" early in an unpin run
|
||||||
|
- **First-time experience unchanged** → Empty cache means full search; no improvement on initial page load. Subsequent operations are where the win happens
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Single-PR change. No data migration. No persistent state to migrate.
|
||||||
|
|
||||||
|
1. Implement `skipKeys` plumbing through `searchDecisionTree` and worker
|
||||||
|
2. Extract `deriveFromLeaves` helper
|
||||||
|
3. Add cache to `useAppState`; restructure the worker effect to filter-then-spawn
|
||||||
|
4. Tests for skip-keys correctness, derive helper parity, cache-cap eviction, ranking/mode invalidation
|
||||||
|
5. Browser verify: pin/unpin behave instantly after the first search
|
||||||
|
6. Bump version (`1.3.2`); CHANGELOG entry; ship
|
||||||
|
|
||||||
|
Rollback: revert. v1.3.1 behavior restored — every pin/unpin runs a fresh worker.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Memory measurement on representative scenarios (8-set typical, 10-set extreme) — instrument once for the changelog notes
|
||||||
|
- Whether `skipKeys` transfer becomes a bottleneck at very large cache sizes — defer; haven't observed it in typical use
|
||||||
|
- Whether to expose a debug toggle to disable the cache (useful for A/B feel testing) — defer; can add as a query param if needed
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
After v1.3.1 made the decision-tree search exhaustive, every pin/unpin click triggers a fresh full search (~7s in the worker for the user's typical 8-open-set scenario). That re-run is wasteful: a leaf's outcome (`achievedSpecs`, `priorityScore`) depends only on its 12-course assignment plus ranking and mode. Pinning or unpinning a course never changes the value of any leaf that has already been evaluated; it only changes which leaves are reachable in the current search.
|
||||||
|
|
||||||
|
We should cache evaluated leaves and re-derive the top-K and per-set ceilings from the cache when pin/unpin operations leave ranking and mode untouched. Pin operations become 100% cache hits (instant). Unpin operations get a partial cache hit — show the cached subset immediately as a lower-bound result, then run the worker to fill in only the leaves that haven't been evaluated yet.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a main-thread leaf cache: `Map<assignmentKey, PlanOutcome>` keyed by the existing `assignmentKey` (sorted setId:courseId join). Cache instance is held in a ref on `useAppState`.
|
||||||
|
- Cache invalidation triggers: any change to `state.ranking`, `state.mode`, or the cancellation list (data version). Pin, unpin, and adopt-plan operations leave the cache intact.
|
||||||
|
- New effect logic in `useAppState`: when the search-effect dependencies change, first FILTER the existing cache against the new pinned/excluded state and derive top-K + per-set ceilings from the filtered subset, rendering immediately. Then check whether the filtered count equals the expected cartesian-product size; if not, spawn a worker to compute the missing leaves only.
|
||||||
|
- Add an optional `skipKeys: Set<string>` parameter to `searchDecisionTree` and to the worker's `WorkerRequest`. In `evaluateLeaf`, leaves whose `assignmentKey` is in `skipKeys` are skipped entirely — no optimizer call, no callback emit, no iteration counted toward progress (or counted but not evaluated; design choice in tasks).
|
||||||
|
- The worker streams new leaves the same way it streams `topKUpdate` / `choiceUpdate` events today; main thread inserts each new leaf into the cache as it arrives.
|
||||||
|
- Soft cap: if the cache exceeds **500,000** entries, clear it entirely. Subsequent searches recompute from scratch. The cap exists only to bound worst-case memory; typical exploration paths never approach it.
|
||||||
|
- "Searching" UI indicators (per-set spinner, global progress bar) only appear when the worker actually runs (i.e., not on full-cache-hit pin clicks).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
_None._
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `optimization-engine`: introduces leaf caching across search invocations, the `skipKeys` worker contract, the immediate-render-then-stream pipeline for partial cache hits, and the cache-cap eviction policy. The optimizer and LP feasibility checker are untouched.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `app/src/state/appState.ts` — add `leafCacheRef` (Map<string, PlanOutcome>); restructure the worker effect to filter cache, derive immediate state, and spawn the worker only for the delta. Reset cache on ranking/mode change. Apply the 500k cap.
|
||||||
|
- `app/src/solver/decisionTree.ts` — add `skipKeys?: Set<string>` to `SearchCallbacks`/`searchDecisionTree` signature; in `evaluateLeaf` short-circuit when the leaf's assignmentKey is already in `skipKeys`. Export a `deriveFromLeaves(leaves, K, mode, ranking, openSets, excludedCourseIds)` helper that produces `{ topK, setAnalyses }` from a leaf collection, used both by the worker (final emit) and the main-thread filter path.
|
||||||
|
- `app/src/workers/decisionTree.worker.ts` — accept `skipKeys?: string[]` in `WorkerRequest`; convert to `Set<string>` and pass to `searchDecisionTree`.
|
||||||
|
- `app/src/solver/__tests__/searchDecisionTree.test.ts` — add tests: skipKeys correctly bypasses optimizer; `deriveFromLeaves` matches a fresh search's output when given the same leaves; cache filter pin/unpin idempotence (same final state regardless of pin order).
|
||||||
|
- New unit test file (or extension of existing): cache-cap eviction, ranking/mode invalidation.
|
||||||
|
- `app/vite.config.ts` — bump to `1.3.2`
|
||||||
|
- `CHANGELOG.md` — release entry
|
||||||
|
- No data-file changes
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Persistent leaf cache across pin and unpin operations
|
||||||
|
The application SHALL maintain a main-thread cache of evaluated decision-tree leaves keyed by the leaf's `assignmentKey` (the deterministic sorted `setId:courseId` join already used as the comparator tiebreaker). The cache SHALL persist across pin, unpin, and adopt-plan operations as long as `state.ranking` and `state.mode` are unchanged. Each cache entry SHALL store the full `PlanOutcome` (`courseAssignments`, `achievedSpecs`, `priorityScore`).
|
||||||
|
|
||||||
|
#### Scenario: Pin operation hits cache fully
|
||||||
|
- **WHEN** the user has completed a search with no pins on a small scenario, then pins a course
|
||||||
|
- **THEN** the new top-K and per-set ceilings are derived entirely from the cache without spawning a worker
|
||||||
|
- **AND** no "searching" indicators appear in the UI
|
||||||
|
|
||||||
|
#### Scenario: Cache survives consecutive pin clicks
|
||||||
|
- **WHEN** the user pins multiple courses one after another (or via "Adopt plan")
|
||||||
|
- **THEN** every pin produces an instant UI update sourced from the existing cache
|
||||||
|
|
||||||
|
#### Scenario: Unpin gets immediate cached subset and streams improvements
|
||||||
|
- **WHEN** the user unpins a course after a search has populated the cache
|
||||||
|
- **THEN** the UI immediately renders top-K and per-set ceilings derived from the cache subset matching the new state
|
||||||
|
- **AND** a worker spawns to compute the missing leaves
|
||||||
|
- **AND** as the worker streams new leaves, the UI's top-K and ceilings improve monotonically
|
||||||
|
|
||||||
|
### Requirement: `skipKeys` worker contract
|
||||||
|
The worker request SHALL accept an optional `skipKeys: string[]` field. The worker SHALL convert this list to a `Set<string>` and pass it to `searchDecisionTree`. Inside `evaluateLeaf`, leaves whose `assignmentKey` is in `skipKeys` SHALL be skipped: the optimizer SHALL NOT be invoked, no `topKUpdate` or `choiceUpdate` event SHALL be emitted for them, and the leaf SHALL NOT mutate per-set `evaluated` flags. Skipped leaves SHALL still increment the iteration counter so that throttled `progress` events report the total tree size, not just the delta.
|
||||||
|
|
||||||
|
#### Scenario: Worker bypasses optimizer for cached leaves
|
||||||
|
- **WHEN** the worker receives a request with `skipKeys` containing the keys of N cached leaves
|
||||||
|
- **THEN** the worker performs at most `(iterationsTotal − N)` optimizer evaluations
|
||||||
|
|
||||||
|
#### Scenario: Progress reports total tree size
|
||||||
|
- **WHEN** the worker is processing a request with `skipKeys` containing 50,000 keys out of an `iterationsTotal` of 200,000
|
||||||
|
- **THEN** progress events include `iterations` counting up to 200,000 (not 150,000) so the displayed percentage reflects whole-tree progress
|
||||||
|
|
||||||
|
### Requirement: Cache invalidation on ranking, mode, or data change
|
||||||
|
The leaf cache SHALL be cleared when `state.ranking` changes, when `state.mode` changes, or when the underlying course/specialization data is changed (e.g., a course is marked cancelled). Pin/unpin operations SHALL NOT trigger cache invalidation.
|
||||||
|
|
||||||
|
#### Scenario: Mode toggle clears cache
|
||||||
|
- **WHEN** the user toggles between maximize-count and priority-order
|
||||||
|
- **THEN** the cache is emptied and the next search runs as a full recomputation
|
||||||
|
|
||||||
|
#### Scenario: Ranking re-order clears cache
|
||||||
|
- **WHEN** the user reorders the specialization ranking
|
||||||
|
- **THEN** the cache is emptied and the next search runs as a full recomputation
|
||||||
|
|
||||||
|
#### Scenario: Pin does not clear cache
|
||||||
|
- **WHEN** the user pins or unpins a course
|
||||||
|
- **THEN** the cache retains all previously evaluated leaves
|
||||||
|
|
||||||
|
### Requirement: Cache size cap
|
||||||
|
The leaf cache SHALL be cleared when its size exceeds 500,000 entries. Subsequent searches SHALL repopulate the cache from scratch.
|
||||||
|
|
||||||
|
#### Scenario: Cap clears cache when exceeded
|
||||||
|
- **WHEN** the cache is at 500,000 entries and a new search would add at least one more entry
|
||||||
|
- **THEN** the cache is emptied before the next entry is inserted, and the new search proceeds without `skipKeys`
|
||||||
|
|
||||||
|
### Requirement: `deriveFromLeaves` shared helper
|
||||||
|
The decision-tree module SHALL export a pure function `deriveFromLeaves(leaves, K, mode, ranking, openSetIds, excludedCourseIds): { topK, setAnalyses }` that produces the top-K plan list and per-set ceiling table from a collection of leaf outcomes. This helper SHALL be used both by the worker at `allComplete` and by the main thread when rendering filtered cache results.
|
||||||
|
|
||||||
|
#### Scenario: Helper output matches a fresh search
|
||||||
|
- **WHEN** `deriveFromLeaves` is called with the complete leaf set from a finished `searchDecisionTree` run
|
||||||
|
- **THEN** the returned `topK` and `setAnalyses` match the values that the search itself returned (modulo deterministic tiebreaker stability)
|
||||||
|
|
||||||
|
#### Scenario: Helper output is correct for filtered subsets
|
||||||
|
- **WHEN** `deriveFromLeaves` is called with a strict subset of cached leaves matching the user's current pinned/excluded state
|
||||||
|
- **THEN** the returned top-K and ceilings reflect only those leaves and never reference courses outside the filter
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
## 1. Solver: skipKeys + deriveFromLeaves
|
||||||
|
|
||||||
|
- [x] 1.1 In `app/src/solver/decisionTree.ts`, extend `SearchCallbacks` with no new fields (callbacks unchanged); extend `searchDecisionTree` signature to accept `skipKeys?: Set<string>` as a new optional parameter
|
||||||
|
- [x] 1.2 In `evaluateLeaf`, after computing `aKey = assignmentKey(accumulated)`, short-circuit when `skipKeys?.has(aKey)`: increment `iterations`, call `emitProgress()`, then return without invoking the optimizer or any callback. Per-set ceiling and `evaluated` flag updates are skipped — the main thread already has those values from the cached path
|
||||||
|
- [x] 1.3 Implement `export function deriveFromLeaves(leaves: Iterable<PlanOutcome>, K: number, mode: OptimizationMode, ranking: string[], openSetIds: string[], excludedCourseIds?: Set<string>): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] }`
|
||||||
|
- Initialize `setAnalyses` for every `setId` in `openSetIds` using the same per-mode reorder helpers (`reorderForTarget` / `reorderByReachableQualCount`)
|
||||||
|
- For each leaf, run the same per-set ceiling-update loop already in `evaluateLeaf`, plus `topK.tryInsert`
|
||||||
|
- Return the same shape `searchDecisionTree` returns minus `iterations`/`partial`
|
||||||
|
- [x] 1.4 Refactor `searchDecisionTree` to call `deriveFromLeaves` for its own final emission (or keep the inline loop and ensure both paths produce identical output — must add a parity test either way)
|
||||||
|
|
||||||
|
## 2. Worker contract
|
||||||
|
|
||||||
|
- [x] 2.1 In `app/src/workers/decisionTree.worker.ts`, extend `WorkerRequest` with `skipKeys?: string[]`
|
||||||
|
- [x] 2.2 In the message handler, convert `skipKeys` to `Set<string>` and pass it through to `searchDecisionTree`
|
||||||
|
|
||||||
|
## 3. App state: cache + filter pipeline
|
||||||
|
|
||||||
|
- [x] 3.1 In `app/src/state/appState.ts`, add `leafCacheRef = useRef<{ ranking: string[]; mode: OptimizationMode; leaves: Map<string, PlanOutcome> }>({ ranking: [], mode: 'maximize-count', leaves: new Map() })`
|
||||||
|
- [x] 3.2 Add a helper `function shouldInvalidate(cache, ranking, mode): boolean` that returns true when ranking or mode has changed (use shallow equality on ranking via `JSON.stringify` or element-wise compare)
|
||||||
|
- [x] 3.3 Add `function filterCacheToCurrentState(cache, pinnedCourses, excludedCourseIds, openSetIds): PlanOutcome[]` that returns leaves where (a) every pinned set's assignment matches the leaf, (b) no excluded courses appear in the leaf's assignments, and (c) the leaf's assignment keys are exactly `openSetIds` (filters out leaves cached under a different set partition)
|
||||||
|
- [x] 3.4 Restructure the existing search effect:
|
||||||
|
- On every effect run, check `shouldInvalidate(cacheRef.current, ranking, mode)`. If true, clear `cacheRef.current.leaves` and update `ranking` / `mode` fields
|
||||||
|
- Compute `filtered = filterCacheToCurrentState(...)` and `expectedTotal = product over openSetIds of orderedCourses[setId].length` (use the same reorder helpers, mode-dependent)
|
||||||
|
- Compute `{ topK, setAnalyses } = deriveFromLeaves(filtered, ...)` and call all the existing `setX` setters to render immediately
|
||||||
|
- Set `searchProgress = { iterations: filtered.length, iterationsTotal: expectedTotal }`
|
||||||
|
- If `filtered.length === expectedTotal`: set `treeLoading=false`, return (no worker)
|
||||||
|
- Else: set `treeLoading=true`, debounce, spawn worker as today, BUT include `skipKeys: [...cacheRef.current.leaves.keys()]` in the request
|
||||||
|
- [x] 3.5 In the worker `onmessage` handler:
|
||||||
|
- On `topKUpdate`: insert any newly-seen leaves into the cache (worker emits leaves implicitly via topK; we may need a richer event — see 3.6)
|
||||||
|
- On `choiceUpdate`: insert any newly-seen leaves implicitly is hard; better to add an explicit leaf-emit event
|
||||||
|
- [x] 3.6 Add a new `WorkerResponse` event type `{ type: 'leafEvaluated'; leaf: PlanOutcome }` emitted from inside `evaluateLeaf` when the leaf is NOT skipped. This is what feeds the main-thread cache. Throttling: emit each leaf as its own event (small payload, ~300 bytes); existing topKUpdate/choiceUpdate already throttle the heavy work
|
||||||
|
- [x] 3.7 Update `appState`'s onmessage to handle `leafEvaluated`: insert into cache; if cache size > 500_000 (after insert), clear it
|
||||||
|
- [x] 3.8 On `allComplete`, ensure final `topK` / `setAnalyses` come from the worker (which had the full picture) — don't second-guess from cache
|
||||||
|
|
||||||
|
## 4. Cache cap
|
||||||
|
|
||||||
|
- [x] 4.1 Define `const LEAF_CACHE_CAP = 500_000` near the cache ref
|
||||||
|
- [x] 4.2 In the `leafEvaluated` handler, after insertion, check size; if `> LEAF_CACHE_CAP` then `cache.leaves.clear()` (subsequent searches behave as v1.3.1)
|
||||||
|
|
||||||
|
## 5. Tests
|
||||||
|
|
||||||
|
- [x] 5.1 In `app/src/solver/__tests__/searchDecisionTree.test.ts`: add a test that runs `searchDecisionTree` twice on the same scenario, capturing all assignmentKeys from the first run, then passing them as `skipKeys` to the second run. Assert the second run's `iterations` equals `iterationsTotal` (all visited) and that the optimizer was not called for skipped leaves (use a counter via the optimizer mock or by asserting timing — second run should be at least 50× faster)
|
||||||
|
- [x] 5.2 Add a test for `deriveFromLeaves`: run a small `searchDecisionTree`, capture all leaves via a `leafEvaluated`-style hook (or by augmenting the search result), call `deriveFromLeaves` with the same inputs, assert the output `topK` and `setAnalyses` match the search's
|
||||||
|
- [x] 5.3 Add a test for `filterCacheToCurrentState`: build a small synthetic cache, filter for various pinned/excluded states, assert the filtered subset is correct
|
||||||
|
- [x] 5.4 Add a test for cache-cap eviction: synthesize 500_001 leaf insertions; assert cache is cleared after the threshold is crossed
|
||||||
|
- [x] 5.5 Add a test for invalidation: change ranking, then mode; assert cache is empty after each
|
||||||
|
- [x] 5.6 Run full suite; confirm 78+ existing tests still pass
|
||||||
|
|
||||||
|
## 6. Browser verification
|
||||||
|
|
||||||
|
- [x] 6.1 Start dev server. Pin a course and observe: no "searching" spinner appears, top-K and per-set ceilings update instantly
|
||||||
|
- [x] 6.2 Adopt-plan a complete plan (8 pins): instant
|
||||||
|
- [x] 6.3 Unpin a course: cached subset renders immediately; per-set spinner + global progress bar appear; results refine over a few seconds
|
||||||
|
- [x] 6.4 Toggle mode: full re-search runs (cache invalidated)
|
||||||
|
- [x] 6.5 Re-order ranking: full re-search runs (cache invalidated)
|
||||||
|
- [x] 6.6 Verify no console errors; verify memory in devtools stays bounded (<200 MB heap for typical use)
|
||||||
|
|
||||||
|
## 7. Version + changelog
|
||||||
|
|
||||||
|
- [x] 7.1 Bump `__APP_VERSION__` to `1.3.2` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||||
|
- [x] 7.2 Add `## v1.3.2` entry to `CHANGELOG.md` describing: leaf caching, instant pin/unpin, partial-hit streaming on unpin, 500k cap
|
||||||
Reference in New Issue
Block a user