## 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`/`AllocationResult` shapes 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` keyed by `specId` ```ts externalCredits: Record; // 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])`: ```ts 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_` variables with capacity equal to the external value, contributing to the same `need_` 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: ```ts 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`. 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 for `maximizeCount` excludes 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 `{}`). 1. `feasibility.ts`: add `externalCredits?: Record` parameter to `checkFeasibility`, `computeUpperBounds`, `preFilterCandidates`. Subtract from demand; add to bounds. 2. `optimizer.ts`: thread the parameter through `maximizeCount`, `priorityOrder`, `determineStatuses`, `optimize`. Keep the `Math.min(3, …)` cap and `priorityOrder`'s `>= 3` guard. Verify `missing_required` status still wins over external coverage. 3. `decisionTree.ts` + worker: accept `externalCredits` in the search input and `WorkerRequest`; thread through. 4. `appState.ts`: add `externalCredits` to `AppState`, reducer (`setExternalCredit { specId, credits }`), localStorage load/save, and leaf-cache signature. 5. `SpecializationRanking.tsx`: extend `CreditBar` with an `external` prop and the amber stripe; extend `AllocationBreakdown` with the External line; add inline editable chip on the spec card. 6. Tests: feasibility demand reduction (full and partial), upper-bound boost, lifted ceiling, missing_required precedence, cache invalidation on external change. 7. 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. 8. 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.