Files
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

146 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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.