diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3e8a1..200a7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 ### Changes diff --git a/app/src/App.tsx b/app/src/App.tsx index a326274..e9b60bb 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -138,7 +138,10 @@ function App() { partial={topPlansPartial} loading={treeLoading} progress={searchProgress} + pinnedCourses={state.pinnedCourses} onAdopt={adoptPlan} + onPin={pinCourse} + onUnpin={unpinCourse} /> ; onAdopt: (assignments: Record) => 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 )}
{visible.map((plan, i) => ( - + ))}
@@ -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; onAdopt: (assignments: Record) => 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 (
-
- {assignmentEntries.map(([setId, courseId], i) => ( - - {i > 0 && · } - {setNameById[setId]?.replace('Elective Set ', 'S')}: - {courseById[courseId]?.name ?? courseId} - - ))} +
+ {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 ( + + ); + })}
); diff --git a/app/src/solver/__tests__/leafCache.test.ts b/app/src/solver/__tests__/leafCache.test.ts new file mode 100644 index 0000000..1e76d49 --- /dev/null +++ b/app/src/solver/__tests__/leafCache.test.ts @@ -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 = {}; + 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 = {}; + 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']); + }); +}); diff --git a/app/src/solver/decisionTree.ts b/app/src/solver/decisionTree.ts index f464429..4ef9a1c 100644 --- a/app/src/solver/decisionTree.ts +++ b/app/src/solver/decisionTree.ts @@ -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, + skipKeys?: Set, + pinnedAssignments?: Record, ): 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 = {}; @@ -248,19 +254,29 @@ export function searchDecisionTree( function evaluateLeaf(accumulated: Record): void { iterations++; + // Build the full 12-set assignment so cache keys remain stable across + // pin/unpin operations. + const fullAssignment: Record = { ...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, + K: number, + mode: OptimizationMode, + ranking: string[], + openSetIds: string[], + excludedCourseIds?: Set, +): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } { + const scorer = makePriorityScorer(ranking); + const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds); + const priorityTarget = selectPriorityTarget(ranking, upperBounds); + + const setAnalyses: Record = {}; + 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 = {}; + const ceilingComparator = makeCeilingComparator(mode); + const outcomeComparator = makeOutcomeComparator(mode); + const topK = new BoundedRankedList(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 diff --git a/app/src/state/appState.ts b/app/src/state/appState.ts index 196d62c..5d5711b 100644 --- a/app/src/state/appState.ts +++ b/app/src/state/appState.ts @@ -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; +} + 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(null); const debounceRef = useRef>(); + const leafCacheRef = useRef({ + 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 = {}; + 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> = {}; + 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(); + for (const a of cachedAnalyses) setMap.set(a.setId, a); + worker.onmessage = (e: MessageEvent) => { 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 }), []); diff --git a/app/src/workers/decisionTree.worker.ts b/app/src/workers/decisionTree.worker.ts index 694ca51..f60f90c 100644 --- a/app/src/workers/decisionTree.worker.ts +++ b/app/src/workers/decisionTree.worker.ts @@ -4,18 +4,21 @@ import type { SetAnalysis, PlanOutcome } from '../solver/decisionTree'; export interface WorkerRequest { pinnedCourseIds: string[]; + pinnedAssignments?: Record; 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) => { 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) => { 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 = { diff --git a/app/vite.config.ts b/app/vite.config.ts index 8c301c0..0a84f9e 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], define: { - __APP_VERSION__: JSON.stringify('1.3.1'), + __APP_VERSION__: JSON.stringify('1.3.2'), __APP_VERSION_DATE__: JSON.stringify('2026-05-09'), }, server: { diff --git a/openspec/changes/decision-tree-leaf-cache/.openspec.yaml b/openspec/changes/decision-tree-leaf-cache/.openspec.yaml new file mode 100644 index 0000000..0478d8f --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-09 diff --git a/openspec/changes/decision-tree-leaf-cache/README.md b/openspec/changes/decision-tree-leaf-cache/README.md new file mode 100644 index 0000000..f6d496c --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/README.md @@ -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). diff --git a/openspec/changes/decision-tree-leaf-cache/design.md b/openspec/changes/decision-tree-leaf-cache/design.md new file mode 100644 index 0000000..8be6c07 --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/design.md @@ -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 diff --git a/openspec/changes/decision-tree-leaf-cache/proposal.md b/openspec/changes/decision-tree-leaf-cache/proposal.md new file mode 100644 index 0000000..9fa688f --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/proposal.md @@ -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` 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` 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); 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` 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` 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 diff --git a/openspec/changes/decision-tree-leaf-cache/specs/optimization-engine/spec.md b/openspec/changes/decision-tree-leaf-cache/specs/optimization-engine/spec.md new file mode 100644 index 0000000..c5df68b --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/specs/optimization-engine/spec.md @@ -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` 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 diff --git a/openspec/changes/decision-tree-leaf-cache/tasks.md b/openspec/changes/decision-tree-leaf-cache/tasks.md new file mode 100644 index 0000000..f91febd --- /dev/null +++ b/openspec/changes/decision-tree-leaf-cache/tasks.md @@ -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` 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, K: number, mode: OptimizationMode, ranking: string[], openSetIds: string[], excludedCourseIds?: Set): { 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` 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 }>({ 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