Files
Bill 4b80fac500 v1.3.0: Streamed top-K decision-tree plans + priority-aware ceiling
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.
2026-05-09 14:51:32 -04:00

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 nested computeCeiling loop 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). Extract priorityScore from optimizer.ts:71-74 into 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 last SATURATION_LIMIT = 500 iterations. Return a partial: true flag if the cap is hit before saturation.
  • Worker protocol: WorkerRequest gains optional topK (default 10) and saturationLimit. WorkerResponse becomes a tagged union with three event types: topKUpdate, choiceUpdate (replaces today's coarser setComplete), and allComplete (now carries topK and partial).
  • App state and a new "Top Plans" UI panel consume the streamed top-K; the existing per-set table consumes the finer-grained choiceUpdate events. 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: new searchDecisionTree, BoundedRankedList, PlanOutcome type, priorityTarget selection, child reordering, saturation termination
  • app/src/solver/optimizer.ts — extract priorityScore (currently inline at lines 71-74) into a shared utility (either exported from optimizer or a new priority.ts)
  • app/src/workers/decisionTree.worker.ts — message-protocol update; consume topK/saturationLimit request fields, emit topKUpdate/choiceUpdate/allComplete tagged events
  • app/src/state/appState.ts — add topK slice, wire new event types from worker
  • app/src/components/ — new TopPlans.tsx (or similar) component; existing decision-tree per-set component switches from setComplete to per-cell choiceUpdate handler
  • app/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__ to 1.3.0, date today)
  • CHANGELOG.md — release entry
  • No data-file changes; no schema migration; no backwards-compatibility shims (the worker is internal)