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.
4.4 KiB
Why
The decision tree currently has a user-visible contradiction: a specialization can be labeled "Achievable" while no per-set ceiling shows a path to achieving it. Concrete reproduction with the new Healthcare specialization (J27): pin SP2=Business of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance, SE1=GIE, rank HCR first — HCR shows "Achievable" but every per-set choice's ceiling outcome excludes HCR. Root cause is in app/src/solver/decisionTree.ts:55, where computeCeiling compares enumerated combinations using strict > on count alone, so the first equal-count outcome found wins permanently regardless of the user's priority ranking.
Beyond the bug, the tool currently shows only per-(set, choice) ceiling cells. Users have no global "best plans" view and must mentally compose compatible choices across sets. The fix and the new view share the same enumeration work, so addressing both together is cheaper than addressing them sequentially.
What Changes
- Replace
analyzeDecisionTree's nestedcomputeCeilingloop with a single full-tree search (searchDecisionTree) that simultaneously populates two outputs from one DFS:- The existing per-set per-choice ceiling table (unchanged shape, now updated progressively per cell)
- A new bounded ranked list of up to K complete plan outcomes (
PlanOutcome[], default K=10)
- Comparison rule everywhere becomes
(count desc, priorityScore desc, deterministic tiebreaker). ExtractpriorityScorefromoptimizer.ts:71-74into a shared utility used by both modules. - Reorder DFS children at every level so courses qualifying for the user's first reachable top-ranked spec (the
priorityTarget) are tried first. This ensures high-priority outcomes surface early in the stream. - Bound the search with two complementary terminators: a hard iteration cap (
MAX_TREE_ITERATIONS = 10000) and saturation termination when the top-K has not changed for the lastSATURATION_LIMIT = 500iterations. Return apartial: trueflag if the cap is hit before saturation. - Worker protocol:
WorkerRequestgains optionaltopK(default 10) andsaturationLimit.WorkerResponsebecomes a tagged union with three event types:topKUpdate,choiceUpdate(replaces today's coarsersetComplete), andallComplete(now carriestopKandpartial). - App state and a new "Top Plans" UI panel consume the streamed top-K; the existing per-set table consumes the finer-grained
choiceUpdateevents. Per-set table component shape is unchanged. - "Achievable" status semantics stay permissive (raw upper-bound check). Per the user's intent, this is correct: it should mean "reachable somewhere in the tree" regardless of whether the search has yet found a path.
Capabilities
New Capabilities
None — this extends an existing capability rather than adding a new one.
Modified Capabilities
optimization-engine: introduces the streaming top-K search, priority-aware ceiling comparison, heuristic enumeration ordering, and the new worker event protocol. The optimizer itself (LP solver, S2 enumeration) is untouched; this change touches the decision-tree layer that wraps it.
Impact
app/src/solver/decisionTree.ts— major rewrite: newsearchDecisionTree,BoundedRankedList,PlanOutcometype,priorityTargetselection, child reordering, saturation terminationapp/src/solver/optimizer.ts— extractpriorityScore(currently inline at lines 71-74) into a shared utility (either exported from optimizer or a newpriority.ts)app/src/workers/decisionTree.worker.ts— message-protocol update; consumetopK/saturationLimitrequest fields, emittopKUpdate/choiceUpdate/allCompletetagged eventsapp/src/state/appState.ts— addtopKslice, wire new event types from workerapp/src/components/— newTopPlans.tsx(or similar) component; existing decision-tree per-set component switches fromsetCompleteto per-cellchoiceUpdatehandlerapp/src/solver/__tests__/decisionTree.test.ts— add scenario regression test, priority-ordering test, monotonicity test, saturation/cap tests; existing 4 tests must continue to pass (with priority-tiebreak amendments where needed)app/vite.config.ts— version bump (__APP_VERSION__to1.3.0, date today)CHANGELOG.md— release entry- No data-file changes; no schema migration; no backwards-compatibility shims (the worker is internal)