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:
+110
-39
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user