Files
Bill 4b80fac500 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.
2026-05-09 14:51:32 -04:00

74 lines
5.9 KiB
Markdown

## ADDED Requirements
### Requirement: Streamed ranked top-K plan outcomes
The decision-tree analysis SHALL maintain a bounded ranked list of up to K complete plan outcomes (default K=10) and emit a stream update each time the visible list changes. Each `PlanOutcome` SHALL include the full course assignment for open sets (`Record<setId, courseId>`), the achieved specializations, and the priority score used for ranking. The list SHALL be ordered by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker on courseAssignments)`.
#### Scenario: Top-K converges on user's top-priority spec
- **WHEN** the user pins courses such that their first reachable ranked spec (e.g., HCR) is achievable somewhere in the remaining decision tree
- **THEN** the final `topK[0].achievedSpecs` includes that spec
#### Scenario: Streaming is monotonically improving
- **WHEN** the worker emits a sequence of `topKUpdate` events during a single analysis
- **THEN** each emitted topK is greater than or equal to the previous one entry-for-entry under the comparator
#### Scenario: Tied outcomes with different course plans appear as separate entries
- **WHEN** two distinct course assignments produce the same `achievedSpecs` and `priorityScore`
- **THEN** both appear as separate ranked entries in the top-K (deterministic tiebreaker resolves their order)
### Requirement: Heuristic enumeration ordering
The decision-tree search SHALL identify a `priorityTarget` (the first specialization in the user's ranking whose upper-bound credit potential meets the threshold) and SHALL reorder the children at each level of its DFS so that courses qualifying for the `priorityTarget` are tried before courses that do not. Reordering SHALL be a stable sort that preserves declaration order on ties.
#### Scenario: Priority target derived from first reachable ranked spec
- **WHEN** the user's ranking is `[HCR, BNK, ...]` and HCR's upper bound is ≥ 9
- **THEN** `priorityTarget = 'HCR'` and DFS children at every level are reordered HCR-first
#### Scenario: No reachable spec disables the heuristic
- **WHEN** no specialization in the ranking has upper bound ≥ 9
- **THEN** `priorityTarget = null` and DFS children are not reordered
### Requirement: Bounded search with saturation termination
The decision-tree search SHALL terminate when EITHER (a) the top-K ranked list has not changed for the last `SATURATION_LIMIT` iterations (default 500), OR (b) the iteration count exceeds `MAX_TREE_ITERATIONS` (default 10000). When (b) terminates the search before (a), the result SHALL include `partial: true`.
#### Scenario: Saturation stops a converged search early
- **WHEN** the top-K becomes stable well before the iteration cap
- **THEN** the search stops within `SATURATION_LIMIT` iterations of the last top-K change
#### Scenario: Iteration cap stops an unconverged search
- **WHEN** the search would otherwise enumerate beyond `MAX_TREE_ITERATIONS` combinations
- **THEN** the search returns its best-found top-K with `partial: true`
### Requirement: Per-cell choice updates from streaming search
For each combination evaluated in the search, for each `(setId, courseId)` in that combination's assignments, the per-set per-choice ceiling SHALL be updated if the combination's outcome is better under the comparison rule than the current ceiling for that choice. Each ceiling change SHALL emit a `choiceUpdate` event identifying the affected `setId` and the updated `SetAnalysis`.
#### Scenario: Per-set ceiling reflects streamed improvements
- **WHEN** an HCR-feasible combination is evaluated mid-search
- **THEN** the per-set ceiling cell for `spr3-analytics-ml` (the HCR-qualifying course in spr3) is updated to include HCR
## MODIFIED Requirements
### Requirement: Decision-tree per-set ceiling comparison
For each open elective set and each course choice within that set, the system SHALL compute a ceiling outcome representing the best achievable specialization result if that course is pinned. The "best" outcome SHALL be determined by `(achievedSpecs.length descending, priorityScore descending, deterministic tiebreaker)`, where `priorityScore` matches the optimizer's existing definition (`sum over specs of (15 - rankIndex(spec))`). When two outcomes have the same count, the higher priority score wins.
#### Scenario: Equal-count outcomes resolved by priority score
- **WHEN** the search finds two combinations both achieving 2 specializations, one with `[FIN, MTO]` and another with `[HCR, BNK]`, and the user's ranking places HCR first
- **THEN** the per-set ceiling reflects `[HCR, BNK]` (higher priority score)
#### Scenario: Higher count beats higher priority
- **WHEN** one combination achieves 3 specializations not including the top-priority spec, and another achieves 2 specializations including it
- **THEN** the 3-specialization outcome wins
### Requirement: Decision-tree worker protocol
The decision-tree worker SHALL accept a `WorkerRequest` that includes optional `topK` (default 10) and `saturationLimit` (default 500) parameters. It SHALL emit a tagged-union `WorkerResponse` stream with three event types: `topKUpdate` (when the ranked top-K list changes), `choiceUpdate` (when a per-set ceiling cell changes), and `allComplete` (when the search terminates, carrying both final top-K and final per-set analyses, plus a `partial` flag).
#### Scenario: Worker accepts K parameter
- **WHEN** the request specifies `topK: 5`
- **THEN** the worker maintains a bounded list of at most 5 entries and emits updates accordingly
#### Scenario: Worker emits final allComplete event
- **WHEN** the search terminates (saturation or cap)
- **THEN** the worker emits `{ type: 'allComplete', topK, setAnalyses, partial }`
#### Scenario: Worker emits per-cell choice updates rather than per-set rollups
- **WHEN** a single combination causes a ceiling change for one course in one set
- **THEN** the worker emits one `choiceUpdate` event identifying that set, not a coarse `setComplete` rollup