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.
This commit is contained in:
2026-03-27 17:33:24 -04:00
parent 1907e266c1
commit 578c87d59d
2 changed files with 45 additions and 1 deletions

View File

@@ -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)', () => {

View File

@@ -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;