Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] NUSMods Optimizer Prototyping #3296

Draft
wants to merge 57 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
6daeea0
Add optimizer button, placeholder component, state
frizensami Jun 27, 2021
39ae230
Add props to timetableoptimizer
frizensami Jun 27, 2021
c18487a
Run format
frizensami Jun 28, 2021
3d3407c
Merge branch 'master' into optimizer
frizensami Jun 28, 2021
6858299
Add precompiled Z3 WASM file + Emscripten wrapper
frizensami Jun 28, 2021
2642863
Add Z3 WebWorker code and message passing types
frizensami Jun 28, 2021
0fb26c0
Modify z3 types filename
frizensami Jun 28, 2021
c7dfbd1
Add callback defs to z3 types
frizensami Jun 28, 2021
edfbbe2
Remove log from z3w.js
frizensami Jun 28, 2021
69d7376
Add worker-loader package to load Z3 WebWorker
frizensami Jun 28, 2021
298d179
Update imports and typedefs in z3Worker
frizensami Jun 28, 2021
e4a23cc
MVP for initializing Z3 with worker-loader
frizensami Jun 28, 2021
8b83870
Fix initialization messages for z3
frizensami Jun 28, 2021
b92f711
Add a temporary alert for z3 init
frizensami Jun 28, 2021
3ffd9e6
Refactor files and rename for minimal example
frizensami Jun 29, 2021
c212883
Add sexpr-plus package for parsing Z3 output
frizensami Jun 30, 2021
75ad08e
Add smtlib-ext lib, creates SMT-LIB code for Z3
frizensami Jun 30, 2021
a01d69f
Implementation of MVP optimizer utils stack
frizensami Jun 30, 2021
cc548b1
Fix incorrect callback type
frizensami Jun 30, 2021
befafca
Wire up button to run optimizer, fix varname bug
frizensami Jun 30, 2021
0ddb1c7
Change lessons based on optimizer output
frizensami Jun 30, 2021
c5bb543
Merge branch 'master' into optimizer
frizensami Jul 1, 2021
62f0acc
Fix some eslint errors
frizensami Jul 1, 2021
43c8927
Update worker-loader to devDependencies
frizensami Jul 1, 2021
17529bb
Fix majority of eslint errors
frizensami Jul 2, 2021
212cf22
Fix webworker eslint errors
frizensami Jul 2, 2021
4b2cfc2
Fix inline webpack loader eslint error
frizensami Jul 2, 2021
7594da1
Correctly name constraint booleans
frizensami Jul 2, 2021
3fb9d34
Z3Message -> Z3WorkerMessage for clarity
frizensami Jul 2, 2021
c6104c2
Remove unnecessary export
frizensami Jul 2, 2021
392302a
Comments for WeekSolver, remove StringIdGenerator
frizensami Jul 3, 2021
b1b1abb
Change slot whoId to ownerId for naming clarity
frizensami Jul 3, 2021
cfd1ad0
Add doc comments for timetable solver
frizensami Jul 3, 2021
caabee3
Change SExpr to SNode in solver for accuracy
frizensami Jul 3, 2021
d9153ab
Publish smtlib-ext to remove dep on github
frizensami Jul 3, 2021
66456ff
Add and apply vendor types for sexpr-plus
frizensami Jul 3, 2021
525d604
Remove all remaining eslint errors
frizensami Jul 3, 2021
fdb4972
Solve typing issues except webworker Z3
frizensami Jul 3, 2021
de387b2
Merge branch 'master' into optimizer
frizensami Jul 3, 2021
947dffe
Just include webworker lib
frizensami Jul 3, 2021
2ba7c34
Add tests for z3weeksolver
frizensami Jul 3, 2021
41c09a0
Ignore type errors for wasm
frizensami Jul 3, 2021
2527a48
Increase test coverage for z3 timetable solver
frizensami Jul 3, 2021
54ea81e
Add test coverage for setBooleanSelectorCosts
frizensami Jul 3, 2021
ccc5497
Autofix eslint
frizensami Jul 3, 2021
5809287
Full test coverage for timetable solver
frizensami Jul 4, 2021
fe120de
Change test -> it for consistency
frizensami Jul 4, 2021
513cd21
Add mocks and some tests for timetableOptimizer
frizensami Jul 4, 2021
d839ae4
Complete test coverage for timetableOptimizer
frizensami Jul 4, 2021
72efcc4
Fix remaining test coverage for optimizer
frizensami Jul 4, 2021
81119bf
Ignore webworkers in codecov
frizensami Jul 4, 2021
3d50400
Partial commit for converter
frizensami Jul 8, 2021
7ff3b04
Fix errors to see converter coverage
frizensami Jul 8, 2021
4f6857a
Test coverage for converter
frizensami Aug 2, 2021
2e75c66
Merge branch 'master' into optimizer
frizensami Aug 2, 2021
952ff8c
Merge branch 'optimizer' of github.com:frizensami/nusmods into optimizer
frizensami Aug 2, 2021
978acb1
Fix merge conflict marker
frizensami Aug 2, 2021
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: 4 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ fixes:
# For now we only report website coverage, and since tests are run directly in
# the /website folder we need to add this
- "::website/"
ignore:
# Ignore webworkers since we can't test them
- "**/*.worker.ts"
- "**/*.worker.js"
5 changes: 4 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@
"webpack": "5.15.0",
"webpack-cli": "4.3.1",
"webpack-dev-server": "3.11.2",
"webpack-merge": "5.7.3"
"webpack-merge": "5.7.3",
"worker-loader": "^3.0.8"
},
"dependencies": {
"@authenio/samlify-node-xmllint": "2.0.0",
Expand Down Expand Up @@ -168,6 +169,8 @@
"reselect": "4.0.0",
"samlify": "2.7.7",
"searchkit": "2.4.1-alpha.5",
"sexpr-plus": "^7.0.0",
"smtlib-ext": "^1.0.0",
"use-subscription": "1.5.1"
},
"browserslist": [
Expand Down
7 changes: 7 additions & 0 deletions website/src/actions/optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const TOGGLE_OPTIMIZER_DISPLAY = 'TOGGLE_OPTIMIZER_DISPLAY' as const;
export function toggleOptimizerDisplay() {
return {
type: TOGGLE_OPTIMIZER_DISPLAY,
payload: null,
};
}
2 changes: 2 additions & 0 deletions website/src/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Actions } from 'types/actions';
import requests from './requests';
import app from './app';
import createUndoReducer from './undoHistory';
import optimizer from './optimizer';

