Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions migrations/seed-requirements.js

Large diffs are not rendered by default.

63 changes: 19 additions & 44 deletions packages/BtLL/reference_gradtrak_reqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1054,11 +1054,6 @@ Function<boolean>(Course) technical_elective_finder (course){
boolean return or([is_anthro_107, is_arch, is_art, is_astron, is_bioeng, is_chem, is_chmeng, is_civeng, is_cogsci, is_comlit_170, is_compbio_175, is_cyplan_101, is_data, is_hist_c184d, is_sts_c104, is_demog, is_desinv, is_meceng_292c, is_deveng_290, is_dighum_101, is_econ, is_educ, is_eecs, is_eneres, is_engin, is_envecon, is_eps, is_espm, is_geog, is_indeng, is_info, is_integbi, is_ldarch, is_legalst_123, is_ling, is_math, is_mcellbi, is_me, is_music, is_neurosc, is_newmedia, is_nuceng, is_nusctx_103, is_philos, is_physics, is_polsci, is_psych_c123, is_pbhtlh, is_pubpol, is_sociol_166, is_spanish_100, is_stat, is_theater_177, is_ugba])
}

Function<number>(number, Course) add_course_units (acc, course){
number units get_attr(course, "units")
number return add([acc, units])
}

Function<boolean>(Course) any_upper_div_tech_finder (course) {
boolean is_design design_upper_div_finder(course)
boolean is_cs cs_upper_div_finder(course)
Expand Down Expand Up @@ -1108,46 +1103,26 @@ Function<List<Requirement>>() main (){

// TODO: add special topics and graduate courses from https://eecs.berkeley.edu/academics/courses/approved-cs-graduate-and-special-topics-courses/

// Upper Division

// Design: 4 units
List<Course> design_eligible filter(courses, design_upper_div_finder)
List<Course> design_used take_until_units(design_eligible, 4)
number design_units reduce(design_used, add_course_units, 0)
NumberRequirement design_upper_div {design_eligible, design_units, 4, "Design Upper Division Units"}

// CS: 8 units
List<Course> cs_eligible filter(courses, cs_upper_div_finder)
List<Course> cs_pool filter(cs_eligible, (c) {
boolean is_used one_common_course([c], design_used)
boolean return not(is_used)
})
List<Course> cs_used take_until_units(cs_pool, 8)
number cs_units reduce(cs_used, add_course_units, 0)
NumberRequirement cs_upper_div {cs_pool, cs_units, 8, "CS Upper Division Units"}

// CS/EE/EECS: 8 units
List<Course> eecs_eligible filter(courses, eecs_upper_div_finder)
List<Course> eecs_pool filter(eecs_eligible, (c) {
boolean used_design one_common_course([c], design_used)
boolean used_cs one_common_course([c], cs_used)
boolean return not(or([used_design, used_cs]))
})
List<Course> eecs_used take_until_units(eecs_pool, 8)
number eecs_units reduce(eecs_used, add_course_units, 0)
NumberRequirement eecs_upper_div {eecs_pool, eecs_units, 8, "CS/EE/EECS Upper Division Units"}

// Technical elective: 4 units
// Upper Division — optimal unit-based assignment, no course used twice
List<Course> design_eligible filter(courses, design_upper_div_finder)
List<Course> cs_eligible filter(courses, cs_upper_div_finder)
List<Course> eecs_eligible filter(courses, eecs_upper_div_finder)
List<Course> tech_elec_eligible filter(courses, technical_elective_finder)
List<Course> tech_pool filter(tech_elec_eligible, (c) {
boolean used_design one_common_course([c], design_used)
boolean used_cs one_common_course([c], cs_used)
boolean used_eecs one_common_course([c], eecs_used)
boolean return not(or([used_design, used_cs, used_eecs]))
})
List<Course> tech_used take_until_units(tech_pool, 4)
number tech_units reduce(tech_used, add_course_units, 0)
NumberRequirement tech_elec_upper_div {tech_pool, tech_units, 4, "Upper Division Technical Elective"}

List<Course> design_courses assign_by_units(design_eligible, cs_eligible, eecs_eligible, tech_elec_eligible, 4, 8, 8, 4, 0)
List<Course> cs_courses assign_by_units(design_eligible, cs_eligible, eecs_eligible, tech_elec_eligible, 4, 8, 8, 4, 1)
List<Course> eecs_courses assign_by_units(design_eligible, cs_eligible, eecs_eligible, tech_elec_eligible, 4, 8, 8, 4, 2)
List<Course> tech_courses assign_by_units(design_eligible, cs_eligible, eecs_eligible, tech_elec_eligible, 4, 8, 8, 4, 3)

number design_units sum_units(design_courses)
number cs_units sum_units(cs_courses)
number eecs_units sum_units(eecs_courses)
number tech_units sum_units(tech_courses)

NumberRequirement design_upper_div {design_courses, design_units, 4, "Design Upper Division Units"}
NumberRequirement cs_upper_div {cs_courses, cs_units, 8, "CS Upper Division Units"}
NumberRequirement eecs_upper_div {eecs_courses, eecs_units, 8, "CS/EE/EECS Upper Division Units"}
NumberRequirement tech_elec_upper_div {tech_courses, tech_units, 4, "Upper Division Technical Elective"}

// min 24 total units
number total_tech_units add([design_units, cs_units, eecs_units, tech_units])
Expand Down
139 changes: 139 additions & 0 deletions packages/BtLL/src/lib/course_assignment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { Data, FunctionMapEntry, Variables } from "../types";
import { Course, coursesEqual } from "./course";

// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------

// Dedup a list-of-lists of courses into a flat course array + per-list
// indices into that array. Used by every matcher in this file so equality
// (subject + number) is computed once.
function buildCourseIndex(eligibleLists: Course[][]): {
allCourses: Course[];
eligible: number[][];
Expand All @@ -16,6 +23,27 @@ function buildCourseIndex(eligibleLists: Course[][]): {
return { allCourses, eligible };
}

// Berkeley course-numbering convention:
// - lower division: 1–99
// - upper division: 100–199
// - graduate: ≥ 200
// Course numbers may have a letter prefix (C/N/W) and/or a letter suffix
// (A/B/L/AC/…), e.g. "C100", "170L", "C170A". We strip both and parse the
// digits.
export function isUpperDivision(course: Course): boolean {
const num = course.number?.data ?? "";
const match = num.match(/^[A-Z]*(\d+)[A-Z]*$/);
if (!match) return false;
const n = parseInt(match[1], 10);
return n >= 100 && n <= 199;
}

// ---------------------------------------------------------------------------
// assign_by_count — bipartite matching (Kuhn's augmenting-path algorithm)
// Each category needs ≥1 course; no course used twice. Maximizes categories
// satisfied.
// ---------------------------------------------------------------------------

function tryAugment(
cat: number,
eligible: number[][],
Expand Down Expand Up @@ -57,6 +85,78 @@ export function runBipartiteMatch(eligibleLists: Course[][]): Course[][] {
return matchCat.map((ci) => (ci >= 0 ? [allCourses[ci]] : []));
}

// ---------------------------------------------------------------------------
// assign_by_units — unit-threshold bucket assignment (backtracking)
// Each bucket needs ≥T_k units; no course used twice. Maximizes buckets
// satisfied. Uses most-constrained-first ordering to prune early.
// ---------------------------------------------------------------------------

export function runUnitAssignment(
eligibleLists: Course[][],
thresholds: number[]
): Course[][] {
const k = eligibleLists.length;
const { allCourses, eligible } = buildCourseIndex(eligibleLists);

// Per-course: which buckets is it eligible for
const courseEligible: number[][] = allCourses.map((_, ci) =>
eligible.reduce<number[]>((acc, bucketList, bi) => {
if (bucketList.includes(ci)) acc.push(bi);
return acc;
}, [])
);

// Sort courses: most constrained first (fewest eligible buckets)
const sortedCourseIndices = allCourses
.map((_, i) => i)
.sort((a, b) => courseEligible[a].length - courseEligible[b].length);

const unitsAssigned = new Array<number>(k).fill(0);
const assigned: Course[][] = Array.from({ length: k }, () => []);

let bestSatisfied = 0;
let bestAssigned: Course[][] = Array.from({ length: k }, () => []);

function backtrack(courseOrder: number[], pos: number): void {
const satisfied = unitsAssigned.filter((u, i) => u >= thresholds[i]).length;
if (satisfied > bestSatisfied) {
bestSatisfied = satisfied;
bestAssigned = assigned.map((b) => [...b]);
}
if (pos >= courseOrder.length) return;

const remaining = courseOrder.slice(pos);
const potentialUnits = new Array<number>(k).fill(0);
for (const ci of remaining) {
for (const bi of courseEligible[ci]) {
potentialUnits[bi] += allCourses[ci].units?.data ?? 0;
}
}
const maxPossible = unitsAssigned.filter(
(u, i) => u >= thresholds[i] || u + potentialUnits[i] >= thresholds[i]
).length;
if (maxPossible <= bestSatisfied) return;

const ci = courseOrder[pos];
for (const bi of courseEligible[ci]) {
const units = allCourses[ci].units?.data ?? 0;
assigned[bi].push(allCourses[ci]);
unitsAssigned[bi] += units;
backtrack(courseOrder, pos + 1);
assigned[bi].pop();
unitsAssigned[bi] -= units;
}
backtrack(courseOrder, pos + 1);
}

backtrack(sortedCourseIndices, 0);
return bestAssigned;
}

// ---------------------------------------------------------------------------
// BtLL built-in registrations
// ---------------------------------------------------------------------------

export const functions: FunctionMapEntry[] = [
[
"assign_by_count",
Expand Down Expand Up @@ -91,4 +191,43 @@ export const functions: FunctionMapEntry[] = [
},
},
],
[
"assign_by_units",
{
type: "Function<List<Course>>(List<Course>, List<Course>, List<Course>, List<Course>, number, number, number, number, number)",
data: {
eval: (
_: Variables,
l0: Data<Course[]>,
l1: Data<Course[]>,
l2: Data<Course[]>,
l3: Data<Course[]>,
t0: Data<number>,
t1: Data<number>,
t2: Data<number>,
t3: Data<number>,
slot: Data<number>
): Data<Course[]> => {
const lists = [l0, l1, l2, l3];
const thresholds = [t0.data, t1.data, t2.data, t3.data];
const result = runUnitAssignment(
lists.map((l) => l.data),
thresholds
);
return { data: result[slot.data], type: "List<Course>" };
},
args: [
"List<Course>",
"List<Course>",
"List<Course>",
"List<Course>",
"number",
"number",
"number",
"number",
"number",
],
},
},
],
];
60 changes: 59 additions & 1 deletion packages/BtLL/src/test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { LNS_REQ_BTLL } from "../reference_gradtrak_reqs";
import { init } from "./interpreter";
import type { Course } from "./lib/course";
import { runBipartiteMatch } from "./lib/course_assignment";
import {
isUpperDivision,
runBipartiteMatch,
runUnitAssignment,
} from "./lib/course_assignment";

const mkCourse = (number: string): Course => ({
subject: { data: "CS", type: "string" },
Expand Down Expand Up @@ -493,6 +497,60 @@ const TESTS = [
}
},
},
{
name: "isUpperDivision — true cases",
test: () => {
const trueCases = [
"100",
"170",
"199",
"C100",
"170L",
"C170A",
"N130",
"W140B",
"170AC",
];
return trueCases.every((n) => isUpperDivision(mkCourse(n)));
},
},
{
name: "isUpperDivision — false cases",
test: () => {
const falseCases = ["61", "61A", "99", "200", "C50", "1", "99B"];
return falseCases.every((n) => !isUpperDivision(mkCourse(n)));
},
},
{
name: "runUnitAssignment — single-major regression",
test: () => {
// 2 buckets needing 4 units each. Course a (4 units) eligible for both;
// course b (4 units) eligible for bucket 1 only. Optimal: a→0, b→1.
const a = mkCourse("170");
const b = mkCourse("161");
const result = runUnitAssignment([[a, b], [b]], [4, 4]);
return result[0].length > 0 && result[1].length > 0;
},
},
{
name: "runUnitAssignment — bucket needing multiple courses to meet threshold",
test: () => {
const [a, b, c] = ["100", "110", "120"].map(mkCourse);
const result = runUnitAssignment([[a, b, c]], [12]);
return result[0].length === 3;
},
},
{
name: "runUnitAssignment — zero-unit course cannot satisfy a non-zero threshold",
test: () => {
const zeroUnit: Course = {
...mkCourse("170"),
units: { data: 0, type: "number" },
};
const result = runUnitAssignment([[zeroUnit]], [4]);
return result[0].length === 0;
},
},
];

// Test function
Expand Down
Loading