diff --git a/apps/searchneu/lib/scheduler/binaryMeetingTime.ts b/apps/searchneu/lib/scheduler/binaryMeetingTime.ts index 32349284..c49416fd 100644 --- a/apps/searchneu/lib/scheduler/binaryMeetingTime.ts +++ b/apps/searchneu/lib/scheduler/binaryMeetingTime.ts @@ -18,29 +18,22 @@ function timeToSlotIndex(time: number): number { return Math.floor(totalMinutes / MINUTES_PER_SLOT); } -/** - * Get global slot index (0-2015) for a given day and slot within that day. - */ -function getGlobalSlotIndex(day: number, slot: number): number { - return day * SLOTS_PER_DAY + slot; -} - /** * Convert a section's meeting times to a binary mask. - * Each occupied 5-minute slot gets one bit set. + * Uses a range-mask formula to set a contiguous block of bits per meeting + * in a single BigInt expression instead of looping over individual slots. */ export function meetingTimesToBinaryMask(section: SectionWithCourse): bigint { - let mask = BigInt(0); // Use BigInt constructor to avoid ES2020 literal + let mask = BigInt(0); for (const meetingTime of section.meetingTimes) { const startSlot = timeToSlotIndex(meetingTime.startTime); - const endSlotExclusive = timeToSlotIndex(meetingTime.endTime); - - // Set bits for each occupied slot + const numSlots = timeToSlotIndex(meetingTime.endTime) - startSlot; + if (numSlots <= 0) continue; + // Create a block of `numSlots` consecutive 1-bits: (1 << numSlots) - 1 + const slotBlock = (BigInt(1) << BigInt(numSlots)) - BigInt(1); for (const day of meetingTime.days) { - for (let slot = startSlot; slot < endSlotExclusive; slot++) { - const globalSlot = getGlobalSlotIndex(day, slot); - mask |= BigInt(1) << BigInt(globalSlot); - } + const globalStart = day * SLOTS_PER_DAY + startSlot; + mask |= slotBlock << BigInt(globalStart); } } return mask; diff --git a/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/addOptionalCourses.test.ts b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/addOptionalCourses.test.ts new file mode 100644 index 00000000..cca0443f --- /dev/null +++ b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/addOptionalCourses.test.ts @@ -0,0 +1,666 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +import { SectionWithCourse } from "../filters"; +import { + addOptionalCourses, + generateCombinationsOptimized, + MAX_RESULTS, +} from "../generateCombinations"; +import { + meetingTimesToBinaryMask, + hasConflictInSchedule, +} from "../binaryMeetingTime"; +import { createMockSection } from "./mocks"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build pre-computed masks for a list of courses, matching the shape expected by addOptionalCourses. */ +function buildMasks(sectionsByCourse: SectionWithCourse[][]): bigint[][] { + return sectionsByCourse.map((sections) => + sections.map(meetingTimesToBinaryMask), + ); +} + +/** Combined mask for an array of sections (OR of all individual masks). */ +function combinedMask(sections: SectionWithCourse[]): bigint { + return sections.reduce( + (acc, s) => acc | meetingTimesToBinaryMask(s), + BigInt(0), + ); +} + +/** Extract sorted section-ID sets from a list of schedules for order-independent comparison. */ +function toIdSets(schedules: SectionWithCourse[][]): Set[] { + return schedules.map((s) => new Set(s.map((sec) => sec.id))); +} + +// --------------------------------------------------------------------------- +// Fixtures — non-conflicting time slots +// --------------------------------------------------------------------------- + +const S_MON_8 = createMockSection(1, [ + { days: [1], startTime: 800, endTime: 900 }, +]); +const S_MON_10 = createMockSection(2, [ + { days: [1], startTime: 1000, endTime: 1100 }, +]); +const S_MON_12 = createMockSection(3, [ + { days: [1], startTime: 1200, endTime: 1300 }, +]); +const S_TUE_8 = createMockSection(4, [ + { days: [2], startTime: 800, endTime: 900 }, +]); +const S_TUE_10 = createMockSection(5, [ + { days: [2], startTime: 1000, endTime: 1100 }, +]); +const S_WED_8 = createMockSection(6, [ + { days: [3], startTime: 800, endTime: 900 }, +]); +const S_WED_10 = createMockSection(7, [ + { days: [3], startTime: 1000, endTime: 1100 }, +]); +// Conflicts with S_MON_8 (same day/time) +const S_MON_8_B = createMockSection(8, [ + { days: [1], startTime: 800, endTime: 900 }, +]); +// Multi-meeting: MWF lecture + TR lab +const S_MWF_LECTURE_TR_LAB = createMockSection(9, [ + { days: [1, 3, 5], startTime: 900, endTime: 950 }, // MWF 9-9:50 + { days: [2, 4], startTime: 1100, endTime: 1150 }, // TR 11-11:50 +]); +// Conflicts with lecture block +const S_MON_OVERLAP_LECTURE = createMockSection(10, [ + { days: [1], startTime: 930, endTime: 1030 }, +]); +// Conflicts with lab block only +const S_TUE_OVERLAP_LAB = createMockSection(11, [ + { days: [2], startTime: 1130, endTime: 1230 }, +]); +// Doesn't conflict with either block +const S_MON_BEFORE = createMockSection(12, [ + { days: [1], startTime: 800, endTime: 850 }, +]); + +// --------------------------------------------------------------------------- +// Basic correctness +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — basic correctness", () => { + test("no optional courses → returns exactly the base schedule", () => { + const base = [S_MON_8]; + const mask = combinedMask(base); + const results = addOptionalCourses(base, mask, [], []); + assert.equal(results.length, 1); + assert.deepEqual(results[0], base); + }); + + test("one optional course, no conflict → returns [base] and [base + optional]", () => { + const base = [S_MON_8]; + const optionals = [[S_TUE_10]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 2); + const idSets = toIdSets(results); + assert.ok(idSets.some((s) => s.size === 1 && s.has(S_MON_8.id))); + assert.ok( + idSets.some( + (s) => s.size === 2 && s.has(S_MON_8.id) && s.has(S_TUE_10.id), + ), + ); + }); + + test("one optional course, always conflicts → returns only base schedule", () => { + const base = [S_MON_8]; + const optionals = [[S_MON_8_B]]; // same slot as base + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 1); + assert.deepEqual(results[0], base); + }); + + test("two independent optional courses, no conflicts → 4 results (2×2)", () => { + const base = [S_MON_8]; + const optionals = [[S_TUE_10], [S_WED_8]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 4); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); + + test("empty base schedule with optional courses works correctly", () => { + const optionals = [[S_MON_8, S_MON_10], [S_TUE_8]]; + const results = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + ); + // skip/skip, MON_8/skip, MON_10/skip, skip/TUE_8, MON_8/TUE_8, MON_10/TUE_8 = 6 + assert.equal(results.length, 6); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); + + test("optional section that conflicts with another optional is excluded", () => { + // Both optional sections occupy MON@8 — can't pick both + const optionals = [[S_MON_8], [S_MON_8_B]]; + const results = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + ); + const idSets = toIdSets(results); + // Must not contain both S_MON_8 and S_MON_8_B in the same schedule + assert.ok(!idSets.some((s) => s.has(S_MON_8.id) && s.has(S_MON_8_B.id))); + }); + + test("no output schedules contain time conflicts", () => { + const base = [S_MON_8, S_TUE_8]; + const optionals = [ + [S_MON_10, S_MON_8_B], + [S_WED_10, S_WED_8], + ]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.ok(results.length > 0); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); +}); + +// --------------------------------------------------------------------------- +// Sections with multiple meeting times (lecture + lab) +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — multiple meeting times per section", () => { + test("section with MWF+TR meetings: overlapping optional on Mon is excluded", () => { + const base = [S_MWF_LECTURE_TR_LAB]; + const optionals = [[S_MON_OVERLAP_LECTURE]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + // Only the base-only schedule; overlap with lecture block on Mon + assert.equal(results.length, 1); + assert.ok(!results[0].includes(S_MON_OVERLAP_LECTURE)); + }); + + test("section with MWF+TR meetings: overlapping optional on Tue (lab) is excluded", () => { + const base = [S_MWF_LECTURE_TR_LAB]; + const optionals = [[S_TUE_OVERLAP_LAB]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 1); + assert.ok(!results[0].includes(S_TUE_OVERLAP_LAB)); + }); + + test("section with MWF+TR meetings: non-overlapping optional is included", () => { + const base = [S_MWF_LECTURE_TR_LAB]; + const optionals = [[S_MON_BEFORE]]; // Mon 8-8:50, lecture starts at 9 + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 2); + const withOptional = results.find((s) => s.includes(S_MON_BEFORE)); + assert.ok(withOptional !== undefined); + assert.ok(!hasConflictInSchedule(withOptional)); + }); + + test("optional course with multiple meeting times: all blocks checked for conflicts", () => { + // S_MWF_LECTURE_TR_LAB occupies MWF@9 and TR@11. The optional below occupies MWF@9 too. + const conflictingMulti = createMockSection(20, [ + { days: [1, 3, 5], startTime: 900, endTime: 950 }, // conflicts with lecture + { days: [6], startTime: 800, endTime: 900 }, // Saturday — no conflict + ]); + const optionals = [[conflictingMulti]]; + const results = addOptionalCourses( + [S_MWF_LECTURE_TR_LAB], + combinedMask([S_MWF_LECTURE_TR_LAB]), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 1); + assert.ok(!results[0].includes(conflictingMulti)); + }); +}); + +// --------------------------------------------------------------------------- +// numCourses parameter +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — numCourses filtering", () => { + // base has 1 section, 2 optional courses → without numCourses: 4 results (1, 1+A, 1+B, 1+A+B) + const base = [S_MON_8]; + const optionals = [[S_TUE_10], [S_WED_8]]; + + test("numCourses = base length → only the base schedule", () => { + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + 1, // numCourses = 1, base already has 1 + ); + assert.equal(results.length, 1); + assert.deepEqual( + results[0].map((s) => s.id), + [S_MON_8.id], + ); + }); + + test("numCourses = base + 1 → only schedules with exactly one optional added", () => { + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + 2, + ); + assert.equal(results.length, 2); + assert.ok(results.every((s) => s.length === 2)); + }); + + test("numCourses = base + 2 → only schedules with both optionals added", () => { + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + 3, + ); + assert.equal(results.length, 1); + assert.equal(results[0].length, 3); + }); + + test("numCourses met mid-recursion: shortcut skips remaining optional courses", () => { + // base = 1 section, numCourses = 2, THREE optional courses. + // After adding the first optional the target is reached. The shortcut at + // `currentSchedule.length === numCourses` must jump straight to the base + // case (which does the push) rather than continuing into the remaining two + // optional courses. If the shortcut were broken we'd get wrong counts or + // missing/duplicate results. + const base = [S_MON_8]; + const optionals = [[S_TUE_10], [S_WED_8], [S_MON_12]]; // 3 optional courses + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + 2, + ); + // Exactly three length-2 results: base + each individual optional + assert.equal(results.length, 3); + assert.ok(results.every((s) => s.length === 2)); + const idSets = toIdSets(results); + assert.ok(idSets.some((s) => s.has(S_MON_8.id) && s.has(S_TUE_10.id))); + assert.ok(idSets.some((s) => s.has(S_MON_8.id) && s.has(S_WED_8.id))); + assert.ok(idSets.some((s) => s.has(S_MON_8.id) && s.has(S_MON_12.id))); + }); + + test("numCourses impossible to reach → no results", () => { + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + 10, // impossible: only 3 sections total + ); + assert.equal(results.length, 0); + }); + + test("numCourses undefined → all valid combinations returned", () => { + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + undefined, + ); + assert.equal(results.length, 4); + }); +}); + +// --------------------------------------------------------------------------- +// maxResults cap +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — maxResults cap", () => { + test("respects maxResults = 1", () => { + const optionals = [[S_MON_8], [S_TUE_8], [S_WED_8]]; + const results = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + undefined, + 1, + ); + assert.equal(results.length, 1); + }); + + test("respects maxResults when there are many valid combos", () => { + // 4 independent optional courses → 2^4 = 16 combos; cap at 5 + const optionals = [ + [S_MON_8], + [S_TUE_8], + [S_WED_8], + [createMockSection(50, [{ days: [4], startTime: 800, endTime: 900 }])], + ]; + const results = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + undefined, + 5, + ); + assert.equal(results.length, 5); + }); + + test("maxResults larger than total results → returns all", () => { + const optionals = [[S_MON_10], [S_WED_10]]; + const uncapped = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + ); + const capped = addOptionalCourses( + [], + BigInt(0), + optionals, + buildMasks(optionals), + undefined, + 100, + ); + assert.equal(capped.length, uncapped.length); + }); +}); + +// --------------------------------------------------------------------------- +// Push/pop correctness — schedules must be independent snapshots +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — push/pop isolation", () => { + test("returned schedules are independent copies (mutating one doesn't affect others)", () => { + const optionals = [[S_TUE_10], [S_WED_8]]; + const results = addOptionalCourses( + [S_MON_8], + combinedMask([S_MON_8]), + optionals, + buildMasks(optionals), + ); + assert.equal(results.length, 4); + // Mutate the first result + results[0].push(S_MON_12); + // Other results must not be affected + assert.ok(results.slice(1).every((s) => !s.includes(S_MON_12))); + }); + + test("base schedule array is not mutated by the call", () => { + const base = [S_MON_8]; + const optionals = [[S_TUE_10]]; + addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + assert.equal(base.length, 1); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: generateCombinationsOptimized + addOptionalCourses +// (simulates what generateSchedules does, without the DB layer) +// --------------------------------------------------------------------------- + +describe("locked + optional courses integration", () => { + test("locked courses generate valid base schedules, each extended with optionals", () => { + // Two locked courses, each with 2 sections + const lockedCourseA = [S_MON_8, S_MON_10]; // A sections + const lockedCourseB = [S_TUE_8, S_WED_8]; // B sections (all non-conflicting with A) + const lockedSchedules = generateCombinationsOptimized([ + lockedCourseA, + lockedCourseB, + ]); + + const optionals = [[S_WED_10, S_MON_12]]; + const optMasks = buildMasks(optionals); + + const all: SectionWithCourse[][] = []; + for (const { schedule, mask } of lockedSchedules) { + const extended = addOptionalCourses(schedule, mask, optionals, optMasks); + all.push(...extended); + } + + // All results must be conflict-free + assert.ok(all.length > 0); + assert.ok(all.every((s) => !hasConflictInSchedule(s))); + // Every result must contain both locked sections + assert.ok( + all.every( + (s) => + lockedCourseA.some((a) => s.includes(a)) && + lockedCourseB.some((b) => s.includes(b)), + ), + ); + }); + + test("MAX_RESULTS is respected across the combined locked+optional loop", () => { + const lockedCourses = [ + [S_MON_8, S_MON_10], + [S_TUE_8, S_TUE_10], + ]; + const lockedSchedules = generateCombinationsOptimized(lockedCourses); + const optionals = [[S_WED_8, S_WED_10]]; + const optMasks = buildMasks(optionals); + + const all: SectionWithCourse[][] = []; + for (const { schedule, mask } of lockedSchedules) { + if (all.length >= MAX_RESULTS) break; + const remaining = MAX_RESULTS - all.length; + all.push( + ...addOptionalCourses( + schedule, + mask, + optionals, + optMasks, + undefined, + remaining, + ), + ); + } + + assert.ok(all.length <= MAX_RESULTS); + assert.ok(all.every((s) => !hasConflictInSchedule(s))); + }); + + test("numCourses filters correctly across locked + optional pipeline", () => { + const lockedCourses = [[S_MON_8]]; + const lockedSchedules = generateCombinationsOptimized(lockedCourses); + const optionals = [[S_TUE_8], [S_WED_8]]; + const optMasks = buildMasks(optionals); + + // numCourses = 2 → only schedules with exactly 1 optional added + const results: SectionWithCourse[][] = []; + for (const { schedule, mask } of lockedSchedules) { + results.push( + ...addOptionalCourses(schedule, mask, optionals, optMasks, 2), + ); + } + + assert.ok(results.every((s) => s.length === 2)); + }); +}); + +// --------------------------------------------------------------------------- +// Edge: optional course with empty sections list +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — optional course with no sections", () => { + test("empty optional course is silently skipped (no crash)", () => { + const base = [S_MON_8]; + const optionals: SectionWithCourse[][] = [[]]; // one optional course, zero sections + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + // The only choice for the empty course is skip → returns [base] + assert.equal(results.length, 1); + assert.deepEqual(results[0], base); + }); + + test("empty optional among non-empty optionals", () => { + const base = [S_MON_8]; + const optionals: SectionWithCourse[][] = [[], [S_TUE_10]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + // Empty course: skip only. TUE_10 course: skip or include → 2 results + assert.equal(results.length, 2); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); +}); + +// --------------------------------------------------------------------------- +// Exact result verification for deterministic small cases +// --------------------------------------------------------------------------- + +describe("addOptionalCourses — exact result IDs", () => { + test("base + one optional: exact section IDs in each result", () => { + const base = [S_MON_8]; + const optionals = [[S_TUE_10]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + const idSets = toIdSets(results); + // Result 1: just base + assert.ok(idSets.some((s) => s.size === 1 && s.has(S_MON_8.id))); + // Result 2: base + TUE_10 + assert.ok( + idSets.some( + (s) => s.size === 2 && s.has(S_MON_8.id) && s.has(S_TUE_10.id), + ), + ); + }); + + test("one optional with two sections: only the non-conflicting section appears", () => { + const base = [S_MON_8]; + // S_MON_8_B conflicts with base; S_TUE_10 does not + const optionals = [[S_MON_8_B, S_TUE_10]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + const idSets = toIdSets(results); + // No result should contain S_MON_8_B + assert.ok(idSets.every((s) => !s.has(S_MON_8_B.id))); + // One result should contain S_TUE_10 + assert.ok(idSets.some((s) => s.has(S_TUE_10.id))); + }); + + test("two optionals: all 4 exact combinations present", () => { + const base = [S_MON_8]; + const optionals = [[S_TUE_10], [S_WED_8]]; + const results = addOptionalCourses( + base, + combinedMask(base), + optionals, + buildMasks(optionals), + ); + const idSets = toIdSets(results); + assert.equal(idSets.length, 4); + // Exact 4 combinations + const has = (ids: number[]) => + idSets.some((s) => s.size === ids.length && ids.every((id) => s.has(id))); + assert.ok(has([S_MON_8.id])); + assert.ok(has([S_MON_8.id, S_TUE_10.id])); + assert.ok(has([S_MON_8.id, S_WED_8.id])); + assert.ok(has([S_MON_8.id, S_TUE_10.id, S_WED_8.id])); + }); +}); + +// --------------------------------------------------------------------------- +// Correctness comparison — capped results must be a valid subset +// --------------------------------------------------------------------------- + +describe("generateCombinationsOptimized — capped results are a valid subset", () => { + test("every schedule in the capped result also appears in the uncapped result", () => { + // Small enough that uncapped is tractable + const sectionsByCourse = [ + [S_MON_8, S_MON_10, S_MON_12], + [S_TUE_8, S_TUE_10], + [S_WED_8, S_WED_10], + ]; + + const uncapped = generateCombinationsOptimized(sectionsByCourse); + const capped = generateCombinationsOptimized(sectionsByCourse, 3); + + assert.ok(capped.length <= 3); + assert.ok(capped.length <= uncapped.length); + + const uncappedIdSets = toIdSets(uncapped.map((r) => r.schedule)); + + for (const { schedule } of capped) { + const ids = new Set(schedule.map((s) => s.id)); + const found = uncappedIdSets.some((u) => { + if (u.size !== ids.size) return false; + for (const id of ids) if (!u.has(id)) return false; + return true; + }); + assert.ok( + found, + `capped schedule [${[...ids]}] not found in uncapped results`, + ); + } + }); + + test("every result is conflict-free regardless of cap", () => { + const sectionsByCourse = [ + [S_MON_8, S_MON_10, S_TUE_8], + [S_WED_8, S_WED_10], + ]; + const results = generateCombinationsOptimized(sectionsByCourse, 5); + assert.ok( + results.every(({ schedule }) => !hasConflictInSchedule(schedule)), + ); + }); +}); diff --git a/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/benchmark.bench.ts b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/benchmark.bench.ts new file mode 100644 index 00000000..a0fa90b2 --- /dev/null +++ b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/benchmark.bench.ts @@ -0,0 +1,1129 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { SectionWithCourse } from "../filters"; +import { + generateCombinationsOptimized, + MAX_RESULTS, +} from "../generateCombinations"; +import { + meetingTimesToBinaryMask, + masksConflict, + hasConflictInSchedule, +} from "../binaryMeetingTime"; +import { incrementIndex } from "../generateCombinations"; +import { createMockSection } from "./mocks"; + +const TIME_SLOTS = [ + { startTime: 800, endTime: 915 }, + { startTime: 930, endTime: 1045 }, + { startTime: 1100, endTime: 1215 }, + { startTime: 1300, endTime: 1415 }, + { startTime: 1430, endTime: 1545 }, + { startTime: 1600, endTime: 1715 }, + { startTime: 1730, endTime: 1845 }, + { startTime: 1900, endTime: 2015 }, +]; + +const DAY_PATTERNS = [ + [1, 3, 5], // MWF + [2, 4], // TR + [1, 3], // MW + [2, 4], // TR + [1], // M only + [5], // F only +]; + +const SUBJECTS = ["CS", "MATH", "PHYS", "ECON", "ENGW", "BIOL", "CHEM", "PSYC"]; +const NUMBERS = [ + "1000", + "2000", + "3000", + "4000", + "1500", + "2500", + "3500", + "4500", +]; + +function buildSectionsByCourse( + numCourses: number, + sectionsPerCourse: number, +): SectionWithCourse[][] { + const sectionsByCourse: SectionWithCourse[][] = []; + for (let c = 0; c < numCourses; c++) { + const courseId = c + 1; + const courseSubject = SUBJECTS[c % SUBJECTS.length]; + const courseNumber = NUMBERS[c % NUMBERS.length]; + const sections: SectionWithCourse[] = []; + for (let i = 0; i < sectionsPerCourse; i++) { + const slot = TIME_SLOTS[i % TIME_SLOTS.length]; + const days = DAY_PATTERNS[i % DAY_PATTERNS.length]; + sections.push( + createMockSection( + courseId * 1000 + i, + [{ days, startTime: slot.startTime, endTime: slot.endTime }], + { courseId, courseSubject, courseNumber }, + ), + ); + } + sectionsByCourse.push(sections); + } + return sectionsByCourse; +} + +function generateCombinationsOriginal( + sectionsByCourse: SectionWithCourse[][], + maxResults?: number, +): SectionWithCourse[][] { + if (sectionsByCourse.length === 0) return []; + if (sectionsByCourse.length === 1) + return sectionsByCourse[0].map((section) => [section]); + + const sortedIndices = sectionsByCourse + .map((sections, idx) => ({ sections, idx, count: sections.length })) + .sort((a, b) => a.count - b.count); + + const sortedSections = sortedIndices.map((item) => item.sections); + const result: SectionWithCourse[][] = []; + const sizes = sortedSections.map((s) => s.length); + const indexes = new Array(sizes.length).fill(0); + + const sectionMasks: bigint[][] = sortedSections.map((sections) => + sections.map(meetingTimesToBinaryMask), + ); + + while (true) { + if (maxResults !== undefined && result.length >= maxResults) break; + + const combination: SectionWithCourse[] = []; + const combinationMasks: bigint[] = []; + let conflictIndex = -1; + + for (let i = 0; i < indexes.length; i++) { + const section = sortedSections[i][indexes[i]]; + const mask = sectionMasks[i][indexes[i]]; + + for (let j = 0; j < combinationMasks.length; j++) { + if (masksConflict(combinationMasks[j], mask)) { + conflictIndex = i; + break; + } + } + + if (conflictIndex !== -1) break; + + combination.push(section); + combinationMasks.push(mask); + } + + if (conflictIndex === -1) { + result.push(combination); + if (incrementIndex(indexes, sizes, sizes.length - 1)) break; + } else { + if (incrementIndex(indexes, sizes, conflictIndex)) break; + } + } + + return result; +} + +function printResult( + label: string, + origCount: number, + origMs: number, + optCount: number, + optMs: number, +): void { + const speedup = origMs / optMs; + const w = 12; + console.log(`\n ┌─ ${label}`); + console.log( + ` │ original : ${String(origCount).padStart(w)} results ${origMs.toFixed(1).padStart(9)} ms`, + ); + console.log( + ` │ optimized: ${String(optCount).padStart(w)} results ${optMs.toFixed(1).padStart(9)} ms (${speedup.toFixed(1)}x faster)`, + ); + console.log(` └${"─".repeat(60)}`); +} + +test( + "benchmark small: 4 courses × 8 sections (4,096 brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 4; + const sectionsPerCourse = 8; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "small (4 × 8, 4,096 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark medium: 6 courses × 10 sections (1,000,000 brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 6; + const sectionsPerCourse = 10; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "medium (6 × 10, 1,000,000 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark large: 8 courses × 12 sections (429,981,696 brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 12; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "large (8 × 12, 429,981,696 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark stress: 8 courses × 20 sections (25,600,000,000 brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 20; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "stress (8 × 20, 25,600,000,000 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark extreme: 8 courses × 30 sections (6.56e11 brute force combos)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 30; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "extreme (8 × 30, 6.56e11 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark massive: 10 courses × 15 sections (576,650,390,625 brute force combos)", + { timeout: 120_000 }, + () => { + const numCourses = 10; + const sectionsPerCourse = 15; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "massive (10 × 15, 576,650,390,625 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark deep: 12 courses × 10 sections (1e12 brute force combos)", + { timeout: 120_000 }, + () => { + const numCourses = 12; + const sectionsPerCourse = 10; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "deep (12 × 10, 1e12 brute force — overconstrained, 0 expected)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + // 12 courses share only 8 distinct time slots, so no conflict-free complete schedule + // exists in this mock data. The test benchmarks how fast both versions can exhaust the + // search space and correctly determine there are no valid schedules. + assert.strictEqual( + optimizedResults.length, + 0, + "overconstrained: no valid schedules expected", + ); + assert.strictEqual( + originalResults.length, + 0, + "overconstrained: no valid schedules expected", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + }, +); + +test( + "benchmark wide: 6 courses × 50 sections (15,625,000,000 brute force combos)", + { timeout: 120_000 }, + () => { + const numCourses = 6; + const sectionsPerCourse = 50; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "wide (6 × 50, 15,625,000,000 brute force)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.ok( + optimizedResults.length > 0, + "should find at least one valid schedule", + ); + assert.ok( + optimizedResults.length <= MAX_RESULTS, + "should not exceed MAX_RESULTS", + ); + assert.ok( + elapsedOpt < 10_000, + `should complete under 10s, took ${elapsedOpt.toFixed(2)}ms`, + ); + for (const { schedule } of optimizedResults) { + assert.strictEqual( + schedule.length, + numCourses, + "each schedule should have one section per course", + ); + assert.equal( + hasConflictInSchedule(schedule), + false, + "no schedule should have time conflicts", + ); + } + }, +); + +test( + "benchmark uncapped: 8 courses × 12 sections - no maxResults (real production difference)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 12; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized(sectionsByCourse); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal(sectionsByCourse); + const elapsedOrig = performance.now() - startOrig; + + printResult( + "uncapped (8 × 12, no cap — full exhaustion)", + originalResults.length, + elapsedOrig, + optimizedResults.length, + elapsedOpt, + ); + + assert.strictEqual( + optimizedResults.length, + originalResults.length, + "both versions should find the same total number of valid schedules", + ); + }, +); + +// --------------------------------------------------------------------------- +// Helper builders for adversarial / realistic scenarios +// --------------------------------------------------------------------------- + +/** Every section meets at the SAME time — maximum conflicts, tons of pruning */ +function buildAllConflicting( + numCourses: number, + sectionsPerCourse: number, +): SectionWithCourse[][] { + const sectionsByCourse: SectionWithCourse[][] = []; + for (let c = 0; c < numCourses; c++) { + const sections: SectionWithCourse[] = []; + for (let i = 0; i < sectionsPerCourse; i++) { + sections.push( + createMockSection( + (c + 1) * 1000 + i, + [{ days: [1, 3, 5], startTime: 930, endTime: 1045 }], + { + courseId: c + 1, + courseSubject: SUBJECTS[c % SUBJECTS.length], + courseNumber: NUMBERS[c % NUMBERS.length], + }, + ), + ); + } + sectionsByCourse.push(sections); + } + return sectionsByCourse; +} + +/** Every section meets at a UNIQUE time — zero conflicts, no pruning possible */ +function buildNoConflicts( + numCourses: number, + sectionsPerCourse: number, +): SectionWithCourse[][] { + const days = [1, 2, 3, 4, 5]; + const sectionsByCourse: SectionWithCourse[][] = []; + for (let c = 0; c < numCourses; c++) { + const day = days[c % days.length]; + const baseTime = 800 + c * 60; // each course on its own hour + const sections: SectionWithCourse[] = []; + for (let i = 0; i < sectionsPerCourse; i++) { + sections.push( + createMockSection( + (c + 1) * 1000 + i, + [{ days: [day], startTime: baseTime, endTime: baseTime + 50 }], + { + courseId: c + 1, + courseSubject: SUBJECTS[c % SUBJECTS.length], + courseNumber: NUMBERS[c % NUMBERS.length], + }, + ), + ); + } + sectionsByCourse.push(sections); + } + return sectionsByCourse; +} + +/** Mixed: some courses have many sections, some have few (realistic) */ +function buildMixedSizes(courseSizes: number[]): SectionWithCourse[][] { + const sectionsByCourse: SectionWithCourse[][] = []; + for (let c = 0; c < courseSizes.length; c++) { + const sections: SectionWithCourse[] = []; + for (let i = 0; i < courseSizes[c]; i++) { + const slot = TIME_SLOTS[i % TIME_SLOTS.length]; + const days = DAY_PATTERNS[i % DAY_PATTERNS.length]; + sections.push( + createMockSection( + (c + 1) * 1000 + i, + [{ days, startTime: slot.startTime, endTime: slot.endTime }], + { + courseId: c + 1, + courseSubject: SUBJECTS[c % SUBJECTS.length], + courseNumber: NUMBERS[c % NUMBERS.length], + }, + ), + ); + } + sectionsByCourse.push(sections); + } + return sectionsByCourse; +} + +// --------------------------------------------------------------------------- +// Scaling comparison tests +// --------------------------------------------------------------------------- + +test( + "extreme: 8 courses × 30 sections (6.56e11 brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 30; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[extreme] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[extreme] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[extreme] SPEEDUP: ${speedup.toFixed(1)}x`); + + assert.ok(optimizedResults.length > 0); + assert.ok(optimizedResults.length <= MAX_RESULTS); + }, +); + +test( + "massive: 10 courses × 15 sections (576 billion brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 10; + const sectionsPerCourse = 15; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[massive] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[massive] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[massive] SPEEDUP: ${speedup.toFixed(1)}x`); + + assert.ok(optimizedResults.length > 0); + assert.ok(optimizedResults.length <= MAX_RESULTS); + }, +); + +test( + "deep: 12 courses × 10 sections (1 trillion brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 12; + const sectionsPerCourse = 10; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[deep] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[deep] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[deep] SPEEDUP: ${speedup.toFixed(1)}x`); + + // 12 courses share only 8 distinct time slots — no conflict-free complete schedule exists + assert.strictEqual( + optimizedResults.length, + 0, + "overconstrained: no valid schedules expected", + ); + assert.strictEqual( + originalResults.length, + 0, + "overconstrained: no valid schedules expected", + ); + }, +); + +test( + "wide: 6 courses × 50 sections (15.6 billion brute force)", + { timeout: 120_000 }, + () => { + const numCourses = 6; + const sectionsPerCourse = 50; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[wide] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[wide] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[wide] SPEEDUP: ${speedup.toFixed(1)}x`); + + assert.ok(optimizedResults.length > 0); + assert.ok(optimizedResults.length <= MAX_RESULTS); + }, +); + +// --------------------------------------------------------------------------- +// Adversarial tests +// --------------------------------------------------------------------------- + +test( + "adversarial: all-conflicting 8 × 20 (pruning stress test)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 20; + const sectionsByCourse = buildAllConflicting(numCourses, sectionsPerCourse); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[all-conflict] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[all-conflict] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[all-conflict] SPEEDUP: ${speedup.toFixed(1)}x`); + + // Should find zero valid schedules since everything conflicts + assert.equal(optimizedResults.length, 0, "no valid schedules should exist"); + assert.equal(originalResults.length, 0, "no valid schedules should exist"); + }, +); + +test( + "adversarial: no-conflict 8 × 12 (every combo is valid)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 12; + const sectionsByCourse = buildNoConflicts(numCourses, sectionsPerCourse); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[no-conflict] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[no-conflict] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[no-conflict] SPEEDUP: ${speedup.toFixed(1)}x`); + + // Every combo is valid so both should hit the cap + assert.equal( + optimizedResults.length, + MAX_RESULTS, + "should hit MAX_RESULTS cap", + ); + assert.equal( + originalResults.length, + MAX_RESULTS, + "should hit MAX_RESULTS cap", + ); + }, +); + +// --------------------------------------------------------------------------- +// Realistic mixed sizes test +// --------------------------------------------------------------------------- + +test( + "realistic: mixed sizes [3, 7, 25, 4, 15, 2, 12, 8]", + { timeout: 120_000 }, + () => { + const courseSizes = [3, 7, 25, 4, 15, 2, 12, 8]; + const sectionsByCourse = buildMixedSizes(courseSizes); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOrig = performance.now() - startOrig; + + const brute = courseSizes.reduce((a, b) => a * b, 1); + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[realistic] ${courseSizes.join("×")} = ${brute.toLocaleString()} brute force`, + ); + console.log( + `[realistic] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[realistic] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[realistic] SPEEDUP: ${speedup.toFixed(1)}x`); + + assert.ok(optimizedResults.length > 0); + assert.ok(optimizedResults.length <= MAX_RESULTS); + }, +); + +// --------------------------------------------------------------------------- +// Uncapped production scenario +// --------------------------------------------------------------------------- + +test( + "uncapped: 8 × 12 with NO maxResults (the production scenario)", + { timeout: 120_000 }, + () => { + const numCourses = 8; + const sectionsPerCourse = 12; + const sectionsByCourse = buildSectionsByCourse( + numCourses, + sectionsPerCourse, + ); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized(sectionsByCourse); + const elapsedOpt = performance.now() - startOpt; + + const startOrig = performance.now(); + const originalResults = generateCombinationsOriginal(sectionsByCourse); + const elapsedOrig = performance.now() - startOrig; + + const speedup = elapsedOrig / elapsedOpt; + console.log( + `[uncapped] ORIGINAL: ${originalResults.length} schedules in ${elapsedOrig.toFixed(2)}ms`, + ); + console.log( + `[uncapped] OPTIMIZED: ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + console.log(`[uncapped] SPEEDUP: ${speedup.toFixed(1)}x`); + + // Both should find the same total number of valid schedules + assert.equal( + optimizedResults.length, + originalResults.length, + "both versions should find the same number of schedules when uncapped", + ); + }, +); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test("edge: single course, 50 sections", { timeout: 120_000 }, () => { + const sectionsByCourse = buildSectionsByCourse(1, 50); + + const startOpt = performance.now(); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + const elapsedOpt = performance.now() - startOpt; + + console.log( + `[single] ${optimizedResults.length} schedules in ${elapsedOpt.toFixed(2)}ms`, + ); + assert.equal(optimizedResults.length, 50, "one section per schedule"); +}); + +test("edge: empty input", { timeout: 120_000 }, () => { + const optimizedResults = generateCombinationsOptimized([], MAX_RESULTS); + assert.equal(optimizedResults.length, 0); +}); + +test( + "edge: two courses, one section each, conflicting", + { timeout: 120_000 }, + () => { + const sectionsByCourse = buildAllConflicting(2, 1); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + assert.equal(optimizedResults.length, 0, "should find no valid schedule"); + }, +); + +test( + "edge: two courses, one section each, non-conflicting", + { timeout: 120_000 }, + () => { + const sectionsByCourse = buildNoConflicts(2, 1); + const optimizedResults = generateCombinationsOptimized( + sectionsByCourse, + MAX_RESULTS, + ); + assert.equal(optimizedResults.length, 1, "exactly one valid schedule"); + }, +); diff --git a/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/binaryMeetingTime.test.ts b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/binaryMeetingTime.test.ts index 762c3161..d5dbd8c4 100644 --- a/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/binaryMeetingTime.test.ts +++ b/apps/searchneu/lib/scheduler/binaryMeetingTimeTests/binaryMeetingTime.test.ts @@ -1,10 +1,20 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; - -import { hasConflictInSchedule } from "../binaryMeetingTime"; +import { + generateCombinationsOptimized, + incrementIndex, +} from "../generateCombinations"; +import { + hasConflictInSchedule, + meetingTimesToBinaryMask, + masksConflict, +} from "../binaryMeetingTime"; import { createMockSection } from "./mocks"; -// Test suite for hasConflictInSchedule function +// --------------------------------------------------------------------------- +// Unit tests: hasConflictInSchedule +// --------------------------------------------------------------------------- + describe("hasConflictInSchedule", () => { // helper function to create a schedule from mock sections const schedule = (...sections: ReturnType[]) => @@ -166,3 +176,295 @@ describe("hasConflictInSchedule", () => { ); }); }); + +// --------------------------------------------------------------------------- +// Unit tests: incrementIndex +// --------------------------------------------------------------------------- + +describe("incrementIndex", () => { + test("simple increment at last position", () => { + const idx = [0, 0]; + const overflow = incrementIndex(idx, [3, 3], 1); + assert.equal(overflow, false); + assert.deepEqual(idx, [0, 1]); + }); + + test("carry when last position overflows", () => { + const idx = [0, 2]; + const overflow = incrementIndex(idx, [3, 3], 1); + assert.equal(overflow, false); + assert.deepEqual(idx, [1, 0]); + }); + + test("full overflow when all positions exhausted", () => { + const idx = [2, 2]; + const overflow = incrementIndex(idx, [3, 3], 1); + assert.equal(overflow, true); + }); + + test("single-element array: overflow immediately", () => { + const idx = [0]; + const overflow = incrementIndex(idx, [1], 0); + assert.equal(overflow, true); + }); + + test("single-element array: normal increment", () => { + const idx = [0]; + const overflow = incrementIndex(idx, [2], 0); + assert.equal(overflow, false); + assert.deepEqual(idx, [1]); + }); + + test("increment at position 0", () => { + const idx = [0, 0, 0]; + const overflow = incrementIndex(idx, [3, 3, 3], 0); + assert.equal(overflow, false); + assert.deepEqual(idx, [1, 0, 0]); + }); + + test("carry cascades across multiple positions", () => { + const idx = [0, 2, 2]; + const overflow = incrementIndex(idx, [3, 3, 3], 2); + assert.equal(overflow, false); + assert.deepEqual(idx, [1, 0, 0]); + }); + + test("overflow when first position also wraps", () => { + const idx = [2, 2, 2]; + const overflow = incrementIndex(idx, [3, 3, 3], 2); + assert.equal(overflow, true); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: meetingTimesToBinaryMask +// --------------------------------------------------------------------------- + +describe("meetingTimesToBinaryMask", () => { + test("section with no meeting times → mask is zero", () => { + const s = createMockSection(100, []); + assert.equal(meetingTimesToBinaryMask(s), BigInt(0)); + }); + + test("zero-duration meeting (startTime === endTime) → mask is zero", () => { + const s = createMockSection(101, [ + { days: [1], startTime: 900, endTime: 900 }, + ]); + assert.equal(meetingTimesToBinaryMask(s), BigInt(0)); + }); + + test("two identical sections produce the same mask", () => { + const a = createMockSection(102, [ + { days: [1, 3], startTime: 800, endTime: 850 }, + ]); + const b = createMockSection(103, [ + { days: [1, 3], startTime: 800, endTime: 850 }, + ]); + assert.equal(meetingTimesToBinaryMask(a), meetingTimesToBinaryMask(b)); + }); + + test("same time, different days → different masks, no conflict", () => { + const mon = createMockSection(104, [ + { days: [1], startTime: 900, endTime: 1000 }, + ]); + const tue = createMockSection(105, [ + { days: [2], startTime: 900, endTime: 1000 }, + ]); + const mMon = meetingTimesToBinaryMask(mon); + const mTue = meetingTimesToBinaryMask(tue); + assert.notEqual(mMon, mTue); + assert.equal(mMon & mTue, BigInt(0)); + }); + + test("back-to-back meetings on the same day → no conflict", () => { + const first = createMockSection(106, [ + { days: [3], startTime: 800, endTime: 900 }, + ]); + const second = createMockSection(107, [ + { days: [3], startTime: 900, endTime: 1000 }, + ]); + const m1 = meetingTimesToBinaryMask(first); + const m2 = meetingTimesToBinaryMask(second); + assert.equal(m1 & m2, BigInt(0), "back-to-back should not conflict"); + }); + + test("overlapping meetings on the same day → conflict", () => { + const a = createMockSection(108, [ + { days: [2], startTime: 900, endTime: 1000 }, + ]); + const b = createMockSection(109, [ + { days: [2], startTime: 930, endTime: 1030 }, + ]); + const mA = meetingTimesToBinaryMask(a); + const mB = meetingTimesToBinaryMask(b); + assert.notEqual(mA & mB, BigInt(0), "overlapping meetings should conflict"); + }); + + test("multi-day meeting sets bits for all specified days", () => { + const mwf = createMockSection(110, [ + { days: [1, 3, 5], startTime: 900, endTime: 950 }, + ]); + const monOnly = createMockSection(111, [ + { days: [1], startTime: 900, endTime: 950 }, + ]); + const wedOnly = createMockSection(112, [ + { days: [3], startTime: 900, endTime: 950 }, + ]); + const friOnly = createMockSection(113, [ + { days: [5], startTime: 900, endTime: 950 }, + ]); + const mMwf = meetingTimesToBinaryMask(mwf); + // MWF mask must conflict with each individual day mask + assert.notEqual(mMwf & meetingTimesToBinaryMask(monOnly), BigInt(0)); + assert.notEqual(mMwf & meetingTimesToBinaryMask(wedOnly), BigInt(0)); + assert.notEqual(mMwf & meetingTimesToBinaryMask(friOnly), BigInt(0)); + }); + + test("multiple meeting blocks in one section are all encoded", () => { + // Lecture MWF + Lab TR at a different time + const lectureLab = createMockSection(114, [ + { days: [1, 3, 5], startTime: 900, endTime: 950 }, + { days: [2, 4], startTime: 1100, endTime: 1150 }, + ]); + const conflictsLecture = createMockSection(115, [ + { days: [1], startTime: 920, endTime: 1000 }, + ]); + const conflictsLab = createMockSection(116, [ + { days: [2], startTime: 1130, endTime: 1200 }, + ]); + const noConflict = createMockSection(117, [ + { days: [1], startTime: 1300, endTime: 1400 }, + ]); + + const mLL = meetingTimesToBinaryMask(lectureLab); + assert.notEqual( + mLL & meetingTimesToBinaryMask(conflictsLecture), + BigInt(0), + "lecture block conflict", + ); + assert.notEqual( + mLL & meetingTimesToBinaryMask(conflictsLab), + BigInt(0), + "lab block conflict", + ); + assert.equal( + mLL & meetingTimesToBinaryMask(noConflict), + BigInt(0), + "no conflict expected", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Unit tests: masksConflict +// --------------------------------------------------------------------------- + +describe("masksConflict", () => { + test("both masks zero → no conflict", () => { + assert.equal(masksConflict(BigInt(0), BigInt(0)), false); + }); + + test("one mask zero → no conflict", () => { + assert.equal(masksConflict(BigInt(255), BigInt(0)), false); + assert.equal(masksConflict(BigInt(0), BigInt(255)), false); + }); + + test("non-overlapping masks → no conflict", () => { + assert.equal(masksConflict(BigInt(0b1010), BigInt(0b0101)), false); + }); + + test("overlapping masks → conflict", () => { + assert.equal(masksConflict(BigInt(0b1100), BigInt(0b0110)), true); + }); + + test("identical non-zero masks → conflict", () => { + assert.equal(masksConflict(BigInt(42), BigInt(42)), true); + }); +}); + +// --------------------------------------------------------------------------- +// generateCombinationsOptimized: empty course guard + mask correctness +// --------------------------------------------------------------------------- + +describe("generateCombinationsOptimized — empty course & mask correctness", () => { + test("single empty course → returns []", () => { + assert.deepEqual(generateCombinationsOptimized([[]]), []); + }); + + test("empty course among multiple → returns [] (was a crash bug)", () => { + const s = createMockSection(200, [ + { days: [1], startTime: 900, endTime: 1000 }, + ]); + assert.deepEqual(generateCombinationsOptimized([[s], []]), []); + assert.deepEqual(generateCombinationsOptimized([[], [s]]), []); + assert.deepEqual(generateCombinationsOptimized([[s], [], [s]]), []); + }); + + test("single course at exactly maxResults → returns maxResults schedules", () => { + const sections = Array.from({ length: 5 }, (_, i) => + createMockSection(300 + i, [ + { days: [i + 1], startTime: 800, endTime: 900 }, + ]), + ); + const results = generateCombinationsOptimized([sections], 3); + assert.equal(results.length, 3); + }); + + test("single course exceeding maxResults → capped at maxResults", () => { + const sections = Array.from({ length: 10 }, (_, i) => + createMockSection(400 + i, [ + { days: [i % 7], startTime: 800, endTime: 850 }, + ]), + ); + const results = generateCombinationsOptimized([sections], 4); + assert.equal(results.length, 4); + }); + + test("returned mask equals OR of all section masks in the schedule", () => { + const sA = createMockSection(500, [ + { days: [1], startTime: 800, endTime: 900 }, + ]); + const sB = createMockSection(501, [ + { days: [2], startTime: 900, endTime: 1000 }, + ]); + const sC = createMockSection(502, [ + { days: [3], startTime: 1000, endTime: 1100 }, + ]); + + const results = generateCombinationsOptimized([[sA], [sB], [sC]]); + assert.equal(results.length, 1); + + const expected = + meetingTimesToBinaryMask(sA) | + meetingTimesToBinaryMask(sB) | + meetingTimesToBinaryMask(sC); + assert.equal(results[0].mask, expected); + }); + + test("returned mask is consistent across multiple results", () => { + const sA1 = createMockSection(600, [ + { days: [1], startTime: 800, endTime: 900 }, + ]); + const sA2 = createMockSection(601, [ + { days: [2], startTime: 800, endTime: 900 }, + ]); + const sB = createMockSection(602, [ + { days: [3], startTime: 800, endTime: 900 }, + ]); + + const results = generateCombinationsOptimized([[sA1, sA2], [sB]]); + assert.equal(results.length, 2); + + for (const { schedule, mask } of results) { + const expected = schedule.reduce( + (acc, s) => acc | meetingTimesToBinaryMask(s), + BigInt(0), + ); + assert.equal( + mask, + expected, + `mask mismatch for schedule [${schedule.map((s) => s.id)}]`, + ); + } + }); +}); diff --git a/apps/searchneu/lib/scheduler/generateCombinations.ts b/apps/searchneu/lib/scheduler/generateCombinations.ts new file mode 100644 index 00000000..3e448afb --- /dev/null +++ b/apps/searchneu/lib/scheduler/generateCombinations.ts @@ -0,0 +1,195 @@ +import { SectionWithCourse } from "./filters"; +import { meetingTimesToBinaryMask } from "./binaryMeetingTime"; + +// Cap to prevent runaway generation when courses have many non-conflicting sections +export const MAX_RESULTS = 100; + +/** + * Used to keep track of indexes of sections and increment them when they conflict w the current schedule + * Returns true if overflow (we're done), false otherwise + */ +export const incrementIndex = ( + indexes: number[], + sizes: number[], + position: number, +): boolean => { + indexes[position]++; + + // Handle carry/overflow like an odometer + while (position >= 0 && indexes[position] >= sizes[position]) { + indexes[position] = 0; + position--; + if (position >= 0) { + indexes[position]++; + } + } + + // If position < 0, we've overflowed completely + return position < 0; +}; + +/** + * Increment indexes starting at `pos`, carrying left as needed. + * Returns the position that was successfully incremented, or -1 if overflow. + */ +const incrementPosition = ( + indexes: number[], + sizes: number[], + pos: number, +): number => { + while (pos >= 0) { + indexes[pos]++; + if (indexes[pos] < sizes[pos]) return pos; + indexes[pos] = 0; + pos--; + } + return -1; +}; + +/** + * Optimized iterative generation with conflict-aware skipping. + * Uses binary time representation for O(1) conflict checking. + * + * Key optimisation over the naive approach: maintains a running `prefixMask` + * array so each new position is checked against a single OR-combined bigint + * (O(1)) rather than looping over all previously-accepted masks (O(n)). + * This also avoids rebuilding the combination from scratch each iteration — + * when only the last index advances the prefix state for positions 0..n-2 + * is already valid and is reused directly. + * + * Returns both the schedule and its combined time-mask so callers can avoid + * recomputing it when adding optional courses. + */ +export const generateCombinationsOptimized = ( + sectionsByCourse: SectionWithCourse[][], + maxResults?: number, +): { schedule: SectionWithCourse[]; mask: bigint }[] => { + if (sectionsByCourse.length === 0) return []; + if (sectionsByCourse.length === 1) { + const limit = maxResults ?? Infinity; + return sectionsByCourse[0].slice(0, limit).map((section) => ({ + schedule: [section], + mask: meetingTimesToBinaryMask(section), + })); + } + + // A course with no sections means no valid schedule can include it + if (sectionsByCourse.some((s) => s.length === 0)) return []; + + // Sort courses by number of sections (fewest first) to hit conflicts early + const sortedSections = sectionsByCourse + .map((sections, idx) => ({ sections, idx, count: sections.length })) + .sort((a, b) => a.count - b.count) + .map((item) => item.sections); + + const result: { schedule: SectionWithCourse[]; mask: bigint }[] = []; + const n = sortedSections.length; + const sizes = sortedSections.map((s) => s.length); + const indexes = new Array(n).fill(0); + + // Pre-compute binary masks for all sections once + const sectionMasks: bigint[][] = sortedSections.map((sections) => + sections.map(meetingTimesToBinaryMask), + ); + + // prefixMasks[i] = OR of masks for accepted sections at positions 0..i-1. + // When we advance without carry, prefixMasks[0..pos-1] remain valid. + const prefixMasks = new Array(n + 1).fill(BigInt(0)); + + let pos = 0; + + while (true) { + if (pos === n) { + // All positions accepted — record the valid schedule + result.push({ + schedule: sortedSections.map((s, i) => s[indexes[i]]), + mask: prefixMasks[n], + }); + if (maxResults !== undefined && result.length >= maxResults) break; + // Advance from the last position + const newPos = incrementPosition(indexes, sizes, n - 1); + if (newPos < 0) break; + pos = newPos; + continue; + } + + const mask = sectionMasks[pos][indexes[pos]]; + if ((prefixMasks[pos] & mask) !== BigInt(0)) { + // Conflict — skip to next section at this position (carry if needed) + const newPos = incrementPosition(indexes, sizes, pos); + if (newPos < 0) break; + pos = newPos; + } else { + // No conflict — accept and move forward + prefixMasks[pos + 1] = prefixMasks[pos] | mask; + pos++; + } + } + + return result; +}; + +/** + * Try adding optional courses to a base schedule. + * + * Optimisations: + * - Optional section masks are pre-computed once by the caller and passed in. + * - A single combined `bigint` mask is threaded through recursion instead of + * an array — conflict check is O(1) rather than O(n). + * - The mutable `currentSchedule` array uses push/pop instead of spreading a + * new array on every recursive call. + */ +export const addOptionalCourses = ( + baseSchedule: SectionWithCourse[], + baseMask: bigint, + optionalSectionsByCourse: SectionWithCourse[][], + optionalSectionMasks: bigint[][], + numCourses?: number, + maxResults?: number, +): SectionWithCourse[][] => { + const results: SectionWithCourse[][] = []; + // Mutated in-place; copied only when pushed to results + const currentSchedule: SectionWithCourse[] = [...baseSchedule]; + + const recurse = (combinedMask: bigint, courseIndex: number) => { + if (maxResults !== undefined && results.length >= maxResults) return; + + if (courseIndex === optionalSectionsByCourse.length) { + if (numCourses === undefined || currentSchedule.length === numCourses) { + results.push([...currentSchedule]); + } + return; + } + + if (numCourses !== undefined) { + const remainingSlots = optionalSectionsByCourse.length - courseIndex; + if (currentSchedule.length + remainingSlots < numCourses) return; + + if (currentSchedule.length === numCourses) { + recurse(combinedMask, optionalSectionsByCourse.length); + return; + } + } + + // Choice A: Skip this optional course + recurse(combinedMask, courseIndex + 1); + + if (maxResults !== undefined && results.length >= maxResults) return; + + // Choice B: Try each section of this optional course + const sections = optionalSectionsByCourse[courseIndex]; + const masks = optionalSectionMasks[courseIndex]; + for (let i = 0; i < sections.length; i++) { + const sectionMask = masks[i]; + if ((combinedMask & sectionMask) === BigInt(0)) { + currentSchedule.push(sections[i]); + recurse(combinedMask | sectionMask, courseIndex + 1); + currentSchedule.pop(); + if (maxResults !== undefined && results.length >= maxResults) return; + } + } + }; + + recurse(baseMask, 0); + return results; +}; diff --git a/apps/searchneu/lib/scheduler/generateSchedules.test.ts b/apps/searchneu/lib/scheduler/generateSchedules.test.ts index b9b38c78..6882f7f3 100644 --- a/apps/searchneu/lib/scheduler/generateSchedules.test.ts +++ b/apps/searchneu/lib/scheduler/generateSchedules.test.ts @@ -1,699 +1,332 @@ import { describe, test } from "node:test"; -import assert from "node:assert"; - -// Test the core logic of conflict detection and schedule generation -// These tests focus on the logic that can be tested without database mocking - -describe("schedule generation logic", () => { - // Test conflict detection logic - // This mirrors the hasTimeConflict logic in generateSchedules.ts - const hasTimeConflict = ( - time1: { days: number[]; startTime: number; endTime: number }, - time2: { days: number[]; startTime: number; endTime: number }, - ): boolean => { - // Check if they share any days - const sharedDays = time1.days.filter((day) => time2.days.includes(day)); - if (sharedDays.length === 0) return false; - - // Check if time ranges overlap - return !( - time1.endTime <= time2.startTime || time2.endTime <= time1.startTime +import assert from "node:assert/strict"; + +/** + * End-to-end scheduling logic tests — no database required. + * + * generateSchedules() itself hits the DB, so we test the two pure functions + * it composes: generateCombinationsOptimized (locked courses) and + * addOptionalCourses. Together they cover everything generateSchedules does + * except the DB query and the optional-sort heuristic. + * + * The previous version of this file tested locally-defined shadow copies of + * hasTimeConflict, generateCombinations, and addOptionalCourses — none of + * which were the production implementations. + */ + +import { + generateCombinationsOptimized, + addOptionalCourses, + MAX_RESULTS, +} from "./generateCombinations"; +import { + meetingTimesToBinaryMask, + hasConflictInSchedule, +} from "./binaryMeetingTime"; +import { SectionWithCourse } from "./filters"; +import { createMockSection } from "./binaryMeetingTimeTests/mocks"; + +// --------------------------------------------------------------------------- +// Helpers (mirrors the helpers in generateSchedules.ts) +// --------------------------------------------------------------------------- + +function buildMasks(courses: SectionWithCourse[][]): bigint[][] { + return courses.map((sections) => sections.map(meetingTimesToBinaryMask)); +} + +/** Run the full locked+optional pipeline, mirroring generateSchedules logic. */ +function simulateGenerateSchedules( + lockedCourses: SectionWithCourse[][], + optionalCourses: SectionWithCourse[][], + numCourses?: number, +): SectionWithCourse[][] { + const optionalMasks = buildMasks(optionalCourses); + const lockedSchedules = generateCombinationsOptimized(lockedCourses); + + if (lockedCourses.length === 0 && optionalCourses.length > 0) { + return addOptionalCourses( + [], + BigInt(0), + optionalCourses, + optionalMasks, + numCourses, + MAX_RESULTS, ); - }; + } + + if (optionalCourses.length === 0) { + const schedules = lockedSchedules.map((r) => r.schedule); + return numCourses !== undefined + ? schedules.filter((s) => s.length === numCourses) + : schedules; + } + + const all: SectionWithCourse[][] = []; + for (const { schedule, mask } of lockedSchedules) { + if (numCourses !== undefined && schedule.length > numCourses) continue; + const remaining = MAX_RESULTS - all.length; + all.push( + ...addOptionalCourses( + schedule, + mask, + optionalCourses, + optionalMasks, + numCourses, + remaining, + ), + ); + if (all.length >= MAX_RESULTS) break; + } + return all; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +// Four non-conflicting courses — one section each, all different days/times +const CS_SEC = createMockSection( + 1, + [{ days: [1], startTime: 900, endTime: 1000 }], + { courseId: 1 }, +); +const MATH_SEC = createMockSection( + 2, + [{ days: [2], startTime: 1000, endTime: 1100 }], + { courseId: 2 }, +); +const PHYS_SEC = createMockSection( + 3, + [{ days: [3], startTime: 1100, endTime: 1200 }], + { courseId: 3 }, +); +const ENGW_SEC = createMockSection( + 4, + [{ days: [4], startTime: 1300, endTime: 1400 }], + { courseId: 4 }, +); + +// Two sections of CS — one on Mon, one conflicts with MATH +const CS_SEC_A = createMockSection( + 10, + [{ days: [1], startTime: 900, endTime: 1000 }], + { courseId: 10 }, +); +const CS_SEC_B = createMockSection( + 11, + [{ days: [2], startTime: 1000, endTime: 1100 }], + { courseId: 10 }, +); // conflicts with MATH_SEC2 + +const MATH_SEC2 = createMockSection( + 12, + [{ days: [2], startTime: 1000, endTime: 1100 }], + { courseId: 11 }, +); + +// Optional course sections +const OPT_A = createMockSection( + 20, + [{ days: [5], startTime: 900, endTime: 1000 }], + { courseId: 20 }, +); +const OPT_B = createMockSection( + 21, + [{ days: [5], startTime: 1000, endTime: 1100 }], + { courseId: 20 }, +); +const OPT_C = createMockSection( + 22, + [{ days: [1], startTime: 900, endTime: 1000 }], + { courseId: 21 }, +); // conflicts with CS_SEC + +// --------------------------------------------------------------------------- +// Locked courses only +// --------------------------------------------------------------------------- + +describe("locked courses only", () => { + test("all non-conflicting → one schedule returned", () => { + const results = simulateGenerateSchedules( + [[CS_SEC], [MATH_SEC], [PHYS_SEC]], + [], + ); + assert.equal(results.length, 1); + assert.equal(results[0].length, 3); + assert.ok(!hasConflictInSchedule(results[0])); + }); - describe("time conflict detection", () => { - test("should detect conflicts when times overlap on same days", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1030 }; - const time2 = { days: [1, 3], startTime: 1000, endTime: 1130 }; + test("conflicting sections across courses → empty result", () => { + const results = simulateGenerateSchedules( + [[CS_SEC_B], [MATH_SEC2]], // both on Tue@10 + [], + ); + assert.equal(results.length, 0); + }); - // They share days [1, 3] and times overlap (900-1030 overlaps with 1000-1130) - assert.strictEqual(hasTimeConflict(time1, time2), true); - }); + test("course with multiple sections: conflicting section filtered out", () => { + // CS has two sections; one conflicts with MATH, one doesn't + const results = simulateGenerateSchedules( + [[CS_SEC_A, CS_SEC_B], [MATH_SEC2]], + [], + ); + assert.equal(results.length, 1); + assert.ok(results[0].some((s) => s.id === CS_SEC_A.id)); // only CS_SEC_A fits + assert.ok(results[0].some((s) => s.id === MATH_SEC2.id)); + assert.ok(!hasConflictInSchedule(results[0])); + }); - test("should not detect conflicts when times don't overlap", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1030 }; - const time2 = { days: [1, 3], startTime: 1100, endTime: 1230 }; + test("empty locked courses → empty result", () => { + const results = simulateGenerateSchedules([], []); + assert.equal(results.length, 0); + }); - // They share days but times don't overlap (1030 <= 1100) - assert.strictEqual(hasTimeConflict(time1, time2), false); - }); + test("single course → one schedule per section", () => { + const results = simulateGenerateSchedules([[CS_SEC_A, CS_SEC_B]], []); + assert.equal(results.length, 2); + }); - test("should not detect conflicts when days don't overlap", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1030 }; - const time2 = { days: [2, 4], startTime: 900, endTime: 1030 }; + test("numCourses matches locked count → all results kept", () => { + const results = simulateGenerateSchedules([[CS_SEC], [MATH_SEC]], [], 2); + assert.equal(results.length, 1); + assert.equal(results[0].length, 2); + }); - // Different days, so no conflict even though times are the same - assert.strictEqual(hasTimeConflict(time1, time2), false); - }); + test("numCourses < locked count → no results (can't drop locked courses)", () => { + const results = simulateGenerateSchedules( + [[CS_SEC], [MATH_SEC], [PHYS_SEC]], + [], + 2, // impossible — all 3 locked courses are required + ); + assert.equal(results.length, 0); + }); +}); - test("should handle exact time boundaries correctly", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1030 }; - const time2 = { days: [1, 3], startTime: 1030, endTime: 1200 }; +// --------------------------------------------------------------------------- +// Optional courses only +// --------------------------------------------------------------------------- - // One ends exactly when the other starts - should not conflict - assert.strictEqual(hasTimeConflict(time1, time2), false); - }); +describe("optional courses only", () => { + test("single optional with one section → empty schedule and schedule with section", () => { + const results = simulateGenerateSchedules([], [[OPT_A]]); + assert.equal(results.length, 2); + assert.ok(results.some((s) => s.length === 0)); + assert.ok(results.some((s) => s.length === 1 && s[0].id === OPT_A.id)); + }); - test("should detect conflicts with partial day overlap", () => { - const time1 = { days: [1, 3, 5], startTime: 900, endTime: 1030 }; - const time2 = { days: [3, 5], startTime: 1000, endTime: 1130 }; + test("two non-conflicting optional courses → all 4 combinations", () => { + const results = simulateGenerateSchedules([], [[OPT_A], [ENGW_SEC]]); + assert.equal(results.length, 4); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); - // They share days [3, 5] and times overlap - assert.strictEqual(hasTimeConflict(time1, time2), true); - }); + test("numCourses = 1 with two optional courses → only single-course schedules", () => { + const results = simulateGenerateSchedules([], [[OPT_A], [OPT_B]], 1); + assert.ok(results.every((s) => s.length === 1)); + }); - test("should not conflict when one completely contains the other but different days", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1200 }; - const time2 = { days: [2, 4], startTime: 1000, endTime: 1100 }; + test("optional course where all sections conflict → only empty schedule", () => { + // OPT_C conflicts with CS_SEC (same Mon@9 slot), but we pass it as an optional + // against an empty base — no conflict with empty base, so it should be included + const results = simulateGenerateSchedules([], [[OPT_C]]); + assert.equal(results.length, 2); // empty + [OPT_C] + }); +}); - // Different days, so no conflict - assert.strictEqual(hasTimeConflict(time1, time2), false); - }); +// --------------------------------------------------------------------------- +// Mixed locked + optional courses +// --------------------------------------------------------------------------- - test("should detect conflicts when one time completely contains the other on same days", () => { - const time1 = { days: [1, 3], startTime: 900, endTime: 1200 }; - const time2 = { days: [1, 3], startTime: 1000, endTime: 1100 }; +describe("mixed locked + optional courses", () => { + test("locked course + non-conflicting optional → base and extended schedules", () => { + const results = simulateGenerateSchedules( + [[CS_SEC]], + [[OPT_A]], // Fri@9, no conflict with Mon@9 + ); + assert.equal(results.length, 2); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + assert.ok(results.every((s) => s.some((sec) => sec.id === CS_SEC.id))); + }); - // Time2 is completely within time1 on the same days - assert.strictEqual(hasTimeConflict(time1, time2), true); - }); + test("locked course + optional that conflicts → only base schedule", () => { + const results = simulateGenerateSchedules( + [[CS_SEC]], // Mon@9 + [[OPT_C]], // Mon@9 — conflict + ); + assert.equal(results.length, 1); + assert.ok(!results[0].some((s) => s.id === OPT_C.id)); }); - describe("combination generation logic", () => { - // Test the logic for generating combinations - // This mirrors the generateCombinations logic in generateSchedules.ts - const generateCombinations = (arrays: T[][]): T[][] => { - if (arrays.length === 0) return []; - if (arrays.length === 1) return arrays[0].map((item) => [item]); - - const result: T[][] = []; - - const generateRecursive = ( - currentCombination: T[], - arrayIndex: number, - ) => { - if (arrayIndex === arrays.length) { - result.push([...currentCombination]); - return; - } - - for (const item of arrays[arrayIndex]) { - currentCombination.push(item); - generateRecursive(currentCombination, arrayIndex + 1); - currentCombination.pop(); - } - }; - - generateRecursive([], 0); - return result; - }; - - test("should return empty array for empty input", () => { - const result = generateCombinations([]); - assert.deepStrictEqual(result, []); - }); - - test("should return single items for single array", () => { - const result = generateCombinations([[1, 2, 3]]); - assert.deepStrictEqual(result, [[1], [2], [3]]); - }); - - test("should generate all combinations for two arrays", () => { - const result = generateCombinations([ - ["A", "B"], - ["1", "2"], - ]); - assert.deepStrictEqual(result, [ - ["A", "1"], - ["A", "2"], - ["B", "1"], - ["B", "2"], - ]); - }); - - test("should generate all combinations for three arrays", () => { - const result = generateCombinations([["A"], ["1", "2"], ["X", "Y"]]); - assert.deepStrictEqual(result, [ - ["A", "1", "X"], - ["A", "1", "Y"], - ["A", "2", "X"], - ["A", "2", "Y"], - ]); - }); - - test("should handle arrays with different lengths", () => { - const result = generateCombinations([["A", "B", "C"], ["1"], ["X", "Y"]]); - assert.deepStrictEqual(result, [ - ["A", "1", "X"], - ["A", "1", "Y"], - ["B", "1", "X"], - ["B", "1", "Y"], - ["C", "1", "X"], - ["C", "1", "Y"], - ]); - }); + test("multiple locked + multiple optional → all conflict-free", () => { + const results = simulateGenerateSchedules( + [[CS_SEC], [MATH_SEC]], + [[OPT_A], [PHYS_SEC]], + ); + assert.ok(results.length > 0); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + // Every result must contain both locked courses + assert.ok( + results.every( + (s) => + s.some((sec) => sec.id === CS_SEC.id) && + s.some((sec) => sec.id === MATH_SEC.id), + ), + ); }); - describe("schedule validation logic", () => { - // Test the logic for validating schedules - // This mirrors the isValidSchedule and sectionsHaveConflict logic - const hasTimeConflict = ( - time1: { days: number[]; startTime: number; endTime: number }, - time2: { days: number[]; startTime: number; endTime: number }, - ): boolean => { - const sharedDays = time1.days.filter((day) => time2.days.includes(day)); - if (sharedDays.length === 0) return false; - return !( - time1.endTime <= time2.startTime || time2.endTime <= time1.startTime - ); - }; - - const sectionsHaveConflict = ( - section1: { - meetingTimes: Array<{ - days: number[]; - startTime: number; - endTime: number; - }>; - }, - section2: { - meetingTimes: Array<{ - days: number[]; - startTime: number; - endTime: number; - }>; - }, - ): boolean => { - for (const time1 of section1.meetingTimes) { - for (const time2 of section2.meetingTimes) { - if (hasTimeConflict(time1, time2)) { - return true; - } - } - } - return false; - }; - - const isValidSchedule = ( - sections: Array<{ - meetingTimes: Array<{ - days: number[]; - startTime: number; - endTime: number; - }>; - }>, - ): boolean => { - for (let i = 0; i < sections.length; i++) { - for (let j = i + 1; j < sections.length; j++) { - if (sectionsHaveConflict(sections[i], sections[j])) { - return false; - } - } - } - return true; - }; - - test("should validate schedule with no conflicts", () => { - const schedule = [ - { - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - { - meetingTimes: [{ days: [2, 4], startTime: 1100, endTime: 1230 }], - }, - ]; - - assert.strictEqual(isValidSchedule(schedule), true); - }); - - test("should invalidate schedule with conflicts", () => { - const schedule = [ - { - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - { - meetingTimes: [{ days: [1, 3], startTime: 1000, endTime: 1130 }], - }, - ]; - - assert.strictEqual(isValidSchedule(schedule), false); - }); - - test("should validate schedule with multiple meeting times per section", () => { - const schedule = [ - { - meetingTimes: [ - { days: [1], startTime: 900, endTime: 1030 }, - { days: [3], startTime: 900, endTime: 1030 }, - ], - }, - { - meetingTimes: [{ days: [2, 4], startTime: 1100, endTime: 1230 }], - }, - ]; - - assert.strictEqual(isValidSchedule(schedule), true); - }); - - test("should invalidate schedule when one meeting time conflicts", () => { - const schedule = [ - { - meetingTimes: [ - { days: [1], startTime: 900, endTime: 1030 }, - { days: [3], startTime: 900, endTime: 1030 }, - ], - }, - { - meetingTimes: [ - { days: [1], startTime: 1000, endTime: 1130 }, // Conflicts with first section's Monday meeting - { days: [4], startTime: 1100, endTime: 1230 }, - ], - }, - ]; - - assert.strictEqual(isValidSchedule(schedule), false); - }); - - test("should validate schedule with sections having no meeting times", () => { - const schedule = [ - { - meetingTimes: [], - }, - { - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - ]; - - assert.strictEqual(isValidSchedule(schedule), true); - }); + test("numCourses = locked + 1 → only schedules that add exactly one optional", () => { + const results = simulateGenerateSchedules( + [[CS_SEC]], // 1 locked + [[OPT_A], [PHYS_SEC]], // 2 optional + 2, // must have exactly 2 total + ); + assert.ok(results.every((s) => s.length === 2)); + assert.ok(results.every((s) => s.some((sec) => sec.id === CS_SEC.id))); }); - describe("optional courses logic", () => { - // Test the logic for adding optional courses to locked schedules - // This mirrors the addOptionalCourses logic in generateSchedules.ts - type Section = { - id: number; - meetingTimes: Array<{ - days: number[]; - startTime: number; - endTime: number; - }>; - }; - - const hasTimeConflict = ( - time1: { days: number[]; startTime: number; endTime: number }, - time2: { days: number[]; startTime: number; endTime: number }, - ): boolean => { - const sharedDays = time1.days.filter((day) => time2.days.includes(day)); - if (sharedDays.length === 0) return false; - return !( - time1.endTime <= time2.startTime || time2.endTime <= time1.startTime - ); - }; - - const sectionsHaveConflict = ( - section1: Section, - section2: Section, - ): boolean => { - for (const time1 of section1.meetingTimes) { - for (const time2 of section2.meetingTimes) { - if (hasTimeConflict(time1, time2)) { - return true; - } - } - } - return false; - }; - - const isValidSchedule = (sections: Section[]): boolean => { - for (let i = 0; i < sections.length; i++) { - for (let j = i + 1; j < sections.length; j++) { - if (sectionsHaveConflict(sections[i], sections[j])) { - return false; - } - } - } - return true; - }; - - const addOptionalCourses = ( - baseSchedule: Section[], - optionalSectionsByCourse: Section[][], - ): Section[][] => { - const results: Section[][] = []; - - const generateOptionalCombinations = ( - currentSchedule: Section[], - courseIndex: number, - ) => { - if (courseIndex === optionalSectionsByCourse.length) { - results.push([...currentSchedule]); - return; - } - - // Try not adding this optional course - generateOptionalCombinations(currentSchedule, courseIndex + 1); - - // Try adding each section of this optional course if it doesn't conflict - for (const section of optionalSectionsByCourse[courseIndex]) { - const testSchedule = [...currentSchedule, section]; - if (isValidSchedule(testSchedule)) { - generateOptionalCombinations(testSchedule, courseIndex + 1); - } - } - }; - - generateOptionalCombinations(baseSchedule, 0); - return results; - }; - - test("should return only base schedule when no optional courses", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - ]; - - const result = addOptionalCourses(baseSchedule, []); - - assert.strictEqual(result.length, 1); - assert.deepStrictEqual(result[0], baseSchedule); - }); - - test("should add optional course when it doesn't conflict", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [2, 4], startTime: 1100, endTime: 1230 }], - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 2 schedules: one with and one without the optional course - assert.strictEqual(result.length, 2); - - // One should be just the base schedule - assert.strictEqual( - result.some((s) => s.length === 1 && s[0].id === 1), - true, - ); - - // One should include both courses - assert.strictEqual( - result.some((s) => s.length === 2 && s.some((sec) => sec.id === 2)), - true, - ); - }); - - test("should not add optional course when it conflicts", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [1, 3], startTime: 1000, endTime: 1130 }], // Conflicts - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should only have 1 schedule: the base schedule without the conflicting optional course - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].length, 1); - assert.strictEqual(result[0][0].id, 1); - }); - - test("should try multiple sections of an optional course", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1, 3], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [1, 3], startTime: 1000, endTime: 1130 }], // Conflicts - }, - { - id: 3, - meetingTimes: [{ days: [2, 4], startTime: 1100, endTime: 1230 }], // Doesn't conflict - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 2 schedules: one without optional, one with section 3 - assert.strictEqual(result.length, 2); - - // One should be just the base schedule - assert.strictEqual( - result.some((s) => s.length === 1 && s[0].id === 1), - true, - ); - - // One should include section 3 (not section 2 which conflicts) - assert.strictEqual( - result.some((s) => s.length === 2 && s.some((sec) => sec.id === 3)), - true, - ); - assert.strictEqual( - result.some((s) => s.some((sec) => sec.id === 2)), - false, - ); - }); - - test("should generate all valid subsets of multiple optional courses", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [2], startTime: 1100, endTime: 1230 }], - }, - ], - [ + test("MAX_RESULTS cap respected across locked+optional pipeline", () => { + // Build many non-conflicting sections so combinations explode + const locked: SectionWithCourse[][] = [ + [createMockSection(1000, [{ days: [1], startTime: 800, endTime: 850 }])], + [createMockSection(1001, [{ days: [2], startTime: 800, endTime: 850 }])], + ]; + const optional: SectionWithCourse[][] = Array.from( + { length: 8 }, + (_, i) => [ + createMockSection(2000 + i, [ { - id: 3, - meetingTimes: [{ days: [3], startTime: 1300, endTime: 1430 }], + days: [3 + (i % 2)], + startTime: 900 + i * 100, + endTime: 950 + i * 100, }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 4 schedules: none, just 2, just 3, both 2 and 3 - assert.strictEqual(result.length, 4); - - // Check all combinations exist - assert.strictEqual( - result.some((s) => s.length === 1 && s[0].id === 1), - true, - ); // Just base - assert.strictEqual( - result.some( - (s) => - s.length === 2 && - s.some((sec) => sec.id === 2) && - !s.some((sec) => sec.id === 3), - ), - true, - ); // Base + 2 - assert.strictEqual( - result.some( - (s) => - s.length === 2 && - s.some((sec) => sec.id === 3) && - !s.some((sec) => sec.id === 2), - ), - true, - ); // Base + 3 - assert.strictEqual( - result.some( - (s) => - s.length === 3 && - s.some((sec) => sec.id === 2) && - s.some((sec) => sec.id === 3), - ), - true, - ); // Base + 2 + 3 - }); - - test("should handle optional courses that conflict with each other", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [2], startTime: 1100, endTime: 1230 }], - }, - ], - [ - { - id: 3, - meetingTimes: [{ days: [2], startTime: 1130, endTime: 1300 }], // Conflicts with course 2 - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 3 schedules: none, just 2, just 3 (but NOT both 2 and 3) - assert.strictEqual(result.length, 3); - - // Check combinations - assert.strictEqual( - result.some((s) => s.length === 1 && s[0].id === 1), - true, - ); // Just base - assert.strictEqual( - result.some( - (s) => - s.length === 2 && - s.some((sec) => sec.id === 2) && - !s.some((sec) => sec.id === 3), - ), - true, - ); // Base + 2 - assert.strictEqual( - result.some( - (s) => - s.length === 2 && - s.some((sec) => sec.id === 3) && - !s.some((sec) => sec.id === 2), - ), - true, - ); // Base + 3 - assert.strictEqual( - result.some( - (s) => s.some((sec) => sec.id === 2) && s.some((sec) => sec.id === 3), - ), - false, - ); // NOT both 2 and 3 - }); - - test("should handle empty base schedule", () => { - const baseSchedule: Section[] = []; - - const optionalSections: Section[][] = [ - [ - { - id: 1, - meetingTimes: [{ days: [1], startTime: 900, endTime: 1030 }], - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 2 schedules: empty and with section 1 - assert.strictEqual(result.length, 2); - assert.strictEqual( - result.some((s) => s.length === 0), - true, - ); - assert.strictEqual( - result.some((s) => s.length === 1 && s[0].id === 1), - true, - ); - }); - - test("should handle complex scenario with multiple sections per optional course", () => { - const baseSchedule: Section[] = [ - { - id: 1, - meetingTimes: [{ days: [1], startTime: 900, endTime: 1030 }], - }, - ]; - - const optionalSections: Section[][] = [ - [ - { - id: 2, - meetingTimes: [{ days: [1], startTime: 1000, endTime: 1130 }], // Conflicts - }, - { - id: 3, - meetingTimes: [{ days: [2], startTime: 1100, endTime: 1230 }], // Doesn't conflict - }, - ], - [ - { - id: 4, - meetingTimes: [{ days: [3], startTime: 1300, endTime: 1430 }], - }, - ], - ]; - - const result = addOptionalCourses(baseSchedule, optionalSections); - - // Should have 4 schedules: none, just 3, just 4, both 3 and 4 - assert.strictEqual(result.length, 4); - - // Should NOT include section 2 (conflicts with base) - assert.strictEqual( - result.some((s) => s.some((sec) => sec.id === 2)), - false, - ); - - // Should include various combinations of 3 and 4 - assert.strictEqual( - result.some((s) => s.length === 1), - true, - ); // Just base - assert.strictEqual( - result.some( - (s) => - s.some((sec) => sec.id === 3) && !s.some((sec) => sec.id === 4), - ), - true, - ); // Base + 3 - assert.strictEqual( - result.some( - (s) => - s.some((sec) => sec.id === 4) && !s.some((sec) => sec.id === 3), - ), - true, - ); // Base + 4 - assert.strictEqual( - result.some( - (s) => s.some((sec) => sec.id === 3) && s.some((sec) => sec.id === 4), - ), - true, - ); // Base + 3 + 4 - }); + ]), + ], + ); + const results = simulateGenerateSchedules(locked, optional); + assert.ok(results.length <= MAX_RESULTS); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); +}); + +// --------------------------------------------------------------------------- +// All results are always conflict-free (invariant) +// --------------------------------------------------------------------------- + +describe("schedule invariants", () => { + test("no returned schedule ever has a time conflict", () => { + const courses = [[CS_SEC_A, CS_SEC_B], [MATH_SEC2], [PHYS_SEC]]; + const results = simulateGenerateSchedules(courses, [[OPT_A, OPT_C]]); + assert.ok(results.every((s) => !hasConflictInSchedule(s))); + }); + + test("every schedule contains exactly one section per locked course", () => { + const locked = [[CS_SEC_A, CS_SEC_B], [MATH_SEC2]]; + const lockedSchedules = generateCombinationsOptimized(locked); + // Each result must have exactly 2 sections (one per locked course) + assert.ok( + lockedSchedules.every( + ({ schedule }) => schedule.length === locked.length, + ), + ); }); }); diff --git a/apps/searchneu/lib/scheduler/generateSchedules.ts b/apps/searchneu/lib/scheduler/generateSchedules.ts index 29cdee5c..c6b55ab8 100644 --- a/apps/searchneu/lib/scheduler/generateSchedules.ts +++ b/apps/searchneu/lib/scheduler/generateSchedules.ts @@ -10,7 +10,12 @@ import { } from "@/lib/db"; import { eq, sql } from "drizzle-orm"; import { SectionWithCourse } from "./filters"; -import { meetingTimesToBinaryMask, masksConflict } from "./binaryMeetingTime"; +import { meetingTimesToBinaryMask } from "./binaryMeetingTime"; +import { + MAX_RESULTS, + generateCombinationsOptimized, + addOptionalCourses, +} from "./generateCombinations"; export const getSectionsAndMeetingTimes = (courseId: number) => { // This code is from the catalog page, ideally we want to abstract this in the future @@ -115,175 +120,6 @@ export const getSectionsAndMeetingTimes = (courseId: number) => { return sections; }; -/** - * Used to keep track of indexes of sections and increment them when they conflict w the current schedule - * Returns true if overflow (we're done), false otherwise - */ -export const incrementIndex = ( - indexes: number[], - sizes: number[], - position: number, -): boolean => { - indexes[position]++; - - // Handle carry/overflow like an odometer - while (position >= 0 && indexes[position] >= sizes[position]) { - indexes[position] = 0; - position--; - if (position >= 0) { - indexes[position]++; - } - } - - // If position < 0, we've overflowed completely - return position < 0; -}; - -/** - * Optimized iterative generation with conflict-aware skipping. - * Uses binary time representation for O(1) conflict checking. - */ -const generateCombinationsOptimized = ( - sectionsByCourse: SectionWithCourse[][], -): SectionWithCourse[][] => { - if (sectionsByCourse.length === 0) return []; - if (sectionsByCourse.length === 1) - return sectionsByCourse[0].map((section) => [section]); - - // Sort courses by number of sections (fewest first) - const sortedIndices = sectionsByCourse - .map((sections, idx) => ({ sections, idx, count: sections.length })) - .sort((a, b) => a.count - b.count); - - const sortedSections = sortedIndices.map((item) => item.sections); - const result: SectionWithCourse[][] = []; - const sizes = sortedSections.map((s) => s.length); - const indexes = new Array(sizes.length).fill(0); - - // Pre-compute binary masks for all sections once - const sectionMasks: bigint[][] = sortedSections.map((sections) => - sections.map(meetingTimesToBinaryMask), - ); - - while (true) { - // Build combination incrementally and check conflicts as we go - const combination: SectionWithCourse[] = []; - const combinationMasks: bigint[] = []; - let conflictIndex = -1; - - // Build combination one course at a time, checking for conflicts - for (let i = 0; i < indexes.length; i++) { - const section = sortedSections[i][indexes[i]]; - const mask = sectionMasks[i][indexes[i]]; - - // Check if this section conflicts with any already in the combination - for (let j = 0; j < combinationMasks.length; j++) { - if (masksConflict(combinationMasks[j], mask)) { - conflictIndex = i; - break; - } - } - - if (conflictIndex !== -1) { - // Found conflict at position i, stop building this combination - break; - } - - combination.push(section); - combinationMasks.push(mask); - } - - if (conflictIndex === -1) { - // No conflict - we built a complete valid schedule - result.push(combination); - // Increment last index normally - if (incrementIndex(indexes, sizes, sizes.length - 1)) break; - } else { - // Conflict found at position conflictIndex - // Increment that position to skip this branch - if (incrementIndex(indexes, sizes, conflictIndex)) break; - } - } - - return result; -}; - -/** - * Helper function to try adding optional courses to a base schedule. - */ -const addOptionalCourses = ( - baseSchedule: SectionWithCourse[], - optionalSectionsByCourse: SectionWithCourse[][], - numCourses?: number, -): SectionWithCourse[][] => { - const results: SectionWithCourse[][] = []; - const baseMasks = baseSchedule.map(meetingTimesToBinaryMask); - - const generateOptionalCombinations = ( - currentSchedule: SectionWithCourse[], - currentMasks: bigint[], - courseIndex: number, - ) => { - // Condition: If we hit the end of the available courses - if (courseIndex === optionalSectionsByCourse.length) { - // If numCourses is defined, only push if length matches exactly - // Otherwise, push everything - if (numCourses === undefined || currentSchedule.length === numCourses) { - results.push([...currentSchedule]); - } - return; - } - - // Optimization: If it's impossible to reach numCourses even if we took - // every remaining course, stop this branch - if (numCourses !== undefined) { - const remainingSlots = optionalSectionsByCourse.length - courseIndex; - if (currentSchedule.length + remainingSlots < numCourses) return; - - // Optimization: If we already have enough courses, don't try to add more - // Just jump to the end of the recursion to validate the current length - if (currentSchedule.length === numCourses) { - generateOptionalCombinations( - currentSchedule, - currentMasks, - optionalSectionsByCourse.length, - ); - return; - } - } - - // Choice A: Try not adding this optional course - generateOptionalCombinations( - currentSchedule, - currentMasks, - courseIndex + 1, - ); - - // Choice B: Try adding each section of this optional course - for (const section of optionalSectionsByCourse[courseIndex]) { - const sectionMask = meetingTimesToBinaryMask(section); - let hasConflict = false; - for (const mask of currentMasks) { - if (masksConflict(mask, sectionMask)) { - hasConflict = true; - break; - } - } - - if (!hasConflict) { - generateOptionalCombinations( - [...currentSchedule, section], - [...currentMasks, sectionMask], - courseIndex + 1, - ); - } - } - }; - - generateOptionalCombinations(baseSchedule, baseMasks, 0); - return results; -}; - // the main generate schedule function // takes a list of locked course IDs and optional course IDs // returns a list of valid schedules (each schedule is a list of sections) @@ -300,34 +136,52 @@ export const generateSchedules = async ( ); optionalSectionsByCourse.sort((a, b) => a.length - b.length); - const validLockedSchedules = generateCombinationsOptimized( + // Pre-compute optional section masks once — reused for every locked schedule + const optionalSectionMasks: bigint[][] = optionalSectionsByCourse.map( + (sections: SectionWithCourse[]) => sections.map(meetingTimesToBinaryMask), + ); + + const lockedSchedules = generateCombinationsOptimized( lockedSectionsByCourse, + MAX_RESULTS, ); // Edge case: No locked courses if (lockedCourseIds.length === 0 && optionalCourseIds.length > 0) { - return addOptionalCourses([], optionalSectionsByCourse, numCourses); + return addOptionalCourses( + [], + BigInt(0), + optionalSectionsByCourse, + optionalSectionMasks, + numCourses, + MAX_RESULTS, + ); } // If no optional courses, filter the locked schedules by the required count if (optionalCourseIds.length === 0) { + const schedules = lockedSchedules.map((r) => r.schedule); return numCourses !== undefined - ? validLockedSchedules.filter((s) => s.length === numCourses) - : validLockedSchedules; + ? schedules.filter((s) => s.length === numCourses) + : schedules; } const allSchedules: SectionWithCourse[][] = []; - for (const lockedSchedule of validLockedSchedules) { + for (const { schedule, mask } of lockedSchedules) { // If a locked schedule is already too big, it can't be valid - if (numCourses !== undefined && lockedSchedule.length > numCourses) - continue; + if (numCourses !== undefined && schedule.length > numCourses) continue; + const remaining = MAX_RESULTS - allSchedules.length; const schedulesWithOptional = addOptionalCourses( - lockedSchedule, + schedule, + mask, optionalSectionsByCourse, - numCourses, // Pass target down + optionalSectionMasks, + numCourses, + remaining, ); - allSchedules.push(...schedulesWithOptional); + for (const s of schedulesWithOptional) allSchedules.push(s); + if (allSchedules.length >= MAX_RESULTS) break; } return allSchedules; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a30437a0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,213 @@ +{ + "name": "sneu", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sneu", + "devDependencies": { + "prettier": "^3.8.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "turbo": "^2.9.3" + } + }, + "node_modules/@turbo/darwin-64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.5.tgz", + "integrity": "sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/darwin-arm64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.5.tgz", + "integrity": "sha512-vkF/9F/l3aWd4bHxTui5Hh0F5xrTZ4e3rbBsc57zA6O8gNbmHN3B6eZ5psAIP2CnJRZ8ZxRjV3WZHeNXMXkPBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/linux-64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.5.tgz", + "integrity": "sha512-z/Get5NUaUxm5HSGFqVMICDRjFNsCUhSc4wnFa/PP1QD0NXCjr7bu9a2EM6md/KMCBW0Qe393Ac+UM7/ryDDTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/linux-arm64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.5.tgz", + "integrity": "sha512-jyBifaNoI5/NheyswomiZXJvjdAdvT7hDRYzQ4meP0DKGvpXUjnqsD+4/J2YSDQ34OHxFkL30FnSCUIVOh2PHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/windows-64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.5.tgz", + "integrity": "sha512-ph24K5uPtvo7UfuyDXnBiB/8XvrO+RQWbbw5zkA/bVNoy9HDiNoIJJj3s62MxT9tjEb6DnPje5PXSz1UR7QAyg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turbo/windows-arm64": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.5.tgz", + "integrity": "sha512-6c5RccT/+iR39SdT1G5HyZaD2n57W77o+l0TTfxG/cVlhV94Acyg2gTQW7zUOhW1BeQpBjHzu9x8yVBZwrHh7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/turbo": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.5.tgz", + "integrity": "sha512-JXNkRe6H6MjSlk5UQRTjyoKX5YN2zlc2632xcSlSFBao5yvbMWTpv9SNolOZlZmUlcDOHuszPLItbKrvcXnnZA==", + "dev": true, + "license": "MIT", + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "@turbo/darwin-64": "2.9.5", + "@turbo/darwin-arm64": "2.9.5", + "@turbo/linux-64": "2.9.5", + "@turbo/linux-arm64": "2.9.5", + "@turbo/windows-64": "2.9.5", + "@turbo/windows-arm64": "2.9.5" + } + } + } +}