Files
Bill cb49123930 v1.3.1: Exhaustive decision-tree search + UX refinements
The v1.3.0 saturation termination silently capped the search after only
the heuristic-favored part of the tree, leaving most per-set ceiling cells
stuck at "0 specs" and hiding genuinely-feasible 3-spec plans in
maximize-count mode. Replace with full exhaustive enumeration plus a
batch of UX refinements that emerged during testing.

Algorithm:

- Drop the saturation early-termination entirely. Search now runs the
  full open-set cartesian product to completion; the iteration cap is
  also removed so no scenario exits partial.
- Add mode-dependent DFS child ordering: priority-order keeps the
  priority-target-first heuristic; maximize-count orders children by
  descending count of qualifications for reachable specs (generalist
  courses tried first).
- Make the (count, priorityScore) comparator mode-aware: priority-order
  ranks by (priorityScore, count) so the user's top spec surfaces;
  maximize-count ranks by (count, priorityScore) so the highest count
  wins. The same rule drives both top-K position and per-cell ceiling
  selection (and the Recommended badge).
- Add an evaluated boolean to each ChoiceOutcome and set it on first
  leaf evaluation. Distinguishes "still searching" from "evaluated, no
  specs achieved" so the UI never shows misleading 0 specs for a cell
  the search hasn't reached yet.
- Throttled progress events (~100ms) carrying iterations / total leaf
  count, drive both the per-set spinner and the global progress bar.

UI:

- Top Plans header shows a horizontal progress bar with
  "iterations / total · NN%" while the search runs; collapses to
  "Search complete · N explored" on completion.
- Per-set spinner next to each elective set heading while any choice
  in that set is unevaluated.
- Per-cell pulsing dot + "searching" text for unevaluated cells.
- Replace the "(HCR, BNK, ...)" text labels on each course with
  color-coded SpecTag pills using a new fixed per-spec palette
  (app/src/data/specColors.ts). Same palette applied to the Top Plans
  achievement badges so the two views are visually consistent.
