Files
emba-course-solver/openspec/changes/add-external-credits/tasks.md
T
Bill 3a5ebaa17a 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.
2026-05-10 11:47:22 -04:00

74 lines
7.9 KiB
Markdown

## 1. Solver: external-credit-aware feasibility and bounds
- [x] 1.1 In `app/src/solver/feasibility.ts`, extend `checkFeasibility` to accept `externalCredits?: Record<string, number>`. Before building per-spec demand constraints, compute `adjusted = max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0))`. If `adjusted === 0`, omit both the `need_<spec>` constraint and any `x_<course>_<spec>` variables for that spec entirely. Otherwise set `need_<spec> = { min: adjusted }`
- [x] 1.2 Decide how to count externally-met specs in `result.allocations`: emit no in-program allocations for them (the LP doesn't allocate to them), and let `optimizer.determineStatuses` mark them as `achieved` based on the achieved-set rather than allocation totals. Document this in a one-line comment in `feasibility.ts`
- [x] 1.3 Extend `computeUpperBounds` to accept `externalCredits?: Record<string, number>` and add `(externalCredits[spec.id] ?? 0)` to each spec's potential before storing the bound
- [x] 1.4 Extend `preFilterCandidates` to accept `externalCredits?: Record<string, number>` and use the boosted potential when comparing against `CREDIT_THRESHOLD`
## 2. Solver: optimizer ceiling and status
- [x] 2.1 In `app/src/solver/optimizer.ts`, thread `externalCredits?: Record<string, number>` through `maximizeCount`, `priorityOrder`, `determineStatuses`, `optimize`, and `checkWithS2`. All call sites pass through (workers, app state)
- [x] 2.2 Keep the hard 3-spec cap in `maximizeCount` (`const maxSize = Math.min(3, achievable.length)`). External credits may substitute into the 3 but never raise the count above 3 (program policy)
- [x] 2.3 In `determineStatuses`, ensure `missing_required` precedence is preserved: when `spec.requiredCourseId` is unsatisfied, status remains `missing_required` regardless of `externalCredits[spec.id]`
- [x] 2.4 Keep `priorityOrder`'s `if (achieved.length >= 3) break;` guard alongside the maxSize cap, mirroring `maximizeCount`
## 3. Solver: decision tree + worker plumbing
- [x] 3.1 In `app/src/solver/decisionTree.ts`, thread `externalCredits?: Record<string, number>` into `searchDecisionTree`, `evaluateLeaf`, `reorderForTarget`, `reorderByReachableQualCount`, and any helper that calls `computeUpperBounds`/`optimize`/`checkFeasibility`. Default to `{}` when absent
- [x] 3.2 In `app/src/workers/decisionTree.worker.ts`, extend `WorkerRequest` with `externalCredits: Record<string, number>` and pass it through to `searchDecisionTree`
## 4. App state: external credits storage and cache invalidation
- [x] 4.1 In `app/src/state/appState.ts`, extend `AppState` with `externalCredits: Record<string, number>`
- [x] 4.2 Add reducer action `{ type: 'setExternalCredit'; specId: string; credits: number }`. The reducer SHALL clamp negatives and `NaN` to `0`, treat `0` as deletion (omit the key from the new map), and produce an immutable update
- [x] 4.3 Update `defaultState()` to include `externalCredits: {}`. Update `loadState()` to tolerate missing/invalid `externalCredits` (default to `{}`)
- [x] 4.4 Extend the leaf-cache `LeafCache` type with `externalCredits: Record<string, number>` and the cache-invalidation check (currently comparing `ranking` and `mode`) to also compare external credits via deterministic stringification of sorted non-zero entries
- [x] 4.5 Extend `pinnedAssignments` and the worker `WorkerRequest` build to include `externalCredits`. Pass `externalCredits` into the main-thread `optimize(...)` call as well
- [x] 4.6 Export a `setExternalCredit` callback from `useAppState`
## 5. UI: bar segment and breakdown
- [x] 5.1 In `app/src/components/SpecializationRanking.tsx`, extend `CreditBar` props with `external: number` (default `0`)
- [x] 5.2 Update `maxWidth` to `Math.max(potential + external, threshold)`
- [x] 5.3 Render the amber stripe (`#f59e0b`) at the leftmost position with width `(external / maxWidth) * 100%`. The existing potential and allocated stripes shift to start at `external / maxWidth` (potential ends at `(external + potential) / maxWidth`; allocated ends at `(external + allocated) / maxWidth`). Tick marks and threshold marker positions remain expressed in absolute credits, scaled by `maxWidth`
- [x] 5.4 Switch the allocated stripe color to green (`#22c55e`) when `(allocated + external) >= threshold`, blue (`#3b82f6`) otherwise
- [x] 5.5 In `AllocationBreakdown`, accept `external: number` and prepend a `External` line item when `external > 0`. Use a small visual cue (italic label, amber accent text, or border) so it is identifiable without a legend
- [x] 5.6 Pass `external` through from the spec card render path to both `CreditBar` and `AllocationBreakdown`
## 6. UI: inline editable credits chip
- [x] 6.1 Add an `ExternalCreditChip` component (in `SpecializationRanking.tsx` or a new file under `app/src/components/`) that takes `value`, `onChange(next: number)`, and renders the display state (`+<value>` or blank) and an inline numeric input on click/tap
- [x] 6.2 Implement commit-on-blur and commit-on-Enter; clamp invalid input to `0`; allow decimals; reject negatives by clamping
- [x] 6.3 Wire the chip into both the desktop chip layout and the mobile row layout in `SpecializationRanking.tsx`. Position so it does not displace drag handle, status badge, or rank number
- [x] 6.4 Wire the chip's `onChange` to the new `setExternalCredit` callback from `useAppState`
## 7. Tests
- [x] 7.1 In `app/src/solver/__tests__/feasibility.test.ts` (new or existing), add tests:
- LP demand reduction: 4.0 external + 5.0 in-program-feasible → feasible; without the 4.0 external, same in-program selection is infeasible
- External alone meets threshold: 9.0 external → spec is feasible with no in-program allocation
- Empty external credits preserves prior behavior on a representative scenario
- Upper-bound boost: spec with in-program potential 6.0 and external 5.0 yields bound 11.0
- `preFilterCandidates` includes spec with insufficient in-program potential when external closes the gap
- [x] 7.2 In `app/src/solver/__tests__/optimizer.test.ts` (new or existing), add tests:
- Hard 3-spec cap holds with and without external credits
- External credits can substitute into the 3-spec set (e.g., HCR via 9 external)
- `missing_required` survives external-only achievement: BRM with 9.0 external but no Brand Strategy stays `missing_required` and is not in the achieved set
- `priorityOrder` respects the same external-credit math and the 3-spec cap
- [x] 7.3 Add a state-layer test (or extend existing app-state tests) confirming the leaf cache is cleared when `externalCredits` changes and is retained when it does not
- [x] 7.4 Run full suite; confirm all prior tests still pass
## 8. Browser verification
- [ ] 8.1 Start dev server. Click the chip on a non-required-course spec (e.g., Banking), enter `2.5`. Bar updates with amber segment; achievement count and per-spec status update accordingly
- [ ] 8.2 Increase external credits to `9.0`. Spec shows as `achieved` with the amber segment crossing the threshold tick. Allocation breakdown shows only the External line
- [ ] 8.3 Click the chip on a required-course-gated spec (e.g., BRM) without selecting Brand Strategy, enter `9.0`. Spec stays in `missing_required`. Bar shows full amber but status badge is unchanged
- [ ] 8.4 Combine: pin courses for an in-program 3-spec achievement, then add `9.0` external to a fourth spec. Verify the optimizer still reports 3 (cap holds) but the chosen 3 may shift to include the externally-credited spec
- [ ] 8.5 Reload page. External credit values persist. Cache invalidation visible (next search re-runs)
- [ ] 8.6 Confirm no console errors; verify the chip is reachable and editable on both desktop and mobile layouts
## 9. Version + changelog
- [x] 9.1 Bump `__APP_VERSION__` and `__APP_VERSION_DATE__` in `app/vite.config.ts` to the next release (e.g., `1.4.1`)
- [x] 9.2 Add a `CHANGELOG.md` entry: external credits per spec via inline chip, amber bar segment, achievement coloring on combined credit, lifted 3-spec ceiling, required-course gates unchanged