v1.5.0: External credits per specialization

Students can now record credits earned in courses taken outside the J27
program via an inline editable amber chip on each spec card. Values flow
through the LP (per-spec demand reduces by external amount), upper-bound
math, decision-tree search, and the credit bar visualization. The 9-credit
threshold and the 3-spec achievement cap are unchanged; required-course
gates remain authoritative — external credits never satisfy them.
This commit is contained in:
2026-05-10 11:47:22 -04:00
parent 2ebfb9d2ec
commit 3a5ebaa17a
17 changed files with 893 additions and 72 deletions
@@ -0,0 +1,91 @@
## ADDED Requirements
### Requirement: External credits as per-spec input
The application SHALL accept a per-specialization external credit value, expressed as a non-negative number. External credits represent credits earned in courses taken outside the J27 program that the student asserts toward a specialization. The values SHALL be stored in `AppState.externalCredits` as `Record<string, number>` keyed by specialization id. Missing keys SHALL be treated as `0`. The values SHALL be persisted to localStorage alongside the rest of `AppState`.
#### Scenario: External credits default to zero
- **WHEN** a specialization has no entry in `externalCredits`
- **THEN** the application SHALL treat its external credits as `0` everywhere (LP demand, upper bounds, bar visualization)
#### Scenario: External credits persist across reload
- **WHEN** the user enters an external credit value and reloads the page
- **THEN** the value SHALL be restored from localStorage and applied to the LP and the bar
#### Scenario: Negative or non-numeric input is rejected
- **WHEN** the user attempts to commit a negative number, NaN, or empty string as an external credit value
- **THEN** the value SHALL clamp to `0` (treated as no entry)
### Requirement: External credits reduce LP demand
The LP feasibility checker SHALL reduce each specialization's demand from `≥ 9` to `≥ max(0, 9 external[spec])`. Specializations whose external credits already meet or exceed the 9-credit threshold SHALL be omitted from the LP entirely (no `need_<spec>` constraint, no `x_<course>_<spec>` variables for that spec) while still being counted as achievable in the optimizer's output.
#### Scenario: Partial external coverage reduces required in-program credits
- **WHEN** a specialization has 4.0 external credits and 5.0 in-program credits available from the student's selections
- **THEN** the LP SHALL find the spec feasible with `need_<spec>` set to `≥ 5`
#### Scenario: External alone meets threshold
- **WHEN** a specialization has 9.0 or more external credits
- **THEN** the LP SHALL omit that spec's demand constraint and any related variables, and the optimizer SHALL include the spec in the achieved set without consuming any in-program credits
#### Scenario: No external credits preserves prior behavior
- **WHEN** every specialization's external credit value is `0`
- **THEN** the LP SHALL produce the same constraints, variables, and feasibility verdict as before the change
### Requirement: External credits raise upper-bound and pre-filter potentials
`computeUpperBounds` and `preFilterCandidates` SHALL add `external[spec]` to each specialization's potential credit total. A specialization SHALL pass the pre-filter if `(in-program potential + external) ≥ 9`.
#### Scenario: External credits unlock previously-unreachable spec
- **WHEN** a specialization's in-program potential is 6.0 (below the 9-credit threshold) but the student has 5.0 external credits in it
- **THEN** the spec SHALL pass `preFilterCandidates` and SHALL receive an upper bound of `11.0`
#### Scenario: External credits do not exceed reasonable bounds
- **WHEN** external credits are added on top of the in-program upper bound
- **THEN** the resulting upper bound SHALL be the simple sum (no cap), reflecting the truthful credit total
### Requirement: Required-course gates remain authoritative
A specialization with a `requiredCourseId` SHALL retain `missing_required` status whenever the required course is neither selected nor available in an open elective set, regardless of the external credit total. External credits SHALL NOT advance the status of such a specialization to `achieved` or `achievable`.
#### Scenario: External credits cannot satisfy a required course gate
- **WHEN** the BRM specialization has 9.0 external credits but Brand Strategy is not selected and is in a pinned set holding a different course
- **THEN** BRM's status SHALL remain `missing_required`
- **AND** BRM SHALL NOT be counted in the achieved set
#### Scenario: Required course gate becomes satisfiable
- **WHEN** the required course is in an open elective set
- **THEN** the spec MAY transition to `achievable` once the LP-with-external-credits confirms feasibility, following the same rules as without external credits
### Requirement: 3-spec achievement cap is policy, not just budget
`maximizeCount` and `priorityOrder` SHALL cap the achieved set at 3 specializations regardless of external credit totals. External credits MAY shift which 3 specs are selected (e.g., admitting a spec that has no in-program qualifying courses, or freeing in-program credits for a different combination), but SHALL NOT raise the count above 3.
#### Scenario: Hard cap holds without external credits
- **WHEN** all `external[spec]` values are `0`
- **THEN** `maximizeCount` SHALL never return a subset larger than 3
#### Scenario: Hard cap holds with sufficient external credits
- **WHEN** the student has 9 or more external credits in a spec that the in-program courses do not naturally support
- **THEN** the optimizer MAY include that spec in the achieved set in place of one it would otherwise pick, but `maximizeCount` SHALL never return a subset larger than 3
#### Scenario: priorityOrder respects the cap
- **WHEN** the student has external credits sufficient to make 4 or more specs feasible
- **THEN** `priorityOrder` SHALL stop adding specs to the achieved set after the third
### Requirement: Leaf cache invalidates on external-credit change
The leaf cache in `useAppState` SHALL be cleared when any value in `externalCredits` changes (treated identically to a `ranking` or `mode` change). The cache invalidation signature SHALL include a deterministic stringification of `externalCredits` (e.g., sorted JSON of non-zero entries).
#### Scenario: Editing an external credit value clears the cache
- **WHEN** the user changes the external credit value for any specialization
- **THEN** the leaf cache SHALL be emptied and the next search SHALL run as a full recomputation
#### Scenario: No-op edit does not clear cache
- **WHEN** the user opens the chip input and commits the same value that was already there
- **THEN** the cache SHALL be retained (the signature is unchanged)
### Requirement: External credits propagate through the worker contract
The `WorkerRequest` SHALL include `externalCredits: Record<string, number>`. The decision-tree worker SHALL pass this value through to `searchDecisionTree`, which SHALL thread it into all `optimize`, `checkFeasibility`, `computeUpperBounds`, and `preFilterCandidates` calls used during the search.
#### Scenario: Worker uses external credits during exhaustive search
- **WHEN** the worker performs an exhaustive search with non-zero external credits
- **THEN** every leaf's `PlanOutcome.achievedSpecs` SHALL reflect the external-credit-aware feasibility verdict
#### Scenario: Empty external credits behaves like prior worker
- **WHEN** the worker receives `externalCredits: {}` (or all-zero values)
- **THEN** the worker's behavior and outputs SHALL match the prior implementation
@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: Inline editable external-credits chip on each spec card
Each specialization in the ranking panel SHALL include an inline editable chip for entering an external credit value. The chip SHALL display blank or `+0` when the spec's `externalCredits` value is `0`, and SHALL display `+<value>` (e.g., `+2.5`) when non-zero. Clicking or tapping the chip SHALL switch it to a numeric input field; pressing Enter or blurring the input SHALL commit the value. The chip SHALL be present on both the desktop chip layout and the mobile row layout, sized so it does not displace existing affordances (drag handle, status badge, credit bar).
#### Scenario: Chip shows the current value
- **WHEN** a specialization has `externalCredits[specId] === 2.5`
- **THEN** the chip SHALL display `+2.5`
#### Scenario: Chip is blank when value is zero
- **WHEN** a specialization has `externalCredits[specId] === 0` (or no entry)
- **THEN** the chip SHALL render in its blank/placeholder state (e.g., `+0` or an unobtrusive add-icon)
#### Scenario: Click activates input
- **WHEN** the user clicks or taps the chip
- **THEN** the chip SHALL switch to a numeric input pre-filled with the current value
#### Scenario: Enter commits the value
- **WHEN** the user types a valid non-negative number into the input and presses Enter
- **THEN** the value SHALL be saved to `externalCredits[specId]` and the chip SHALL return to display mode showing the new value
#### Scenario: Blur commits the value
- **WHEN** the user types a valid non-negative number into the input and clicks elsewhere
- **THEN** the value SHALL be saved and the chip SHALL return to display mode
#### Scenario: Invalid input clamps to zero
- **WHEN** the user commits an empty string, NaN, or a negative number
- **THEN** the value SHALL be saved as `0` and the chip SHALL return to its blank state
### Requirement: Credit bar renders external segment
`CreditBar` SHALL accept an `external` prop (a non-negative number). When `external > 0`, the bar SHALL render an amber stripe (`#f59e0b`) at the leftmost edge whose width is proportional to `external / maxWidth`. The existing in-program allocated stripe SHALL stack on top, starting at `external / maxWidth` and ending at `(external + allocated) / maxWidth`. The potential stripe SHALL stack next, starting at `(external + allocated) / maxWidth` and ending at `(external + potential) / maxWidth`. `maxWidth` SHALL be `Math.max(potential + external, threshold)` so that the threshold tick remains correctly positioned.
#### Scenario: External segment renders for non-zero value
- **WHEN** a spec has `external = 2.5`, `allocated = 5.0`, `potential = 7.5`, `threshold = 9`
- **THEN** the bar SHALL render an amber segment from `0%` to `2.5/10 = 25%` of the bar width, an allocated segment from `25%` to `75%`, and a potential segment from `75%` to `100%` (where `maxWidth = 10`)
#### Scenario: No external segment when value is zero
- **WHEN** a spec has `external = 0`
- **THEN** the bar SHALL render with the same layout as before this change (no amber segment)
#### Scenario: External alone exceeds threshold
- **WHEN** a spec has `external = 10` and `threshold = 9`
- **THEN** the threshold tick SHALL still appear at the `9 / maxWidth` position, with the amber segment crossing it
### Requirement: Achievement coloring keys off combined credit
The credit bar's "achieved" green color (`#22c55e`) SHALL switch on when `allocated + external ≥ threshold`. Otherwise the in-program allocated stripe SHALL render in the existing in-progress blue (`#3b82f6`).
#### Scenario: Combined credit reaches threshold via external
- **WHEN** `allocated = 7.0` and `external = 2.5`
- **THEN** the in-program allocated stripe SHALL render in green
#### Scenario: Combined credit below threshold
- **WHEN** `allocated = 4.0` and `external = 2.5`
- **THEN** the in-program allocated stripe SHALL render in blue
### Requirement: Allocation breakdown shows External line
`AllocationBreakdown` SHALL prepend an `External` line when `externalCredits[specId] > 0`, displaying the credit value (e.g., `External — 2.5`). The line SHALL be visually distinguishable from in-program contributions (e.g., italic label, amber accent, or other lightweight treatment) so the reader can identify it without explanation.
#### Scenario: External line appears for non-zero value
- **WHEN** a specialization has `external = 2.5` and one in-program contribution of `2.0` from "Real Estate Finance"
- **THEN** the breakdown SHALL list `External — 2.5` followed by `Real Estate Finance — 2.0`
#### Scenario: External line absent for zero value
- **WHEN** a specialization has `external = 0`
- **THEN** the breakdown SHALL render exactly as before this change (no External line)
#### Scenario: External-only spec shows only the External line
- **WHEN** a specialization has `external = 9` and no in-program contributions
- **THEN** the breakdown SHALL contain a single `External — 9.0` line