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:
2026-05-09 15:47:56 -04:00
parent 4b80fac500
commit cb49123930
16 changed files with 780 additions and 110 deletions
@@ -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);
});