// Persisted reducers
import moduleBankReducer, { persistConfig as moduleBankPersistConfig } from './moduleBank';
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function reducers(state: State = defaultState, action: Actions):
requests: requests(state.requests, action),
timetables: timetables(state.timetables, action),
app: app(state.app, action),
optimizer: optimizer(state.optimizer, action),
theme: theme(state.theme, action),
settings: settings(state.settings, action),
planner: planner(state.planner, action),
Expand Down
22 changes: 22 additions & 0 deletions website/src/reducers/optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { OptimizerState } from 'types/reducers';
import { Actions } from 'types/actions';

import { TOGGLE_OPTIMIZER_DISPLAY } from 'actions/optimizer';

export const defaultOptimizerState: OptimizerState = {
isOptimizerShown: false,
};

function optimizer(state: OptimizerState = defaultOptimizerState, action: Actions): OptimizerState {
switch (action.type) {
case TOGGLE_OPTIMIZER_DISPLAY:
return {
...state,
isOptimizerShown: !state.isOptimizerShown,
};
default:
return state;
}
}

export default optimizer;
165 changes: 165 additions & 0 deletions website/src/types/optimizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Intermediate types used to contain timetable information relevant to the optimizer
import { mapValues, groupBy } from 'lodash';
import { Module, RawLesson, StartTime, EndTime } from 'types/modules';
import { SemTimetableConfig } from 'types/timetables';

// {module id}__{lesson type}__{lesson id} e.g., CS3203__Lecture__1
export type UniqueLessonString = string;
export type Z3LessonID = number;

/**
* Main input format into the optimizer layers
* */
export type OptimizerInput = {
moduleInfo: ModuleInfoWithConstraints[];
constraints: GlobalConstraints;
};

