Files
emba-course-solver/openspec/changes/decision-tree-priority-streaming/tasks.md
T
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

6.4 KiB

1. Shared Priority Utility

  • 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)
  • 1.2 Update maximizeCount to call the shared helper instead of the inline definition
  • 1.3 Add a unit test covering priorityScore with 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), define interface PlanOutcome { courseAssignments: Record<string, string>; achievedSpecs: string[]; priorityScore: number }
  • 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
  • 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 with upperBounds[id] >= 9, else null
  • 3.2 Implement reorderForTarget(setId, target, excludedCourseIds): Course[] — stable sort coursesBySet[setId] so target-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 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 }
  • 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
  • 3.5 Delete the old computeCeiling function once searchDecisionTree is wired in (no longer called)

4. Comparison Rule

  • 4.1 Implement compareOutcomes(a, b) returning <0 / 0 / >0 for (count desc, priorityScore desc, deterministic-tiebreak asc)
  • 4.2 Use compareOutcomes for both BoundedRankedList's comparator and setAnalyses[setId].choices[courseId] ceiling updates

5. Worker Protocol

  • 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
  • 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

  • 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
  • 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)
  • 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)

  • 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
  • 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 topKPartial is 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, ...]. Assert topK[0].achievedSpecs.includes('HCR') AND setAnalyses for spr3 has analytics-ml's ceilingSpecs.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 compareOutcomes for 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 = Infinity in test); assert partial: true after MAX_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__ to 1.3.0 and __APP_VERSION_DATE__ in app/vite.config.ts
  • 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