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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user