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:
@@ -0,0 +1,253 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
searchDecisionTree,
|
||||
BoundedRankedList,
|
||||
compareOutcomes,
|
||||
selectPriorityTarget,
|
||||
reorderForTarget,
|
||||
MAX_TREE_ITERATIONS,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { ELECTIVE_SETS } from '../../data/electiveSets';
|
||||
import { priorityScore } from '../priority';
|
||||
|
||||
const cancelledIds = new Set(COURSES.filter((c) => c.cancelled).map((c) => c.id));
|
||||
const allSpecIds = SPECIALIZATIONS.map((s) => s.id);
|
||||
|
||||
describe('priorityScore', () => {
|
||||
it('weights specs by their position in ranking', () => {
|
||||
const ranking = ['HCR', 'BNK', 'FIN'];
|
||||
expect(priorityScore(['HCR'], ranking)).toBe(15);
|
||||
expect(priorityScore(['BNK'], ranking)).toBe(14);
|
||||
expect(priorityScore(['FIN'], ranking)).toBe(13);
|
||||
expect(priorityScore(['HCR', 'BNK'], ranking)).toBe(29);
|
||||
expect(priorityScore([], ranking)).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to lowest weight for unranked specs', () => {
|
||||
const ranking = ['HCR'];
|
||||
// FALLBACK_RANK = 14 → weight = 15 - 14 = 1
|
||||
expect(priorityScore(['STR'], ranking)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BoundedRankedList', () => {
|
||||
const cmp = (a: number, b: number) => b - a; // sort descending
|
||||
|
||||
it('inserts items maintaining descending order', () => {
|
||||
const list = new BoundedRankedList<number>(5, cmp);
|
||||
expect(list.tryInsert(3)).toBe(true);
|
||||
expect(list.tryInsert(7)).toBe(true);
|
||||
expect(list.tryInsert(5)).toBe(true);
|
||||
expect(list.toArray()).toEqual([7, 5, 3]);
|
||||
});
|
||||
|
||||
it('drops worst entry when over capacity', () => {
|
||||
const list = new BoundedRankedList<number>(3, cmp);
|
||||
[10, 5, 8, 1, 12].forEach((v) => list.tryInsert(v));
|
||||
expect(list.toArray()).toEqual([12, 10, 8]);
|
||||
});
|
||||
|
||||
it('rejects items that cannot enter at capacity', () => {
|
||||
const list = new BoundedRankedList<number>(2, cmp);
|
||||
list.tryInsert(10);
|
||||
list.tryInsert(5);
|
||||
expect(list.tryInsert(1)).toBe(false);
|
||||
expect(list.toArray()).toEqual([10, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareOutcomes', () => {
|
||||
const make = (specs: string[], score: number, key: string): PlanOutcome => ({
|
||||
courseAssignments: { spr1: key },
|
||||
achievedSpecs: specs,
|
||||
priorityScore: score,
|
||||
});
|
||||
|
||||
it('higher count beats lower count regardless of priority', () => {
|
||||
const a = make(['BNK', 'FIN', 'CRF'], 5, 'a');
|
||||
const b = make(['HCR'], 100, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count → higher priority score wins', () => {
|
||||
const a = make(['HCR', 'BNK'], 29, 'a');
|
||||
const b = make(['FIN', 'MTO'], 22, 'b');
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('equal count and score → deterministic tiebreak by assignment key', () => {
|
||||
const a = make(['HCR'], 15, 'aaa');
|
||||
const b = make(['HCR'], 15, 'bbb');
|
||||
// a has lex-smaller key, so a wins (returns negative)
|
||||
expect(compareOutcomes(a, b)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectPriorityTarget / reorderForTarget', () => {
|
||||
it('returns first reachable spec in ranking', () => {
|
||||
const upper = { HCR: 5, BNK: 12, FIN: 20 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK', 'FIN'], upper)).toBe('BNK');
|
||||
});
|
||||
|
||||
it('returns null when no spec is reachable', () => {
|
||||
const upper = { HCR: 5, BNK: 7 };
|
||||
expect(selectPriorityTarget(['HCR', 'BNK'], upper)).toBe(null);
|
||||
});
|
||||
|
||||
it('reorders set children with target-qualifying first', () => {
|
||||
// spr3 contains analytics-ml (HCR), mergers-acq (CRF/FIN/LCM/STR-S1), etc.
|
||||
const reordered = reorderForTarget('spr3', 'HCR', cancelledIds);
|
||||
expect(reordered[0].id).toBe('spr3-analytics-ml');
|
||||
});
|
||||
|
||||
it('returns courses unchanged when target is null', () => {
|
||||
const original = reorderForTarget('spr3', null, cancelledIds);
|
||||
expect(original.map((c) => c.id)).toEqual([
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr3-digital-strategy',
|
||||
'spr3-managing-high-tech',
|
||||
'spr3-analytics-ml',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ELECTIVE_SETS
|
||||
.map((s) => s.id)
|
||||
.filter(
|
||||
(id) =>
|
||||
!PINNED.some(
|
||||
(p) => COURSES.find((c) => c.id === p)?.setId === id,
|
||||
),
|
||||
);
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
it('topK[0] achieves HCR when HCR is ranked first', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.topK.length).toBeGreaterThan(0);
|
||||
expect(result.topK[0].achievedSpecs).toContain('HCR');
|
||||
});
|
||||
|
||||
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
const spr3 = result.setAnalyses.find((a) => a.setId === 'spr3');
|
||||
const aml = spr3?.choices.find((c) => c.courseId === 'spr3-analytics-ml');
|
||||
expect(aml?.ceilingSpecs).toContain('HCR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — ordering and streaming', () => {
|
||||
it('streamed topK is monotonically improving', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
|
||||
const snapshots: PlanOutcome[][] = [];
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
{
|
||||
onTopKUpdate: (topK) => {
|
||||
snapshots.push(topK);
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
|
||||
expect(snapshots.length).toBeGreaterThan(0);
|
||||
// Each snapshot's [0] is no worse than predecessor's [0]
|
||||
for (let i = 1; i < snapshots.length; i++) {
|
||||
const prev = snapshots[i - 1][0];
|
||||
const curr = snapshots[i][0];
|
||||
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — termination', () => {
|
||||
it('saturation stops the search when topK converges', () => {
|
||||
// Tiny scenario: should saturate within a few hundred iterations
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const result = searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
allSpecIds,
|
||||
'maximize-count',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBeLessThan(MAX_TREE_ITERATIONS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke', () => {
|
||||
it('user scenario completes in < 5s for K=10', () => {
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
];
|
||||
const OPEN_SETS = ['spr1', 'spr3', 'sum2', 'sum3', 'fall1', 'fall2', 'fall3', 'fall4'];
|
||||
const RANKING = ['HCR', ...allSpecIds.filter((id) => id !== 'HCR')];
|
||||
const start = Date.now();
|
||||
searchDecisionTree(
|
||||
PINNED,
|
||||
OPEN_SETS,
|
||||
RANKING,
|
||||
'priority-order',
|
||||
10,
|
||||
undefined,
|
||||
cancelledIds,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user