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:
2026-05-09 16:27:52 -04:00
parent cb49123930
commit ee7ea352c4
14 changed files with 759 additions and 26 deletions
@@ -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