From 578c87d59d8df2c5dcb6c1a5170c05bd0839c7da Mon Sep 17 00:00:00 2001 From: Bill Ballou Date: Fri, 27 Mar 2026 17:33:24 -0400 Subject: [PATCH] fix: mark specs as unreachable when infeasible alongside achieved specs determineStatuses() was marking specs as 'achievable' based solely on per-specialization upper bounds, ignoring credit sharing with achieved specs. Now performs an LP feasibility check to verify the spec can actually be achieved alongside the current achieved set. --- app/src/solver/__tests__/optimizer.test.ts | 31 ++++++++++++++++++++++ app/src/solver/optimizer.ts | 15 ++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/solver/__tests__/optimizer.test.ts b/app/src/solver/__tests__/optimizer.test.ts index dfe2d2a..f9ebeeb 100644 --- a/app/src/solver/__tests__/optimizer.test.ts +++ b/app/src/solver/__tests__/optimizer.test.ts @@ -109,6 +109,37 @@ describe('determineStatuses', () => { // Most specs only have 1 qualifying course in spr1 (2.5 credits < 9) expect(statuses['FIN']).toBe('unreachable'); }); + + it('marks spec as unreachable when infeasible alongside achieved specs due to credit sharing', () => { + // Bug scenario: CRF+STR achieved, LCM has 10 credit upper bound but + // shared courses (spr3, fall3) are consumed by CRF/STR + const selectedCourses = [ + 'spr1-collaboration', // LCM, MGT + 'spr2-financial-services', // BNK, CRF, FIN, FIM + 'spr3-mergers-acquisitions', // CRF, FIN, LCM, STR(S1) + 'spr4-foundations-entrepreneurship', // ENT, MGT, STR(S1) + 'spr5-corporate-finance', // CRF, FIN + 'sum1-global-immersion', // GLB + 'sum2-business-drivers', // STR(S1) + 'sum3-valuation', // BNK, CRF, FIN, FIM + 'fall1-managing-change', // LCM, MGT, STR(S2) + 'fall2-decision-models', // MGT, MTO + 'fall3-corporate-governance', // LCM, MGT, SBI, STR(S1) + 'fall4-game-theory', // MGT, STR(S1) + ]; + + // Baseline: LCM is achievable when no specs are achieved (upper bound alone) + const statusesBaseline = determineStatuses(selectedCourses, [], []); + expect(statusesBaseline['LCM']).toBe('achievable'); + + // Core bug scenario: CRF+STR achieved (without MGT), LCM should still be unreachable + const statusesWithoutMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR']); + expect(statusesWithoutMgt['LCM']).toBe('unreachable'); + + // LCM upper bound is 10 (>= 9) but infeasible alongside CRF+STR+MGT + const statusesWithMgt = determineStatuses(selectedCourses, [], ['CRF', 'STR', 'MGT']); + expect(statusesWithMgt['LCM']).toBe('unreachable'); + }); }); describe('optimize (integration)', () => { diff --git a/app/src/solver/optimizer.ts b/app/src/solver/optimizer.ts index b5a1784..351c977 100644 --- a/app/src/solver/optimizer.ts +++ b/app/src/solver/optimizer.ts @@ -178,7 +178,20 @@ export function determineStatuses( continue; } - statuses[spec.id] = 'achievable'; + // Verify spec is actually feasible alongside already-achieved specs, + // but only when all course slots are committed (no open sets remain). + // With open sets, the user can still pick different courses, so LP + // feasibility over selected courses alone would give false negatives. + if (openSetSet.size === 0) { + const testSet = [...achieved, spec.id]; + const filteredCourseIds = excludedCourseIds + ? selectedCourseIds.filter((id) => !excludedCourseIds.has(id)) + : selectedCourseIds; + const feasResult = checkWithS2(filteredCourseIds, testSet); + statuses[spec.id] = feasResult.feasible ? 'achievable' : 'unreachable'; + } else { + statuses[spec.id] = 'achievable'; + } } return statuses;