-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Setup requirement graph data structure (#140)
### 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
1 parent
a32b2de
commit a4a4dd2
Showing
10 changed files
with
12,979 additions
and
6,317 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -16,3 +16,5 @@ jobs: | |
run: npm run lint | ||
- name: Type Check | ||
run: npm run tsc | ||
- name: Test | ||
run: npm run test |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'] | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)$'] | ||
}; |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters