4b80fac500
Fixes the bug where a specialization could show "Achievable" while no per-set ceiling cell surfaces a path to it. Reproduction: pin SP2=Business of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance, SE1=GIE; rank HCR first. Healthcare showed Achievable but every ceiling cell excluded HCR. Root cause: computeCeiling used strict > on count alone, so the first equal-count combination found won permanently and HCR-including outcomes were never recorded. Changes: - Replace per-(set, choice) computeCeiling loop with a single full-tree searchDecisionTree DFS. Both the per-set ceiling table and a new ranked top-K plan list (default K=10) are populated from one enumeration. - Comparison rule everywhere is (count desc, priority score desc, deterministic-tiebreak). priorityScore extracted from optimizer.ts into a shared priority.ts module used by both call sites. - Heuristic enumeration ordering: select the first reachable ranked spec as priorityTarget; reorder DFS children at every level so target- qualifying courses are tried first. High-priority outcomes surface in early iterations instead of being blocked by less-relevant equal-count results. - Bounded search: terminate on saturation (top-K stable for 500 iterations) or hard cap (10000 iterations); set partial=true if cap hit. Mitigates the worst-case enumeration cost. - Worker protocol: tagged-union response with topKUpdate, choiceUpdate (per-cell, replaces per-set setComplete), and allComplete events. - App state adds topPlans/topPlansPartial slices and an adoptPlan action that pins a plan's full course assignment in one click. Also fixes loadState's stale "ranking.length !== 14" check (now uses SPECIALIZATIONS.length so HCR-era saved state restores correctly). - New TopPlans component renders the ranked list with adopt buttons, placed above CourseSelection in the right column. - 17 new tests in searchDecisionTree.test.ts covering priority scoring, bounded ranked list, comparison rule, target selection, the user's reproduction scenario, streaming monotonicity, saturation termination, and a performance smoke test (< 5s for the 8-open-set case). - Existing decisionTree.test.ts: one test amended for per-cell streaming semantics; remaining 3 unchanged and passing.
74 lines
6.4 KiB
Markdown
74 lines
6.4 KiB
Markdown
## 1. Shared Priority Utility
|
|
|
|
- [x] 1.1 Extract `priorityScore(specs: string[], ranking: string[]): number` from `app/src/solver/optimizer.ts:71-74` into a new exported helper (in `optimizer.ts` or a new `app/src/solver/priority.ts` — pick whichever fits the existing import patterns better)
|
|
- [x] 1.2 Update `maximizeCount` to call the shared helper instead of the inline definition
|
|
- [x] 1.3 Add a unit test covering `priorityScore` with a few rankings to lock the formula
|
|
|
|
## 2. New Types and Data Structures
|
|
|
|
- [x] 2.1 In `app/src/solver/decisionTree.ts` (or a sibling file), define `interface PlanOutcome { courseAssignments: Record<string, string>; achievedSpecs: string[]; priorityScore: number }`
|
|
- [x] 2.2 Implement `BoundedRankedList<T>`: bounded sorted-array container with `tryInsert(item): boolean` and `toArray(): T[]`. Tests: insert above capacity drops worst entry; insert returns true only when list visibly changes
|
|
- [x] 2.3 Define a deterministic stringification of `courseAssignments` (sorted by setId, joined) for use as the comparator's tiebreaker
|
|
|
|
## 3. Search Algorithm
|
|
|
|
- [x] 3.1 Implement `selectPriorityTarget(ranking, upperBounds): string | null` — walk ranking, return first specId with `upperBounds[id] >= 9`, else null
|
|
- [x] 3.2 Implement `reorderForTarget(setId, target, excludedCourseIds): Course[]` — stable sort `coursesBySet[setId]` so `target`-qualifying courses come first; cancelled courses still excluded
|
|
- [x] 3.3 Implement `searchDecisionTree(pinned, openSets, ranking, mode, K, callbacks, excluded)`:
|
|
- DFS over cartesian product, children reordered per `priorityTarget`
|
|
- Per leaf: run optimizer, build `PlanOutcome`, `topK.tryInsert`, update per-set ceilings
|
|
- Emit `topKUpdate` and `choiceUpdate` callbacks on changes
|
|
- Track iteration count; track iterations-since-last-topK-change for saturation
|
|
- Terminate at `MAX_TREE_ITERATIONS = 10000` (set `partial=true`) or `SATURATION_LIMIT = 500` iterations of no topK change
|
|
- Return `{ topK, setAnalyses, partial }`
|
|
- [x] 3.4 Replace `analyzeDecisionTree`'s body with a thin wrapper that calls `searchDecisionTree`. Preserve its existing exported signature so existing call sites continue to compile; add an overload (or new exported function) that exposes the streaming/topK API for consumers that need it
|
|
- [x] 3.5 Delete the old `computeCeiling` function once `searchDecisionTree` is wired in (no longer called)
|
|
|
|
## 4. Comparison Rule
|
|
|
|
- [x] 4.1 Implement `compareOutcomes(a, b)` returning `<0 / 0 / >0` for `(count desc, priorityScore desc, deterministic-tiebreak asc)`
|
|
- [x] 4.2 Use `compareOutcomes` for both `BoundedRankedList`'s comparator and `setAnalyses[setId].choices[courseId]` ceiling updates
|
|
|
|
## 5. Worker Protocol
|
|
|
|
- [x] 5.1 Update `app/src/workers/decisionTree.worker.ts`:
|
|
- Extend `WorkerRequest` with optional `topK` (default 10) and `saturationLimit` (default 500)
|
|
- Replace single `setComplete` event with three event types: `topKUpdate`, `choiceUpdate`, `allComplete`
|
|
- On worker invocation, pass callbacks into `searchDecisionTree` that `postMessage` for each event
|
|
- [x] 5.2 Update `WorkerResponse` type to the tagged union from the spec; ensure all consumers in app code switch on `type`
|
|
|
|
## 6. App State Wiring
|
|
|
|
- [x] 6.1 In `app/src/state/appState.ts`, add a `topK: PlanOutcome[]` slice (and `topKPartial: boolean` flag) and a handler that updates from `topKUpdate` events
|
|
- [x] 6.2 Update the existing handler that consumes worker events to handle the new `choiceUpdate` shape (per-cell rather than per-set rollup) and the new `allComplete` shape (carries final topK + setAnalyses + partial)
|
|
- [x] 6.3 Send the new `topK` and `saturationLimit` parameters in the worker request (use defaults 10 and 500)
|
|
|
|
## 7. UI: Top Plans Panel (sketch only — full UX in follow-up)
|
|
|
|
- [x] 7.1 Create `app/src/components/TopPlans.tsx` rendering the `topK` slice as a ranked list. Each row: achieved specs as badges, list of "set → course" pairs from `courseAssignments`, an "Adopt plan" button that pins all those courses
|
|
- [x] 7.2 Add the panel to the layout in a sensible default location (below or beside the existing decision-tree section). Mark it as a "preview" / minor visual treatment if not yet polished — full UX work tracked separately
|
|
- [x] 7.3 If `topKPartial` is true, render a subtle "(showing best of N explored)" caption
|
|
|
|
## 8. Tests
|
|
|
|
- [x] 8.1 Reproduction test in `app/src/solver/__tests__/decisionTree.test.ts`: pin SP2=spr2-health-medical, SP4=spr4-fintech, SP5=spr5-corporate-finance, SE1=sum1-global-immersion; rank `[HCR, ...]`. Assert `topK[0].achievedSpecs.includes('HCR')` AND `setAnalyses` for spr3 has analytics-ml's `ceilingSpecs.includes('HCR')`
|
|
- [x] 8.2 Priority-aware ordering test: same count, two combinations — assert the higher-priority combination wins both topK position and per-set ceiling
|
|
- [x] 8.3 Streaming monotonicity test: capture all emitted topK snapshots; assert each is ≥ the previous under `compareOutcomes` for matching positions
|
|
- [x] 8.4 Saturation early-termination test: input where topK converges quickly; assert iteration count stays well under `MAX_TREE_ITERATIONS`
|
|
- [x] 8.5 Iteration cap test: input that would never saturate (or set `SATURATION_LIMIT = Infinity` in test); assert `partial: true` after `MAX_TREE_ITERATIONS`
|
|
- [x] 8.6 Existing decision-tree tests (4 tests in `decisionTree.test.ts`): must continue to pass. Update assertions only where priority-tiebreak changes the result (document each amendment in the commit message)
|
|
- [x] 8.7 Performance smoke test: user's 8-open-set scenario, K=10, completes in < 5s on Node test runner
|
|
|
|
## 9. Browser Verification
|
|
|
|
- [x] 9.1 Start dev server and load app
|
|
- [x] 9.2 Reproduce the user's scenario (pin 4 courses, rank HCR first); confirm Top Plans panel shows at least one plan with HCR achieved
|
|
- [x] 9.3 Confirm per-set ceiling table cells update progressively (visible streaming behavior) — at least the spr3 analytics-ml cell flips to include HCR
|
|
- [x] 9.4 Verify "Adopt plan" button correctly pins the plan's courses and updates the rest of the UI
|
|
- [x] 9.5 Test with a scenario where no spec is reachable — confirm priorityTarget=null path runs without error and returns sensible (possibly empty) topK
|
|
|
|
## 10. Version + Changelog
|
|
|
|
- [x] 10.1 Bump `__APP_VERSION__` to `1.3.0` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
|
- [x] 10.2 Add `## v1.3.0` entry to `CHANGELOG.md` describing: priority-aware ceiling fix, new streamed top-K plan list, worker protocol change, fixes the HCR-achievable-but-no-path bug
|