/**
* Callbacks to communicate with the caller of TimetableOptimizer
* */
export interface OptimizerCallbacks {
onOptimizerInitialized(): void;
onSmtlib2InputCreated(s: string): void;
onSmtLib2ResultOutput(s: string): void;
onTimetableOutput(timetable: OptimizerOutput): void;
}

/**
* Final timetable output to the optimizer caller
* */
export type OptimizerOutput = {
isSat: boolean;
timetable: SemTimetableConfig;
};

/**
* Modules for optimizer to consider.
* required is a constraint indicating if the module can be dropped to fulfil other constraints
* */
export type ModuleInfoWithConstraints = {
mod: Module;
required: boolean;
lessonsGrouped: LessonsByGroupsByClassNo;
};

// Mapping between lesson types -> classNo -> lessons.
// We have to take one classNo of each lessonType, so this indicates all the slots to be filled
// per classNo per lessonType
export type LessonsByGroupsByClassNo = {
[lessonType: string]: LessonsForLessonType;
};

export type LessonsForLessonType = { [classNo: string]: readonly RawLesson[] };

/*
* A list of times that are assigned to a particular owner (e.g., a lesson)
* Used throughout optimizer to indicate that a particular block of time should be reserved if a owner id is chosen
*/
export interface SlotConstraint {
startEndTimes: Array<[number, number]>; // Array of start and end times as integers
ownerId: number; // Numeric ID of owner, since we will encode this as an integer constraint
ownerString: string; // string representing the owner: user-interpretable, used for varnames
}

/*
* Indicating that a varname (boolean selector) has a cost attached to it if it is chosen.
*/
export interface WorkloadCost {
varname: string;
cost: number;
}

// User-selected constraints to pass to optimizer
export interface GlobalConstraints {
frizensami marked this conversation as resolved.
Show resolved Hide resolved
// Min/max number of MCs + whether the constraint is active
isWorkloadActive: boolean;
minWorkload: number;
maxWorkload: number;
// Find exactly N free days + whether the constraint is active
isFreeDayActive: boolean;
numRequiredFreeDays: number;
// Force these exact free days + whether the constraint is active
isSpecificFreeDaysActive: boolean;
specificFreeDays: Array<string>;
// When lessons should start and end + whether the constraint is active
isTimeConstraintActive: boolean;
earliestLessonStartTime: StartTime;
latestLessonEndTime: EndTime;
// The hours where a lunch break should be allocated,
// how many half-hour slots to allocate, and whether the constraint is active
isLunchBreakActive: boolean;
lunchStart: StartTime;
lunchEnd: EndTime;
lunchHalfHours: number;
// Ask optimizer to compact timetable to leave as few gaps between lessons as possible
isPreferCompactTimetable: boolean;
}

/**
* Defs for communicating between Optimizer <-> WebWorker <-> WASM wrapper
* */
// Need to disable since there's an eslint bug with enums
// eslint-disable-next-line no-shadow
export enum Z3WorkerMessageKind {
// Request to init
INIT = 'INIT',
// Z3 initialized
INITIALIZED = 'INITIALIZED',
// Run the optimizer
OPTIMIZE = 'OPTIMIZE',
// Print output
PRINT = 'PRINT',
// Error
ERR = 'ERR',
// Z3 finished runnung
EXIT = 'EXIT',
// Z3 aborted
ABORT = 'ABORT',
}

/**
* Message to be sent back and forth between a Z3 webworker and any callers
* */
export interface Z3WorkerMessage {
kind: Z3WorkerMessageKind;
msg: string;
}

// TODO Shouldn't be here
export const defaultConstraints: GlobalConstraints = {
isWorkloadActive: false,
minWorkload: 0,
maxWorkload: 30,
isFreeDayActive: false,
numRequiredFreeDays: 1,
isSpecificFreeDaysActive: false,
specificFreeDays: [],
earliestLessonStartTime: '0800',
latestLessonEndTime: '2200',
lunchStart: '1100',
lunchEnd: '1500',
lunchHalfHours: 2,
isLunchBreakActive: false,
isTimeConstraintActive: false,
isPreferCompactTimetable: false,
};