- "Top outcome if picked ↓" caption above the right side of each open
  elective set so the spec tags are clearly identified as decision-tree
  outcomes (not the course's own qualifications).
- Recommended badge moved inline next to the course name (instead of
  on a separate row below) to keep button heights stable.

Tests:

- Replace the saturation early-termination test with an exhaustion test
  asserting every cell ends with evaluated: true and partial: false.
- Add mode-dependent ordering test (max-count visits Climate Finance
  before Corporate Governance in fall3).
- Add evaluated-flag transition test.
- Add throttled progress-event test (>= ~100ms between consecutive
  emits).
- Performance smoke updated to a 60s budget for the exhaustive
  user-scenario search; 8-open-set typical case completes in ~7s.

Files: solver/decisionTree.ts, solver/priority.ts (already shipped),
data/specColors.ts (new), components/{TopPlans,CourseSelection}.tsx,
state/appState.ts, workers/decisionTree.worker.ts,
__tests__/searchDecisionTree.test.ts, vite.config.ts, CHANGELOG.md,
openspec/changes/decision-tree-exhaustive-search/* (full change spec).
2026-05-09 15:47:56 -04:00

7.5 KiB

1. Drop saturation, raise cap

  • 1.1 In app/src/solver/decisionTree.ts, remove the SATURATION_LIMIT constant and all references to iterationsSinceTopKChange (declaration, increment, reset, comparison)
  • 1.2 Raise MAX_TREE_ITERATIONS to 100_000
  • 1.3 Confirm the only termination paths are now: (a) iteration cap fires (sets partial = true), or (b) DFS exhausts the cartesian product

2. Mode-dependent ordering

  • 2.1 Add reorderByReachableQualCount(setId, upperBounds, excludedCourseIds): Course[] — returns courses sorted by descending count of qualifications for specs whose upperBounds[specId] >= 9. Stable sort; ties keep declaration order; cancelled courses excluded.
  • 2.2 In searchDecisionTree, replace the orderedCoursesPerSet initialization to gate by mode: priority-order keeps reorderForTarget(setId, priorityTarget, ...); maximize-count uses reorderByReachableQualCount(setId, upperBounds, ...)
  • 2.3 Unit test: assert that in maximize-count mode, the first DFS leaf for the user's reproduction scenario contains fall3-climate-finance (the most-generalist Fall Set 3 course) before fall3-emerging-tech

3. Evaluated/unevaluated cell state

  • 3.1 Add evaluated: boolean field to ChoiceOutcome (app/src/solver/decisionTree.ts); initialize to false in the per-set analysis init loop
  • 3.2 In evaluateLeaf, after running the optimizer and computing result, set choice.evaluated = true for every (setId, courseId) in the leaf's assignments BEFORE the comparison. (The cell is "evaluated" the moment a leaf containing it is run, regardless of whether the result improves the ceiling.)
  • 3.3 Confirm the choiceUpdate callback fires whenever either evaluated flips to true or the ceiling improves — so the UI sees the transition. Update the existing condition to fire on both
  • 3.4 Unit test: after one leaf evaluation containing (spr3, spr3-analytics-ml), that cell has evaluated: true; cells in other sets that aren't in the leaf assignment remain evaluated: false

4. Throttled progress events

  • 4.1 Add progress to the WorkerResponse tagged union in app/src/workers/decisionTree.worker.ts: { type: 'progress'; iterations: number; iterationsTotal: number }
  • 4.2 Compute iterationsTotal in searchDecisionTree before DFS starts: product of orderedCoursesPerSet[setId].length over all openSetIds. Pass it through to the progress callback
  • 4.3 Add onProgress?: (iterations: number, iterationsTotal: number) => void to SearchCallbacks. Track lastProgressEmit: number (default 0); call onProgress from inside evaluateLeaf when Date.now() - lastProgressEmit >= 100
  • 4.4 In the worker, wire onProgress to postMessage({ type: 'progress', iterations, iterationsTotal })

5. App state wiring

  • 5.1 In app/src/state/appState.ts, add a searchProgress: { iterations: number; iterationsTotal: number } | null slice (default null); update on progress events; reset to null on new search start and on allComplete
  • 5.2 Export searchProgress from useAppState alongside topPlans/topPlansPartial
  • 5.3 The choiceUpdate handler already updates the per-set map; verify the new evaluated field flows through unchanged (no code change needed; analysis is forwarded as-is)

6. Top Plans header (global progress)

  • 6.1 In app/src/components/TopPlans.tsx, accept searchProgress and loading props and render in the header:
    • while loading && searchProgress: Searching… {iterations.toLocaleString()} / {iterationsTotal.toLocaleString()} explored
    • after complete (!loading && !partial): Search complete · {totalEvaluated} explored
    • after complete with partial: Search incomplete · cap hit at {MAX_TREE_ITERATIONS} (existing partial caption replaced/extended)
  • 6.2 Pass searchProgress from App.tsx into <TopPlans>

7. CourseSelection per-set + per-cell rendering

  • 7.1 In app/src/components/CourseSelection.tsx, in the ElectiveSet heading area: render a small spinner/dot next to the set name when loading === true AND analysis?.choices.some(c => !c.evaluated). The existing "high impact" badge stays
  • 7.2 Replace the per-cell ceiling render branch:
    • !evaluated: render a faint "·" or pulsing dot (use the existing skeleton pattern restyled, or a small glyph) instead of "0 specs"
    • evaluated && ceilingCount === 0: render "0 specs" in muted grey
    • evaluated && ceilingCount > 0: render existing colored "N specs (LIST)" treatment
  • 7.3 Compute the recommended choice per set: pick the choice with the best (ceilingCount desc, priorityScore desc) — only consider choices with evaluated === true. Render a small ⭐ Recommended badge on that row. Hide the badge if no choice is yet evaluated
  • 7.4 The recommended derivation needs priorityScore; import makePriorityScorer from app/src/solver/priority and memoize per-render with state.ranking

8. Tests

  • 8.1 Remove the saturation early-termination test from app/src/solver/__tests__/searchDecisionTree.test.ts (the test that asserts iteration count stays well under cap when topK converges quickly — no longer applies)
  • 8.2 Add an exhaustion test: small scenario (e.g., 2 open sets); after searchDecisionTree returns, every choice in every open set has evaluated: true and partial === false
  • 8.3 Add a mode-dependent ordering test: in maximize-count mode for the user's reproduction scenario, the first leaf evaluated contains fall3-climate-finance (verify by capturing the first onChoiceUpdate event for fall3 and inspecting the assignment)
  • 8.4 Add an evaluated-flag transition test: assert all cells start evaluated: false; after one leaf evaluation, only cells with that leaf's assignments are evaluated: true
  • 8.5 Update the streaming monotonicity test if needed (still valid in concept; just verify with new termination)
  • 8.6 Add a progress-event throttling test: capture onProgress calls during a search; assert minimum interval >= 90ms between consecutive calls (small jitter tolerance)
  • 8.7 Update the performance smoke test to allow longer time budget (e.g., 60 seconds) since the search is now exhaustive
  • 8.8 Run full test suite; confirm all pass

9. Browser verification

  • 9.1 Start dev server; reproduce user's pin scenario (SP2/SP4/SP5/SE1, HCR first)
  • 9.2 Switch to maximize-count mode; confirm Top Plans surfaces 3-spec plans (e.g., [BNK, CRF, FIN] triples) if any are feasible — if not feasible for this scenario, try a less-pinned scenario to confirm 3-spec plans CAN appear
  • 9.3 Confirm per-set spinner appears next to "Spring Elective Set 1" while search runs and clears when complete
  • 9.4 Confirm per-cell rendering shows "·" or similar for unevaluated cells, then transitions to "N specs" or "0 specs" as evaluation completes
  • 9.5 Confirm ⭐ Recommended appears on one course per set after at least one cell in that set is evaluated; verify it matches the best (count, priorityScore)
  • 9.6 Confirm Top Plans header shows progress text (Searching… N / Total) during search and Search complete after
  • 9.7 Adopt-plan still works correctly; no regression

10. Version + changelog

  • 10.1 Bump __APP_VERSION__ to 1.3.1 and __APP_VERSION_DATE__ in app/vite.config.ts
  • 10.2 Add ## v1.3.1 entry to CHANGELOG.md describing: exhaustive search (drop saturation), mode-dependent ordering, evaluated/unevaluated cell distinction, per-set + global progress indicators, Recommended marker