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
+110 -39
View File
@@ -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> {
}
}
/**
* 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 {
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),
);
return makeOutcomeComparator('maximize-count')(a, b);
}
interface CeilingComparable {
@@ -122,10 +169,19 @@ interface CeilingComparable {
key: string;
}
function compareCeiling(a: CeilingComparable, b: CeilingComparable): number {
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);
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,
};
}