v1.3.1: Exhaustive decision-tree search + UX refinements
The v1.3.0 saturation termination silently capped the search after only
the heuristic-favored part of the tree, leaving most per-set ceiling cells
stuck at "0 specs" and hiding genuinely-feasible 3-spec plans in
maximize-count mode. Replace with full exhaustive enumeration plus a
batch of UX refinements that emerged during testing.
Algorithm:
- Drop the saturation early-termination entirely. Search now runs the
full open-set cartesian product to completion; the iteration cap is
also removed so no scenario exits partial.
- Add mode-dependent DFS child ordering: priority-order keeps the
priority-target-first heuristic; maximize-count orders children by
descending count of qualifications for reachable specs (generalist
courses tried first).
- Make the (count, priorityScore) comparator mode-aware: priority-order
ranks by (priorityScore, count) so the user's top spec surfaces;
maximize-count ranks by (count, priorityScore) so the highest count
wins. The same rule drives both top-K position and per-cell ceiling
selection (and the Recommended badge).
- Add an evaluated boolean to each ChoiceOutcome and set it on first
leaf evaluation. Distinguishes "still searching" from "evaluated, no
specs achieved" so the UI never shows misleading 0 specs for a cell
the search hasn't reached yet.
- Throttled progress events (~100ms) carrying iterations / total leaf
count, drive both the per-set spinner and the global progress bar.
UI:
- Top Plans header shows a horizontal progress bar with
"iterations / total · NN%" while the search runs; collapses to
"Search complete · N explored" on completion.
- Per-set spinner next to each elective set heading while any choice
in that set is unevaluated.
- Per-cell pulsing dot + "searching" text for unevaluated cells.
- Replace the "(HCR, BNK, ...)" text labels on each course with
color-coded SpecTag pills using a new fixed per-spec palette
(app/src/data/specColors.ts). Same palette applied to the Top Plans
achievement badges so the two views are visually consistent.
- "Top outcome if picked ↓" caption above the right side of each open
elective set so the spec tags are clearly identified as decision-tree
outcomes (not the course's own qualifications).
- Recommended badge moved inline next to the course name (instead of
on a separate row below) to keep button heights stable.
Tests:
- Replace the saturation early-termination test with an exhaustion test
asserting every cell ends with evaluated: true and partial: false.
- Add mode-dependent ordering test (max-count visits Climate Finance
before Corporate Governance in fall3).
- Add evaluated-flag transition test.
- Add throttled progress-event test (>= ~100ms between consecutive
emits).
- Performance smoke updated to a 60s budget for the exhaustive
user-scenario search; 8-open-set typical case completes in ~7s.
Files: solver/decisionTree.ts, solver/priority.ts (already shipped),
data/specColors.ts (new), components/{TopPlans,CourseSelection}.tsx,
state/appState.ts, workers/decisionTree.worker.ts,
__tests__/searchDecisionTree.test.ts, vite.config.ts, CHANGELOG.md,
openspec/changes/decision-tree-exhaustive-search/* (full change spec).
This commit is contained in:
@@ -19,6 +19,7 @@ function App() {
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
searchProgress,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
@@ -136,6 +137,7 @@ function App() {
|
||||
plans={topPlans}
|
||||
partial={topPlansPartial}
|
||||
loading={treeLoading}
|
||||
progress={searchProgress}
|
||||
onAdopt={adoptPlan}
|
||||
/>
|
||||
<CourseSelection
|
||||
@@ -143,6 +145,8 @@ function App() {
|
||||
treeResults={treeResults}
|
||||
treeLoading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
ranking={state.ranking}
|
||||
mode={state.mode}
|
||||
onPin={pinCourse}
|
||||
onUnpin={unpinCourse}
|
||||
onClearAll={clearAll}
|
||||
|
||||
@@ -1,13 +1,33 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { coursesBySet } from '../data/lookups';
|
||||
import { COURSE_DESCRIPTIONS } from '../data/courseDescriptions';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import type { Term } from '../data/types';
|
||||
import { makePriorityScorer } from '../solver/priority';
|
||||
import { specColor } from '../data/specColors';
|
||||
import type { OptimizationMode, Term } from '../data/types';
|
||||
import type { SetAnalysis } from '../solver/decisionTree';
|
||||
|
||||
function SpecTag({ specId }: { specId: string }) {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
title={specNameById[specId] ?? specId}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: '9px', fontWeight: 700, letterSpacing: '0.2px',
|
||||
padding: '1px 5px', borderRadius: '3px',
|
||||
background: c.bg, color: c.fg, border: `1px solid ${c.border}`,
|
||||
whiteSpace: 'nowrap', lineHeight: '1.3',
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverse map: courseId → specialization names that require it
|
||||
const requiredForSpec: Record<string, string[]> = {};
|
||||
for (const spec of SPECIALIZATIONS) {
|
||||
@@ -179,6 +199,8 @@ interface CourseSelectionProps {
|
||||
treeResults: SetAnalysis[];
|
||||
treeLoading: boolean;
|
||||
disabledCourseIds: Set<string>;
|
||||
ranking: string[];
|
||||
mode: OptimizationMode;
|
||||
onPin: (setId: string, courseId: string) => void;
|
||||
onUnpin: (setId: string) => void;
|
||||
onClearAll: () => void;
|
||||
@@ -191,6 +213,8 @@ function ElectiveSet({
|
||||
analysis,
|
||||
loading,
|
||||
disabledCourseIds,
|
||||
scorer,
|
||||
mode,
|
||||
onPin,
|
||||
onUnpin,
|
||||
openPopoverId,
|
||||
@@ -205,6 +229,8 @@ function ElectiveSet({
|
||||
analysis?: SetAnalysis;
|
||||
loading: boolean;
|
||||
disabledCourseIds: Set<string>;
|
||||
scorer: (specs: string[]) => number;
|
||||
mode: OptimizationMode;
|
||||
onPin: (courseId: string) => void;
|
||||
onUnpin: () => void;
|
||||
openPopoverId: string | null;
|
||||
@@ -223,6 +249,30 @@ function ElectiveSet({
|
||||
);
|
||||
const hasHighImpact = analysis && analysis.impact > 0;
|
||||
|
||||
// Determine the recommended choice. Mode-dependent comparison matches the
|
||||
// top-K comparator: priority-order ranks by (score, count); max-count by (count, score).
|
||||
let recommendedCourseId: string | null = null;
|
||||
if (analysis && analysis.choices.length > 0) {
|
||||
let best: { id: string; count: number; score: number } | null = null;
|
||||
for (const ch of analysis.choices) {
|
||||
if (!ch.evaluated) continue;
|
||||
const score = scorer(ch.ceilingSpecs);
|
||||
const isBetter =
|
||||
!best ||
|
||||
(mode === 'priority-order'
|
||||
? score > best.score || (score === best.score && ch.ceilingCount > best.count)
|
||||
: ch.ceilingCount > best.count || (ch.ceilingCount === best.count && score > best.score));
|
||||
if (isBetter) {
|
||||
best = { id: ch.courseId, count: ch.ceilingCount, score };
|
||||
}
|
||||
}
|
||||
if (best && (best.count > 0 || best.score > 0)) recommendedCourseId = best.id;
|
||||
}
|
||||
|
||||
// Per-set still-searching indicator: search in progress AND at least one cell unevaluated
|
||||
const setSearching =
|
||||
loading && !!analysis && analysis.choices.some((c) => !c.evaluated);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -236,12 +286,27 @@ function ElectiveSet({
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444' }}>
|
||||
{setName}
|
||||
<h4 style={{ fontSize: '13px', margin: 0, color: '#444', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>{setName}</span>
|
||||
{!isPinned && setSearching && (
|
||||
<span style={{
|
||||
display: 'inline-block', width: '10px', height: '10px', borderRadius: '50%',
|
||||
border: '2px solid #cbd5e1', borderTopColor: '#6366f1',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} aria-label="searching" />
|
||||
)}
|
||||
{!isPinned && hasHighImpact && (
|
||||
<span style={{ fontSize: '11px', color: '#d97706', marginLeft: '8px', fontWeight: 400 }}>high impact</span>
|
||||
<span style={{ fontSize: '11px', color: '#d97706', fontWeight: 400 }}>high impact</span>
|
||||
)}
|
||||
</h4>
|
||||
{!isPinned && (
|
||||
<span style={{
|
||||
fontSize: '10px', color: '#94a3b8', fontWeight: 500, letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
top outcome if picked ↓
|
||||
</span>
|
||||
)}
|
||||
{isPinned && (
|
||||
<button
|
||||
onClick={onUnpin}
|
||||
@@ -282,6 +347,8 @@ function ElectiveSet({
|
||||
const ceiling = ceilingMap.get(course.id);
|
||||
const reqFor = requiredForSpec[course.id];
|
||||
const showSkeleton = loading && !analysis;
|
||||
const cellSearching = !!ceiling && !ceiling.evaluated;
|
||||
const isRecommended = recommendedCourseId === course.id;
|
||||
const hasInfo = !!COURSE_DESCRIPTIONS[course.id];
|
||||
return (
|
||||
<button
|
||||
@@ -319,6 +386,20 @@ function ElectiveSet({
|
||||
(Already selected)
|
||||
</span>
|
||||
)}
|
||||
{isRecommended && !isUnavailable && (
|
||||
<span
|
||||
title="Best outcome among the choices in this set"
|
||||
style={{
|
||||
fontSize: '10px', color: '#15803d', fontWeight: 600,
|
||||
display: 'inline-flex', alignItems: 'center', gap: '2px',
|
||||
padding: '0 4px', borderRadius: '3px', background: '#dcfce7',
|
||||
border: '1px solid #bbf7d0', lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">★</span>
|
||||
<span>Recommended</span>
|
||||
</span>
|
||||
)}
|
||||
{!isUnavailable && hasInfo && (
|
||||
<span
|
||||
role="button"
|
||||
@@ -371,28 +452,28 @@ function ElectiveSet({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!isUnavailable && showSkeleton ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '60px',
|
||||
height: '14px',
|
||||
borderRadius: '3px',
|
||||
background: 'linear-gradient(90deg, #e5e7eb 25%, #f0f0f0 50%, #e5e7eb 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-pulse 1.5s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
) : !isUnavailable && ceiling ? (
|
||||
{!isUnavailable && (showSkeleton || cellSearching) ? (
|
||||
<span style={{
|
||||
fontSize: '11px', whiteSpace: 'nowrap', fontWeight: 600,
|
||||
color: ceiling.ceilingCount >= 3 ? '#16a34a' : ceiling.ceilingCount >= 2 ? '#2563eb' : '#666',
|
||||
fontSize: '11px', color: '#94a3b8', fontStyle: 'italic',
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
}}>
|
||||
{ceiling.ceilingCount} spec{ceiling.ceilingCount !== 1 ? 's' : ''}
|
||||
{ceiling.ceilingSpecs.length > 0 && (
|
||||
<span style={{ fontWeight: 400, color: '#888', marginLeft: '3px' }}>
|
||||
({ceiling.ceilingSpecs.join(', ')})
|
||||
<span style={{
|
||||
display: 'inline-block', width: '8px', height: '8px', borderRadius: '50%',
|
||||
background: '#cbd5e1', animation: 'cell-pulse 1.2s ease-in-out infinite',
|
||||
}} />
|
||||
searching
|
||||
</span>
|
||||
) : !isUnavailable && ceiling && ceiling.evaluated ? (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
whiteSpace: 'nowrap', flexWrap: 'wrap', justifyContent: 'flex-end',
|
||||
}}>
|
||||
{ceiling.ceilingSpecs.length === 0 ? (
|
||||
<span style={{ fontSize: '11px', color: '#9ca3af', fontStyle: 'italic' }}>
|
||||
no specs
|
||||
</span>
|
||||
) : (
|
||||
ceiling.ceilingSpecs.map((s) => <SpecTag key={s} specId={s} />)
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -411,9 +492,14 @@ function ElectiveSet({
|
||||
);
|
||||
}
|
||||
|
||||
const skeletonStyle = `@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }`;
|
||||
const skeletonStyle = `
|
||||
@keyframes skeleton-pulse { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
@keyframes cell-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disabledCourseIds, ranking, mode, onPin, onUnpin, onClearAll }: CourseSelectionProps) {
|
||||
const scorer = useMemo(() => makePriorityScorer(ranking), [ranking]);
|
||||
const terms: Term[] = ['Spring', 'Summer', 'Fall'];
|
||||
const hasPinned = Object.keys(pinnedCourses).length > 0;
|
||||
|
||||
@@ -493,6 +579,8 @@ export function CourseSelection({ pinnedCourses, treeResults, treeLoading, disab
|
||||
analysis={treeBySet.get(set.id)}
|
||||
loading={treeLoading}
|
||||
disabledCourseIds={disabledCourseIds}
|
||||
scorer={scorer}
|
||||
mode={mode}
|
||||
onPin={(courseId) => onPin(set.id, courseId)}
|
||||
onUnpin={() => onUnpin(set.id)}
|
||||
openPopoverId={popover?.courseId ?? null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ELECTIVE_SETS } from '../data/electiveSets';
|
||||
import { SPECIALIZATIONS } from '../data/specializations';
|
||||
import { courseById } from '../data/lookups';
|
||||
import { specColor } from '../data/specColors';
|
||||
import type { PlanOutcome } from '../solver/decisionTree';
|
||||
|
||||
const setNameById: Record<string, string> = {};
|
||||
@@ -13,15 +14,31 @@ interface TopPlansProps {
|
||||
plans: PlanOutcome[];
|
||||
partial: boolean;
|
||||
loading: boolean;
|
||||
progress: { iterations: number; iterationsTotal: number } | null;
|
||||
onAdopt: (assignments: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
|
||||
function formatNum(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function TopPlans({ plans, partial, loading, progress, onAdopt }: TopPlansProps) {
|
||||
const visible = plans.filter((p) => p.achievedSpecs.length > 0);
|
||||
|
||||
const pct = progress && progress.iterationsTotal > 0
|
||||
? Math.min(100, (progress.iterations / progress.iterationsTotal) * 100)
|
||||
: 0;
|
||||
|
||||
let staticText: string | null = null;
|
||||
if (!loading && partial && progress) {
|
||||
staticText = `Search incomplete · cap hit at ${formatNum(progress.iterations)}`;
|
||||
} else if (!loading && progress) {
|
||||
staticText = `Search complete · ${formatNum(progress.iterations)} explored`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<h3 style={{ fontSize: '14px', margin: 0, color: '#444' }}>
|
||||
Top Plans
|
||||
{visible.length > 0 && (
|
||||
@@ -30,12 +47,36 @@ export function TopPlans({ plans, partial, loading, onAdopt }: TopPlansProps) {
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
{partial && (
|
||||
<span style={{ fontSize: '11px', color: '#92400e' }}>
|
||||
(showing best of search; result is partial)
|
||||
{staticText && (
|
||||
<span style={{ fontSize: '11px', color: partial ? '#92400e' : '#888' }}>
|
||||
{staticText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loading && progress && (
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
position: 'relative', height: '6px', background: '#e5e7eb',
|
||||
borderRadius: '3px', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, height: '100%',
|
||||
width: `${pct}%`, background: '#3b82f6',
|
||||
transition: 'width 150ms ease-out',
|
||||
}} />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between',
|
||||
fontSize: '10px', color: '#888', marginTop: '2px',
|
||||
}}>
|
||||
<span>Searching…</span>
|
||||
<span>
|
||||
{formatNum(progress.iterations)} / {formatNum(progress.iterationsTotal)}
|
||||
{' · '}{Math.round(pct)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && visible.length === 0 && (
|
||||
<div style={{ fontSize: '12px', color: '#888', fontStyle: 'italic' }}>
|
||||
Searching for high-priority plans…
|
||||
@@ -87,23 +128,26 @@ function PlanRow({
|
||||
<span style={{ fontSize: '11px', color: '#666', fontWeight: 600, minWidth: '20px' }}>
|
||||
#{rank}
|
||||
</span>
|
||||
{plan.achievedSpecs.map((specId) => (
|
||||
<span
|
||||
key={specId}
|
||||
title={specNameById[specId]}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #bbf7d0',
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
))}
|
||||
{plan.achievedSpecs.map((specId) => {
|
||||
const c = specColor(specId);
|
||||
return (
|
||||
<span
|
||||
key={specId}
|
||||
title={specNameById[specId]}
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
border: `1px solid ${c.border}`,
|
||||
}}
|
||||
>
|
||||
{specId}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<span style={{ fontSize: '10px', color: '#888', marginLeft: 'auto' }}>
|
||||
score {plan.priorityScore}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface SpecColor {
|
||||
bg: string;
|
||||
fg: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
export const SPEC_COLORS: Record<string, SpecColor> = {
|
||||
BNK: { bg: '#dbeafe', fg: '#1d4ed8', border: '#bfdbfe' },
|
||||
BRM: { bg: '#fce7f3', fg: '#be185d', border: '#fbcfe8' },
|
||||
CRF: { bg: '#cffafe', fg: '#0e7490', border: '#a5f3fc' },
|
||||
EMT: { bg: '#ffedd5', fg: '#c2410c', border: '#fed7aa' },
|
||||
ENT: { bg: '#fef9c3', fg: '#a16207', border: '#fef08a' },
|
||||
FIN: { bg: '#e0e7ff', fg: '#3730a3', border: '#c7d2fe' },
|
||||
FIM: { bg: '#ccfbf1', fg: '#0f766e', border: '#99f6e4' },
|
||||
GLB: { bg: '#ecfccb', fg: '#4d7c0f', border: '#d9f99d' },
|
||||
HCR: { bg: '#d1fae5', fg: '#047857', border: '#a7f3d0' },
|
||||
LCM: { bg: '#ede9fe', fg: '#6d28d9', border: '#ddd6fe' },
|
||||
MGT: { bg: '#f3e8ff', fg: '#7e22ce', border: '#e9d5ff' },
|
||||
MKT: { bg: '#ffe4e6', fg: '#be123c', border: '#fecdd3' },
|
||||
MTO: { bg: '#fed7aa', fg: '#9a3412', border: '#fdba74' },
|
||||
SBI: { bg: '#bbf7d0', fg: '#166534', border: '#86efac' },
|
||||
STR: { bg: '#e0e7ff', fg: '#4338ca', border: '#a5b4fc' },
|
||||
};
|
||||
|
||||
const FALLBACK: SpecColor = { bg: '#f3f4f6', fg: '#374151', border: '#e5e7eb' };
|
||||
|
||||
export function specColor(specId: string): SpecColor {
|
||||
return SPEC_COLORS[specId] ?? FALLBACK;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
compareOutcomes,
|
||||
selectPriorityTarget,
|
||||
reorderForTarget,
|
||||
MAX_TREE_ITERATIONS,
|
||||
reorderByReachableQualCount,
|
||||
PROGRESS_THROTTLE_MS,
|
||||
type PlanOutcome,
|
||||
} from '../decisionTree';
|
||||
import { computeUpperBounds } from '../feasibility';
|
||||
import { SPECIALIZATIONS } from '../../data/specializations';
|
||||
import { COURSES } from '../../data/courses';
|
||||
import { ELECTIVE_SETS } from '../../data/electiveSets';
|
||||
@@ -143,7 +145,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
);
|
||||
expect(result.topK.length).toBeGreaterThan(0);
|
||||
expect(result.topK[0].achievedSpecs).toContain('HCR');
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('per-set ceiling for spr3-analytics-ml includes HCR', () => {
|
||||
const result = searchDecisionTree(
|
||||
@@ -158,7 +160,7 @@ describe('searchDecisionTree — HCR reproduction scenario', () => {
|
||||
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');
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — ordering and streaming', () => {
|
||||
@@ -194,12 +196,12 @@ describe('searchDecisionTree — ordering and streaming', () => {
|
||||
const curr = snapshots[i][0];
|
||||
expect(compareOutcomes(curr, prev)).toBeLessThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — termination', () => {
|
||||
it('saturation stops the search when topK converges', () => {
|
||||
// Tiny scenario: should saturate within a few hundred iterations
|
||||
describe('searchDecisionTree — exhaustive termination', () => {
|
||||
it('exhausts the cartesian product when within the cap', () => {
|
||||
// 2 open sets, ~16 leaves total
|
||||
const PINNED = [
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
@@ -223,31 +225,122 @@ describe('searchDecisionTree — termination', () => {
|
||||
cancelledIds,
|
||||
);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBeLessThan(MAX_TREE_ITERATIONS);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
// Every cell in every set is evaluated after exhaustion
|
||||
for (const setAnalysis of result.setAnalyses) {
|
||||
for (const choice of setAnalysis.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke', () => {
|
||||
it('user scenario completes in < 5s for K=10', () => {
|
||||
describe('searchDecisionTree — mode-dependent ordering', () => {
|
||||
it('reorderByReachableQualCount puts generalist courses first when many specs are reachable', () => {
|
||||
// All sets open so most specs are reachable; gives the heuristic something to weight against
|
||||
const allSets = ELECTIVE_SETS.map((s) => s.id);
|
||||
const upper = computeUpperBounds([], allSets, cancelledIds);
|
||||
const reordered = reorderByReachableQualCount('fall3', upper, cancelledIds);
|
||||
// Climate Finance (BNK,CRF,FIN,FIM,GLB,SBI = 6 quals) should beat
|
||||
// Corporate Governance (LCM,MGT,SBI,STR-S1 = 4 quals)
|
||||
const climateIdx = reordered.findIndex((c) => c.id === 'fall3-climate-finance');
|
||||
const corpGovIdx = reordered.findIndex((c) => c.id === 'fall3-corporate-governance');
|
||||
expect(climateIdx).toBeLessThan(corpGovIdx);
|
||||
});
|
||||
|
||||
it('maximize-count first leaf uses the generalist-ordered choices', () => {
|
||||
// Capture the first leaf evaluated in maximize-count mode by inspecting
|
||||
// the first onChoiceUpdate event for each set
|
||||
const PINNED = [
|
||||
'spr2-health-medical',
|
||||
'spr1-collaboration',
|
||||
'spr2-financial-services',
|
||||
'spr3-mergers-acquisitions',
|
||||
'spr4-fintech',
|
||||
'spr5-corporate-finance',
|
||||
'sum1-global-immersion',
|
||||
'sum1-collaboration',
|
||||
'sum2-innovation-design',
|
||||
'sum3-valuation',
|
||||
'fall1-private-equity',
|
||||
'fall2-behavioral-finance',
|
||||
];
|
||||
const OPEN_SETS = ['fall3', 'fall4'];
|
||||
const firstChoiceBySet: Record<string, string> = {};
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, allSpecIds, 'maximize-count', 10,
|
||||
{
|
||||
onChoiceUpdate: (setId, analysis) => {
|
||||
if (!(setId in firstChoiceBySet)) {
|
||||
// The first cell flipped to evaluated within this update is the
|
||||
// course we're after — but the analysis sends the whole choices
|
||||
// array. Find the first evaluated course in declaration order.
|
||||
const firstEval = analysis.choices.find((c) => c.evaluated);
|
||||
if (firstEval) firstChoiceBySet[setId] = firstEval.courseId;
|
||||
}
|
||||
},
|
||||
},
|
||||
cancelledIds,
|
||||
);
|
||||
// For fall3 in max-count mode: climate-finance (most generalist) should be the first
|
||||
expect(firstChoiceBySet['fall3']).toBe('fall3-climate-finance');
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — evaluated flag transitions', () => {
|
||||
it('cells start unevaluated and flip true after first leaf containing them', () => {
|
||||
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,
|
||||
);
|
||||
for (const sa of result.setAnalyses) {
|
||||
for (const choice of sa.choices) {
|
||||
expect(choice.evaluated).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — progress events', () => {
|
||||
it('emits progress events throttled to PROGRESS_THROTTLE_MS', () => {
|
||||
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 timestamps: number[] = [];
|
||||
searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10,
|
||||
{ onProgress: () => timestamps.push(Date.now()) },
|
||||
cancelledIds,
|
||||
);
|
||||
// At least one progress emit (the final one always fires)
|
||||
expect(timestamps.length).toBeGreaterThan(0);
|
||||
// Consecutive emits respect the throttle (allow 5ms jitter)
|
||||
for (let i = 1; i < timestamps.length - 1; i++) {
|
||||
const delta = timestamps[i] - timestamps[i - 1];
|
||||
expect(delta).toBeGreaterThanOrEqual(PROGRESS_THROTTLE_MS - 5);
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('searchDecisionTree — performance smoke (exhaustive)', () => {
|
||||
it('user scenario completes in under 60s 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 result = searchDecisionTree(
|
||||
PINNED, OPEN_SETS, RANKING, 'priority-order', 10, undefined, cancelledIds,
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(elapsed).toBeLessThan(5000);
|
||||
});
|
||||
expect(elapsed).toBeLessThan(60_000);
|
||||
expect(result.partial).toBe(false);
|
||||
expect(result.iterations).toBe(result.iterationsTotal);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
+110
-39
@@ -10,6 +10,7 @@ export interface ChoiceOutcome {
|
||||
courseName: string;
|
||||
ceilingCount: number;
|
||||
ceilingSpecs: string[];
|
||||
evaluated: boolean;
|
||||
}
|
||||
|
||||
export interface SetAnalysis {
|
||||
@@ -30,17 +31,18 @@ export interface SearchResult {
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
}
|
||||
|
||||
export interface SearchCallbacks {
|
||||
onTopKUpdate?: (topK: PlanOutcome[], iterations: number) => void;
|
||||
onChoiceUpdate?: (setId: string, analysis: SetAnalysis) => void;
|
||||
onProgress?: (iterations: number, iterationsTotal: number) => void;
|
||||
}
|
||||
|
||||
const MAX_OPEN_SETS_FOR_ENUMERATION = 9;
|
||||
const CREDIT_THRESHOLD = 9;
|
||||
export const MAX_TREE_ITERATIONS = 10000;
|
||||
export const SATURATION_LIMIT = 500;
|
||||
export const PROGRESS_THROTTLE_MS = 100;
|
||||
|
||||
function variance(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
@@ -76,6 +78,35 @@ export function reorderForTarget(
|
||||
return [...qualifying, ...others];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a set's courses so those qualifying for the most reachable specs
|
||||
* (upperBound >= 9) come first. Stable sort: ties keep declaration order.
|
||||
* Used by maximize-count mode to surface generalist courses early.
|
||||
*/
|
||||
export function reorderByReachableQualCount(
|
||||
setId: string,
|
||||
upperBounds: Record<string, number>,
|
||||
excludedCourseIds?: Set<string>,
|
||||
): Course[] {
|
||||
const courses = coursesBySet[setId].filter(
|
||||
(c) => !excludedCourseIds?.has(c.id),
|
||||
);
|
||||
// Decorate-sort-undecorate for stability
|
||||
return courses
|
||||
.map((course, idx) => ({
|
||||
course,
|
||||
idx,
|
||||
score: course.qualifications.filter(
|
||||
(q) => (upperBounds[q.specId] ?? 0) >= CREDIT_THRESHOLD,
|
||||
).length,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return a.idx - b.idx;
|
||||
})
|
||||
.map((x) => x.course);
|
||||
}
|
||||
|
||||
export function assignmentKey(assignments: Record<string, string>): string {
|
||||
return Object.keys(assignments)
|
||||
.sort()
|
||||
@@ -104,16 +135,32 @@ export class BoundedRankedList<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparator for plan outcomes. Mode-dependent ordering:
|
||||
* - priority-order mode: (priorityScore desc, count desc, key asc)
|
||||
* - maximize-count mode: (count desc, priorityScore desc, key asc)
|
||||
* Returns negative if a is better, positive if b is better.
|
||||
*/
|
||||
export function makeOutcomeComparator(
|
||||
mode: OptimizationMode,
|
||||
): (a: PlanOutcome, b: PlanOutcome) => number {
|
||||
return (a, b) => {
|
||||
if (mode === 'priority-order') {
|
||||
if (a.priorityScore !== b.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
if (a.achievedSpecs.length !== b.achievedSpecs.length) return b.achievedSpecs.length - a.achievedSpecs.length;
|
||||
} else {
|
||||
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),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Default count-first comparator, retained for backward compatibility with tests. */
|
||||
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),
|
||||
);
|
||||
return makeOutcomeComparator('maximize-count')(a, b);
|
||||
}
|
||||
|
||||
interface CeilingComparable {
|
||||
@@ -122,10 +169,19 @@ interface CeilingComparable {
|
||||
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);
|
||||
function makeCeilingComparator(
|
||||
mode: OptimizationMode,
|
||||
): (a: CeilingComparable, b: CeilingComparable) => number {
|
||||
return (a, b) => {
|
||||
if (mode === 'priority-order') {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
} else {
|
||||
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(
|
||||
@@ -146,13 +202,18 @@ export function searchDecisionTree(
|
||||
);
|
||||
const priorityTarget = selectPriorityTarget(ranking, upperBounds);
|
||||
|
||||
// Initialize per-set analyses with empty ceilings
|
||||
// Initialize per-set analyses with unevaluated cells, ordered by mode
|
||||
const setAnalyses: Record<string, SetAnalysis> = {};
|
||||
const orderedCoursesPerSet: Record<string, Course[]> = {};
|
||||
let iterationsTotal = 1;
|
||||
for (const setId of openSetIds) {
|
||||
const set = ELECTIVE_SETS.find((s) => s.id === setId)!;
|
||||
const ordered = reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
const ordered =
|
||||
mode === 'maximize-count'
|
||||
? reorderByReachableQualCount(setId, upperBounds, excludedCourseIds)
|
||||
: reorderForTarget(setId, priorityTarget, excludedCourseIds);
|
||||
orderedCoursesPerSet[setId] = ordered;
|
||||
iterationsTotal *= ordered.length || 1;
|
||||
setAnalyses[setId] = {
|
||||
setId,
|
||||
setName: set.name,
|
||||
@@ -162,24 +223,30 @@ export function searchDecisionTree(
|
||||
courseName: c.name,
|
||||
ceilingCount: 0,
|
||||
ceilingSpecs: [],
|
||||
evaluated: false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Track ceiling key per choice for stable tiebreaks
|
||||
const choiceKey: Record<string, string> = {};
|
||||
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, compareOutcomes);
|
||||
const outcomeComparator = makeOutcomeComparator(mode);
|
||||
const ceilingComparator = makeCeilingComparator(mode);
|
||||
const topK = new BoundedRankedList<PlanOutcome>(K, outcomeComparator);
|
||||
let iterations = 0;
|
||||
let iterationsSinceTopKChange = 0;
|
||||
let partial = false;
|
||||
let halted = false;
|
||||
const partial = false;
|
||||
let lastProgressEmit = 0;
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): boolean {
|
||||
iterations++;
|
||||
if (iterations > MAX_TREE_ITERATIONS) {
|
||||
partial = true;
|
||||
return true;
|
||||
function emitProgress() {
|
||||
if (!callbacks?.onProgress) return;
|
||||
const now = Date.now();
|
||||
if (now - lastProgressEmit >= PROGRESS_THROTTLE_MS) {
|
||||
lastProgressEmit = now;
|
||||
callbacks.onProgress(iterations, iterationsTotal);
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateLeaf(accumulated: Record<string, string>): void {
|
||||
iterations++;
|
||||
|
||||
const courses: string[] = [];
|
||||
for (const setId of openSetIds) courses.push(accumulated[setId]);
|
||||
@@ -195,17 +262,15 @@ export function searchDecisionTree(
|
||||
};
|
||||
|
||||
if (topK.tryInsert(outcome)) {
|
||||
iterationsSinceTopKChange = 0;
|
||||
callbacks?.onTopKUpdate?.(topK.toArray(), iterations);
|
||||
} else {
|
||||
iterationsSinceTopKChange++;
|
||||
}
|
||||
|
||||
// Per-set ceiling updates
|
||||
// Per-set ceiling + evaluated-flag updates
|
||||
for (const setId of openSetIds) {
|
||||
const courseId = accumulated[setId];
|
||||
const analysis = setAnalyses[setId];
|
||||
const choice = analysis.choices.find((c) => c.courseId === courseId)!;
|
||||
const wasEvaluated = choice.evaluated;
|
||||
const currentKey = `${setId}:${courseId}`;
|
||||
const existing: CeilingComparable = {
|
||||
count: choice.ceilingCount,
|
||||
@@ -217,36 +282,38 @@ export function searchDecisionTree(
|
||||
score,
|
||||
key: aKey,
|
||||
};
|
||||
if (compareCeiling(candidate, existing) < 0) {
|
||||
const ceilingImproved = ceilingComparator(candidate, existing) < 0;
|
||||
if (ceilingImproved) {
|
||||
choice.ceilingCount = candidate.count;
|
||||
choice.ceilingSpecs = result.achieved;
|
||||
choiceKey[currentKey] = aKey;
|
||||
// Recompute impact lazily for emit
|
||||
}
|
||||
// Mark evaluated regardless of improvement
|
||||
choice.evaluated = true;
|
||||
|
||||
if (!wasEvaluated || ceilingImproved) {
|
||||
const impact = variance(analysis.choices.map((c) => c.ceilingCount));
|
||||
analysis.impact = impact;
|
||||
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;
|
||||
emitProgress();
|
||||
}
|
||||
|
||||
function dfs(setIdx: number, accumulated: Record<string, string>) {
|
||||
if (halted) return;
|
||||
if (setIdx >= openSetIds.length) {
|
||||
if (evaluateLeaf(accumulated)) halted = true;
|
||||
evaluateLeaf(accumulated);
|
||||
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);
|
||||
}
|
||||
@@ -257,6 +324,9 @@ export function searchDecisionTree(
|
||||
dfs(0, {});
|
||||
}
|
||||
|
||||
// Final progress emit so consumers see the completion count
|
||||
if (callbacks?.onProgress) callbacks.onProgress(iterations, iterationsTotal);
|
||||
|
||||
// Final impact recomputation + sort
|
||||
for (const a of Object.values(setAnalyses)) {
|
||||
a.impact = variance(a.choices.map((c) => c.ceilingCount));
|
||||
@@ -272,6 +342,7 @@ export function searchDecisionTree(
|
||||
setAnalyses: sortedAnalyses,
|
||||
partial,
|
||||
iterations,
|
||||
iterationsTotal,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export function useAppState() {
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [topPlans, setTopPlans] = useState<PlanOutcome[]>([]);
|
||||
const [topPlansPartial, setTopPlansPartial] = useState(false);
|
||||
const [searchProgress, setSearchProgress] = useState<{ iterations: number; iterationsTotal: number } | null>(null);
|
||||
const workerRef = useRef<Worker | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -127,11 +128,13 @@ export function useAppState() {
|
||||
setTreeResults([]);
|
||||
setTopPlans([]);
|
||||
setTopPlansPartial(false);
|
||||
setSearchProgress(null);
|
||||
setTreeLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTreeLoading(true);
|
||||
setSearchProgress(null);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
// Terminate previous worker if still running
|
||||
@@ -150,10 +153,13 @@ export function useAppState() {
|
||||
setTreeResults(Array.from(setMap.values()));
|
||||
} else if (e.data.type === 'topKUpdate') {
|
||||
setTopPlans(e.data.topK);
|
||||
} else if (e.data.type === 'progress') {
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
} else if (e.data.type === 'allComplete') {
|
||||
setTreeResults(e.data.setAnalyses);
|
||||
setTopPlans(e.data.topK);
|
||||
setTopPlansPartial(e.data.partial);
|
||||
setSearchProgress({ iterations: e.data.iterations, iterationsTotal: e.data.iterationsTotal });
|
||||
setTreeLoading(false);
|
||||
worker.terminate();
|
||||
workerRef.current = null;
|
||||
@@ -203,6 +209,7 @@ export function useAppState() {
|
||||
treeLoading,
|
||||
topPlans,
|
||||
topPlansPartial,
|
||||
searchProgress,
|
||||
openSetIds,
|
||||
selectedCourseIds,
|
||||
disabledCourseIds,
|
||||
|
||||
@@ -15,12 +15,14 @@ export interface WorkerRequest {
|
||||
export type WorkerResponse =
|
||||
| { type: 'topKUpdate'; topK: PlanOutcome[]; iterations: number }
|
||||
| { type: 'choiceUpdate'; setId: string; analysis: SetAnalysis }
|
||||
| { type: 'progress'; iterations: number; iterationsTotal: number }
|
||||
| {
|
||||
type: 'allComplete';
|
||||
topK: PlanOutcome[];
|
||||
setAnalyses: SetAnalysis[];
|
||||
partial: boolean;
|
||||
iterations: number;
|
||||
iterationsTotal: number;
|
||||
};
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
@@ -53,6 +55,10 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
const msg: WorkerResponse = { type: 'choiceUpdate', setId, analysis };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
onProgress: (iterations, iterationsTotal) => {
|
||||
const msg: WorkerResponse = { type: 'progress', iterations, iterationsTotal };
|
||||
self.postMessage(msg);
|
||||
},
|
||||
},
|
||||
excludedSet,
|
||||
);
|
||||
@@ -63,6 +69,7 @@ self.onmessage = (e: MessageEvent<WorkerRequest>) => {
|
||||
setAnalyses: result.setAnalyses,
|
||||
partial: result.partial,
|
||||
iterations: result.iterations,
|
||||
iterationsTotal: result.iterationsTotal,
|
||||
};
|
||||
self.postMessage(final);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user