cb49123930
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).
381 lines
12 KiB
TypeScript
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;
|
|
}
|