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.
11 KiB
Context
The solver treats every credit as in-program: 12 elective slots × 2.5 credits = a 30-credit budget allocated by an LP across specializations whose demand is ≥ 9. Real students sometimes earn additional credits in cross-registered or transfer courses that the registrar accepts toward a J27 specialization. The tool currently has no representation for those credits, so the "achieved", "achievable", and "unreachable" verdicts understate the student's true position.
The user has specified the v1 shape:
- A simple per-spec number (no labels, no qualifications, no marker types).
- Inline editable chip on each spec card.
- An amber bar segment in the existing credit bar.
This document settles the LP integration, the bar layout, the status semantics around required-course gates, and the cache-invalidation hookup.
Goals / Non-Goals
Goals:
- Per-spec external credits as a first-class input that flows through feasibility, upper-bound, status, and visualization
- Zero new abstractions in the data layer —
Course/Specialization/AllocationResultshapes unchanged - LP changes are local and small (demand-side adjustment, no new variables)
- Bar visualization extends naturally — one new amber stripe stacked left
- Inputs persist with the rest of
AppState
Non-Goals:
- Per-entry labels, descriptions, or institution metadata
- Multi-spec qualification (a single external entry that splits across specs)
- Marker types (S1/S2) for external credits
- Letting external credits satisfy required-course gates (BRM/EMT/ENT/SBI)
- Per-spec or global caps on external credits
- Sharing/import-export of external credit values
Decisions
Data shape: Record<string, number> keyed by specId
externalCredits: Record<string, number>; // specId → credits, default 0
Lives on AppState next to ranking, mode, pinnedCourses. Persisted to localStorage. Missing keys treated as 0.
Alternative considered: ExternalCredit[] with {id, specId, credits, label}. Rejected per the user's "simple value" call — labels and entries add UI complexity (list management, IDs, deletion) for no LP benefit. If multi-source attribution becomes necessary later, the shape can grow to an array without touching the LP integration.
LP integration: demand reduction, no new variables
In feasibility.checkFeasibility, the per-spec demand becomes ≥ max(0, 9 − external[spec]):
for (const specId of targetSpecIds) {
const adjusted = Math.max(0, CREDIT_THRESHOLD - (externalCredits[specId] ?? 0));
if (adjusted === 0) continue; // already met externally — drop from LP
constraints[`need_${specId}`] = { min: adjusted };
}
Specs whose external alone meets or exceeds 9 contribute no constraint and no variables — the LP doesn't need to allocate any in-program credit to them. The optimizer still treats them as part of the achieved set.
Alternative considered: Inject synthetic x_external_<spec> variables with capacity equal to the external value, contributing to the same need_<spec> row. Rejected — adds variables for zero benefit; subtraction-from-demand is mathematically equivalent and simpler.
Achievement ceiling: hard 3-spec cap retained
maximizeCount (optimizer.ts:74) caps subset size at Math.min(3, achievable.length). The cap is program policy — the school does not award more than 3 specializations to a student, regardless of credit math. External credits do not change this. They may shift which 3 specs are achievable (e.g., enabling a spec that has no in-program qualifying courses) or free in-program credits to enable a different combination, but the maximum count returned is always ≤3. priorityOrder keeps the matching if (achieved.length >= 3) break; guard for the same reason.
Alternative considered: Lift the cap when external credits make a 4-spec subset LP-feasible. Rejected — the cap reflects a categorical school rule, not a credit-budget consequence. Reporting achieved.length === 4 would be misleading regardless of LP feasibility.
Status semantics: required-course gates beat external credits
A spec with requiredCourseId (BRM/EMT/ENT/SBI) stays in missing_required whenever the required course is not selected and not in an open set, regardless of external credit total. The bar still shows the amber segment (truthful: the student does have those credits) but the status badge doesn't lie.
Alternative considered: Promote to achievable if external credits cover the gap. Rejected — the required-course rule is a categorical gate, not a credit-count check. The registrar will not award the specialization without the required in-program course.
"Achieved" coloring switches at allocated + external ≥ 9
In CreditBar at SpecializationRanking.tsx:59, the green-vs-blue switch becomes:
background: (allocated + external) >= threshold ? '#22c55e' : '#3b82f6'
The user-visible signal "this spec is met" should reflect total credit, not just in-program credit.
Bar layout: amber stripe leftmost, then existing stack
0 9 max
├──────┬─────────────┬──────────────────┬────────────┤ │
│ ext │ allocated │ potential │ unfilled │ │
│amber │ green/blue │ light blue │ gray │ │
└──────┴─────────────┴──────────────────┴────────────┘ │
▲
threshold tick
maxWidth becomes Math.max(potential + external, threshold) so the threshold tick stays correctly positioned even when external alone exceeds 9.
External width: (external / maxWidth) * 100, rendered first.
Allocated stripe: starts at external/maxWidth, ends at (external + allocated)/maxWidth.
Potential stripe: starts at (external + allocated)/maxWidth, ends at (external + potential)/maxWidth.
Color: #f59e0b (amber-500). Distinct from the existing green/blue/light-blue palette and warm-vs-cool reads as a different category.
Alternative considered: Mix external credits into the existing allocated stripe with no visual distinction. Rejected — the user explicitly asked for a different color. Visibility of the external contribution is the whole point.
Input UI: inline editable chip on the spec card
Rendered in the spec card, blank (or +0) when the value is 0, showing the value (e.g., +2.5) when non-zero. Click switches to a numeric input; blur or Enter commits. Validation: parse as float, clamp to ≥ 0, treat NaN as 0.
The chip lives next to the spec name, not on the bar itself, so it does not compete with the bar's visual weight.
Alternative considered: Side-panel disclosure listing all 14 specs. Rejected — separates input from feedback; the bar updates on the spec card and the input should live there too.
Cache invalidation: external credits join ranking and mode
In useAppState, the leaf-cache invalidation check (appState.ts:168-175) currently compares ranking and mode. Extend to include a stable signature of externalCredits (e.g., a sorted JSON of non-zero entries). Any change to external credits clears the leaf cache, since per-leaf PlanOutcome (which encodes achievement and priorityScore) depends on it.
Alternative considered: Re-derive PlanOutcome from cached leaves on external-credit change without re-running the worker. Rejected for v1 — PlanOutcome.achievedSpecs is computed inside the LP, and external credits change which subsets are feasible. Simpler to invalidate.
Worker contract additions
WorkerRequest gains externalCredits: Record<string, number>. The worker passes this through to searchDecisionTree, which threads it into optimize/checkFeasibility/computeUpperBounds calls. No marker or qualification semantics — it's a flat per-spec number.
Risks / Trade-offs
- Trust gap between solver and registrar → Mitigation: the entered numbers are user-asserted; the tool is a planner. A subtle helper line near the input ("Verify with your advisor") is enough; no need for harder gating.
- Misleading achievement when required course is missing → Mitigation: status badge stays
missing_required; achievement count formaximizeCountexcludes specs in that status. The bar's amber segment is informational only. - 3-spec cap retained: external credits never report more than 3 achieved → Accepted. Matches school policy. UI affordance (the chip + amber bar) still surfaces credit toward a 4th spec, so a student is informed about their position even though the cap holds.
- Cache invalidation churn if user types in the chip rapidly → Mitigation: chip commits on blur/Enter, not on every keystroke; cache invalidation runs at most once per commit.
- Allocation breakdown listing "External" with no further attribution → Accepted. The user explicitly chose the labels-free model. If attribution is needed later, the data shape grows.
Migration Plan
Single-PR change. No data migration. localStorage gracefully tolerates missing keys (treat as {}).
feasibility.ts: addexternalCredits?: Record<string, number>parameter tocheckFeasibility,computeUpperBounds,preFilterCandidates. Subtract from demand; add to bounds.optimizer.ts: thread the parameter throughmaximizeCount,priorityOrder,determineStatuses,optimize. Keep theMath.min(3, …)cap andpriorityOrder's>= 3guard. Verifymissing_requiredstatus still wins over external coverage.decisionTree.ts+ worker: acceptexternalCreditsin the search input andWorkerRequest; thread through.appState.ts: addexternalCreditstoAppState, reducer (setExternalCredit { specId, credits }), localStorage load/save, and leaf-cache signature.SpecializationRanking.tsx: extendCreditBarwith anexternalprop and the amber stripe; extendAllocationBreakdownwith the External line; add inline editable chip on the spec card.- Tests: feasibility demand reduction (full and partial), upper-bound boost, lifted ceiling, missing_required precedence, cache invalidation on external change.
- Browser verify: enter external credits on a spec, watch bar update; verify achievement crosses 9 with combined credit; verify required-course gate still blocks BRM/EMT/ENT/SBI.
- Version bump in
vite.config.ts; CHANGELOG entry; ship.
Rollback: revert. localStorage externalCredits key remains in stored state but is harmlessly ignored by the prior code path.
Open Questions
- Default position of the inline chip on the spec card — left of name, right edge, under the bar. Resolve during implementation; pick whatever fits the existing card layout without disrupting drag handle / status badge alignment.
- Whether to show a small helper text ("Verify with your advisor") in the panel or as a tooltip on the chip. Defer to design polish at the UI step.