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
130 changes: 89 additions & 41 deletions apps/frontend/src/app/GradTrak/BtLLInterface/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { useApolloClient } from "@apollo/client/react";
import classNames from "classnames";
import { Check, NavArrowDown, NavArrowRight } from "iconoir-react";

import { type Data, init } from "@repo/BtLL";
import {
type Data,
computeOverlapCap,
init,
runJointReassignment,
} from "@repo/BtLL";

import { IPlan, IPlanTerm, ISelectedCourse } from "@/lib/api";
import { IPlanRequirement } from "@/lib/api/plans";
Expand Down Expand Up @@ -627,6 +632,16 @@ export default function BtLLGradTrakInterface({
}
};

// Pass 1: evaluate each requirement's BtLL program.
type EvalEntry = {
spr: (typeof plan.selectedPlanRequirements)[number];
req: NonNullable<
(typeof plan.selectedPlanRequirements)[number]["planRequirement"]
>;
evaluated: RequirementResult[];
};
const evalEntries: EvalEntry[] = [];

for (const spr of plan.selectedPlanRequirements) {
if (!spr.planRequirement) continue;

Expand All @@ -646,50 +661,83 @@ export default function BtLLGradTrakInterface({
| null;

if (Array.isArray(evaluated) && evaluated.length > 0) {
// Flatten nested requirements onto a continuous index track, starting roots at 0
// so existing database overrides aren't broken.
let counter = evaluated.length;
evalEntries.push({ spr, req, evaluated });
}
}

evaluated.forEach((req, index) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req as any).flatIndex = index;
// Joint reassignment: group major trees by major name, compute the
// overlap cap from college membership, then run the joint optimizer.
// This is a no-op when there is only one major (overlapCap = 0).
const majorTreeMap = new Map<
string,
{ trees: RequirementResult[]; college: string }
>();
for (const { evaluated, req } of evalEntries) {
if (!req.major) continue;
const existing = majorTreeMap.get(req.major);
if (existing) {
existing.trees.push(...evaluated);
} else {
majorTreeMap.set(req.major, {
trees: [...evaluated],
college: req.college ?? "",
});
}
}
const majorEntries = Array.from(majorTreeMap.values());
const overlapCap = computeOverlapCap(
Array.from(majorTreeMap.keys()),
majorEntries.map((e) => e.college)
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runJointReassignment(majorEntries.map((e) => e.trees) as any, overlapCap);

const assignChildren = (reqs: RequirementResult[]) => {
for (const req of reqs) {
if (
req.type?.data === "AndRequirement" ||
req.type?.data === "OrRequirement"
) {
const subReqs = req.requirements?.data ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subReqs.forEach((sub: any) => {
sub.flatIndex = counter++;
});
assignChildren(subReqs);
}
}
};
assignChildren(evaluated);
const totalNodes = counter;

const currentOverrides = spr.manualOverrides ?? [];
const paddedOverrides = Array.from({ length: totalNodes }).map(
(_, i) => (i < currentOverrides.length ? currentOverrides[i] : null)
);

const newSpr = {
...spr,
manualOverrides: paddedOverrides,
};
// Pass 2: assign flatIndex counters and override bookkeeping now that
// course assignments have been finalised by the joint pass.
for (const { spr, req, evaluated } of evalEntries) {
// Flatten nested requirements onto a continuous index track, starting roots at 0
// so existing database overrides aren't broken.
let counter = evaluated.length;

groups.push({
title: req.name,
requirements: evaluated,
source: req,
selectedPlanRequirement: newSpr,
});
}
evaluated.forEach((req, index) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(req as any).flatIndex = index;
});

const assignChildren = (reqs: RequirementResult[]) => {
for (const req of reqs) {
if (
req.type?.data === "AndRequirement" ||
req.type?.data === "OrRequirement"
) {
const subReqs = req.requirements?.data ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
subReqs.forEach((sub: any) => {
sub.flatIndex = counter++;
});
assignChildren(subReqs);
}
}
};
assignChildren(evaluated);
const totalNodes = counter;

const currentOverrides = spr.manualOverrides ?? [];
const paddedOverrides = Array.from({ length: totalNodes }).map(
(_, i) => (i < currentOverrides.length ? currentOverrides[i] : null)
);

const newSpr = {
...spr,
manualOverrides: paddedOverrides,
};

groups.push({
title: req.name,
requirements: evaluated,
source: req,
selectedPlanRequirement: newSpr,
});
}

setEvaluatedGroups(groups);
Expand Down
5 changes: 5 additions & 0 deletions packages/BtLL/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { init } from "./interpreter";
export type { Data } from "./types";
export {
runJointReassignment,
computeOverlapCap,
} from "./lib/joint_assignment";
export type { JointLeaf } from "./lib/joint_assignment";
219 changes: 219 additions & 0 deletions packages/BtLL/src/lib/course_assignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,225 @@ export function runUnitAssignment(
return bestAssigned;
}

// ---------------------------------------------------------------------------
// runJointAssignment — joint optimal matching across one or two majors
//
// Matching model
// --------------
// A "leaf" is an atomic, flexibly-fillable requirement in one major: either
// a count constraint ("≥N courses from this list") or a units constraint
// ("≥T units from this list"). Each leaf belongs to exactly one owner
// (ownerIdx ∈ {0,1}); ownerIdx=1 leaves are absent in the single-major case.
//
// Decision: for each (course c, leaf L), a 0/1 flag x_{c,L}. The matching
// must satisfy two structural rules and one policy rule:
//
// 1. Per-major exclusivity. A course can fill at most one leaf within a
// single major: ∀ owner m, ∀ course c: Σ_{L: owner(L)=m} x_{c,L} ≤ 1.
//
// 2. Overlap cap. A course is "an overlap" iff it fills a leaf in BOTH
// owner 0 AND owner 1. The number of upper-division (100–199) overlap
// courses is capped at `overlapCap`. Lower-division courses may overlap
// freely (Berkeley policy: only UD courses count against the cap).
//
// 3. Objective. Maximize total satisfied leaves across both owners; tiebreak
// by minimizing total course-uses (avoids gratuitously stacking courses
// into already-satisfied leaves).
//
// Note: courses are NOT pre-partitioned into "A only", "B only", "shared".
// The optimizer decides for each course. A course is allowed to be in zero,
// one, or both owners' assignments; it costs an overlap budget unit only if
// it ends up in both AND it's UD.
//
// Single-major degenerate case
// ----------------------------
// When `leaves` contains only ownerIdx=0 leaves (or overlapCap=0), no
// "both-owners" assignment is reachable, so the algorithm collapses to a
// single-major matching. This is intentional: the same primitive handles 1
// or 2 majors uniformly.
//
// Algorithm
// ---------
// Backtrack over courses in most-constrained-first order. At each course,
// branch over the disjoint options:
// (a) skip
// (b) assign to leaf L0 in owner 0
// (c) assign to leaf L1 in owner 1
// (d) assign to L0 AND L1 (only if owner 1 exists; budget-checked if UD)
// Bound by an upper estimate of remaining satisfiable leaves and prune.
// ---------------------------------------------------------------------------

export type LeafConstraint =
| { kind: "count"; threshold: number }
| { kind: "units"; threshold: number };

export interface JointLeaf {
ownerIdx: 0 | 1;
constraint: LeafConstraint;
eligible: Course[];
}

export function runJointAssignment(
leaves: JointLeaf[],
overlapCap: number,
isUD: (c: Course) => boolean = isUpperDivision
): Course[][] {
const k = leaves.length;
if (k === 0) return [];

// Index all distinct courses across all leaves' eligibility lists.
const { allCourses, eligible } = buildCourseIndex(
leaves.map((l) => l.eligible)
);
const numCourses = allCourses.length;

// For each course, list eligible leaves split by owner.
const eligByOwner: { o0: number[]; o1: number[] }[] = allCourses.map(
(_, ci) => ({ o0: [], o1: [] })
);
for (let li = 0; li < k; li++) {
const owner = leaves[li].ownerIdx;
for (const ci of eligible[li]) {
if (owner === 0) eligByOwner[ci].o0.push(li);
else eligByOwner[ci].o1.push(li);
}
}

const hasOwner1 = leaves.some((l) => l.ownerIdx === 1);

// Increment value contributed by adding course ci to leaf li (1 for count
// constraints, course units for units constraints).
const valueOf = (li: number, ci: number): number =>
leaves[li].constraint.kind === "count"
? 1
: (allCourses[ci].units?.data ?? 0);

// Most-constrained-first ordering: fewer total eligible leaves → try
// earlier so dead-end branches are detected sooner.
const courseOrder = allCourses
.map((_, i) => i)
.sort(
(a, b) =>
eligByOwner[a].o0.length +
eligByOwner[a].o1.length -
(eligByOwner[b].o0.length + eligByOwner[b].o1.length)
);

// Mutable per-leaf state during backtracking.
const accum = new Array<number>(k).fill(0);
const assigned: number[][] = Array.from({ length: k }, () => []);

// Best-so-far snapshot.
let bestSatisfied = -1;
let bestCoursesUsed = Infinity;
let bestAssignment: number[][] = Array.from({ length: k }, () => []);

const isSatisfied = (li: number) =>
accum[li] >= leaves[li].constraint.threshold;

const countSatisfied = (): number => {
let n = 0;
for (let li = 0; li < k; li++) if (isSatisfied(li)) n++;
return n;
};

function backtrack(
pos: number,
overlapsUsed: number,
coursesUsed: number
): void {
const satisfied = countSatisfied();
if (
satisfied > bestSatisfied ||
(satisfied === bestSatisfied && coursesUsed < bestCoursesUsed)
) {
bestSatisfied = satisfied;
bestCoursesUsed = coursesUsed;
bestAssignment = assigned.map((b) => [...b]);
}

if (pos >= courseOrder.length) return;

// Bound: optimistic max-possible-satisfied if every remaining course
// contributes maximally to every leaf it's eligible for. This
// overcounts (since per-major exclusivity isn't enforced in the bound)
// but is a valid upper bound and prunes plenty in practice.
const potential = new Array<number>(k).fill(0);
for (let p = pos; p < courseOrder.length; p++) {
const ci = courseOrder[p];
for (const li of eligByOwner[ci].o0) potential[li] += valueOf(li, ci);
for (const li of eligByOwner[ci].o1) potential[li] += valueOf(li, ci);
}
let upperBound = 0;
for (let li = 0; li < k; li++) {
if (
isSatisfied(li) ||
accum[li] + potential[li] >= leaves[li].constraint.threshold
)
upperBound++;
}
if (upperBound < bestSatisfied) return;
if (upperBound === bestSatisfied && coursesUsed >= bestCoursesUsed) return;

const ci = courseOrder[pos];
const e0 = eligByOwner[ci].o0;
const e1 = eligByOwner[ci].o1;

// (a) skip
backtrack(pos + 1, overlapsUsed, coursesUsed);

// (b) assign to one leaf in owner 0
for (const li of e0) {
assigned[li].push(ci);
accum[li] += valueOf(li, ci);
backtrack(pos + 1, overlapsUsed, coursesUsed + 1);
assigned[li].pop();
accum[li] -= valueOf(li, ci);
}

// (c) assign to one leaf in owner 1
if (hasOwner1) {
for (const li of e1) {
assigned[li].push(ci);
accum[li] += valueOf(li, ci);
backtrack(pos + 1, overlapsUsed, coursesUsed + 1);
assigned[li].pop();
accum[li] -= valueOf(li, ci);
}
}

// (d) assign to both owners (overlap). LD overlap is free (cost 0); UD
// overlap consumes 1 from the budget.
if (hasOwner1 && e0.length > 0 && e1.length > 0) {
const cost = isUD(allCourses[ci]) ? 1 : 0;
if (overlapsUsed + cost <= overlapCap) {
for (const li0 of e0) {
for (const li1 of e1) {
assigned[li0].push(ci);
accum[li0] += valueOf(li0, ci);
assigned[li1].push(ci);
accum[li1] += valueOf(li1, ci);
backtrack(pos + 1, overlapsUsed + cost, coursesUsed + 1);
assigned[li0].pop();
accum[li0] -= valueOf(li0, ci);
assigned[li1].pop();
accum[li1] -= valueOf(li1, ci);
}
}
}
}
}

// Suppress unused warning for numCourses (kept for clarity).
void numCourses;

backtrack(0, 0, 0);

// Materialize: per-leaf list of Course objects (deduplicated indices →
// Course refs).
return bestAssignment.map((indices) => indices.map((ci) => allCourses[ci]));
}

// ---------------------------------------------------------------------------
// BtLL built-in registrations
// ---------------------------------------------------------------------------
Expand Down
Loading
Loading