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
@@ -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);
});
});