Specializations move from a 340px left rail to a horizontal 2-row chip
grid at the top (drag L→R to rank). Each chip shows rank, spec-colored
abbreviation tag matching the tags used in plans/schedule, full name on
its own row, status glyph, and a micro credit bar. Hover/tap a chip to
see full status, allocated/threshold credits, and contributing-courses
breakdown in a popover.
The right pane splits into two side-by-side columns on desktop: Top
Plans (left) and Schedule (right), each scrolling independently. The
search progress bar hoists into a global strip below the spec grid so
it stays visible regardless of which column is scrolled.
Schedule blocks render their course choices as a horizontal row of
equal-width buttons (3-5 per set) instead of stacked rows. Pinned sets
collapse to a single line with the course name inline next to the set
title. Term headers (Spring/Summer/Fall) remain as section dividers.
On mobile, the layout becomes a 3-tab segmented control
(Specializations / Plans / Courses) with the search progress strip
above the tabs. The previous floating MobileStatusBanner and
MobileCourseBanner are dropped — tabs replace their navigation
function.
The v1.3.1 comparator used a sum-of-weights priorityScore. With weights
15..1 across 15 specs, three lower-priority specs (BNK+BRM+CRF, sum 39)
could outrank a single top-priority spec (HCR alone, sum 15). In
priority-order mode this surfaced lower-priority plans above the user's
top spec — the opposite of intent.
Fix: replace sum-of-weights with a lexicographic rank weight. Each spec
encodes as a bit, top-ranked spec = highest bit. So [HCR] = 16384 beats
[BNK,BRM,CRF,EMT,ENT,FIN,FIM,GLB,LCM,MGT,MKT,MTO,SBI,STR] = 16383. A plan
containing a higher-ranked spec ALWAYS outranks any plan that doesn't,
regardless of how many lower-ranked specs the latter contains. Lower
specs only act as tiebreakers among plans that all contain the same
higher-ranked spec.
Both modes use lex weight as the priority key; modes still differ in
ordering:
priority-order: (rankWeight desc, count desc, key asc)
maximize-count: (count desc, rankWeight desc, key asc)
Score display changes from the legacy sum (e.g. "score 29") to the lex
weight in compact form (e.g. "score 24.6k"). Hover for full integer.
The display now actually corresponds to ranking order.
Other:
- Cache cap (500k leaves) now retains existing entries instead of
clearing on overflow. New entries past the cap are dropped; the
cached subset stays available as a warm starting point.
- Two new lex-weight tests in searchDecisionTree.test.ts:
- single top-ranked spec outweighs all 14 others combined
- tiebreaker is the next-ranked spec
- All 84 tests pass; cached leaves stay valid across the comparator
change since achievedSpecs (the input to lex compare) is unchanged.
Files: solver/priority.ts (new functions), solver/decisionTree.ts
(comparators take ranking), components/{TopPlans,CourseSelection}.tsx
(score display + Recommended badge), state/appState.ts (cache-cap
behavior), vite.config.ts, CHANGELOG.md.
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.
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).
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.
Apply the J27 (5/6/2026) Stern specialization sheet:
- Add Healthcare (HCR) as the 15th specialization, with HCR cross-listings on
spr2-health-medical, spr3-analytics-ml, sum2-social-media (renamed), and
fall1-managing-change. 10 credits available, no required-course gate.
- Rename sum2-social-media to "Digital Marketing Strategy in Practice";
replace its description with new MSKCC-anchored content; clear instructor
pending confirmation of new lead.
- Switch from delete-and-replace to the previously-unused cancelled flag
(Approach B): mark spr5-customer-insights cancelled, add Managing Growing
Companies back to Summer Set 2 as a cancelled placeholder per the printed
sheet.
- Update data integrity tests: course count 46 -> 47, spec count 14 -> 15;
per-spec "across sets" helper now filters cancelled courses so future
cancellations trigger an obvious assertion failure (BRM 6 -> 5,
MKT 7 -> 6, HCR 4 new).
- Replace hardcoded 14 in optimizer.test.ts with SPECIALIZATIONS.length.
determineStatuses() was marking specs as 'achievable' based solely on
per-specialization upper bounds, ignoring credit sharing with achieved
specs. Now performs an LP feasibility check to verify the spec can
actually be achieved alongside the current achieved set.
- Course info popovers with description, instructors, and specialization
tags; opens on hover (desktop) or tap (mobile) with smart positioning
- Page title and graduation cap favicon in NYU Stern purple
- Desktop layout fits viewport without page-level scrolling
Replace cancelled course in Summer Elective Set 2 with new course
"Innovation and Design" qualifying for Brand Management, Entrepreneurship
and Innovation, Marketing, and Strategy (S2).
- Mark "Managing Growing Companies" as cancelled with visual indicator and solver exclusion
- Prevent selecting duplicate courses across elective sets (e.g., same course in Spring and Summer)
- Add 2.5-credit interval tick marks to specialization progress bars
- Bump version to 1.1.0 with date display in UI header
Populate README with problem description, features, tech stack,
development/deployment instructions, project structure, and solver
explanation. Add CHANGELOG.md marking current state as v1.0.0.
Multi-stage Dockerfile builds the Vite app in Node 22 and serves static
assets from nginx:alpine. Includes gzip compression, SPA fallback routing,
immutable cache headers for hashed assets, and configurable port mapping
(default 8080). Deploy with `docker compose up -d`.
On mobile, the single-column layout makes it easy to lose context when
scrolling between the specializations and course selection panels. This adds
two floating banners that appear via IntersectionObserver:
- Top banner: summarizes specialization statuses (achieved/achievable/missing/unreachable)
- Bottom banner: shows course selection progress (N/12 selected)
Both slide in/out with CSS transitions and scroll to their respective
sections on tap. Only rendered on mobile viewports (max-width: 639px).
- Replace terse one-line optimization mode descriptions with clearer multi-sentence
explanations of how Maximize Count and Priority Order algorithms behave
- Add skeleton loading placeholders on course buttons while analysis is pending
- Auto-expand achieved specializations to show credit breakdown by default
- Add instructional subtitles to Course Selection and Specializations sections
- Make Clear and Clear All buttons more prominent with visible backgrounds
Replace top-level mutual exclusion banner with dynamic per-course
"Required for ..." labels derived from specialization data. Labels
appear on any course that is a specialization prerequisite, across
all elective sets.