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.
6.4 KiB
6.4 KiB
1. Shared Priority Utility
- 1.1 Extract
priorityScore(specs: string[], ranking: string[]): numberfromapp/src/solver/optimizer.ts:71-74into a new exported helper (inoptimizer.tsor a newapp/src/solver/priority.ts— pick whichever fits the existing import patterns better) - 1.2 Update
maximizeCountto call the shared helper instead of the inline definition - 1.3 Add a unit test covering
priorityScorewith a few rankings to lock the formula
2. New Types and Data Structures
- 2.1 In
app/src/solver/decisionTree.ts(or a sibling file), defineinterface PlanOutcome { courseAssignments: Record<string, string>; achievedSpecs: string[]; priorityScore: number } - 2.2 Implement
BoundedRankedList<T>: bounded sorted-array container withtryInsert(item): booleanandtoArray(): T[]. Tests: insert above capacity drops worst entry; insert returns true only when list visibly changes - 2.3 Define a deterministic stringification of
courseAssignments(sorted by setId, joined) for use as the comparator's tiebreaker
3. Search Algorithm
- 3.1 Implement
selectPriorityTarget(ranking, upperBounds): string | null— walk ranking, return first specId withupperBounds[id] >= 9, else null - 3.2 Implement
reorderForTarget(setId, target, excludedCourseIds): Course[]— stable sortcoursesBySet[setId]sotarget-qualifying courses come first; cancelled courses still excluded - 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
topKUpdateandchoiceUpdatecallbacks on changes - Track iteration count; track iterations-since-last-topK-change for saturation
- Terminate at
MAX_TREE_ITERATIONS = 10000(setpartial=true) orSATURATION_LIMIT = 500iterations of no topK change - Return
{ topK, setAnalyses, partial }
- DFS over cartesian product, children reordered per
- 3.4 Replace
analyzeDecisionTree's body with a thin wrapper that callssearchDecisionTree. 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 - 3.5 Delete the old
computeCeilingfunction oncesearchDecisionTreeis wired in (no longer called)
4. Comparison Rule
- 4.1 Implement
compareOutcomes(a, b)returning<0 / 0 / >0for(count desc, priorityScore desc, deterministic-tiebreak asc) - 4.2 Use
compareOutcomesfor bothBoundedRankedList's comparator andsetAnalyses[setId].choices[courseId]ceiling updates
5. Worker Protocol
- 5.1 Update
app/src/workers/decisionTree.worker.ts:- Extend
WorkerRequestwith optionaltopK(default 10) andsaturationLimit(default 500) - Replace single
setCompleteevent with three event types:topKUpdate,choiceUpdate,allComplete - On worker invocation, pass callbacks into
searchDecisionTreethatpostMessagefor each event
- Extend
- 5.2 Update
WorkerResponsetype to the tagged union from the spec; ensure all consumers in app code switch ontype
6. App State Wiring
- 6.1 In
app/src/state/appState.ts, add atopK: PlanOutcome[]slice (andtopKPartial: booleanflag) and a handler that updates fromtopKUpdateevents - 6.2 Update the existing handler that consumes worker events to handle the new
choiceUpdateshape (per-cell rather than per-set rollup) and the newallCompleteshape (carries final topK + setAnalyses + partial) - 6.3 Send the new
topKandsaturationLimitparameters in the worker request (use defaults 10 and 500)
7. UI: Top Plans Panel (sketch only — full UX in follow-up)
- 7.1 Create
app/src/components/TopPlans.tsxrendering thetopKslice as a ranked list. Each row: achieved specs as badges, list of "set → course" pairs fromcourseAssignments, an "Adopt plan" button that pins all those courses - 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
- 7.3 If
topKPartialis true, render a subtle "(showing best of N explored)" caption
8. Tests
- 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, ...]. AsserttopK[0].achievedSpecs.includes('HCR')ANDsetAnalysesfor spr3 has analytics-ml'sceilingSpecs.includes('HCR') - 8.2 Priority-aware ordering test: same count, two combinations — assert the higher-priority combination wins both topK position and per-set ceiling
- 8.3 Streaming monotonicity test: capture all emitted topK snapshots; assert each is ≥ the previous under
compareOutcomesfor matching positions - 8.4 Saturation early-termination test: input where topK converges quickly; assert iteration count stays well under
MAX_TREE_ITERATIONS - 8.5 Iteration cap test: input that would never saturate (or set
SATURATION_LIMIT = Infinityin test); assertpartial: trueafterMAX_TREE_ITERATIONS - 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) - 8.7 Performance smoke test: user's 8-open-set scenario, K=10, completes in < 5s on Node test runner
9. Browser Verification
- 9.1 Start dev server and load app
- 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
- 9.3 Confirm per-set ceiling table cells update progressively (visible streaming behavior) — at least the spr3 analytics-ml cell flips to include HCR
- 9.4 Verify "Adopt plan" button correctly pins the plan's courses and updates the rest of the UI
- 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
- 10.1 Bump
__APP_VERSION__to1.3.0and__APP_VERSION_DATE__inapp/vite.config.ts - 10.2 Add
## v1.3.0entry toCHANGELOG.mddescribing: priority-aware ceiling fix, new streamed top-K plan list, worker protocol change, fixes the HCR-achievable-but-no-path bug