Files
emba-course-solver/app/src/solver/decisionTree.ts
T
Bill cb49123930 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).
2026-05-09 15:47:56 -04:00

381 lines
12 KiB
TypeScript

import { ELECTIVE_SETS } from '../data/electiveSets';
import { coursesBySet } from '../data/lookups';
import type { Course, OptimizationMode } from '../data/types';
import { maximizeCount, priorityOrder } from './optimizer';
import { computeUpperBounds } from './feasibility';
import { makePriorityScorer } from './priority';
export interface ChoiceOutcome {
courseId: string;
courseName: string;
ceilingCount: number;
ceilingSpecs: string[];
evaluated: boolean;
}
export interface SetAnalysis {
setId: string;
setName: string;
impact: number;
choices: ChoiceOutcome[];
}
export interface PlanOutcome {
courseAssignments: Record<string, string>; // setId -> courseId for open sets
achievedSpecs: string[];
priorityScore: number;
}
export interface SearchResult {
topK: PlanOutcome[];
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 PROGRESS_THROTTLE_MS = 100;
function variance(values: number[]): number {
if (values.length <= 1) return 0;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
return values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
}
export function selectPriorityTarget(
ranking: string[],
upperBounds: Record<string, number>,
): string | null {
for (const specId of ranking) {
if ((upperBounds[specId] ?? 0) >= CREDIT_THRESHOLD) return specId;
}
return null;
}
export function reorderForTarget(
setId: string,
target: string | null,
excludedCourseIds?: Set<string>,
): Course[] {
const courses = coursesBySet[setId].filter(
(c) => !excludedCourseIds?.has(c.id),
);
if (!target) return courses;
const qualifying: Course[] = [];
const others: Course[] = [];
for (const c of courses) {
if (c.qualifications.some((q) => q.specId === target)) qualifying.push(c);
else others.push(c);
}
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()
.map((k) => `${k}:${assignments[k]}`)
.join('|');
}
export class BoundedRankedList<T> {
private items: T[] = [];
constructor(
private capacity: number,
private compare: (a: T, b: T) => number,
) {}
tryInsert(item: T): boolean {
let pos = 0;
while (pos < this.items.length && this.compare(item, this.items[pos]) > 0) pos++;
if (pos >= this.capacity) return false;
this.items.splice(pos, 0, item);
if (this.items.length > this.capacity) this.items.pop();
return true;
}
toArray(): T[] {
return [...this.items];
}
}
/**
* 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 {
count: number;
score: number;
key: string;
}
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(
pinnedCourseIds: string[],
openSetIds: string[],
ranking: string[],
mode: OptimizationMode,
K: number,
callbacks?: SearchCallbacks,
excludedCourseIds?: Set<string>,
): SearchResult {
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
const scorer = makePriorityScorer(ranking);
const upperBounds = computeUpperBounds(
pinnedCourseIds,
openSetIds,
excludedCourseIds,
);
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
// 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 =
mode === 'maximize-count'
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
orderedCoursesPerSet[setId] = ordered;
iterationsTotal *= ordered.length || 1;
setAnalyses[setId] = {
setId,
setName: set.name,
impact: 0,
choices: ordered.map((c) => ({
courseId: c.id,
courseName: c.name,
ceilingCount: 0,
ceilingSpecs: [],
evaluated: false,
})),
};
}
const choiceKey: Record<string, string> = {};
const outcomeComparator = makeOutcomeComparator(mode);
const ceilingComparator = makeCeilingComparator(mode);
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
let iterations = 0;
const partial = false;
let lastProgressEmit = 0;
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]);
const selected = [...pinnedCourseIds, ...courses];
const result = fn(selected, ranking, [], excludedCourseIds);
const score = scorer(result.achieved);
const aKey = assignmentKey(accumulated);
const outcome: PlanOutcome = {
courseAssignments: { ...accumulated },
achievedSpecs: result.achieved,
priorityScore: score,
};
if (topK.tryInsert(outcome)) {
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
}
// 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,
score: scorer(choice.ceilingSpecs),
key: choiceKey[currentKey] ?? '',
};
const candidate: CeilingComparable = {
count: result.achieved.length,
score,
key: aKey,
};
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
if (ceilingImproved) {
choice.ceilingCount = candidate.count;
choice.ceilingSpecs = result.achieved;
choiceKey[currentKey] = aKey;
}
// 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 })),
};
callbacks?.onChoiceUpdate?.(setId, updated);
}
}
emitProgress();
}
function dfs(setIdx: number, accumulated: Record<string, string>) {
if (setIdx >= openSetIds.length) {
evaluateLeaf(accumulated);
return;
}
const setId = openSetIds[setIdx];
const courses = orderedCoursesPerSet[setId];
for (const course of courses) {
accumulated[setId] = course.id;
dfs(setIdx + 1, accumulated);
}
delete accumulated[setId];
}
if (openSetIds.length > 0 && openSetIds.every((s) => orderedCoursesPerSet[s].length > 0)) {
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));
}
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
const sortedAnalyses = Object.values(setAnalyses).sort((a, b) => {
if (b.impact !== a.impact) return b.impact - a.impact;
return (setOrder.get(a.setId) ?? 0) - (setOrder.get(b.setId) ?? 0);
});
return {
topK: topK.toArray(),
setAnalyses: sortedAnalyses,
partial,
iterations,
iterationsTotal,
};
}
/**
* Backward-compatible wrapper: produces only the per-set ceiling table.
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
* once per choice update via the legacy onSetComplete callback.
*/
export function analyzeDecisionTree(
pinnedCourseIds: string[],
openSetIds: string[],
ranking: string[],
mode: OptimizationMode,
onSetComplete?: (analysis: SetAnalysis) => void,
excludedCourseIds?: Set<string>,
): SetAnalysis[] {
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
return openSetIds.map((setId) => {
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
return { setId, setName: set.name, impact: 0, choices: [] };
});
}
const result = searchDecisionTree(
pinnedCourseIds,
openSetIds,
ranking,
mode,
10,
onSetComplete
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
: undefined,
excludedCourseIds,
);
return result.setAnalyses;
}