3a5ebaa17a
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.
146 lines
11 KiB
Markdown
146 lines
11 KiB
Markdown
## 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<string, number>` keyed by `specId`
|
||
|
||
```ts
|
||
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])`:
|
||
|
||
```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_<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:
|
||
|
||
```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<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 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<string, number>` 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.
|