v1.3.0: Streamed top-K decision-tree plans + priority-aware ceiling
Fixes the bug where a specialization could show "Achievable" while no per-set ceiling cell surfaces a path to it. Reproduction: pin SP2=Business of Health & Medical Care, SP4=Foundations of Fintech, SP5=Corporate Finance, SE1=GIE; rank HCR first. Healthcare showed Achievable but every ceiling cell excluded HCR. Root cause: computeCeiling used strict > on count alone, so the first equal-count combination found won permanently and HCR-including outcomes were never recorded. Changes: - Replace per-(set, choice) computeCeiling loop with a single full-tree searchDecisionTree DFS. Both the per-set ceiling table and a new ranked top-K plan list (default K=10) are populated from one enumeration. - Comparison rule everywhere is (count desc, priority score desc, deterministic-tiebreak). priorityScore extracted from optimizer.ts into a shared priority.ts module used by both call sites. - Heuristic enumeration ordering: select the first reachable ranked spec as priorityTarget; reorder DFS children at every level so target- qualifying courses are tried first. High-priority outcomes surface in early iterations instead of being blocked by less-relevant equal-count results. - Bounded search: terminate on saturation (top-K stable for 500 iterations) or hard cap (10000 iterations); set partial=true if cap hit. Mitigates the worst-case enumeration cost. - Worker protocol: tagged-union response with topKUpdate, choiceUpdate (per-cell, replaces per-set setComplete), and allComplete events. - App state adds topPlans/topPlansPartial slices and an adoptPlan action that pins a plan's full course assignment in one click. Also fixes loadState's stale "ranking.length !== 14" check (now uses SPECIALIZATIONS.length so HCR-era saved state restores correctly). - New TopPlans component renders the ranked list with adopt buttons, placed above CourseSelection in the right column. - 17 new tests in searchDecisionTree.test.ts covering priority scoring, bounded ranked list, comparison rule, target selection, the user's reproduction scenario, streaming monotonicity, saturation termination, and a performance smoke test (< 5s for the 8-open-set case). - Existing decisionTree.test.ts: one test amended for per-cell streaming semantics; remaining 3 unchanged and passing.
This commit is contained in:
+267
-105
@@ -1,7 +1,9 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import type { OptimizationMode } from '../data/types';
|
||||
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;
|
||||
@@ -13,79 +15,270 @@ export interface ChoiceOutcome {
|
||||
export interface SetAnalysis {
|
||||
setId: string;
|
||||
setName: string;
|
||||
impact: number; // variance in ceiling outcomes
|
||||
impact: number;
|
||||
choices: ChoiceOutcome[];
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
|
||||
/**
|
||||
* Compute the ceiling outcome for a single course choice:
|
||||
* the best achievable result assuming that course is pinned
|
||||
* and all other open sets are chosen optimally.
|
||||
*/
|
||||
function computeCeiling(
|
||||
basePinnedCourses: string[],
|
||||
chosenCourseId: string,
|
||||
otherOpenSetIds: string[],
|
||||
ranking: string[],
|
||||
mode: OptimizationMode,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { count: number; specs: string[] } {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
|
||||
if (otherOpenSetIds.length === 0) {
|
||||
// No other open sets — just solve with this choice added
|
||||
const selected = [...basePinnedCourses, chosenCourseId];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
return { count: result.achieved.length, specs: result.achieved };
|
||||
}
|
||||
|
||||
// Enumerate all combinations of remaining open sets
|
||||
let bestCount = 0;
|
||||
let bestSpecs: string[] = [];
|
||||
|
||||
function enumerate(setIndex: number, accumulated: string[]) {
|
||||
// Early termination: already found max (3)
|
||||
if (bestCount >= 3) return;
|
||||
|
||||
if (setIndex >= otherOpenSetIds.length) {
|
||||
const selected = [...basePinnedCourses, chosenCourseId, ...accumulated];
|
||||
const result = fn(selected, ranking, [], excludedCourseIds);
|
||||
if (result.achieved.length > bestCount) {
|
||||
bestCount = result.achieved.length;
|
||||
bestSpecs = result.achieved;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const setId = otherOpenSetIds[setIndex];
|
||||
const courses = coursesBySet[setId];
|
||||
for (const course of courses) {
|
||||
if (excludedCourseIds?.has(course.id)) continue;
|
||||
enumerate(setIndex + 1, [...accumulated, course.id]);
|
||||
if (bestCount >= 3) return;
|
||||
}
|
||||
}
|
||||
|
||||
enumerate(0, []);
|
||||
return { count: bestCount, specs: bestSpecs };
|
||||
export interface PlanOutcome {
|
||||
courseAssignments: Record<string, string>; // setId -> courseId for open sets
|
||||
achievedSpecs: string[];
|
||||
priorityScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute variance of an array of numbers.
|
||||
*/
|
||||
export interface SearchResult {
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
}
|
||||
|
||||
export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
export const MAX_TREE_ITERATIONS = 10000;
|
||||
export const SATURATION_LIMIT = 500;
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
count: number;
|
||||
score: number;
|
||||
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);
|
||||
}
|
||||
|
||||
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 empty ceilings
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
const orderedCoursesPerSet: Record<string, Course[]> = {};
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
impact: 0,
|
||||
choices: ordered.map((c) => ({
|
||||
courseId: c.id,
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Track ceiling key per choice for stable tiebreaks
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
|
||||
let iterations = 0;
|
||||
let iterationsSinceTopKChange = 0;
|
||||
let partial = false;
|
||||
let halted = false;
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): boolean {
|
||||
iterations++;
|
||||
if (iterations > MAX_TREE_ITERATIONS) {
|
||||
partial = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
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)) {
|
||||
iterationsSinceTopKChange = 0;
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
} else {
|
||||
iterationsSinceTopKChange++;
|
||||
}
|
||||
|
||||
// Per-set ceiling updates
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = accumulated[setId];
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
|
||||
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,
|
||||
};
|
||||
if (compareCeiling(candidate, existing) < 0) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = result.achieved;
|
||||
choiceKey[currentKey] = aKey;
|
||||
// Recompute impact lazily for emit
|
||||
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
|
||||
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;
|
||||
}
|
||||
|
||||
function dfs(setIdx: number, accumulated: Record<string, string>) {
|
||||
if (halted) return;
|
||||
if (setIdx >= openSetIds.length) {
|
||||
if (evaluateLeaf(accumulated)) halted = true;
|
||||
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);
|
||||
}
|
||||
delete accumulated[setId];
|
||||
}
|
||||
|
||||
if (openSetIds.length > 0 && openSetIds.every((s) => orderedCoursesPerSet[s].length > 0)) {
|
||||
dfs(0, {});
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all open sets and compute per-choice ceiling outcomes.
|
||||
* Returns sets ordered by decision impact (highest first).
|
||||
*
|
||||
* onSetComplete is called progressively as each set's analysis finishes.
|
||||
* 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[],
|
||||
@@ -96,52 +289,21 @@ export function analyzeDecisionTree(
|
||||
excludedCourseIds?: Set<string>,
|
||||
): SetAnalysis[] {
|
||||
if (openSetIds.length > MAX_OPEN_SETS_FOR_ENUMERATION) {
|
||||
// Fallback: return empty analyses (caller uses upper bounds instead)
|
||||
return openSetIds.map((setId) => {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
return { setId, setName: set.name, impact: 0, choices: [] };
|
||||
});
|
||||
}
|
||||
|
||||
const analyses: SetAnalysis[] = [];
|
||||
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const otherOpenSets = openSetIds.filter((id) => id !== setId);
|
||||
const courses = coursesBySet[setId];
|
||||
|
||||
const choices: ChoiceOutcome[] = courses
|
||||
.filter((course) => !excludedCourseIds?.has(course.id))
|
||||
.map((course) => {
|
||||
const ceiling = computeCeiling(
|
||||
pinnedCourseIds,
|
||||
course.id,
|
||||
otherOpenSets,
|
||||
ranking,
|
||||
mode,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return {
|
||||
courseId: course.id,
|
||||
courseName: course.name,
|
||||
ceilingCount: ceiling.count,
|
||||
ceilingSpecs: ceiling.specs,
|
||||
};
|
||||
});
|
||||
|
||||
const impact = variance(choices.map((c) => c.ceilingCount));
|
||||
const analysis: SetAnalysis = { setId, setName: set.name, impact, choices };
|
||||
analyses.push(analysis);
|
||||
|
||||
onSetComplete?.(analysis);
|
||||
}
|
||||
|
||||
// Sort by impact descending, then by set order (chronological) for ties
|
||||
const setOrder = new Map(ELECTIVE_SETS.map((s, i) => [s.id, i]));
|
||||
analyses.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 analyses;
|
||||
const result = searchDecisionTree(
|
||||
pinnedCourseIds,
|
||||
openSetIds,
|
||||
ranking,
|
||||
mode,
|
||||
10,
|
||||
onSetComplete
|
||||
? { onChoiceUpdate: (_setId, analysis) => onSetComplete(analysis) }
|
||||
: undefined,
|
||||
excludedCourseIds,
|
||||
);
|
||||
return result.setAnalyses;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user