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 && (