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

7.9 KiB

1. Solver: external-credit-aware feasibility and bounds

  • 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 }
  • 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
  • 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
  • 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

  • 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)
  • 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)
  • 2.3 In determineStatuses, ensure missing_required precedence is preserved: when spec.requiredCourseId is unsatisfied, status remains missing_required regardless of externalCredits[spec.id]
  • 2.4 Keep priorityOrder's if (achieved.length >= 3) break; guard alongside the maxSize cap, mirroring maximizeCount

3. Solver: decision tree + worker plumbing

  • 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
  • 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

  • 4.1 In app/src/state/appState.ts, extend AppState with externalCredits: Record<string, number>
  • 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
  • 4.3 Update defaultState() to include externalCredits: {}. Update loadState() to tolerate missing/invalid externalCredits (default to {})
  • 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
  • 4.5 Extend pinnedAssignments and the worker WorkerRequest build to include externalCredits. Pass externalCredits into the main-thread optimize(...) call as well
  • 4.6 Export a setExternalCredit callback from useAppState

5. UI: bar segment and breakdown

  • 5.1 In app/src/components/SpecializationRanking.tsx, extend CreditBar props with external: number (default 0)
  • 5.2 Update maxWidth to Math.max(potential + external, threshold)
  • 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
  • 5.4 Switch the allocated stripe color to green (#22c55e) when (allocated + external) >= threshold, blue (#3b82f6) otherwise
  • 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
  • 5.6 Pass external through from the spec card render path to both CreditBar and AllocationBreakdown

6. UI: inline editable credits chip

  • 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
  • 6.2 Implement commit-on-blur and commit-on-Enter; clamp invalid input to 0; allow decimals; reject negatives by clamping
  • 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
  • 6.4 Wire the chip's onChange to the new setExternalCredit callback from useAppState

7. Tests

  • 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
  • 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
  • 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
  • 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

  • 9.1 Bump __APP_VERSION__ and __APP_VERSION_DATE__ in app/vite.config.ts to the next release (e.g., 1.4.1)
  • 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