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:
2026-05-09 14:51:32 -04:00
parent 4d6f81d1e5
commit 4b80fac500
15 changed files with 1099 additions and 145 deletions
+267 -105
View File
@@ -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;
}