Skip to content

Commit

Permalink
Setup requirement graph data structure (#140)
Browse files Browse the repository at this point in the history
### Summary

This PR starts to implement proposal in #139 by setup the `RequirementFulfillmentGraph` data structure.

I made a generic `RequirementFulfillmentGraph` that doesn't depend on a specific representation of requirement or course object, so that the structure itself can be developed independently to avoid potential changes from requirement or course representation. It contains all the methods I described in #139. All the methods are tested in Jest.

Inside my implementation of graph, we need to lookup all courses by a requirement and all requirements by a course, so I setup two hash maps for O(1) lookup. `addEdge` and `removeEdge` ensures that they are in sync. 

Unlike Java where every object has `equals` and `hashCode`, we don't have the same luxury in JS. Therefore, I also implemented `HashMap` and `HashSet` that takes in a function `getUniqueHash` for the key type to simulate the `hashCode` method in Java. The basic idea is that we call this function to get a string that can uniquely identify a requirement/course, so that we can use that unique identifier as a key for quick lookup using JavaScript's `Map`. (samlang uses the same approach: 
https://github.com/SamChou19815/samlang/blob/86ffed2edcb58573b34dae94bbea83e80c591d98/samlang-core-utils/index.ts#L54-L134)

### Test Plan

Added unit tests for `HashMap`, `HashSet` and `RequirementFulfillmentGraph`
Added a step for `npm run test` in CI.
  • Loading branch information
SamChou19815 authored Sep 25, 2020
1 parent a32b2de commit a4a4dd2
Show file tree
Hide file tree
Showing 10 changed files with 12,979 additions and 6,317 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ jobs:
run: npm run lint
- name: Type Check
run: npm run tsc
- name: Test
run: npm run test
3 changes: 3 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript']
};
9 changes: 9 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const babelJestPath = require.resolve('babel-jest');

module.exports = {
testPathIgnorePatterns: ['/node_modules/'],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': babelJestPath
},
transformIgnorePatterns: ['^.+\\.module\\.(css|sass|scss)$']
};
18,946 changes: 12,631 additions & 6,315 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"tsc": "tsc",
"build:staging": "vue-cli-service build --mode staging",
"format": "prettier --write '**/*.{js,vue,scss,css,html}'",
"format:check": "prettier --check '**/*.{js,vue,scss,css,html}'"
"format:check": "prettier --check '**/*.{js,vue,scss,css,html}'",
"test": "jest"
},
"dependencies": {
"@firebase/analytics": "^0.2.13",
Expand All @@ -35,6 +36,10 @@
"vuex": "^3.1.2"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/preset-typescript": "^7.10.4",
"@types/jest": "^26.0.14",
"@types/node-fetch": "^2.5.5",
"@typescript-eslint/eslint-plugin": "^2.21.0",
"@typescript-eslint/parser": "^2.21.0",
Expand All @@ -44,8 +49,10 @@
"@vue/eslint-config-airbnb": "^4.0.1",
"@vue/eslint-config-typescript": "^4.0.0",
"babel-eslint": "^10.0.1",
"babel-jest": "^26.3.0",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0",
"jest": "^26.4.2",
"node-sass": "^4.13.1",
"sass-loader": "^8.0.2",
"typescript": "~3.7.5",
Expand Down
63 changes: 63 additions & 0 deletions src/requirements/__test__/requirement-graph.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import RequirementFulfillmentGraph from '../requirement-graph';

it('RequirementFulfillmentGraph works.', () => {
// We mock a requirement graph built using plain strings to represents requirement and courses.
const graph = new RequirementFulfillmentGraph<string, string>(
s => s,
s => s
);

graph.addEdge('CS3410/CS3420', 'CS 3410');
graph.addEdge('CS3410/CS3420', 'CS 3420');
graph.addEdge('Probability', 'MATH 4710');
graph.addEdge('Elective', 'MATH 4710');

expect(graph.getAllRequirements()).toEqual(['CS3410/CS3420', 'Probability', 'Elective']);
expect(graph.getAllCourses()).toEqual(['CS 3410', 'CS 3420', 'MATH 4710']);
expect(graph.getAllEdges()).toEqual([
['CS3410/CS3420', 'CS 3410'],
['CS3410/CS3420', 'CS 3420'],
['Probability', 'MATH 4710'],
['Elective', 'MATH 4710']
]);
expect(graph.getConnectedCoursesFromRequirement('CS3410/CS3420')).toEqual(['CS 3410', 'CS 3420']);
expect(graph.getConnectedCoursesFromRequirement('Probability')).toEqual(['MATH 4710']);
expect(graph.getConnectedCoursesFromRequirement('Elective')).toEqual(['MATH 4710']);
expect(graph.getConnectedRequirementsFromCourse('CS 3410')).toEqual(['CS3410/CS3420']);
expect(graph.getConnectedRequirementsFromCourse('CS 3420')).toEqual(['CS3410/CS3420']);
expect(graph.getConnectedRequirementsFromCourse('MATH 4710')).toEqual([
'Probability',
'Elective'
]);
expect(graph.existsEdge('CS3410/CS3420', 'CS 3410')).toBeTruthy();
expect(graph.existsEdge('CS3410/CS3420', 'CS 3420')).toBeTruthy();
expect(graph.existsEdge('Probability', 'MATH 4710')).toBeTruthy();
expect(graph.existsEdge('Elective', 'MATH 4710')).toBeTruthy();

graph.removeEdge('CS3410/CS3420', 'CS 3420');
expect(graph.getConnectedCoursesFromRequirement('CS3410/CS3420')).toEqual(['CS 3410']);
expect(graph.getConnectedCoursesFromRequirement('Probability')).toEqual(['MATH 4710']);
expect(graph.getConnectedCoursesFromRequirement('Elective')).toEqual(['MATH 4710']);
expect(graph.getConnectedRequirementsFromCourse('CS 3410')).toEqual(['CS3410/CS3420']);
expect(graph.getConnectedRequirementsFromCourse('CS 3420')).toEqual([]);
expect(graph.getConnectedRequirementsFromCourse('MATH 4710')).toEqual([
'Probability',
'Elective'
]);
expect(graph.existsEdge('CS3410/CS3420', 'CS 3410')).toBeTruthy();
expect(graph.existsEdge('CS3410/CS3420', 'CS 3420')).toBeFalsy();
expect(graph.existsEdge('Probability', 'MATH 4710')).toBeTruthy();
expect(graph.existsEdge('Elective', 'MATH 4710')).toBeTruthy();

graph.removeEdge('Probability', 'MATH 4710');
expect(graph.getConnectedCoursesFromRequirement('CS3410/CS3420')).toEqual(['CS 3410']);
expect(graph.getConnectedCoursesFromRequirement('Probability')).toEqual([]);
expect(graph.getConnectedCoursesFromRequirement('Elective')).toEqual(['MATH 4710']);
expect(graph.getConnectedRequirementsFromCourse('CS 3410')).toEqual(['CS3410/CS3420']);
expect(graph.getConnectedRequirementsFromCourse('CS 3420')).toEqual([]);
expect(graph.getConnectedRequirementsFromCourse('MATH 4710')).toEqual(['Elective']);
expect(graph.existsEdge('CS3410/CS3420', 'CS 3410')).toBeTruthy();
expect(graph.existsEdge('CS3410/CS3420', 'CS 3420')).toBeFalsy();
expect(graph.existsEdge('Probability', 'MATH 4710')).toBeFalsy();
expect(graph.existsEdge('Elective', 'MATH 4710')).toBeTruthy();
});
91 changes: 91 additions & 0 deletions src/requirements/requirement-graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { HashMap, HashSet } from './util/collections';

/**
* A mutable graph data structure that represents the mathematically relation between requirement
* and course.
* An edge between requirement `r` and course `c` implies that `c` may help satisfy `r`. It's up to
* the client of the data structure to gradually refine the graph so that an edge `r-c` implies that
* `c` definitely helps satisfies `r`.
*
* We keep type of `Requirement` and `Course` generic, so that they can be easily mocked during
* testing and make this basic graph implementation not tied to anything specific representation.
*/
export default class RequirementFulfillmentGraph<Requirement, Course> {
// Internally, we use a two hash map to represent the bidirection relation
// between requirement and courses.

private readonly requirementToCoursesMap: HashMap<Requirement, HashSet<Course>>;

private readonly courseToRequirementsMap: HashMap<Course, HashSet<Requirement>>;

constructor(
private readonly getRequirementUniqueID: (r: Requirement) => string,
private readonly getCourseUniqueID: (c: Course) => string
) {
this.requirementToCoursesMap = new HashMap(getRequirementUniqueID);
this.courseToRequirementsMap = new HashMap(getCourseUniqueID);
}

public getAllRequirements(): readonly Requirement[] {
return this.requirementToCoursesMap.keys();
}

public getAllCourses(): readonly Course[] {
return this.courseToRequirementsMap.keys();
}

public getAllEdges(): readonly (readonly [Requirement, Course])[] {
const edges: (readonly [Requirement, Course])[] = [];
this.requirementToCoursesMap.forEach((courses, requirement) => {
courses.forEach(course => edges.push([requirement, course]));
});
return edges;
}

public addEdge(requirement: Requirement, course: Course): void {
let existingCoursesLinkedToRequirement = this.requirementToCoursesMap.get(requirement);
if (existingCoursesLinkedToRequirement == null) {
existingCoursesLinkedToRequirement = new HashSet(this.getCourseUniqueID);
this.requirementToCoursesMap.set(requirement, existingCoursesLinkedToRequirement);
}
existingCoursesLinkedToRequirement.add(course);

let existingRequirementsLinkedToCourse = this.courseToRequirementsMap.get(course);
if (existingRequirementsLinkedToCourse == null) {
existingRequirementsLinkedToCourse = new HashSet(this.getRequirementUniqueID);
this.courseToRequirementsMap.set(course, existingRequirementsLinkedToCourse);
}
existingRequirementsLinkedToCourse.add(requirement);
}

public removeEdge(requirement: Requirement, course: Course): void {
const existingCoursesLinkedToRequirement = this.requirementToCoursesMap.get(requirement);
if (existingCoursesLinkedToRequirement != null) {
existingCoursesLinkedToRequirement.delete(course);
}

const existingRequirementsLinkedToCourse = this.courseToRequirementsMap.get(course);
if (existingRequirementsLinkedToCourse != null) {
existingRequirementsLinkedToCourse.delete(requirement);
}
}

public getConnectedCoursesFromRequirement(requirement: Requirement): readonly Course[] {
const courseSet = this.requirementToCoursesMap.get(requirement);
if (courseSet == null) return [];
return courseSet.toArray();
}

public getConnectedRequirementsFromCourse(course: Course): readonly Requirement[] {
const requirementSet = this.courseToRequirementsMap.get(course);
if (requirementSet == null) return [];
return requirementSet.toArray();
}

public existsEdge(requirement: Requirement, course: Course): boolean {
const existingCoursesLinkedToRequirement = this.requirementToCoursesMap.get(requirement);
return (
existingCoursesLinkedToRequirement != null && existingCoursesLinkedToRequirement.has(course)
);
}
}
83 changes: 83 additions & 0 deletions src/requirements/util/__test__/collection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { HashMap, HashSet } from '../collections';

it('HashMap tests', () => {
const map = new HashMap<string, number>(s => `HASH___${s}`);

map.set('1', 1);
map.set('2', 2);

map.forEach(() => {});

expect(map.get('1')).toBe(1);
expect(map.get('2')).toBe(2);
expect(map.get('3')).toBeUndefined();
expect(map.has('1')).toBeTruthy();
expect(map.has('2')).toBeTruthy();
expect(map.has('3')).toBeFalsy();
expect(map.size).toBe(2);

map.set('1', 3);
expect(map.get('1')).toBe(3);
expect(map.get('2')).toBe(2);
expect(map.get('3')).toBeUndefined();
expect(map.has('1')).toBeTruthy();
expect(map.has('2')).toBeTruthy();
expect(map.has('3')).toBeFalsy();
expect(map.size).toBe(2);

map.delete('1');
expect(map.get('1')).toBeUndefined();
expect(map.get('2')).toBe(2);
expect(map.get('3')).toBeUndefined();
expect(map.has('1')).toBeFalsy();
expect(map.has('2')).toBeTruthy();
expect(map.has('3')).toBeFalsy();
expect(map.size).toBe(1);

map.clear();
expect(map.get('1')).toBeUndefined();
expect(map.get('2')).toBeUndefined();
expect(map.get('3')).toBeUndefined();
expect(map.has('4')).toBeFalsy();
expect(map.has('5')).toBeFalsy();
expect(map.has('6')).toBeFalsy();
expect(map.size).toBe(0);
map.forEach(() => {
throw new Error();
});
});

it('HashSet tests', () => {
const set = new HashSet<string>(s => `HASH___${s}`);

set.add('1');
set.add('2');

set.forEach(() => {});

expect(set.has('1')).toBeTruthy();
expect(set.has('2')).toBeTruthy();
expect(set.has('3')).toBeFalsy();
expect(set.size).toBe(2);

set.add('1');
expect(set.has('1')).toBeTruthy();
expect(set.has('2')).toBeTruthy();
expect(set.has('3')).toBeFalsy();
expect(set.size).toBe(2);

set.delete('1');
expect(set.has('1')).toBeFalsy();
expect(set.has('2')).toBeTruthy();
expect(set.has('3')).toBeFalsy();
expect(set.size).toBe(1);

set.clear();
expect(set.has('1')).toBeFalsy();
expect(set.has('2')).toBeFalsy();
expect(set.has('3')).toBeFalsy();
expect(set.size).toBe(0);
set.forEach(() => {
throw new Error();
});
});
87 changes: 87 additions & 0 deletions src/requirements/util/collections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
export class HashMap<K, V> {
private readonly backingMap: Map<string, readonly [K, V]>;

constructor(private readonly getUniqueHash: (key: K) => string) {
this.backingMap = new Map();
}

clear(): void {
this.backingMap.clear();
}

delete(key: K): boolean {
return this.backingMap.delete(this.getUniqueHash(key));
}

set(key: K, value: V): this {
this.backingMap.set(this.getUniqueHash(key), [key, value]);
return this;
}

get(key: K): V | undefined {
return this.backingMap.get(this.getUniqueHash(key))?.[1];
}

has(key: K): boolean {
return this.backingMap.has(this.getUniqueHash(key));
}

get size(): number {
return this.backingMap.size;
}

forEach(callbackFunction: (value: V, key: K) => void): void {
this.backingMap.forEach(([key, value]) => callbackFunction(value, key));
}

keys(): readonly K[] {
const keys: K[] = [];
this.backingMap.forEach(keyValue => keys.push(keyValue[0]));
return keys;
}

entries(): readonly (readonly [K, V])[] {
const entries: (readonly [K, V])[] = [];
this.backingMap.forEach(keyValue => entries.push(keyValue));
return entries;
}
}

export class HashSet<T> {
private readonly backingMap: Map<string, T>;

constructor(private readonly getUniqueHash: (value: T) => string) {
this.backingMap = new Map();
}

add(value: T): this {
this.backingMap.set(this.getUniqueHash(value), value);
return this;
}

clear(): void {
this.backingMap.clear();
}

delete(value: T): boolean {
return this.backingMap.delete(this.getUniqueHash(value));
}

forEach(callbackFunction: (value: T) => void): void {
this.backingMap.forEach(callbackFunction);
}

toArray(): T[] {
const array: T[] = [];
this.forEach(element => array.push(element));
return array;
}

has(value: T): boolean {
return this.backingMap.has(this.getUniqueHash(value));
}

get size(): number {
return this.backingMap.size;
}
}
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"resolveJsonModule": true,
"baseUrl": ".",
"types": [
"webpack-env"
"webpack-env",
"jest"
],
"paths": {
"@/*": [
Expand Down

0 comments on commit a4a4dd2

Please sign in to comment.