From cb49123930191e16f08a01ac3afa5fa32cb3cbd0 Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Sat, 9 May 2026 15:47:56 -0400 Subject: [PATCH] v1.3.1: Exhaustive decision-tree search + UX refinements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CHANGELOG.md | 14 ++ app/src/App.tsx | 4 + app/src/components/CourseSelection.tsx | 140 +++++++++++++--- app/src/components/TopPlans.tsx | 88 ++++++++--- app/src/data/specColors.ts | 29 ++++ .../__tests__/searchDecisionTree.test.ts | 137 +++++++++++++--- app/src/solver/decisionTree.ts | 149 +++++++++++++----- app/src/state/appState.ts | 7 + app/src/workers/decisionTree.worker.ts | 7 + app/vite.config.ts | 2 +- .../.openspec.yaml | 2 + .../decision-tree-exhaustive-search/README.md | 3 + .../decision-tree-exhaustive-search/design.md | 99 ++++++++++++ .../proposal.md | 42 +++++ .../specs/optimization-engine/spec.md | 92 +++++++++++ .../decision-tree-exhaustive-search/tasks.md | 75 +++++++++ 16 files changed, 780 insertions(+), 110 deletions(-) create mode 100644 app/src/data/specColors.ts create mode 100644 openspec/changes/decision-tree-exhaustive-search/.openspec.yaml create mode 100644 openspec/changes/decision-tree-exhaustive-search/README.md create mode 100644 openspec/changes/decision-tree-exhaustive-search/design.md create mode 100644 openspec/changes/decision-tree-exhaustive-search/proposal.md create mode 100644 openspec/changes/decision-tree-exhaustive-search/specs/optimization-engine/spec.md create mode 100644 openspec/changes/decision-tree-exhaustive-search/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 017cbf2..ee3e8a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v1.3.1 — 2026-05-09 + +### Changes + +- **Exhaustive decision-tree search** — replaced the saturation early-termination with full enumeration of the open-set cartesian product. Per-set ceiling cells now reflect the true best outcome for every (set, course) pair instead of leaving most cells stuck at "0 specs". Top Plans surfaces all genuinely-feasible plans, including 3-spec maximize-count plans that the v1.3.0 search missed. The previous iteration cap has been removed; search runs to full completion. +- **Mode-dependent enumeration ordering** — priority-order mode keeps the priority-target-first heuristic; maximize-count mode now orders DFS children by descending count of qualifications for *reachable* specializations, surfacing generalist courses (e.g., Climate Finance with 6 qualifications) before specialists. +- **Mode-aware comparator** — top-K and per-cell ceiling rankings now match the active mode: priority-order ranks by `(priorityScore, count)` so the top-priority spec surfaces; maximize-count ranks by `(count, priorityScore)` so the highest count wins. Recommended badges follow the same rule. +- **"Recommended" badge per set** — each elective set now highlights the choice with the best ceiling outcome under the current mode. Rendered inline next to the course name to keep button height stable. +- **Color-coded spec tags** — the per-cell outcome list and the Top Plans badges now use a fixed per-spec color palette so each specialization is visually identifiable at a glance. +- **"Top outcome if picked ↓" caption** — added a small column header on each open elective set so the spec tags are clearly identified as decision-tree outcomes (not the course's own qualifications). +- **Visual progress bar** — Top Plans header now shows a progress bar with `iterations / total · NN%` while the search runs, replacing the earlier text-only count. +- **Per-cell streaming indicators** — courses that haven't been evaluated yet show a "searching" pulse instead of misleading "0 specs"; cells transition to their final value as the search completes. +- **Per-set spinner** — each elective set heading shows a spinner while at least one of its choices is still unevaluated. + ## v1.3.0 — 2026-05-09 ### Changes diff --git a/app/src/App.tsx b/app/src/App.tsx index 719791f..a326274 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -19,6 +19,7 @@ function App() { treeLoading, topPlans, topPlansPartial, + searchProgress, openSetIds, selectedCourseIds, disabledCourseIds, @@ -136,6 +137,7 @@ function App() { plans={topPlans} partial={topPlansPartial} loading={treeLoading} + progress={searchProgress} onAdopt={adoptPlan} /> + {specId} + + ); +} + // Reverse map: courseId → specialization names that require it const requiredForSpec: Record = {}; for (const spec of SPECIALIZATIONS) { @@ -179,6 +199,8 @@ interface CourseSelectionProps { treeResults: SetAnalysis[]; treeLoading: boolean; disabledCourseIds: Set; + ranking: string[]; + mode: OptimizationMode; onPin: (setId: string, courseId: string) => void; onUnpin: (setId: string) => void; onClearAll: () => void; @@ -191,6 +213,8 @@ function ElectiveSet({ analysis, loading, disabledCourseIds, + scorer, + mode, onPin, onUnpin, openPopoverId, @@ -205,6 +229,8 @@ function ElectiveSet({ analysis?: SetAnalysis; loading: boolean; disabledCourseIds: Set; + scorer: (specs: string[]) => number; + mode: OptimizationMode; onPin: (courseId: string) => void; onUnpin: () => void; openPopoverId: string | null; @@ -223,6 +249,30 @@ function ElectiveSet({ ); const hasHighImpact = analysis && analysis.impact > 0; + // Determine the recommended choice. Mode-dependent comparison matches the + // top-K comparator: priority-order ranks by (score, count); max-count by (count, score). + let recommendedCourseId: string | null = null; + if (analysis && analysis.choices.length > 0) { + let best: { id: string; count: number; score: number } | null = null; + for (const ch of analysis.choices) { + if (!ch.evaluated) continue; + const score = scorer(ch.ceilingSpecs); + const isBetter = + !best || + (mode === 'priority-order' + ? score > best.score || (score === best.score && ch.ceilingCount > best.count) + : ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score)); + if (isBetter) { + best = { id: ch.courseId, count: ch.ceilingCount, score }; + } + } + if (best && (best.count > 0 || best.score > 0)) recommendedCourseId = best.id; + } + + // Per-set still-searching indicator: search in progress AND at least one cell unevaluated + const setSearching = + loading && !!analysis && analysis.choices.some((c) => !c.evaluated); + return (
-

- {setName} +

+ {setName} + {!isPinned && setSearching && ( + + )} {!isPinned && hasHighImpact && ( - high impact + high impact )}

+ {!isPinned && ( + + top outcome if picked ↓ + + )} {isPinned && (