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).
This commit is contained in:
@@ -1,13 +1,33 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { Term } from '../data/types';
|
||||
import { makePriorityScorer } from '../solver/priority';
|
||||
import { specColor } from '../data/specColors';
|
||||
import type { OptimizationMode, Term } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
|
||||
function SpecTag({ specId }: { specId: string }) {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
title={specNameById[specId] ?? specId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
||||
padding: '1px 5px', borderRadius: '3px',
|
||||
background: c.bg, color: c.fg, border: `1px solid ${c.border}`,
|
||||
whiteSpace: 'nowrap', lineHeight: '1.3',
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse map: courseId → specialization names that require it
|
||||
const requiredForSpec: Record<string, string[]> = {};
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
@@ -179,6 +199,8 @@ interface CourseSelectionProps {
|
||||
treeResults: SetAnalysis[];
|
||||
treeLoading: boolean;
|
||||
disabledCourseIds: Set<string>;
|
||||
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<string>;
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -236,12 +286,27 @@ function ElectiveSet({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
|
||||
{setName}
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{setName}</span>
|
||||
{!isPinned && setSearching && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
||||
border: '2px solid #cbd5e1', borderTopColor: '#6366f1',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} aria-label="searching" />
|
||||
)}
|
||||
{!isPinned && hasHighImpact && (
|
||||
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
|
||||
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||
)}
|
||||
</h4>
|
||||
{!isPinned && (
|
||||
<span style={{
|
||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
top outcome if picked ↓
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<button
|
||||
onClick={onUnpin}
|
||||
@@ -282,6 +347,8 @@ function ElectiveSet({
|
||||
const ceiling = ceilingMap.get(course.id);
|
||||
const reqFor = requiredForSpec[course.id];
|
||||
const showSkeleton = loading && !analysis;
|
||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||
const isRecommended = recommendedCourseId === course.id;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
return (
|
||||
<button
|
||||
@@ -319,6 +386,20 @@ function ElectiveSet({
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
style={{
|
||||
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>Recommended</span>
|
||||
</span>
|
||||
)}
|
||||
{!isUnavailable && hasInfo && (
|
||||
<span
|
||||
role="button"
|
||||
@@ -371,28 +452,28 @@ function ElectiveSet({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!isUnavailable && showSkeleton ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '60px',
|
||||
height: '14px',
|
||||
borderRadius: '3px',
|
||||
background: 'linear-gradient(90deg, #e5e7eb 25%, #f0f0f0 50%, #e5e7eb 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
) : !isUnavailable && ceiling ? (
|
||||
{!isUnavailable && (showSkeleton || cellSearching) ? (
|
||||
<span style={{
|
||||
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
|
||||
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
|
||||
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
}}>
|
||||
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
|
||||
{ceiling.ceilingSpecs.length > 0 && (
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
|
||||
({ceiling.ceilingSpecs.join(', ')})
|
||||
<span style={{
|
||||
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
||||
}} />
|
||||
searching
|
||||
</span>
|
||||
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
|
||||
}}>
|
||||
{ceiling.ceilingSpecs.length === 0 ? (
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
no specs
|
||||
</span>
|
||||
) : (
|
||||
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -411,9 +492,14 @@ function ElectiveSet({
|
||||
);
|
||||
}
|
||||
|
||||
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
|
||||
const skeletonStyle = `
|
||||
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
@keyframes cell-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
|
||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||
|
||||
@@ -493,6 +579,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
analysis={treeBySet.get(set.id)}
|
||||
loading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
scorer={scorer}
|
||||
mode={mode}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
|
||||
Reference in New Issue
Block a user