v1.3.2: Leaf cache for instant pin/unpin + TopPlans block UX
Decision-tree leaf outcomes are now cached on the main thread keyed by
their full 12-course assignment. Pin operations filter the cache and
re-derive top-K + per-set ceilings instantly with no worker spawn. Unpin
operations show the cached subset immediately and stream improvements as
a background worker fills in the missing leaves. Cache survives pin,
unpin, and adopt-plan; only ranking or mode changes invalidate it.
Solver / worker:
- searchDecisionTree accepts skipKeys (Set<string>) and pinnedAssignments
(Record<setId,courseId>). Leaves are emitted with their full 12-set
assignment so cache keys are stable across pin/unpin operations.
- evaluateLeaf short-circuits when the leaf's assignmentKey is in
skipKeys: increments iterations + emits progress, but skips the
optimizer call and all callbacks. Keeps progress percentage honest
(counts whole tree, not just delta).
- New deriveFromLeaves pure helper produces {topK, setAnalyses} from a
leaf collection; used by the main-thread cache filter and gives a
reusable derivation primitive for tests.
- Worker request gains skipKeys and pinnedAssignments fields. Worker
response gains a leafEvaluated event so the main thread can populate
its cache as the search streams.
App state:
- leafCacheRef holds Map<assignmentKey, PlanOutcome> scoped to the
current (ranking, mode) pair. The search effect now: invalidates on
ranking/mode change; computes the orderedCourses + expectedTotal;
filters the cache against the current pinned/excluded state; calls
deriveFromLeaves to render immediately; spawns the worker only when
filtered.length < expectedTotal, passing skipKeys.
- Cache cap of 500,000 leaves with full clear on overflow. Bounds
worst-case memory at ~150 MB.
UI (TopPlans):
- Course blocks in the per-plan row are now interactive buttons. Click
pins (or unpins, if the course is currently pinned) the course in
that set. Pinned blocks render in a selected blue color.
- Each plan row now shows the FULL 12-set sequence including pinned
courses (interleaved with the search's recommended choices for the
remaining open sets) so the displayed plan is always complete.
- Spec qualification tags removed from per-block display (kept the
set-label + course-name treatment for clarity).
Tests:
- New app/src/solver/__tests__/leafCache.test.ts with 4 tests:
skipKeys parity (second-pass run with skipKeys evaluates zero
leaves), deriveFromLeaves parity (matches a fresh search), cache
filter on pinned assignments, cache filter on excluded courses.
- All 78 prior tests continue to pass; 82 total.
Browser-verified: pin click on a Top Plans block from the cached
8-open-set scenario completes instantly with no spinner; unpin restores
the original cached subset (also instant when the prior space was
already cached); mode toggle correctly invalidates and re-runs the
search.
This commit is contained in:
@@ -38,6 +38,7 @@ export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||
onLeafEvaluated?: (leaf: PlanOutcome) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
@@ -192,6 +193,8 @@ export function searchDecisionTree(
|
||||
K: number,
|
||||
callbacks?: SearchCallbacks,
|
||||
excludedCourseIds?: Set<string>,
|
||||
skipKeys?: Set<string>,
|
||||
pinnedAssignments?: Record<string, string>,
|
||||
): SearchResult {
|
||||
const fn = mode === 'maximize-count' ? maximizeCount : priorityOrder;
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
@@ -201,6 +204,9 @@ export function searchDecisionTree(
|
||||
excludedCourseIds,
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
// Pinned assignments (setId -> courseId) for any pinned sets — included in
|
||||
// the leaf's full courseAssignments so cache keys are stable across pin/unpin.
|
||||
const pinnedMap = pinnedAssignments ?? {};
|
||||
|
||||
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
@@ -248,19 +254,29 @@ export function searchDecisionTree(
|
||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||
iterations++;
|
||||
|
||||
// Build the full 12-set assignment so cache keys remain stable across
|
||||
// pin/unpin operations.
|
||||
const fullAssignment: Record<string, string> = { ...pinnedMap, ...accumulated };
|
||||
const aKey = assignmentKey(fullAssignment);
|
||||
if (skipKeys?.has(aKey)) {
|
||||
emitProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
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 },
|
||||
courseAssignments: fullAssignment,
|
||||
achievedSpecs: result.achieved,
|
||||
priorityScore: score,
|
||||
};
|
||||
|
||||
callbacks?.onLeafEvaluated?.(outcome);
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
}
|
||||
@@ -346,6 +362,90 @@ export function searchDecisionTree(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure derivation of {topK, setAnalyses} from a collection of leaf outcomes.
|
||||
* Used by the main thread when filtering the leaf cache, and reusable
|
||||
* elsewhere as needed. Does NOT run any optimizer calls — leaves carry
|
||||
* their own pre-computed achievedSpecs/priorityScore.
|
||||
*/
|
||||
export function deriveFromLeaves(
|
||||
leaves: Iterable<PlanOutcome>,
|
||||
K: number,
|
||||
mode: OptimizationMode,
|
||||
ranking: string[],
|
||||
openSetIds: string[],
|
||||
excludedCourseIds?: Set<string>,
|
||||
): { topK: PlanOutcome[]; setAnalyses: SetAnalysis[] } {
|
||||
const scorer = makePriorityScorer(ranking);
|
||||
const upperBounds = computeUpperBounds([], openSetIds, excludedCourseIds);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
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);
|
||||
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 ceilingComparator = makeCeilingComparator(mode);
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
|
||||
for (const leaf of leaves) {
|
||||
topK.tryInsert(leaf);
|
||||
const aKey = assignmentKey(leaf.courseAssignments);
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = leaf.courseAssignments[setId];
|
||||
if (!courseId) continue;
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId);
|
||||
if (!choice) continue;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
score: scorer(choice.ceilingSpecs),
|
||||
key: choiceKey[currentKey] ?? '',
|
||||
};
|
||||
const candidate: CeilingComparable = {
|
||||
count: leaf.achievedSpecs.length,
|
||||
score: leaf.priorityScore,
|
||||
key: aKey,
|
||||
};
|
||||
if (ceilingComparator(candidate, existing) < 0) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = leaf.achievedSpecs;
|
||||
choiceKey[currentKey] = aKey;
|
||||
}
|
||||
choice.evaluated = true;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible wrapper: produces only the per-set ceiling table.
|
||||
* Internally runs searchDecisionTree with K=10 and emits each set's analysis
|
||||
|
||||
Reference in New Issue
Block a user