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,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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<CourseSelection
|
||||
@@ -143,6 +145,8 @@ function App() {
|
||||
treeResults={treeResults}
|
||||
treeLoading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
ranking={state.ranking}
|
||||
mode={state.mode}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
onClearAll={clearAll}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { specColor } from '../data/specColors';
|
||||
import type { PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
const setNameById: Record<string, string> = {};
|
||||
@@ -13,15 +14,31 @@ interface TopPlansProps {
|
||||
plans: PlanOutcome[];
|
||||
partial: boolean;
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlansProps) {
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
|
||||
const pct = progress && progress.iterationsTotal > 0
|
||||
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
let staticText: string | null = null;
|
||||
if (!loading && partial && progress) {
|
||||
staticText = `Search incomplete · cap hit at ${formatNum(progress.iterations)}`;
|
||||
} else if (!loading && progress) {
|
||||
staticText = `Search complete · ${formatNum(progress.iterations)} explored`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
|
||||
Top Plans
|
||||
{visible.length > 0 && (
|
||||
@@ -30,12 +47,36 @@ export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{partial && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e' }}>
|
||||
(showing best of search; result is partial)
|
||||
{staticText && (
|
||||
<span style={{ fontSize: '11px', color: partial ? '#92400e' : '#888' }}>
|
||||
{staticText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && progress && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
borderRadius: '3px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, height: '100%',
|
||||
width: `${pct}%`, background: '#3b82f6',
|
||||
transition: 'width 150ms ease-out',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: '10px', color: '#888', marginTop: '2px',
|
||||
}}>
|
||||
<span>Searching…</span>
|
||||
<span>
|
||||
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
|
||||
{' · '}{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
Searching for high-priority plans…
|
||||
@@ -87,23 +128,26 @@ function PlanRow({
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 600, minWidth: '20px' }}>
|
||||
#{rank}
|
||||
</span>
|
||||
{plan.achievedSpecs.map((specId) => (
|
||||
{plan.achievedSpecs.map((specId) => {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
key={specId}
|
||||
title={specNameById[specId]}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #bbf7d0',
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
border: `1px solid ${c.border}`,
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
|
||||
score {plan.priorityScore}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SpecColor {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export const SPEC_COLORS: Record<string, SpecColor> = {
|
||||
BNK: { bg: '#dbeafe', fg: '#1d4ed8', border: '#bfdbfe' },
|
||||
BRM: { bg: '#fce7f3', fg: '#be185d', border: '#fbcfe8' },
|
||||
CRF: { bg: '#cffafe', fg: '#0e7490', border: '#a5f3fc' },
|
||||
EMT: { bg: '#ffedd5', fg: '#c2410c', border: '#fed7aa' },
|
||||
ENT: { bg: '#fef9c3', fg: '#a16207', border: '#fef08a' },
|
||||
FIN: { bg: '#e0e7ff', fg: '#3730a3', border: '#c7d2fe' },
|
||||
FIM: { bg: '#ccfbf1', fg: '#0f766e', border: '#99f6e4' },
|
||||
GLB: { bg: '#ecfccb', fg: '#4d7c0f', border: '#d9f99d' },
|
||||
HCR: { bg: '#d1fae5', fg: '#047857', border: '#a7f3d0' },
|
||||
LCM: { bg: '#ede9fe', fg: '#6d28d9', border: '#ddd6fe' },
|
||||
MGT: { bg: '#f3e8ff', fg: '#7e22ce', border: '#e9d5ff' },
|
||||
MKT: { bg: '#ffe4e6', fg: '#be123c', border: '#fecdd3' },
|
||||
MTO: { bg: '#fed7aa', fg: '#9a3412', border: '#fdba74' },
|
||||
SBI: { bg: '#bbf7d0', fg: '#166534', border: '#86efac' },
|
||||
STR: { bg: '#e0e7ff', fg: '#4338ca', border: '#a5b4fc' },
|
||||
};
|
||||
|
||||
const FALLBACK: SpecColor = { bg: '#f3f4f6', fg: '#374151', border: '#e5e7eb' };
|
||||
|
||||
export function specColor(specId: string): SpecColor {
|
||||
return SPEC_COLORS[specId] ?? FALLBACK;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
compareOutcomes,
|
||||
selectPriorityTarget,
|
||||
reorderForTarget,
|
||||
MAX_TREE_ITERATIONS,
|
||||
reorderByReachableQualCount,
|
||||
PROGRESS_THROTTLE_MS,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { computeUpperBounds } from '../feasibility';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { ELECTIVE_SETS } from '../../data/electiveSets';
|
||||
@@ -143,7 +145,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
);
|
||||
expect(result.topK.length).toBeGreaterThan(0);
|
||||
expect(result.topK[0].achievedSpecs).toContain('HCR');
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
|
||||
const result = searchDecisionTree(
|
||||
@@ -158,7 +160,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3');
|
||||
const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml');
|
||||
expect(aml?.ceilingSpecs).toContain('HCR');
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — ordering and streaming', () => {
|
||||
@@ -194,12 +196,12 @@ describe('searchDecisionTree — ordering and streaming', () => {
|
||||
const curr = snapshots[i][0];
|
||||
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — termination', () => {
|
||||
it('saturation stops the search when topK converges', () => {
|
||||
// Tiny scenario: should saturate within a few hundred iterations
|
||||
describe('searchDecisionTree — exhaustive termination', () => {
|
||||
it('exhausts the cartesian product when within the cap', () => {
|
||||
// 2 open sets, ~16 leaves total
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
@@ -223,31 +225,122 @@ describe('searchDecisionTree — termination', () => {
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBeLessThan(MAX_TREE_ITERATIONS);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
// Every cell in every set is evaluated after exhaustion
|
||||
for (const setAnalysis of result.setAnalyses) {
|
||||
for (const choice of setAnalysis.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke', () => {
|
||||
it('user scenario completes in < 5s for K=10', () => {
|
||||
describe('searchDecisionTree — mode-dependent ordering', () => {
|
||||
it('reorderByReachableQualCount puts generalist courses first when many specs are reachable', () => {
|
||||
// All sets open so most specs are reachable; gives the heuristic something to weight against
|
||||
const allSets = ELECTIVE_SETS.map((s) => s.id);
|
||||
const upper = computeUpperBounds([], allSets, cancelledIds);
|
||||
const reordered = reorderByReachableQualCount('fall3', upper, cancelledIds);
|
||||
// Climate Finance (BNK,CRF,FIN,FIM,GLB,SBI = 6 quals) should beat
|
||||
// Corporate Governance (LCM,MGT,SBI,STR-S1 = 4 quals)
|
||||
const climateIdx = reordered.findIndex((c) => c.id === 'fall3-climate-finance');
|
||||
const corpGovIdx = reordered.findIndex((c) => c.id === 'fall3-corporate-governance');
|
||||
expect(climateIdx).toBeLessThan(corpGovIdx);
|
||||
});
|
||||
|
||||
it('maximize-count first leaf uses the generalist-ordered choices', () => {
|
||||
// Capture the first leaf evaluated in maximize-count mode by inspecting
|
||||
// the first onChoiceUpdate event for each set
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const firstChoiceBySet: Record<string, string> = {};
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10,
|
||||
{
|
||||
onChoiceUpdate: (setId, analysis) => {
|
||||
if (!(setId in firstChoiceBySet)) {
|
||||
// The first cell flipped to evaluated within this update is the
|
||||
// course we're after — but the analysis sends the whole choices
|
||||
// array. Find the first evaluated course in declaration order.
|
||||
const firstEval = analysis.choices.find((c) => c.evaluated);
|
||||
if (firstEval) firstChoiceBySet[setId] = firstEval.courseId;
|
||||
}
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
// For fall3 in max-count mode: climate-finance (most generalist) should be the first
|
||||
expect(firstChoiceBySet['fall3']).toBe('fall3-climate-finance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — evaluated flag transitions', () => {
|
||||
it('cells start unevaluated and flip true after first leaf containing them', () => {
|
||||
const PINNED = [
|
||||
'spr1-collaboration', 'spr2-financial-services', 'spr3-mergers-acquisitions',
|
||||
'spr4-fintech', 'spr5-corporate-finance', 'sum1-collaboration',
|
||||
'sum2-innovation-design', 'sum3-valuation', 'fall1-private-equity', 'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const result = searchDecisionTree(
|
||||
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10, undefined, cancelledIds,
|
||||
);
|
||||
for (const sa of result.setAnalyses) {
|
||||
for (const choice of sa.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — progress events', () => {
|
||||
it('emits progress events throttled to PROGRESS_THROTTLE_MS', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const timestamps: number[] = [];
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10,
|
||||
{ onProgress: () => timestamps.push(Date.now()) },
|
||||
cancelledIds,
|
||||
);
|
||||
// At least one progress emit (the final one always fires)
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
// Consecutive emits respect the throttle (allow 5ms jitter)
|
||||
for (let i = 1; i < timestamps.length - 1; i++) {
|
||||
const delta = timestamps[i] - timestamps[i - 1];
|
||||
expect(delta).toBeGreaterThanOrEqual(PROGRESS_THROTTLE_MS - 5);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke (exhaustive)', () => {
|
||||
it('user scenario completes in under 60s for K=10', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical', 'spr4-fintech', 'spr5-corporate-finance', 'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const start = Date.now();
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
const result = searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
expect(elapsed).toBeLessThan(60_000);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
+104
-33
@@ -10,6 +10,7 @@ export interface ChoiceOutcome {
|
||||
courseName: string;
|
||||
ceilingCount: number;
|
||||
ceilingSpecs: string[];
|
||||
evaluated: boolean;
|
||||
}
|
||||
|
||||
export interface SetAnalysis {
|
||||
@@ -30,17 +31,18 @@ export interface SearchResult {
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
}
|
||||
|
||||
export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
export const MAX_TREE_ITERATIONS = 10000;
|
||||
export const SATURATION_LIMIT = 500;
|
||||
export const PROGRESS_THROTTLE_MS = 100;
|
||||
|
||||
function variance(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
@@ -76,6 +78,35 @@ export function reorderForTarget(
|
||||
return [...qualifying, ...others];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a set's courses so those qualifying for the most reachable specs
|
||||
* (upperBound >= 9) come first. Stable sort: ties keep declaration order.
|
||||
* Used by maximize-count mode to surface generalist courses early.
|
||||
*/
|
||||
export function reorderByReachableQualCount(
|
||||
setId: string,
|
||||
upperBounds: Record<string, number>,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): Course[] {
|
||||
const courses = coursesBySet[setId].filter(
|
||||
(c) => !excludedCourseIds?.has(c.id),
|
||||
);
|
||||
// Decorate-sort-undecorate for stability
|
||||
return courses
|
||||
.map((course, idx) => ({
|
||||
course,
|
||||
idx,
|
||||
score: course.qualifications.filter(
|
||||
(q) => (upperBounds[q.specId] ?? 0) >= CREDIT_THRESHOLD,
|
||||
).length,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.idx - b.idx;
|
||||
})
|
||||
.map((x) => x.course);
|
||||
}
|
||||
|
||||
export function assignmentKey(assignments: Record<string, string>): string {
|
||||
return Object.keys(assignments)
|
||||
.sort()
|
||||
@@ -104,16 +135,32 @@ export class BoundedRankedList<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) {
|
||||
return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
}
|
||||
if (a.priorityScore !== b.priorityScore) {
|
||||
return b.priorityScore - a.priorityScore;
|
||||
/**
|
||||
* Comparator for plan outcomes. Mode-dependent ordering:
|
||||
* - priority-order mode: (priorityScore desc, count desc, key asc)
|
||||
* - maximize-count mode: (count desc, priorityScore desc, key asc)
|
||||
* Returns negative if a is better, positive if b is better.
|
||||
*/
|
||||
export function makeOutcomeComparator(
|
||||
mode: OptimizationMode,
|
||||
): (a: PlanOutcome, b: PlanOutcome) => number {
|
||||
return (a, b) => {
|
||||
if (mode === 'priority-order') {
|
||||
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
} else {
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
}
|
||||
return assignmentKey(a.courseAssignments).localeCompare(
|
||||
assignmentKey(b.courseAssignments),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Default count-first comparator, retained for backward compatibility with tests. */
|
||||
export function compareOutcomes(a: PlanOutcome, b: PlanOutcome): number {
|
||||
return makeOutcomeComparator('maximize-count')(a, b);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
@@ -122,10 +169,19 @@ interface CeilingComparable {
|
||||
key: string;
|
||||
}
|
||||
|
||||
function compareCeiling(a: CeilingComparable, b: CeilingComparable): number {
|
||||
function makeCeilingComparator(
|
||||
mode: OptimizationMode,
|
||||
): (a: CeilingComparable, b: CeilingComparable) => number {
|
||||
return (a, b) => {
|
||||
if (mode === 'priority-order') {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
} else {
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
};
|
||||
}
|
||||
|
||||
export function searchDecisionTree(
|
||||
@@ -146,13 +202,18 @@ export function searchDecisionTree(
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
// Initialize per-set analyses with empty ceilings
|
||||
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
const orderedCoursesPerSet: Record<string, Course[]> = {};
|
||||
let iterationsTotal = 1;
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
const ordered =
|
||||
mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
iterationsTotal *= ordered.length || 1;
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
@@ -162,24 +223,30 @@ export function searchDecisionTree(
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
evaluated: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Track ceiling key per choice for stable tiebreaks
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const ceilingComparator = makeCeilingComparator(mode);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
let iterations = 0;
|
||||
let iterationsSinceTopKChange = 0;
|
||||
let partial = false;
|
||||
let halted = false;
|
||||
const partial = false;
|
||||
let lastProgressEmit = 0;
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): boolean {
|
||||
iterations++;
|
||||
if (iterations > MAX_TREE_ITERATIONS) {
|
||||
partial = true;
|
||||
return true;
|
||||
function emitProgress() {
|
||||
if (!callbacks?.onProgress) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressEmit >= PROGRESS_THROTTLE_MS) {
|
||||
lastProgressEmit = now;
|
||||
callbacks.onProgress(iterations, iterationsTotal);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||
iterations++;
|
||||
|
||||
const courses: string[] = [];
|
||||
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
||||
@@ -195,17 +262,15 @@ export function searchDecisionTree(
|
||||
};
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
iterationsSinceTopKChange = 0;
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
} else {
|
||||
iterationsSinceTopKChange++;
|
||||
}
|
||||
|
||||
// Per-set ceiling updates
|
||||
// Per-set ceiling + evaluated-flag updates
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = accumulated[setId];
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
|
||||
const wasEvaluated = choice.evaluated;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
@@ -217,36 +282,38 @@ export function searchDecisionTree(
|
||||
score,
|
||||
key: aKey,
|
||||
};
|
||||
if (compareCeiling(candidate, existing) < 0) {
|
||||
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
||||
if (ceilingImproved) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = result.achieved;
|
||||
choiceKey[currentKey] = aKey;
|
||||
// Recompute impact lazily for emit
|
||||
}
|
||||
// Mark evaluated regardless of improvement
|
||||
choice.evaluated = true;
|
||||
|
||||
if (!wasEvaluated || ceilingImproved) {
|
||||
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
|
||||
analysis.impact = impact;
|
||||
const updated: SetAnalysis = {
|
||||
...analysis,
|
||||
impact,
|
||||
choices: analysis.choices.map((c) => ({ ...c })),
|
||||
};
|
||||
setAnalyses[setId].impact = impact;
|
||||
callbacks?.onChoiceUpdate?.(setId, updated);
|
||||
}
|
||||
}
|
||||
|
||||
if (iterationsSinceTopKChange >= SATURATION_LIMIT) return true;
|
||||
return false;
|
||||
emitProgress();
|
||||
}
|
||||
|
||||
function dfs(setIdx: number, accumulated: Record<string, string>) {
|
||||
if (halted) return;
|
||||
if (setIdx >= openSetIds.length) {
|
||||
if (evaluateLeaf(accumulated)) halted = true;
|
||||
evaluateLeaf(accumulated);
|
||||
return;
|
||||
}
|
||||
const setId = openSetIds[setIdx];
|
||||
const courses = orderedCoursesPerSet[setId];
|
||||
for (const course of courses) {
|
||||
if (halted) return;
|
||||
accumulated[setId] = course.id;
|
||||
dfs(setIdx + 1, accumulated);
|
||||
}
|
||||
@@ -257,6 +324,9 @@ export function searchDecisionTree(
|
||||
dfs(0, {});
|
||||
}
|
||||
|
||||
// Final progress emit so consumers see the completion count
|
||||
if (callbacks?.onProgress) callbacks.onProgress(iterations, iterationsTotal);
|
||||
|
||||
// Final impact recomputation + sort
|
||||
for (const a of Object.values(setAnalyses)) {
|
||||
a.impact = variance(a.choices.map((c) => c.ceilingCount));
|
||||
@@ -272,6 +342,7 @@ export function searchDecisionTree(
|
||||
setAnalyses: sortedAnalyses,
|
||||
partial,
|
||||
iterations,
|
||||
iterationsTotal,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export function useAppState() {
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
|
||||
const [topPlansPartial, setTopPlansPartial] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -127,11 +128,13 @@ export function useAppState() {
|
||||
setTreeResults([]);
|
||||
setTopPlans([]);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress(null);
|
||||
setTreeLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTreeLoading(true);
|
||||
setSearchProgress(null);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
// Terminate previous worker if still running
|
||||
@@ -150,10 +153,13 @@ export function useAppState() {
|
||||
setTreeResults(Array.from(setMap.values()));
|
||||
} else if (e.data.type === 'topKUpdate') {
|
||||
setTopPlans(e.data.topK);
|
||||
} else if (e.data.type === 'progress') {
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
} else if (e.data.type === 'allComplete') {
|
||||
setTreeResults(e.data.setAnalyses);
|
||||
setTopPlans(e.data.topK);
|
||||
setTopPlansPartial(e.data.partial);
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
setTreeLoading(false);
|
||||
worker.terminate();
|
||||
workerRef.current = null;
|
||||
@@ -203,6 +209,7 @@ export function useAppState() {
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
searchProgress,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
|
||||
@@ -15,12 +15,14 @@ export interface WorkerRequest {
|
||||
export type WorkerResponse =
|
||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
||||
| {
|
||||
type: 'allComplete';
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
};
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
@@ -53,6 +55,10 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onProgress: (iterations, iterationsTotal) => {
|
||||
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
},
|
||||
excludedSet,
|
||||
);
|
||||
@@ -63,6 +69,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
setAnalyses: result.setAnalyses,
|
||||
partial: result.partial,
|
||||
iterations: result.iterations,
|
||||
iterationsTotal: result.iterationsTotal,
|
||||
};
|
||||
self.postMessage(final);
|
||||
};
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify('1.3.0'),
|
||||
__APP_VERSION__: JSON.stringify('1.3.1'),
|
||||
__APP_VERSION_DATE__: JSON.stringify('2026-05-09'),
|
||||
},
|
||||
server: {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-09
|
||||
@@ -0,0 +1,3 @@
|
||||
# decision-tree-exhaustive-search
|
||||
|
||||
Drop saturation termination; run exhaustive DFS over the open-set cartesian product. Add mode-dependent child ordering (qualifies-for-most-reachable-specs in maximize-count mode, target-first in priority-order mode). Distinguish unevaluated cells from evaluated-zero in the per-set table. Add per-set + global progress indication and a 'Recommended' marker per set.
|
||||
@@ -0,0 +1,99 @@
|
||||
## Context
|
||||
|
||||
v1.3.0 introduced a streaming decision-tree search (`searchDecisionTree`) with two early-termination criteria: a hard iteration cap (`MAX_TREE_ITERATIONS = 10000`) and a saturation criterion (`SATURATION_LIMIT = 500` iterations of no top-K change). The saturation criterion fires too eagerly because the top-K updates only when a *strictly better* outcome is inserted; many leaves produce duplicate outcome classes that don't update top-K but still increment the saturation counter.
|
||||
|
||||
Two visible consequences in the user's testing:
|
||||
|
||||
1. Per-set ceiling table shows "0 specs" for many courses. Root cause: cells are initialized with `{count: 0, specs: []}` and only updated when a leaf containing them is evaluated. The DFS, ordered by the priority-target heuristic, exhausts target-favoring branches first. Saturation fires before the DFS backtracks to non-target choices in early sets.
|
||||
2. Top Plans in maximize-count mode shows only 2-spec outcomes when 3-spec combinations exist. Root cause: the same saturation fires before the search reaches the part of the tree where 3-spec-feasible combinations live (typically generalist-course-heavy combinations).
|
||||
|
||||
Investigation in `app/src/solver/decisionTree.ts:204-225` confirmed both behaviors. A diagnostic showed the user's reproduction case (8 open sets, 49,152 leaves) saturates at ~500 iterations, leaving most cells unevaluated.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Per-set ceiling cells reflect the true best outcome for every (set, course) pair after search completes
|
||||
- Top-K reflects the genuinely-best plans achievable for the given pin/ranking, in either mode
|
||||
- UI distinguishes "still searching" from "search complete, this course achieves nothing"
|
||||
- Search remains responsive: high-quality results appear in the stream within the first ~hundred iterations even though full search takes seconds
|
||||
|
||||
**Non-Goals:**
|
||||
- Sub-second exhaustive search for the 8-open-set worst case (50K leaves × ~1ms LP = ~50s is acceptable in a worker)
|
||||
- Replacing the LP solver or re-architecting the optimizer
|
||||
- Caching across runs
|
||||
- Configurable iteration cap from the UI
|
||||
|
||||
## Decisions
|
||||
|
||||
### Drop saturation termination entirely (not "make it smarter")
|
||||
|
||||
The user explicitly chose Approach A: exhaustive. Smarter saturation criteria (e.g., "stable for N iters AND every cell visited at least once") add complexity without reaching demonstrated correctness — there's always a pathological combination that defeats the heuristic. Exhaustive is simpler, demonstrably correct, and feasible in a worker.
|
||||
|
||||
**Alternative considered:** Two-phase search (fast heuristic + background exhaustive sweep). Rejected — added complexity (phase transition events, partial state), and the user's preference for "exhaustive" was explicit. The progress UI absorbs the same UX pain (user sees the search running) without the implementation cost.
|
||||
|
||||
### Mode-dependent enumeration ordering
|
||||
|
||||
The current target-first heuristic biases toward priority-order mode's expected outcome (high-priority spec achieved early). For maximize-count mode, ordering by qualification breadth is a better fit because generalist courses lead to higher-count plans. Both heuristics are cheap to compute (one upper-bounds map lookup, then a per-course count) and run once per analysis.
|
||||
|
||||
For maximize-count, the score per course is `count of (specId in course.qualifications) where upperBounds[specId] >= 9`. Sorting children by this score descending puts the generalist courses first. Stable sort keeps declaration order on ties.
|
||||
|
||||
**Alternative considered:** A single unified ordering (e.g., always order by qualification breadth). Rejected — for priority-order mode, the user's priority is the meaningful signal; using breadth would suppress the priority spec early in the stream.
|
||||
|
||||
### `evaluated: boolean` on `ChoiceOutcome`
|
||||
|
||||
Adds one boolean per (set, course) cell — negligible overhead. Cleaner than a sentinel value (e.g., `ceilingCount = -1`) that consumers might forget to handle and would need to be filtered everywhere ceilingCount is read. Boolean has obvious semantics in the UI ("not yet known" vs "known, value is 0").
|
||||
|
||||
**Alternative considered:** Omit cells entirely until evaluated. Rejected — the UI needs the courseName to render the row; restructuring to fetch course names from `coursesBySet` would push more responsibility into the renderer for no clear win.
|
||||
|
||||
### Per-set "Recommended" derivation in UI, not in worker
|
||||
|
||||
The recommended choice is a function of `analysis.choices` and the comparator. Computing in the UI keeps the worker protocol simple (no new field), avoids a duplicate computation, and lets the UI re-render cheaply on each choiceUpdate.
|
||||
|
||||
The comparator: `(ceilingCount desc, priorityScore desc)`. Same as the top-K. The "Recommended" course in a set is the one whose ceiling best matches the user's overall objective.
|
||||
|
||||
### Throttled `progress` event (≈100ms)
|
||||
|
||||
Without throttling, the worker would emit a progress event per iteration — 50,000 events × 1ms = 50s of message overhead. With ~100ms throttling: ~500 events per search, each tiny. Implementation: track `lastProgressEmit` timestamp; emit if `Date.now() - lastProgressEmit >= 100`.
|
||||
|
||||
**Alternative considered:** No progress events; rely on `topKUpdate` for activity signal. Rejected — top-K updates fire only when something changes; long stretches of "exploring duplicates" would look like a frozen UI.
|
||||
|
||||
### Mode-aware comparator (emerged during implementation)
|
||||
|
||||
After dropping saturation, exhaustive search surfaces 3-spec non-HCR plans that beat 2-spec HCR plans on the original (count, priorityScore) comparator. This conflicted with v1.3.0 spec scenarios that asserted HCR appears at `topK[0]` in priority-order mode with HCR ranked first. Resolution: make both the top-K and per-cell ceiling comparators mode-dependent.
|
||||
|
||||
- `priority-order` mode: `(priorityScore desc, count desc, key asc)` — surfaces the user's top-priority spec even when higher-count alternatives exist
|
||||
- `maximize-count` mode: `(count desc, priorityScore desc, key asc)` — surfaces the maximum number of specs achievable
|
||||
|
||||
Both comparators share the deterministic `assignmentKey` tiebreaker for streaming stability. The CourseSelection "Recommended" badge uses the same mode-dependent rule so cell recommendations align with the top-K ranking.
|
||||
|
||||
**Alternative considered:** Keep the count-first comparator and let exhaustive search reveal high-count alternatives. Rejected — contradicts user-stated intent ("HCR top priority should surface") and breaks v1.3.0 spec scenarios.
|
||||
|
||||
### `MAX_TREE_ITERATIONS = 100,000`
|
||||
|
||||
Empirically, 8-open-set worst case is ~50K leaves. 100K provides 2× headroom. Larger scenarios (10+ open sets) would still be capped, with `partial: true` displayed. The fallback to "empty choices" already exists for `openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION` (= 9), so this cap rarely fires in practice.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **50s search feels slow** → Progress UI + streamed top-K make it feel active; user can adopt a plan partway through if they like what's shown
|
||||
- **Worker CPU usage during search** → Acceptable; runs in a worker thread, doesn't block UI; user can change pins to abort and restart
|
||||
- **Throttled progress means iteration count "jumps"** → Cosmetic only; UI doesn't depend on monotonic small steps
|
||||
- **`evaluated: false` initial state for every cell** → Slightly verbose payloads; choiceUpdate already sends the full set's choices array, so the change is one boolean field per cell (negligible)
|
||||
- **Mode switch mid-search** → Current behavior already terminates and restarts the worker on any pin/ranking/mode change; unchanged
|
||||
- **Tests need amendments** → Saturation tests removed (3 tests); exhaustion test added; mode-ordering test added; per-cell evaluated transition test added
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Single-PR change. No data migration. Steps:
|
||||
|
||||
1. Algorithm + worker + state changes; tests updated
|
||||
2. UI updates: per-cell evaluated rendering, per-set spinner, global progress, recommended badge
|
||||
3. Browser-verify both modes against the v1.3.0 reproduction scenario; confirm exhaustive search completes and all cells are populated
|
||||
4. Bump version (`1.3.1`); CHANGELOG entry; ship
|
||||
|
||||
Rollback: revert the change; v1.3.0 behavior restored. No persistent state to migrate.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Recommended badge for ties** — if two choices in a set have identical `(count, priorityScore)`, currently the comparator's deterministic tiebreaker (assignmentKey) picks one. UI shows just one Recommended. Acceptable for v1; could be revisited if confusing.
|
||||
- **Should "Recommended" still show before search completes** — derived from current ceilings, so it updates as the search streams. Possibly confusing if the recommendation flips mid-search. Initial behavior: show as soon as any choice has `evaluated: true`; let it update with the stream.
|
||||
- **Future: progressive `partial` flag during search** — out of scope. Today, `partial` only matters at the cap, which fires rarely.
|
||||
@@ -0,0 +1,42 @@
|
||||
## Why
|
||||
|
||||
Real-world testing of v1.3.0 surfaced two related defects in the new decision-tree streaming search:
|
||||
|
||||
1. **Many per-set choices show "0 specs"** — the saturation termination (top-K stable for 500 iterations) fires after exploring only the heuristic-favored part of the tree. Courses in early sets that don't qualify for the priority target are never the chosen course in any evaluated leaf, so their ceilings remain at the initial `{count: 0, specs: []}` and render as a misleading "0 specs".
|
||||
2. **Top plans are not exhaustive in maximize-count mode** — same root cause: saturation accepts "no improvement" too eagerly, since many leaves yield the same already-found outcome class. Higher-count plans (e.g., `[BNK, CRF, FIN]` triples) that exist deeper in the search are never reached.
|
||||
|
||||
Both behaviors mislead the user: the per-set table claims a course leads to no specs when in fact it was never evaluated, and the Top Plans panel hides plans that genuinely exist. The fix is to drop the early-termination heuristic and run an exhaustive search, with mode-dependent enumeration ordering so the most-likely-good outcomes still appear early in the stream.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Remove `SATURATION_LIMIT` early-termination. Search runs the full cartesian product unless `MAX_TREE_ITERATIONS` (raised to 100,000 as safety cap) fires.
|
||||
- Add **mode-dependent child ordering** at every DFS level:
|
||||
- `priority-order` mode: keep the existing `priorityTarget`-qualifying-first heuristic.
|
||||
- `maximize-count` mode: new heuristic — order children by descending count of qualifications they hold for *reachable* specs (specs whose upper bound ≥ 9). "Generalist" courses like Climate Finance (BNK/CRF/FIN/FIM/GLB/SBI) come before specialist courses, surfacing high-count outcomes early.
|
||||
- Distinguish **unevaluated** cells from **evaluated, zero-spec** cells in `ChoiceOutcome`. New field `evaluated: boolean` (default `false`, set `true` on first leaf containing the (set, course) pair). UI renders unevaluated cells with a subtle searching indicator, not "0 specs".
|
||||
- Add **per-set progress indicator** — a small spinner next to the set name shown when `loading` is true and any choice in that set is still unevaluated; clears when every choice has been evaluated or when search completes.
|
||||
- Add **global progress indicator** in the Top Plans panel — `"Searching… N / Total explored"` with running counts, then `"Search complete · N explored"` when done. If `partial: true`, show `"Search incomplete · cap hit at N"`.
|
||||
- Add **"Recommended" marker** per set — the choice with the best `(ceilingCount, priorityScore)` per the same comparator the top-K uses; rendered as a small badge on the recommended row. Derived in the UI from `analysis.choices` (worker protocol unchanged on this front).
|
||||
- Worker emits a new `progress` event throttled to ~100ms intervals, carrying `{ iterations, iterationsTotal }`. Avoids per-iteration message flood while keeping the UI responsive.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_None — this extends the existing optimization engine._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `optimization-engine`: drop saturation termination requirement; require exhaustive search up to a safety cap; add mode-dependent ordering, evaluated/unevaluated cell state, per-set + global progress events, and a per-set recommended-choice derivation.
|
||||
|
||||
## Impact
|
||||
|
||||
- `app/src/solver/decisionTree.ts` — drop `SATURATION_LIMIT`; raise `MAX_TREE_ITERATIONS` to 100,000; add `reorderByReachableQualCount` helper for maximize-count mode; gate the chosen reorder strategy by `mode`; add `evaluated: boolean` to `ChoiceOutcome` and set it on first leaf containing the pair; emit throttled `progress` events; remove saturation logic
|
||||
- `app/src/workers/decisionTree.worker.ts` — add `progress` event type to the tagged union; throttle progress emission
|
||||
- `app/src/state/appState.ts` — track `searchProgress: { iterations, iterationsTotal } | null` slice; consume `progress` events
|
||||
- `app/src/components/TopPlans.tsx` — render global progress text in the header
|
||||
- `app/src/components/CourseSelection.tsx` — per-set spinner (next to set name); per-cell unevaluated rendering (skeleton/dot, not "0 specs"); "Recommended" badge on the best choice per set
|
||||
- `app/src/solver/__tests__/searchDecisionTree.test.ts` — remove saturation tests; add exhaustion test (asserts every (set, course) cell has `evaluated: true` after completion); add mode-dependent ordering test (maximize-count chooses generalist courses first); add unevaluated→evaluated transition test
|
||||
- `app/vite.config.ts` — bump to `1.3.1` (or `1.4.0` if user wants minor; default patch)
|
||||
- `CHANGELOG.md` — release entry
|
||||
- No data file changes; no schema migration
|
||||
@@ -0,0 +1,92 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Mode-dependent enumeration ordering
|
||||
The decision-tree search SHALL select its DFS child-ordering heuristic based on the optimization mode. In `priority-order` mode, children at each level SHALL be ordered with `priorityTarget`-qualifying courses first (existing behavior). In `maximize-count` mode, children SHALL be ordered by the descending count of their qualifications for *reachable* specializations (specializations whose upper-bound credit potential meets the credit threshold).
|
||||
|
||||
#### Scenario: maximize-count orders generalist courses first
|
||||
- **WHEN** maximize-count mode is selected and Fall Set 3 contains both `fall3-climate-finance` (qualifies for BNK/CRF/FIN/FIM/GLB/SBI — 6 reachable specs) and `fall3-emerging-tech` (qualifies for BRM/EMT/ENT/MTO/STR — 5)
|
||||
- **THEN** the DFS visits combinations including `fall3-climate-finance` before combinations including `fall3-emerging-tech`
|
||||
|
||||
#### Scenario: priority-order ordering is unchanged
|
||||
- **WHEN** priority-order mode is selected with HCR ranked first
|
||||
- **THEN** courses qualifying for HCR are tried before courses that do not (existing target-first behavior)
|
||||
|
||||
### Requirement: Cells distinguish unevaluated from evaluated-zero
|
||||
Each `ChoiceOutcome` SHALL carry an `evaluated: boolean` field. The field SHALL initialize to `false`. The field SHALL be set to `true` upon the first leaf evaluation that includes the corresponding `(setId, courseId)` pair. The UI SHALL render unevaluated cells with a visible "still searching" indicator distinct from cells that are evaluated and achieve zero specializations.
|
||||
|
||||
#### Scenario: New cell starts unevaluated
|
||||
- **WHEN** the search begins
|
||||
- **THEN** every choice in every set has `evaluated: false`
|
||||
|
||||
#### Scenario: First leaf marks the cell evaluated
|
||||
- **WHEN** any leaf containing `(spr3, spr3-analytics-ml)` has been evaluated
|
||||
- **THEN** the cell for `spr3-analytics-ml` has `evaluated: true`
|
||||
|
||||
#### Scenario: UI distinguishes unevaluated from evaluated-zero
|
||||
- **WHEN** a cell has `evaluated: false`
|
||||
- **THEN** the UI renders a "searching" indicator (not "0 specs")
|
||||
- **AND WHEN** a cell has `evaluated: true` and `ceilingSpecs.length === 0`
|
||||
- **THEN** the UI renders "0 specs" in muted styling
|
||||
|
||||
### Requirement: Per-set and global progress indication
|
||||
The decision-tree worker SHALL emit a `progress` event carrying `{ iterations, iterationsTotal }` at most once every 100 milliseconds during an active search. The UI SHALL display global search progress in the Top Plans panel header and SHALL display a per-set indicator next to each elective set's name while that set has at least one unevaluated choice and the search is still running.
|
||||
|
||||
#### Scenario: Progress events emitted at throttled rate
|
||||
- **WHEN** the search is running
|
||||
- **THEN** the worker emits at most one `progress` event per 100ms
|
||||
|
||||
#### Scenario: Global progress visible in header
|
||||
- **WHEN** the search is running and 15234 of 49152 leaves have been evaluated
|
||||
- **THEN** the Top Plans header shows progress text such as `Searching… 15234 / 49152 explored`
|
||||
|
||||
#### Scenario: Per-set indicator shown while choices are unevaluated
|
||||
- **WHEN** the search is running and Spring Set 1 has at least one choice with `evaluated: false`
|
||||
- **THEN** a spinner or activity indicator appears next to the Spring Set 1 heading
|
||||
- **AND WHEN** every choice in Spring Set 1 has `evaluated: true` (or the search completes)
|
||||
- **THEN** the indicator clears
|
||||
|
||||
### Requirement: Recommended choice per set
|
||||
For each open elective set, the UI SHALL identify and visually mark the choice with the best `(ceilingCount desc, priorityScore desc)` ordering as the "Recommended" choice. The marker SHALL be visible only when at least one choice in the set has `evaluated: true`. The recommendation SHALL update progressively as the search streams better outcomes for that set's choices.
|
||||
|
||||
#### Scenario: Recommended marker uses same comparator as top-K
|
||||
- **WHEN** Spring Set 3 has choices with ceilings `[HCR, BNK]` (count=2, score=29) and `[FIN, MTO]` (count=2, score=22)
|
||||
- **THEN** the choice with `[HCR, BNK]` is marked Recommended
|
||||
|
||||
#### Scenario: Higher count beats higher priority for recommendation
|
||||
- **WHEN** one choice has ceiling count 3 and another has ceiling count 2 with higher priority score
|
||||
- **THEN** the count-3 choice is Recommended
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Bounded search with saturation termination
|
||||
The decision-tree search SHALL terminate when the iteration count exceeds `MAX_TREE_ITERATIONS` (default 100,000). When this cap terminates the search before the cartesian product has been fully enumerated, the result SHALL include `partial: true`. The search SHALL otherwise enumerate every leaf in the cartesian product of open-set courses (the saturation-limit termination is removed).
|
||||
|
||||
#### Scenario: Search exhausts the cartesian product when within the cap
|
||||
- **WHEN** the open-set cartesian product is smaller than `MAX_TREE_ITERATIONS`
|
||||
- **THEN** the search evaluates every leaf, every cell ends with `evaluated: true`, and `partial` is `false`
|
||||
|
||||
#### Scenario: Search returns partial when cap is hit
|
||||
- **WHEN** the cartesian product exceeds `MAX_TREE_ITERATIONS`
|
||||
- **THEN** the search stops at the cap, sets `partial: true`, and returns the best top-K and ceilings found so far
|
||||
|
||||
### Requirement: Decision-tree worker protocol
|
||||
The decision-tree worker SHALL accept a `WorkerRequest` that includes optional `topK` (default 10). It SHALL emit a tagged-union `WorkerResponse` stream with four event types: `topKUpdate` (when the ranked top-K list changes), `choiceUpdate` (when a per-set ceiling cell changes), `progress` (throttled to 100ms intervals during search, carrying iteration counters), and `allComplete` (when the search terminates, carrying both final top-K and final per-set analyses, plus a `partial` flag).
|
||||
|
||||
#### Scenario: Worker emits progress events
|
||||
- **WHEN** the search runs for more than 100ms
|
||||
- **THEN** the worker emits at least one `progress` event with `iterations` and `iterationsTotal`
|
||||
|
||||
#### Scenario: Worker emits final allComplete event with partial flag
|
||||
- **WHEN** the search terminates
|
||||
- **THEN** the worker emits `{ type: 'allComplete', topK, setAnalyses, partial }`
|
||||
- **AND** `partial` is `true` only if the iteration cap fired
|
||||
|
||||
#### Scenario: Worker emits per-cell choice updates
|
||||
- **WHEN** a single combination causes a ceiling change for one course in one set
|
||||
- **THEN** the worker emits one `choiceUpdate` event identifying that set
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Saturation-limit early termination
|
||||
**Reason**: The saturation criterion (`top-K stable for SATURATION_LIMIT iterations`) terminates the search before many `(set, course)` pairs have been evaluated and before the search reaches deeper combinations that yield higher-count outcomes. The user-visible result is "0 specs" labels on un-evaluated cells and missing high-count plans in the top-K. Replaced by exhaustive enumeration up to the iteration cap.
|
||||
**Migration**: No consumer migration needed. The `searchDecisionTree` function signature is unchanged; behavior changes from "may stop early" to "always exhausts (within cap)". The `partial: true` flag remains as the only signal that the result may be incomplete.
|
||||
@@ -0,0 +1,75 @@
|
||||
## 1. Drop saturation, raise cap
|
||||
|
||||
- [x] 1.1 In `app/src/solver/decisionTree.ts`, remove the `SATURATION_LIMIT` constant and all references to `iterationsSinceTopKChange` (declaration, increment, reset, comparison)
|
||||
- [x] 1.2 Raise `MAX_TREE_ITERATIONS` to `100_000`
|
||||
- [x] 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
|
||||
|
||||
- [x] 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.
|
||||
- [x] 2.2 In `searchDecisionTree`, replace the `orderedCoursesPerSet` initialization to gate by `mode`: `priority-order` keeps `reorderForTarget(setId, priorityTarget, ...)`; `maximize-count` uses `reorderByReachableQualCount(setId, upperBounds, ...)`
|
||||
- [x] 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
|
||||
|
||||
- [x] 3.1 Add `evaluated: boolean` field to `ChoiceOutcome` (`app/src/solver/decisionTree.ts`); initialize to `false` in the per-set analysis init loop
|
||||
- [x] 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.)
|
||||
- [x] 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
|
||||
- [x] 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
|
||||
|
||||
- [x] 4.1 Add `progress` to the `WorkerResponse` tagged union in `app/src/workers/decisionTree.worker.ts`: `{ type: 'progress'; iterations: number; iterationsTotal: number }`
|
||||
- [x] 4.2 Compute `iterationsTotal` in `searchDecisionTree` before DFS starts: product of `orderedCoursesPerSet[setId].length` over all `openSetIds`. Pass it through to the progress callback
|
||||
- [x] 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`
|
||||
- [x] 4.4 In the worker, wire `onProgress` to `postMessage({ type: 'progress', iterations, iterationsTotal })`
|
||||
|
||||
## 5. App state wiring
|
||||
|
||||
- [x] 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`
|
||||
- [x] 5.2 Export `searchProgress` from `useAppState` alongside `topPlans`/`topPlansPartial`
|
||||
- [x] 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)
|
||||
|
||||
- [x] 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)
|
||||
- [x] 6.2 Pass `searchProgress` from App.tsx into `<TopPlans>`
|
||||
|
||||
## 7. CourseSelection per-set + per-cell rendering
|
||||
|
||||
- [x] 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
|
||||
- [x] 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
|
||||
- [x] 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
|
||||
- [x] 7.4 The recommended derivation needs `priorityScore`; import `makePriorityScorer` from `app/src/solver/priority` and memoize per-render with `state.ranking`
|
||||
|
||||
## 8. Tests
|
||||
|
||||
- [x] 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)
|
||||
- [x] 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`
|
||||
- [x] 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)
|
||||
- [x] 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`
|
||||
- [x] 8.5 Update the streaming monotonicity test if needed (still valid in concept; just verify with new termination)
|
||||
- [x] 8.6 Add a progress-event throttling test: capture `onProgress` calls during a search; assert minimum interval >= 90ms between consecutive calls (small jitter tolerance)
|
||||
- [x] 8.7 Update the performance smoke test to allow longer time budget (e.g., 60 seconds) since the search is now exhaustive
|
||||
- [x] 8.8 Run full test suite; confirm all pass
|
||||
|
||||
## 9. Browser verification
|
||||
|
||||
- [x] 9.1 Start dev server; reproduce user's pin scenario (SP2/SP4/SP5/SE1, HCR first)
|
||||
- [x] 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
|
||||
- [x] 9.3 Confirm per-set spinner appears next to "Spring Elective Set 1" while search runs and clears when complete
|
||||
- [x] 9.4 Confirm per-cell rendering shows "·" or similar for unevaluated cells, then transitions to "N specs" or "0 specs" as evaluation completes
|
||||
- [x] 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)`
|
||||
- [x] 9.6 Confirm Top Plans header shows progress text (`Searching… N / Total`) during search and `Search complete` after
|
||||
- [x] 9.7 Adopt-plan still works correctly; no regression
|
||||
|
||||
## 10. Version + changelog
|
||||
|
||||
- [x] 10.1 Bump `__APP_VERSION__` to `1.3.1` and `__APP_VERSION_DATE__` in `app/vite.config.ts`
|
||||
- [x] 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
|
||||
Reference in New Issue
Block a user