/**
* TODO move to utils
* Transforms a module's lessons into a mapping from
* lessonType ==> (classNo ==> list of lessons)
* The optimizer cares that a classNo contains all the slots that should be filled.
* */
export function lessonByGroupsByClassNo(lessons: readonly RawLesson[]): LessonsByGroupsByClassNo {
const lessonByGroups: { [lessonType: string]: readonly RawLesson[] } = groupBy(
lessons,
(lesson) => lesson.lessonType,
);
const lessonByGroupsByClassNumber = mapValues(
lessonByGroups,
(lessonsOfSamelessonType: readonly RawLesson[]) =>
groupBy(lessonsOfSamelessonType, (lesson) => lesson.classNo),
);
return lessonByGroupsByClassNumber;
}
5 changes: 5 additions & 0 deletions website/src/types/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export type ThemeState = Readonly<{
showTitle: boolean;
}>;

/* optimizer */
export type OptimizerState = Readonly<{
isOptimizerShown: boolean;
}>;

/* settings */
export type ModRegRoundKey = { type: RegPeriodType; name?: string };

Expand Down
2 changes: 2 additions & 0 deletions website/src/types/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Requests,
SettingsState,
ThemeState,
OptimizerState,
TimetablesState,
UndoHistoryState,
VenueBank,
Expand All @@ -17,6 +18,7 @@ export type State = {
timetables: TimetablesState;
app: AppState;
theme: ThemeState;
optimizer: OptimizerState;
settings: SettingsState;
planner: PlannerState;
undoHistory: UndoHistoryState<State>;
Expand Down
30 changes: 30 additions & 0 deletions website/src/types/vendor/sexpr-plus.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
declare module 'sexpr-plus' {
// Parses string into expressions
export function parse(input: string): Expr;
// Location of start or end of an expression
export type ExprLocation = {
offset: number;
line: number;
column: number;
};
// Location information for expressions
export type ExprLocations = {
start: ExprLocation;
end: ExprLocation;
};
// Base output type from parsing
export type ExprNode = {
type: string;
content: Expr;
location: ExprLocations;
};
export type Expr = string | ExprNode[];
// try-catch with instanceof during parsing
export interface SyntaxError {
message: string;
expected: string;
found: string;
location: ExprLocations;
name: string;
}
}
10 changes: 10 additions & 0 deletions website/src/types/vendor/worker-loader.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
declare module '*.worker.ts' {
// You need to change `Worker`, if you specified a different value for the `workerType` option
class WebpackWorker extends Worker {
constructor();
}

// Uncomment this if you set the `esModule` option to `false`
// export = WebpackWorker;
export default WebpackWorker;
}
9 changes: 9 additions & 0 deletions website/src/utils/optimizer/__mocks__/converter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const mockOnmessage = jest.fn();
export const mockPostMessage = jest.fn();

const WebpackWorker = jest.fn().mockImplementation(() => ({
onmessage: mockOnmessage,
postMessage: mockPostMessage,
}));

export default WebpackWorker;
9 changes: 9 additions & 0 deletions website/src/utils/optimizer/__mocks__/z3WebWorker.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const mockOnmessage = jest.fn();
export const mockPostMessage = jest.fn();

const WebpackWorker = jest.fn().mockImplementation(() => ({
onmessage: mockOnmessage,
postMessage: mockPostMessage,
}));

export default WebpackWorker;
22 changes: 22 additions & 0 deletions website/src/utils/optimizer/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { invert } from 'lodash';
import { WorkingDays } from 'types/modules';

export const DAYS = WorkingDays.length; // One week mon - sat
export const DAY_START_HOUR = 8; // This just needs to be earlier than the earliest lesson
export const DAY_END_HOUR = 22; // Similarly, later than the latest lesson.
export const HOURS_PER_DAY = DAY_END_HOUR - DAY_START_HOUR; // 14 8 am --> 10 pm. We save 24 - HOURS_PER_DAY variables per day.
export const HOURS_PER_WEEK = HOURS_PER_DAY * DAYS;
export const NUM_WEEKS = 13;

export const DAY_IDXS: Record<string, number> = {
monday: 0,
tuesday: 1,
wednesday: 2,
thursday: 3,
friday: 4,
saturday: 5,
};
export const IDX_DAYS = invert(DAY_IDXS);

// All possible week numbers we should encounter (except WeekRange)
export const ALL_WEEKS = [[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]];
Loading