From 23d684c31841fef8bce82583b4ff1180b67b1958 Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Tue, 28 Dec 2021 16:01:41 -0800 Subject: [PATCH 1/7] Switch computers to desktop - matching partly implemented --- .eslintrc.js | 3 + src/match/findMatching.ts | 73 +++++++++ src/match/matching.ts | 24 +++ src/match/matchingHelpers.ts | 154 ++++++++++++++++++ src/resolvers/Match.ts | 56 ++++--- ...getProjectMatches.ts => getProjectRecs.ts} | 2 +- src/search/index.ts | 2 +- 7 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 src/match/findMatching.ts create mode 100644 src/match/matching.ts create mode 100644 src/match/matchingHelpers.ts rename src/search/{getProjectMatches.ts => getProjectRecs.ts} (98%) diff --git a/.eslintrc.js b/.eslintrc.js index 096be64..b6acebc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,4 +8,7 @@ module.exports = { plugins: [ '@typescript-eslint', ], + rules: { + 'linebreak-style': "off" + } }; diff --git a/src/match/findMatching.ts b/src/match/findMatching.ts new file mode 100644 index 0000000..50751b0 --- /dev/null +++ b/src/match/findMatching.ts @@ -0,0 +1,73 @@ +import { ProjectData } from './matching'; +import { MatchingStats, placeStudentsOfChoice } from './matchingHelpers'; + +/** + * Matches all first place students on projects with the same number of first choices as their size. + * If a project has 5 spaces and 5 or less students who have that project as their first choice, then match all + * those students + * Formally, this is steps 1 and 2 from the python implementation + * @param allProjectData The project data, edited in place to add matches + */ +function step1(allProjectData: ProjectData): void { + Object.values(allProjectData).forEach((value) => { + if (value.numFirstChoice <= value.projSizeRemaining) { + placeStudentsOfChoice(allProjectData, value.projectId, 1, value.projSizeRemaining); + } + }); +} + +/** + * Runs the second step of matching + * - Matches first place to all projects with less first places with + * @param allProjectData + */ +function step2(allProjectData: ProjectData) { + +} + +async function place_student() { + +} + +let testProjectData: ProjectData = { + zzz: { + studentsSelected: { + AA: { + studentId: 'AA', + choice: 1, + }, + CC: { + studentId: 'CC', + choice: 2, + }, + }, + projectId: 'zzz', + projSizeRemaining: 1, + numFirstChoice: 2, + studentsMatched: {}, + }, + yyy: { + studentsSelected: { + AA: { + studentId: 'AA', + choice: 2, + }, + BB: { + studentId: 'BB', + choice: 2, + }, + CC: { + studentId: 'CC', + choice: 1, + }, + }, + projectId: 'yyy', + projSizeRemaining: 2, + numFirstChoice: 1, + studentsMatched: {}, + }, +}; + +step1(testProjectData); +console.log(testProjectData); +console.log(MatchingStats(testProjectData)); diff --git a/src/match/matching.ts b/src/match/matching.ts new file mode 100644 index 0000000..61fa30c --- /dev/null +++ b/src/match/matching.ts @@ -0,0 +1,24 @@ +export interface ProjectData { + [projectId: string]: ProjectDataDictElement +} + +export interface ProjectDataDictElement { + projectId: string + studentsSelected: StudentChoices; + studentsMatched: StudentChoices; + projSizeRemaining: number; + numFirstChoice: number; +} + +export interface StudentChoices { + [studentId: string]: StudentChoice +} + +export interface Student { + studentId: string; +} + +export interface StudentChoice extends Student { + choice: number; + matched?: true | undefined // This works as a kind of default +} diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts new file mode 100644 index 0000000..8943ccd --- /dev/null +++ b/src/match/matchingHelpers.ts @@ -0,0 +1,154 @@ +import { ProjectData } from './matching'; + +// async function generateProjectData(projects: [Project]) { +// return Object.fromEntries( +// projects.map((project) => { +// const key = project.id +// const value: = { +// +// } +// }) +// ) +// } + +function indexOfMatch(array: Array, fn: (element: T) => boolean) { + let result = -1; + array.some((e, i) => { + if (fn(e)) { + result = i; + return true; + } + return false; + }); + return result; +} + +/** + * Marks a student in all project's `studentsSelected` as having been matched somewhere already + * @param projectData - The project data, mutated in place + * @param studentId - Student to mark + */ +function markStudent(projectData: ProjectData, studentId: string): void { + Object.values(projectData) + .forEach((value) => { + if (value.studentsSelected[studentId] !== undefined) { + // eslint-disable-next-line no-param-reassign + value.studentsSelected[studentId].matched = true; + } + }); +} + +/** + * Places a student into a project and then marks them as matched in all projects. Handles correctly updating the + * number of needed students and decrementing num_first_choice + * @param projectData + * @param projectId + * @param studentId + */ +function placeStudent(projectData: ProjectData, projectId: string, studentId: string): void { + const project = projectData[projectId]; + const student = project.studentsSelected[studentId]; + const firstChoice: boolean = student.choice === 1; + // eslint-disable-next-line no-param-reassign + projectData[projectId].studentsMatched[student.studentId] = student; + markStudent(projectData, studentId); + project.projSizeRemaining -= 1; + if (firstChoice) project.numFirstChoice -= 1; +} + +/** + * Places num students of choice on a project or until all students of choice have been placed. NOT balanced by + * anything, only use when you do not expect to have more students to match than num + * @param projectData The project data + * @param projectId The project to match + * @param choice The choice of students to match + * @param count The number of students to match + */ +export function placeStudentsOfChoice( + projectData: ProjectData, + projectId: string, + choice: number, + count: number, +): void { + let counter = 0; + // Iterate over only unmatched students to avoid duplicates + for (const studentChoice of Object.values(projectData[projectId].studentsSelected) + .filter((value) => value.matched !== true)) { + if (studentChoice.choice === choice) { + placeStudent(projectData, projectId, studentChoice.studentId); + counter += 1; + } + if (counter >= count) break; + } +} + +/** + * Counts the number of open spots left in projects + * @param projectData + */ +function countUnfilled(projectData: ProjectData) { + return Object.values(projectData) + .reduce((prevVal, currentVal) => prevVal + currentVal.projSizeRemaining, 0); +} + +/** + * Counts the number of unmarked students (students that didn't get applied to anything) + * @param projectData + */ +function unassignedStudents(projectData: ProjectData) { + const countedStudentIds: string[] = []; + return Object.values(projectData) + .reduce((previousValue, currentValue) => { + const unmatchedStudents = Object.values( + currentValue.studentsSelected, + ) + .filter((student) => student.matched !== true && !countedStudentIds.includes(student.studentId)); + countedStudentIds.push(...unmatchedStudents.map((student) => student.studentId)); + return previousValue + unmatchedStudents.length; + }, 0); +} + +/** + * Measures the effectiveness of a match, or the choice rank number that all students got divided by the number of + * students + * @param projectData + */ +function measureMatchEffectiveness(projectData: ProjectData) { + const rawScore = Object.values(projectData) + .reduce((sumScoreOverall, currentProject) => { + return sumScoreOverall + + Object.values(currentProject.studentsSelected) + .reduce((sumScore, currentStudent) => sumScore + currentStudent.choice, 0); + }, 0); + const totalStudentsSet = new Set(); + Object.values(projectData) + .forEach((project) => { + Object.values(project.studentsSelected) + .forEach((student) => { + totalStudentsSet.add(student.studentId); + }); + }); + const totalStudents = totalStudentsSet.size; + return rawScore / totalStudents; +} + +/** + * Combines a bunch of relevant stats about a matching to check how it's doing + * @param projectData + * @constructor + */ +export function MatchingStats(projectData: ProjectData): Record { + return { + unassignedStudents: unassignedStudents(projectData), + unfilledProjects: countUnfilled(projectData), + matchingScore: measureMatchEffectiveness(projectData), + }; +} + +// +// +// placeStudentsOfChoice(testProjectData, 'zzz', 1, 1); +// placeStudentsOfChoice(testProjectData, 'yyy', 1, 1); +// import { inspect } from 'util'; +// +// console.log(inspect(testProjectData, {showHidden: true, depth: 3, colors: true})); diff --git a/src/resolvers/Match.ts b/src/resolvers/Match.ts index 5855d7a..8428b2a 100644 --- a/src/resolvers/Match.ts +++ b/src/resolvers/Match.ts @@ -1,26 +1,27 @@ -import { - Resolver, Authorized, Query, Mutation, Arg, Ctx, -} from 'type-graphql'; -import { - PrismaClient, -} from '@prisma/client'; +import { Arg, Authorized, Ctx, Mutation, Query, Resolver, } from 'type-graphql'; +import { PrismaClient, } from '@prisma/client'; import { Inject, Service } from 'typedi'; -import { Track, StudentStatus } from '../enums'; -import { Context, AuthRole } from '../context'; -import { - Student, Tag, Match, Preference, Project, -} from '../types'; -import { getProjectMatches } from '../search'; +import { StudentStatus, Track } from '../enums'; +import { AuthRole, Context } from '../context'; +import { Match, Preference, Student, Tag, } from '../types'; +import { getProjectRecs } from '../search'; @Service() @Resolver(Match) export class MatchResolver { @Inject(() => PrismaClient) - private readonly prisma : PrismaClient; + private readonly prisma: PrismaClient; + + async matchStudents( + @Ctx() { auth }: Context, + ) { + + } @Authorized(AuthRole.STUDENT) @Query(() => [Match], { nullable: true }) - async projectMatches( + // TODO: Check how to rename endpoints like this. This is really getting recs not matches. + async projectRecs( @Ctx() { auth }: Context, @Arg('tags', () => [String]) tagIds: string[], ): Promise { @@ -28,7 +29,7 @@ export class MatchResolver { const tags = await this.prisma.tag.findMany({ where: { id: { in: tagIds } } }); if (!student || student.status !== StudentStatus.ACCEPTED) throw Error('You have not been accepted.'); - return getProjectMatches(student, tags); + return getProjectRecs(student, tags); } @Authorized(AuthRole.STUDENT) @@ -36,9 +37,16 @@ export class MatchResolver { async projectPreferences( @Ctx() { auth }: Context, ): Promise { - return this.prisma.projectPreference.findMany({ + return this.prisma.projectPreference.findMany({ where: { student: auth.toWhere() }, - include: { project: { include: { tags: true, mentors: true } } }, + include: { + project: { + include: { + tags: true, + mentors: true + } + } + }, orderBy: [{ ranking: 'asc' }], }); } @@ -54,17 +62,25 @@ export class MatchResolver { const student = await this.prisma.student.findUnique({ where: auth.toWhere() }); const projects = await this.prisma.project.findMany({ where: { id: { in: projectIds } }, - include: { tags: true, mentors: true }, + include: { + tags: true, + mentors: true + }, }); if (!student || student.status !== StudentStatus.ACCEPTED) throw Error('You have not been accepted.'); if (projectIds.length < 3) throw Error('You must select at least 3 project preferences.'); if (projects.length !== projectIds.length) throw Error('You selected a project which does not exist.'); - projects.forEach(({ id, track }) => { + projects.forEach(({ + id, + track + }) => { if ( (student.track === Track.BEGINNER && track !== Track.BEGINNER) || (student.track !== Track.BEGINNER && track === Track.BEGINNER) - ) throw Error(`You cannot select project ID ${id} because it is not in your track.`); + ) { + throw Error(`You cannot select project ID ${id} because it is not in your track.`); + } }); await this.prisma.projectPreference.deleteMany({ where: { student: auth.toWhere() } }); diff --git a/src/search/getProjectMatches.ts b/src/search/getProjectRecs.ts similarity index 98% rename from src/search/getProjectMatches.ts rename to src/search/getProjectRecs.ts index 029faad..c2cfa37 100644 --- a/src/search/getProjectMatches.ts +++ b/src/search/getProjectRecs.ts @@ -122,7 +122,7 @@ async function buildQueryFor(student: Student, tags: Tag[]): Promise { +export async function getProjectRecs(student: Student, tags: Tag[]): Promise { const prisma = Container.get(PrismaClient); const elastic = Container.get(Client); diff --git a/src/search/index.ts b/src/search/index.ts index cc423b1..1724af8 100644 --- a/src/search/index.ts +++ b/src/search/index.ts @@ -7,4 +7,4 @@ export default function searchSyncHandler(): void { export * from './ElasticEntry'; export * from './sync'; -export * from './getProjectMatches'; +export * from './getProjectRecs'; From 6bf8d50e7c7830acc3887069116d27c20b981cf7 Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Thu, 30 Dec 2021 13:36:24 -0800 Subject: [PATCH 2/7] Simplified and improved the code Realized the extraneous work being done in findMatching.ts and removed that Added more metrics to matchingHelpers.ts Created more robust data typing in matchingTypes.ts Created more simple way to test the algorithm's implementation --- src/match/findMatching.ts | 97 +++++------ src/match/matchingHelpers.ts | 183 ++++++++++++++++---- src/match/{matching.ts => matchingTypes.ts} | 19 +- src/match/testMatching.ts | 14 ++ 4 files changed, 219 insertions(+), 94 deletions(-) rename src/match/{matching.ts => matchingTypes.ts} (52%) create mode 100644 src/match/testMatching.ts diff --git a/src/match/findMatching.ts b/src/match/findMatching.ts index 50751b0..93d81e9 100644 --- a/src/match/findMatching.ts +++ b/src/match/findMatching.ts @@ -1,73 +1,54 @@ -import { ProjectData } from './matching'; -import { MatchingStats, placeStudentsOfChoice } from './matchingHelpers'; +import { ProjectData } from './matchingTypes'; +import { + countStudentsOfChoices, + placeStudentsOfChoice, + placeStudentsOfChoicesBalanced, + range +} from './matchingHelpers'; /** - * Matches all first place students on projects with the same number of first choices as their size. + * Matches all first place students on projects with the same number of (or less) first choices as their size. * If a project has 5 spaces and 5 or less students who have that project as their first choice, then match all - * those students + * those students. + * * Formally, this is steps 1 and 2 from the python implementation * @param allProjectData The project data, edited in place to add matches */ -function step1(allProjectData: ProjectData): void { - Object.values(allProjectData).forEach((value) => { - if (value.numFirstChoice <= value.projSizeRemaining) { - placeStudentsOfChoice(allProjectData, value.projectId, 1, value.projSizeRemaining); - } - }); +export function step1(allProjectData: ProjectData): void { + Object.values(allProjectData) + .forEach((project) => { + placeStudentsOfChoicesBalanced(allProjectData, project.projectId, 1, project.projSizeRemaining); + }); } /** - * Runs the second step of matching - * - Matches first place to all projects with less first places with + * Assigns second places on projects, preferring to do it simply if possible otherwise doing it balanced. * @param allProjectData */ -function step2(allProjectData: ProjectData) { - +export function step2(allProjectData: ProjectData): void { + Object.values(allProjectData) + .forEach((project) => { + placeStudentsOfChoicesBalanced(allProjectData, project.projectId, 2, project.projSizeRemaining) + }); } -async function place_student() { +/** + * Assigns remaining spots (choices >= 3) in groups of size batch, up to limit + * @param allProjectData + * @param start + * @param batch - The size of the batches of choices to work on at once. + * @param limit - The upper limit of choices to process until + */ +export function step3(allProjectData: ProjectData, start: number, batch: number, limit: number) { + for (let startChoice = start; startChoice < limit; startChoice += batch) { + // Avoid going over the limit in the last iteration + const choices = startChoice + batch < limit + ? range(startChoice, startChoice + batch) + : range(startChoice, limit + 1); + Object.values(allProjectData) + .forEach((project) => { + placeStudentsOfChoicesBalanced(allProjectData, project.projectId, choices, project.projSizeRemaining); + }); + } } - -let testProjectData: ProjectData = { - zzz: { - studentsSelected: { - AA: { - studentId: 'AA', - choice: 1, - }, - CC: { - studentId: 'CC', - choice: 2, - }, - }, - projectId: 'zzz', - projSizeRemaining: 1, - numFirstChoice: 2, - studentsMatched: {}, - }, - yyy: { - studentsSelected: { - AA: { - studentId: 'AA', - choice: 2, - }, - BB: { - studentId: 'BB', - choice: 2, - }, - CC: { - studentId: 'CC', - choice: 1, - }, - }, - projectId: 'yyy', - projSizeRemaining: 2, - numFirstChoice: 1, - studentsMatched: {}, - }, -}; - -step1(testProjectData); -console.log(testProjectData); -console.log(MatchingStats(testProjectData)); diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts index 8943ccd..e4462b4 100644 --- a/src/match/matchingHelpers.ts +++ b/src/match/matchingHelpers.ts @@ -1,15 +1,4 @@ -import { ProjectData } from './matching'; - -// async function generateProjectData(projects: [Project]) { -// return Object.fromEntries( -// projects.map((project) => { -// const key = project.id -// const value: = { -// -// } -// }) -// ) -// } +import { ProjectData, ProjectDataDictElement, StudentChoice } from './matchingTypes'; function indexOfMatch(array: Array, fn: (element: T) => boolean) { let result = -1; @@ -23,6 +12,50 @@ function indexOfMatch(array: Array, fn: (element: T) => boolean) { return result; } +/** + * A very basic implementation of python's range function, used to generate an array of numbers from a start + * (inclusive) and end (exclusive) + * @param start - The start of the range, inclusive + * @param stop - The end of the range, exclusive + */ +export function range(start: number, stop: number): number[] { + const arr = []; + for (let i = start; i < stop; i += 1) { + arr.push(i); + } + return arr; +} + +/** + * Checks to see if a StudentChoice matches either a choice number or an array of choice numbers + * @param choice + * @param studentChoice + */ +function compareChoice(choice: number[] | number, studentChoice: StudentChoice): boolean { + return Array.isArray(choice) + ? (choice as number[]).includes(studentChoice.choice) + : studentChoice.choice === (choice as number); +} + +/** + * Counts the number of unmarked students on a project who voted for it with a given choice. + * @param project + * @param choice + */ +export function countStudentsOfChoices(project: ProjectDataDictElement, choice: number[] | number): number { + return Object.values(project.studentsSelected) + .filter( + (student) => student.matched !== true, + // eslint-disable-next-line arrow-body-style + ) + .reduce((secondChoiceCounter, student) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line no-bitwise + return secondChoiceCounter + ((compareChoice(choice, student)) | 0); + }, 0); +} + /** * Marks a student in all project's `studentsSelected` as having been matched somewhere already * @param projectData - The project data, mutated in place @@ -61,20 +94,21 @@ function placeStudent(projectData: ProjectData, projectId: string, studentId: st * anything, only use when you do not expect to have more students to match than num * @param projectData The project data * @param projectId The project to match - * @param choice The choice of students to match + * @param choice The choice number(s) to look at * @param count The number of students to match */ export function placeStudentsOfChoice( projectData: ProjectData, projectId: string, - choice: number, + choice: number[] | number, count: number, ): void { + // Handle the options for choice let counter = 0; // Iterate over only unmatched students to avoid duplicates for (const studentChoice of Object.values(projectData[projectId].studentsSelected) .filter((value) => value.matched !== true)) { - if (studentChoice.choice === choice) { + if (compareChoice(choice, studentChoice)) { placeStudent(projectData, projectId, studentChoice.studentId); counter += 1; } @@ -82,6 +116,57 @@ export function placeStudentsOfChoice( } } +/** + * Counts the number of votes a certain student has remaining in non-filled projects. + * @param projectData + * @param studentId + */ +function countStudentVotes(projectData: ProjectData, studentId: string): number { + return Object.values(projectData) + .filter((project) => project.projSizeRemaining > 0) + .reduce((previousCount, project) => { + const doesStudentExist = Object.keys( + project.studentsSelected, + ) + .some((id) => id === studentId); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line no-bitwise + return previousCount + (doesStudentExist | 0); + // Prev line casts a bool to an int very fast (2 orders of mag faster than other methods) + }, 0); +} + +/** + * Places students similar to placeStudentsOfChoice, however it is smarter and will break ties by removing the + * student who appears least frequently in other remaining votes. This is based on the assumption that this person + * will be the most likely to accidentally have all their voted projects filled up on then. + * @param projectData + * @param projectId + * @param choice + * @param count + */ +export function placeStudentsOfChoicesBalanced( + projectData: ProjectData, + projectId: string, + choice: number[] | number, + count: number, +): void { + // A list of all unmarked students matching the choice + const students: StudentChoice[] = Object.values(projectData[projectId].studentsSelected) + .filter((student) => compareChoice(choice, student) && student.matched !== true); + // An array of mappings from student IDs to the number of votes they have on non-filled projects + const studentFrequency: [string, number][] = students.map( + (student) => [student.studentId, countStudentVotes(projectData, student.studentId)], + ); + // Sort with least occurrences at the start + studentFrequency.sort((a, b) => a[1] - b[1]); + + // Get the first few count students and apply them + studentFrequency.slice(0, count) + .forEach((student) => placeStudent(projectData, projectId, student[0])); +} + /** * Counts the number of open spots left in projects * @param projectData @@ -95,7 +180,7 @@ function countUnfilled(projectData: ProjectData) { * Counts the number of unmarked students (students that didn't get applied to anything) * @param projectData */ -function unassignedStudents(projectData: ProjectData) { +function unassignedStudentsCount(projectData: ProjectData): number { const countedStudentIds: string[] = []; return Object.values(projectData) .reduce((previousValue, currentValue) => { @@ -108,27 +193,43 @@ function unassignedStudents(projectData: ProjectData) { }, 0); } +/** + * Returns the set of all unmatched student IDs + * @param projectData + */ +export function unassignedStudentProjects(projectData: ProjectData): { [key: string]: ProjectDataDictElement[] } { + const countedStudentIds: { [key: string]: ProjectDataDictElement[] } = {}; + Object.values(projectData) + .forEach((project) => { + const unmatchedStudents = Object.values( + project.studentsSelected, + ) + .filter((student) => student.matched !== true); + // For each unmatched student, go through and add the project we found it in to the return + unmatchedStudents.forEach((student) => { + if (countedStudentIds[student.studentId] === undefined) countedStudentIds[student.studentId] = [project]; + countedStudentIds[student.studentId].push(project); + }); + }, 0); + Object.keys(countedStudentIds).forEach((studentId) => { + countedStudentIds[studentId].sort( + (a, b) => a.studentsSelected[studentId].choice - b.studentsSelected[studentId].choice, + ); + }); + return countedStudentIds; +} + /** * Measures the effectiveness of a match, or the choice rank number that all students got divided by the number of * students * @param projectData + * @param totalStudents */ -function measureMatchEffectiveness(projectData: ProjectData) { +function measureMatchEffectiveness(projectData: ProjectData, totalStudents: number) { const rawScore = Object.values(projectData) - .reduce((sumScoreOverall, currentProject) => { - return sumScoreOverall - + Object.values(currentProject.studentsSelected) - .reduce((sumScore, currentStudent) => sumScore + currentStudent.choice, 0); - }, 0); - const totalStudentsSet = new Set(); - Object.values(projectData) - .forEach((project) => { - Object.values(project.studentsSelected) - .forEach((student) => { - totalStudentsSet.add(student.studentId); - }); - }); - const totalStudents = totalStudentsSet.size; + .reduce((sumScoreOverall, currentProject) => sumScoreOverall + + Object.values(currentProject.studentsMatched) + .reduce((sumScore, currentStudent) => sumScore + currentStudent.choice, 0), 0); return rawScore / totalStudents; } @@ -138,10 +239,24 @@ function measureMatchEffectiveness(projectData: ProjectData) { * @constructor */ export function MatchingStats(projectData: ProjectData): Record { + // eslint-disable-next-line func-names + const totalStudents = (function () { + const totalStudentsSet = new Set(); + Object.values(projectData) + .forEach((project) => { + Object.values(project.studentsSelected) + .forEach((student) => { + totalStudentsSet.add(student.studentId); + }); + }); + return totalStudentsSet.size; + }()); return { - unassignedStudents: unassignedStudents(projectData), - unfilledProjects: countUnfilled(projectData), - matchingScore: measureMatchEffectiveness(projectData), + totalProjects: Object.keys(projectData).length, + totalStudents, + unassignedStudents: unassignedStudentsCount(projectData), + unfilledSlots: countUnfilled(projectData), + matchingScore: measureMatchEffectiveness(projectData, totalStudents), }; } diff --git a/src/match/matching.ts b/src/match/matchingTypes.ts similarity index 52% rename from src/match/matching.ts rename to src/match/matchingTypes.ts index 61fa30c..bde73c6 100644 --- a/src/match/matching.ts +++ b/src/match/matchingTypes.ts @@ -2,8 +2,23 @@ export interface ProjectData { [projectId: string]: ProjectDataDictElement } -export interface ProjectDataDictElement { - projectId: string +export interface ProjectDetails { + projTags: string[]; + timezone: number; + backgroundRural: boolean; + bio: string; + projDescription: string; + preferStudentUnderRep: boolean; + okExtended: boolean; + okTimezoneDifference: boolean; + preferToolExistingKnowledge: boolean; + name: string; + company?: string; + track: string; +} + +export interface ProjectDataDictElement extends ProjectDetails { + projectId: string; studentsSelected: StudentChoices; studentsMatched: StudentChoices; projSizeRemaining: number; diff --git a/src/match/testMatching.ts b/src/match/testMatching.ts new file mode 100644 index 0000000..e1a14f9 --- /dev/null +++ b/src/match/testMatching.ts @@ -0,0 +1,14 @@ +import { sampleData } from './matchingData'; +import { + step1, step2, step3, +} from './findMatching'; +import { MatchingStats, unassignedStudentProjects } from './matchingHelpers'; + +step1(sampleData); +console.log(MatchingStats(sampleData)); +step2(sampleData); +console.log(MatchingStats(sampleData)); +step3(sampleData, 3, 19, 20); +console.log(MatchingStats(sampleData)); +const thing = unassignedStudentProjects(sampleData); +console.log("breakpoint"); From 7ff3d2d064696e04fbcf75c5f113a91d31ca22ee Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Thu, 30 Dec 2021 18:25:20 -0800 Subject: [PATCH 3/7] Improved the results of the matching by adding randomness and running it multiple times to find the best solutions. --- src/match/findMatching.ts | 87 +++++++++++-------- src/match/matchingHelpers.ts | 162 +++++++++++++---------------------- src/match/matchingTypes.ts | 20 ++++- src/match/testMatching.ts | 15 +--- 4 files changed, 128 insertions(+), 156 deletions(-) diff --git a/src/match/findMatching.ts b/src/match/findMatching.ts index 93d81e9..971ec13 100644 --- a/src/match/findMatching.ts +++ b/src/match/findMatching.ts @@ -1,54 +1,67 @@ -import { ProjectData } from './matchingTypes'; +import { Matching, MatchingStats, ProjectData } from './matchingTypes'; import { - countStudentsOfChoices, - placeStudentsOfChoice, + matchingStats, placeStudentsOfChoicesBalanced, - range + range, } from './matchingHelpers'; +import { sampleData } from './matchingData'; /** - * Matches all first place students on projects with the same number of (or less) first choices as their size. - * If a project has 5 spaces and 5 or less students who have that project as their first choice, then match all - * those students. + * Assigns students of choice starting from start going to limit in batches of size batch using the balanced + * algorithm to break ties. Examples of use: + * step3(sampleData, 1, 1, 1) - Matches all first choice students it can + * step3(sampleData, 2, 2, 1) - Matches all second choice students it can + * step3(sampleData, 3, 20, 3) - Matches 3,4,5 then 5,6,7 then 8,9,10... etc until 20. * - * Formally, this is steps 1 and 2 from the python implementation - * @param allProjectData The project data, edited in place to add matches - */ -export function step1(allProjectData: ProjectData): void { - Object.values(allProjectData) - .forEach((project) => { - placeStudentsOfChoicesBalanced(allProjectData, project.projectId, 1, project.projSizeRemaining); - }); -} - -/** - * Assigns second places on projects, preferring to do it simply if possible otherwise doing it balanced. - * @param allProjectData - */ -export function step2(allProjectData: ProjectData): void { - Object.values(allProjectData) - .forEach((project) => { - placeStudentsOfChoicesBalanced(allProjectData, project.projectId, 2, project.projSizeRemaining) - }); -} - -/** - * Assigns remaining spots (choices >= 3) in groups of size batch, up to limit * @param allProjectData - * @param start + * @param start - Starting number, inclusive + * @param end - The number to process until, inclusive * @param batch - The size of the batches of choices to work on at once. - * @param limit - The upper limit of choices to process until */ -export function step3(allProjectData: ProjectData, start: number, batch: number, limit: number) { - for (let startChoice = start; startChoice < limit; startChoice += batch) { +function matchChoices(allProjectData: ProjectData, start: number, end: number, batch: number): void { + for (let startChoice = start; startChoice <= end; startChoice += batch) { // Avoid going over the limit in the last iteration - const choices = startChoice + batch < limit + const choices = startChoice + batch < end ? range(startChoice, startChoice + batch) - : range(startChoice, limit + 1); - + : range(startChoice, end + 1); Object.values(allProjectData) .forEach((project) => { placeStudentsOfChoicesBalanced(allProjectData, project.projectId, choices, project.projSizeRemaining); }); } } + +/** + * Generates a single match of all students to projects. May have missing students. + * @param data + */ +function generateMatch(data: ProjectData): Matching { + matchChoices(data, 1, 1, 2); + matchChoices(data, 3, 20, 1); + return { + match: data, + stats: matchingStats(data), + }; +} + +/** + * Generates a match that probably has no unassigned students (very likely but not guaranteed, call again if it fails) + * @param {ProjectData} data - The project information to create a match for. Will not be mutated. + */ +export function generateReliableMatch(data: ProjectData): Matching { + const startTime = process.hrtime(); + let copyOfData = JSON.parse(JSON.stringify(data)); + let bestMatch: Matching = generateMatch(copyOfData); + for (let i = 0; i < 50; i += 1) { + copyOfData = JSON.parse(JSON.stringify(data)); + const match = generateMatch(copyOfData); + if (match.stats.unassignedStudents < bestMatch.stats.unassignedStudents + || (match.stats.unassignedStudents === bestMatch.stats.unassignedStudents + && match.stats.matchingScore < bestMatch.stats.matchingScore)) { + bestMatch = match; + } + } + const endTime = process.hrtime(startTime); + bestMatch.stats.runtimeMs = endTime[0] * 1000 + endTime[1] / 1000000; + return bestMatch; +} diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts index e4462b4..0d67664 100644 --- a/src/match/matchingHelpers.ts +++ b/src/match/matchingHelpers.ts @@ -1,15 +1,15 @@ -import { ProjectData, ProjectDataDictElement, StudentChoice } from './matchingTypes'; - -function indexOfMatch(array: Array, fn: (element: T) => boolean) { - let result = -1; - array.some((e, i) => { - if (fn(e)) { - result = i; - return true; - } - return false; - }); - return result; +import assert from 'assert'; +import { + MatchingStats, ProjectData, ProjectDataDictElement, StudentChoice, +} from './matchingTypes'; + +/* Randomize array in-place using Durstenfeld shuffle algorithm https://stackoverflow.com/a/12646864 */ +function shuffleArray(array: Array): void { + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + // eslint-disable-next-line no-param-reassign + [array[i], array[j]] = [array[j], array[i]]; + } } /** @@ -37,25 +37,6 @@ function compareChoice(choice: number[] | number, studentChoice: StudentChoice): : studentChoice.choice === (choice as number); } -/** - * Counts the number of unmarked students on a project who voted for it with a given choice. - * @param project - * @param choice - */ -export function countStudentsOfChoices(project: ProjectDataDictElement, choice: number[] | number): number { - return Object.values(project.studentsSelected) - .filter( - (student) => student.matched !== true, - // eslint-disable-next-line arrow-body-style - ) - .reduce((secondChoiceCounter, student) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - // eslint-disable-next-line no-bitwise - return secondChoiceCounter + ((compareChoice(choice, student)) | 0); - }, 0); -} - /** * Marks a student in all project's `studentsSelected` as having been matched somewhere already * @param projectData - The project data, mutated in place @@ -82,6 +63,11 @@ function placeStudent(projectData: ProjectData, projectId: string, studentId: st const project = projectData[projectId]; const student = project.studentsSelected[studentId]; const firstChoice: boolean = student.choice === 1; + // We should never try to match a student that's already been matched + assert( + student.matched !== true, + `Tried to place a student that's already been matched ${JSON.stringify(student)}`, + ); // eslint-disable-next-line no-param-reassign projectData[projectId].studentsMatched[student.studentId] = student; markStudent(projectData, studentId); @@ -89,33 +75,6 @@ function placeStudent(projectData: ProjectData, projectId: string, studentId: st if (firstChoice) project.numFirstChoice -= 1; } -/** - * Places num students of choice on a project or until all students of choice have been placed. NOT balanced by - * anything, only use when you do not expect to have more students to match than num - * @param projectData The project data - * @param projectId The project to match - * @param choice The choice number(s) to look at - * @param count The number of students to match - */ -export function placeStudentsOfChoice( - projectData: ProjectData, - projectId: string, - choice: number[] | number, - count: number, -): void { - // Handle the options for choice - let counter = 0; - // Iterate over only unmatched students to avoid duplicates - for (const studentChoice of Object.values(projectData[projectId].studentsSelected) - .filter((value) => value.matched !== true)) { - if (compareChoice(choice, studentChoice)) { - placeStudent(projectData, projectId, studentChoice.studentId); - counter += 1; - } - if (counter >= count) break; - } -} - /** * Counts the number of votes a certain student has remaining in non-filled projects. * @param projectData @@ -123,8 +82,11 @@ export function placeStudentsOfChoice( */ function countStudentVotes(projectData: ProjectData, studentId: string): number { return Object.values(projectData) + // Only look at non-filled projects .filter((project) => project.projSizeRemaining > 0) + // Sum the number of these projects that contain the given studentId .reduce((previousCount, project) => { + // Check if the student exists in the studentSelected const doesStudentExist = Object.keys( project.studentsSelected, ) @@ -152,18 +114,44 @@ export function placeStudentsOfChoicesBalanced( choice: number[] | number, count: number, ): void { - // A list of all unmarked students matching the choice - const students: StudentChoice[] = Object.values(projectData[projectId].studentsSelected) - .filter((student) => compareChoice(choice, student) && student.matched !== true); - // An array of mappings from student IDs to the number of votes they have on non-filled projects - const studentFrequency: [string, number][] = students.map( - (student) => [student.studentId, countStudentVotes(projectData, student.studentId)], - ); - // Sort with least occurrences at the start - studentFrequency.sort((a, b) => a[1] - b[1]); + // An array of students on this project who have only one remaining project + const singleStudents: [string, number][] = Object.values(projectData[projectId].studentsSelected) + .filter((student) => student.matched !== true) + .map( + (student): [string, number] => [student.studentId, countStudentVotes(projectData, student.studentId)], + ) + .filter( + (studentVotes) => studentVotes[1] === 1, + ); + + // Randomize the array and then match as many single students as possible. + let counter = 0; + shuffleArray(singleStudents); + singleStudents.slice(0, count) + .forEach((student) => { + placeStudent(projectData, projectId, student[0]); + counter += 1; + }); - // Get the first few count students and apply them - studentFrequency.slice(0, count) + // An array of mappings from student IDs to the number of votes they have on non-filled projects for unmarked + // students matching the choice + const studentFrequency: [string, number][] = Object.values(projectData[projectId].studentsSelected) + .filter((student) => compareChoice(choice, student) && student.matched !== true) + .map( + (student) => [student.studentId, countStudentVotes(projectData, student.studentId)], + ); + // Sort with least occurrences at the start and randomize the order within blocks of the same number of occurrences + studentFrequency.sort((a, b) => { + let value = a[1] - b[1]; + if (value === 0) { + value = 0.5 - Math.random(); + } + return value; + }); + + // Get the first few count students and apply them to any remaining slots. This count and counter thing will always + // work, slice handles all this very nicely. + studentFrequency.slice(0, count - counter) .forEach((student) => placeStudent(projectData, projectId, student[0])); } @@ -193,32 +181,6 @@ function unassignedStudentsCount(projectData: ProjectData): number { }, 0); } -/** - * Returns the set of all unmatched student IDs - * @param projectData - */ -export function unassignedStudentProjects(projectData: ProjectData): { [key: string]: ProjectDataDictElement[] } { - const countedStudentIds: { [key: string]: ProjectDataDictElement[] } = {}; - Object.values(projectData) - .forEach((project) => { - const unmatchedStudents = Object.values( - project.studentsSelected, - ) - .filter((student) => student.matched !== true); - // For each unmatched student, go through and add the project we found it in to the return - unmatchedStudents.forEach((student) => { - if (countedStudentIds[student.studentId] === undefined) countedStudentIds[student.studentId] = [project]; - countedStudentIds[student.studentId].push(project); - }); - }, 0); - Object.keys(countedStudentIds).forEach((studentId) => { - countedStudentIds[studentId].sort( - (a, b) => a.studentsSelected[studentId].choice - b.studentsSelected[studentId].choice, - ); - }); - return countedStudentIds; -} - /** * Measures the effectiveness of a match, or the choice rank number that all students got divided by the number of * students @@ -238,7 +200,7 @@ function measureMatchEffectiveness(projectData: ProjectData, totalStudents: numb * @param projectData * @constructor */ -export function MatchingStats(projectData: ProjectData): Record { +export function matchingStats(projectData: ProjectData): MatchingStats { // eslint-disable-next-line func-names const totalStudents = (function () { const totalStudentsSet = new Set(); @@ -259,11 +221,3 @@ export function MatchingStats(projectData: ProjectData): Record matchingScore: measureMatchEffectiveness(projectData, totalStudents), }; } - -// -// -// placeStudentsOfChoice(testProjectData, 'zzz', 1, 1); -// placeStudentsOfChoice(testProjectData, 'yyy', 1, 1); -// import { inspect } from 'util'; -// -// console.log(inspect(testProjectData, {showHidden: true, depth: 3, colors: true})); diff --git a/src/match/matchingTypes.ts b/src/match/matchingTypes.ts index bde73c6..5bb4d84 100644 --- a/src/match/matchingTypes.ts +++ b/src/match/matchingTypes.ts @@ -1,5 +1,5 @@ export interface ProjectData { - [projectId: string]: ProjectDataDictElement + [projectId: string]: ProjectDataDictElement; } export interface ProjectDetails { @@ -26,7 +26,7 @@ export interface ProjectDataDictElement extends ProjectDetails { } export interface StudentChoices { - [studentId: string]: StudentChoice + [studentId: string]: StudentChoice; } export interface Student { @@ -35,5 +35,19 @@ export interface Student { export interface StudentChoice extends Student { choice: number; - matched?: true | undefined // This works as a kind of default + matched?: true | undefined; // This works as a kind of default +} + +export interface MatchingStats { + totalProjects: number; + totalStudents: number; + unassignedStudents: number; + unfilledSlots: number; + matchingScore: number; + runtimeMs?: number +} + +export interface Matching { + match: ProjectData; + stats: MatchingStats; } diff --git a/src/match/testMatching.ts b/src/match/testMatching.ts index e1a14f9..f24686c 100644 --- a/src/match/testMatching.ts +++ b/src/match/testMatching.ts @@ -1,14 +1,5 @@ +import { generateReliableMatch } from './findMatching'; import { sampleData } from './matchingData'; -import { - step1, step2, step3, -} from './findMatching'; -import { MatchingStats, unassignedStudentProjects } from './matchingHelpers'; -step1(sampleData); -console.log(MatchingStats(sampleData)); -step2(sampleData); -console.log(MatchingStats(sampleData)); -step3(sampleData, 3, 19, 20); -console.log(MatchingStats(sampleData)); -const thing = unassignedStudentProjects(sampleData); -console.log("breakpoint"); +const { stats } = generateReliableMatch(sampleData); +console.log(stats); From edbef856f399b0461aa2030e5d9e44e1f5d5a87c Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Mon, 10 Jan 2022 00:51:04 -0800 Subject: [PATCH 4/7] Finished up the pure logical side of the algorithm. Need to better integrate it now. --- src/match/matchingHelpers.ts | 45 +++++++++++++++++++++++++++++++++++- src/match/matchingTypes.ts | 18 ++------------- src/resolvers/Match.ts | 17 +++++++++++--- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts index 0d67664..0f48898 100644 --- a/src/match/matchingHelpers.ts +++ b/src/match/matchingHelpers.ts @@ -1,6 +1,7 @@ import assert from 'assert'; +import { Project, ProjectPreference } from '@prisma/client'; import { - MatchingStats, ProjectData, ProjectDataDictElement, StudentChoice, + MatchingStats, ProjectData, StudentChoice, StudentChoices, } from './matchingTypes'; /* Randomize array in-place using Durstenfeld shuffle algorithm https://stackoverflow.com/a/12646864 */ @@ -99,6 +100,25 @@ function countStudentVotes(projectData: ProjectData, studentId: string): number }, 0); } +/** + * Counts the number of unmarked students on a project who voted for it with a given choice. + * @param studentsSelected + * @param choice + */ +export function countStudentsOfChoices(studentsSelected: StudentChoices, choice: number[] | number): number { + return Object.values(studentsSelected) + .filter( + (student) => student.matched !== true, + // eslint-disable-next-line arrow-body-style + ) + .reduce((secondChoiceCounter, student) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line no-bitwise + return secondChoiceCounter + ((compareChoice(choice, student)) | 0); + }, 0); +} + /** * Places students similar to placeStudentsOfChoice, however it is smarter and will break ties by removing the * student who appears least frequently in other remaining votes. This is based on the assumption that this person @@ -221,3 +241,26 @@ export function matchingStats(projectData: ProjectData): MatchingStats { matchingScore: measureMatchEffectiveness(projectData, totalStudents), }; } + +export function parsePrismaData(prismaData: (Project & {projectPreferences: ProjectPreference[]})[]): ProjectData { + const projectData: ProjectData = {}; + prismaData.forEach((prismaProject) => { + // Generate the student choices + const studentsSelected: StudentChoices = {}; + prismaProject.projectPreferences.forEach((prismaStudentChoice) => { + studentsSelected[prismaStudentChoice.studentId] = { + studentId: prismaStudentChoice.studentId, + choice: prismaStudentChoice.ranking, + }; + }); + // Fill in the rest of the data + projectData[prismaProject.id] = { + studentsSelected, + projectId: prismaProject.id, + numFirstChoice: countStudentsOfChoices(studentsSelected, 1), + projSizeRemaining: Object.keys(studentsSelected).length, + studentsMatched: {}, + }; + }); + return projectData; +} diff --git a/src/match/matchingTypes.ts b/src/match/matchingTypes.ts index 5bb4d84..4d6c3d3 100644 --- a/src/match/matchingTypes.ts +++ b/src/match/matchingTypes.ts @@ -2,27 +2,13 @@ export interface ProjectData { [projectId: string]: ProjectDataDictElement; } -export interface ProjectDetails { - projTags: string[]; - timezone: number; - backgroundRural: boolean; - bio: string; - projDescription: string; - preferStudentUnderRep: boolean; - okExtended: boolean; - okTimezoneDifference: boolean; - preferToolExistingKnowledge: boolean; - name: string; - company?: string; - track: string; -} - -export interface ProjectDataDictElement extends ProjectDetails { +export interface ProjectDataDictElement { projectId: string; studentsSelected: StudentChoices; studentsMatched: StudentChoices; projSizeRemaining: number; numFirstChoice: number; + [x: string]: any; // Patch for testing data having too much going on, TODO: remove once postgres data arrives } export interface StudentChoices { diff --git a/src/resolvers/Match.ts b/src/resolvers/Match.ts index 8428b2a..b642c82 100644 --- a/src/resolvers/Match.ts +++ b/src/resolvers/Match.ts @@ -1,10 +1,12 @@ import { Arg, Authorized, Ctx, Mutation, Query, Resolver, } from 'type-graphql'; -import { PrismaClient, } from '@prisma/client'; +import { PrismaClient, Project, ProjectPreference } from '@prisma/client'; import { Inject, Service } from 'typedi'; import { StudentStatus, Track } from '../enums'; import { AuthRole, Context } from '../context'; import { Match, Preference, Student, Tag, } from '../types'; import { getProjectRecs } from '../search'; +import { parsePrismaData } from '../match/matchingHelpers'; +import { generateReliableMatch } from '../match/findMatching'; @Service() @Resolver(Match) @@ -12,10 +14,19 @@ export class MatchResolver { @Inject(() => PrismaClient) private readonly prisma: PrismaClient; + // TODO: Figure out what auth role this should have + @Query(() => [Match], { nullable: true }) async matchStudents( @Ctx() { auth }: Context, - ) { - + ): Promise { + const prismaProjectData: (Project & { projectPreferences: ProjectPreference[] })[] = await this.prisma.project.findMany({ + include: { + projectPreferences: true, + }, + }); + const projectData = parsePrismaData(prismaProjectData); + const matching = generateReliableMatch(projectData); + console.log(matching); } @Authorized(AuthRole.STUDENT) From 35db52c5b5a4a24c69c177c7b18eebe3bb1c6ef0 Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Mon, 10 Jan 2022 01:04:33 -0800 Subject: [PATCH 5/7] Remove unnecessary references to testing data --- src/match/findMatching.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/match/findMatching.ts b/src/match/findMatching.ts index 971ec13..b9e6d2f 100644 --- a/src/match/findMatching.ts +++ b/src/match/findMatching.ts @@ -1,10 +1,9 @@ -import { Matching, MatchingStats, ProjectData } from './matchingTypes'; +import { Matching, ProjectData } from './matchingTypes'; import { matchingStats, placeStudentsOfChoicesBalanced, range, } from './matchingHelpers'; -import { sampleData } from './matchingData'; /** * Assigns students of choice starting from start going to limit in batches of size batch using the balanced From 8b8f9a9fc680e10113b2bf454134de4256cd989b Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Sat, 15 Jan 2022 22:18:43 -0800 Subject: [PATCH 6/7] Prepared most of the GraphQL endpoint stuff. Waiting on a bit more feedback and needs additional testing before ready for review. --- src/match/findMatching.ts | 17 ++++--- src/match/matchingHelpers.ts | 32 +++++++++++-- src/match/matchingTypes.ts | 24 +++++++--- src/resolvers/Match.ts | 37 +++++++++------ src/types/Matching.ts | 55 +++++++++++++++++++++++ src/types/{Match.ts => Recommendation.ts} | 2 +- src/types/index.ts | 2 +- 7 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 src/types/Matching.ts rename src/types/{Match.ts => Recommendation.ts} (86%) diff --git a/src/match/findMatching.ts b/src/match/findMatching.ts index b9e6d2f..e8ac4e6 100644 --- a/src/match/findMatching.ts +++ b/src/match/findMatching.ts @@ -1,4 +1,4 @@ -import { Matching, ProjectData } from './matchingTypes'; +import { MatchingExternal, MatchingInternal, ProjectData } from './matchingTypes'; import { matchingStats, placeStudentsOfChoicesBalanced, @@ -34,7 +34,7 @@ function matchChoices(allProjectData: ProjectData, start: number, end: number, b * Generates a single match of all students to projects. May have missing students. * @param data */ -function generateMatch(data: ProjectData): Matching { +function generateMatch(data: ProjectData): MatchingInternal { matchChoices(data, 1, 1, 2); matchChoices(data, 3, 20, 1); return { @@ -47,10 +47,10 @@ function generateMatch(data: ProjectData): Matching { * Generates a match that probably has no unassigned students (very likely but not guaranteed, call again if it fails) * @param {ProjectData} data - The project information to create a match for. Will not be mutated. */ -export function generateReliableMatch(data: ProjectData): Matching { +export function generateReliableMatch(data: ProjectData): MatchingExternal { const startTime = process.hrtime(); let copyOfData = JSON.parse(JSON.stringify(data)); - let bestMatch: Matching = generateMatch(copyOfData); + let bestMatch: MatchingInternal = generateMatch(copyOfData); for (let i = 0; i < 50; i += 1) { copyOfData = JSON.parse(JSON.stringify(data)); const match = generateMatch(copyOfData); @@ -61,6 +61,11 @@ export function generateReliableMatch(data: ProjectData): Matching { } } const endTime = process.hrtime(startTime); - bestMatch.stats.runtimeMs = endTime[0] * 1000 + endTime[1] / 1000000; - return bestMatch; + return { + match: bestMatch.match, + stats: { + ...bestMatch.stats, + runtimeMs: endTime[0] * 1000 + endTime[1] / 1000000, + }, + }; } diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts index 0f48898..5b6966e 100644 --- a/src/match/matchingHelpers.ts +++ b/src/match/matchingHelpers.ts @@ -1,8 +1,13 @@ import assert from 'assert'; import { Project, ProjectPreference } from '@prisma/client'; import { - MatchingStats, ProjectData, StudentChoice, StudentChoices, + MatchingExternal, + MatchingStatsInternal, + ProjectData, + StudentChoice, + StudentChoices, } from './matchingTypes'; +import { MatchingProjectDatum, MatchingResult } from '../types/Matching'; /* Randomize array in-place using Durstenfeld shuffle algorithm https://stackoverflow.com/a/12646864 */ function shuffleArray(array: Array): void { @@ -220,7 +225,7 @@ function measureMatchEffectiveness(projectData: ProjectData, totalStudents: numb * @param projectData * @constructor */ -export function matchingStats(projectData: ProjectData): MatchingStats { +export function matchingStats(projectData: ProjectData): MatchingStatsInternal { // eslint-disable-next-line func-names const totalStudents = (function () { const totalStudentsSet = new Set(); @@ -242,7 +247,7 @@ export function matchingStats(projectData: ProjectData): MatchingStats { }; } -export function parsePrismaData(prismaData: (Project & {projectPreferences: ProjectPreference[]})[]): ProjectData { +export function parsePrismaData(prismaData: (Project & { projectPreferences: ProjectPreference[] })[]): ProjectData { const projectData: ProjectData = {}; prismaData.forEach((prismaProject) => { // Generate the student choices @@ -264,3 +269,24 @@ export function parsePrismaData(prismaData: (Project & {projectPreferences: Proj }); return projectData; } + +/** + * Marshals data from internal structure to datatype required for export + */ +export function prepareDataForExport(matchingData: MatchingExternal): MatchingResult { + const matchingProjectData: MatchingProjectDatum[] = Object + .values(matchingData.match) + .map((value) => ({ + projectId: value.projectId, + studentsSelected: Object.values(value.studentsSelected) + .map((student) => student.studentId), + studentsMatched: Object.values(value.studentsMatched) + .map((student) => student.studentId), + projSizeRemaining: value.projSizeRemaining, + numFirstChoice: value.numFirstChoice, + })); + return { + stats: matchingData.stats, + match: matchingProjectData, + }; +} diff --git a/src/match/matchingTypes.ts b/src/match/matchingTypes.ts index 4d6c3d3..f925656 100644 --- a/src/match/matchingTypes.ts +++ b/src/match/matchingTypes.ts @@ -2,13 +2,17 @@ export interface ProjectData { [projectId: string]: ProjectDataDictElement; } -export interface ProjectDataDictElement { +export interface ProjectDataDictElementCore { projectId: string; studentsSelected: StudentChoices; studentsMatched: StudentChoices; projSizeRemaining: number; numFirstChoice: number; - [x: string]: any; // Patch for testing data having too much going on, TODO: remove once postgres data arrives +} + +/// This gross thing is a match for having too many unnecessary fields in my testing data. +export interface ProjectDataDictElement extends ProjectDataDictElementCore { + [x: string]: any; } export interface StudentChoices { @@ -24,16 +28,24 @@ export interface StudentChoice extends Student { matched?: true | undefined; // This works as a kind of default } -export interface MatchingStats { +export interface MatchingStatsInternal { totalProjects: number; totalStudents: number; unassignedStudents: number; unfilledSlots: number; matchingScore: number; - runtimeMs?: number } -export interface Matching { +export interface MatchingStatsExternal extends MatchingStatsInternal { + runtimeMs: number; +} + +export interface MatchingInternal { + match: ProjectData; + stats: MatchingStatsInternal; +} + +export interface MatchingExternal { match: ProjectData; - stats: MatchingStats; + stats: MatchingStatsExternal; } diff --git a/src/resolvers/Match.ts b/src/resolvers/Match.ts index b642c82..1ae39ae 100644 --- a/src/resolvers/Match.ts +++ b/src/resolvers/Match.ts @@ -1,41 +1,50 @@ import { Arg, Authorized, Ctx, Mutation, Query, Resolver, } from 'type-graphql'; -import { PrismaClient, Project, ProjectPreference } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import { Inject, Service } from 'typedi'; -import { StudentStatus, Track } from '../enums'; +import { ProjectStatus, StudentStatus, Track } from '../enums'; import { AuthRole, Context } from '../context'; -import { Match, Preference, Student, Tag, } from '../types'; +import { + Preference, + Recommendation, + Student, + Tag, +} from '../types'; import { getProjectRecs } from '../search'; -import { parsePrismaData } from '../match/matchingHelpers'; +import { parsePrismaData, prepareDataForExport } from '../match/matchingHelpers'; import { generateReliableMatch } from '../match/findMatching'; +import { MatchingResult } from '../types/Matching'; @Service() -@Resolver(Match) +@Resolver(Recommendation) export class MatchResolver { @Inject(() => PrismaClient) private readonly prisma: PrismaClient; - // TODO: Figure out what auth role this should have - @Query(() => [Match], { nullable: true }) - async matchStudents( - @Ctx() { auth }: Context, - ): Promise { - const prismaProjectData: (Project & { projectPreferences: ProjectPreference[] })[] = await this.prisma.project.findMany({ + @Authorized(AuthRole.ADMIN) + @Query(() => [MatchingResult]) + async matchStudents(): Promise { + const prismaProjectData = await this.prisma.project.findMany({ + where: { + status: { + not: ProjectStatus.DRAFT, + }, + }, include: { projectPreferences: true, }, }); const projectData = parsePrismaData(prismaProjectData); const matching = generateReliableMatch(projectData); - console.log(matching); + return prepareDataForExport(matching); } @Authorized(AuthRole.STUDENT) - @Query(() => [Match], { nullable: true }) + @Query(() => [Recommendation], { nullable: true }) // TODO: Check how to rename endpoints like this. This is really getting recs not matches. async projectRecs( @Ctx() { auth }: Context, @Arg('tags', () => [String]) tagIds: string[], - ): Promise { + ): Promise { const student = await this.prisma.student.findUnique({ where: auth.toWhere() }); const tags = await this.prisma.tag.findMany({ where: { id: { in: tagIds } } }); diff --git a/src/types/Matching.ts b/src/types/Matching.ts new file mode 100644 index 0000000..77c587b --- /dev/null +++ b/src/types/Matching.ts @@ -0,0 +1,55 @@ +// eslint-disable-next-line max-classes-per-file +import { ObjectType, Field } from 'type-graphql'; +import { + MatchingStatsExternal, +} from '../match/matchingTypes'; + +@ObjectType() +export class MatchingStats implements MatchingStatsExternal { + @Field(() => Number) + totalProjects: number + + @Field(() => Number) + totalStudents: number + + @Field(() => Number) + unassignedStudents: number + + @Field(() => Number) + unfilledSlots: number + + @Field(() => Number) + matchingScore: number + + @Field(() => Number) + runtimeMs: number +} + +@ObjectType() +export class MatchingProjectDatum { + @Field(() => String) + projectId: string + + @Field(() => [String]) + studentsMatched: string[] + + // Below fields are pretty much just debugging data in case this is desired by the frontend. + // TODO: Confirm that this is desired on the frontend + @Field(() => Number) + numFirstChoice: number + + @Field(() => Number) + projSizeRemaining: number; + + @Field(() => [String]) + studentsSelected: string[]; +} + +@ObjectType() +export class MatchingResult { + @Field(() => [MatchingProjectDatum]) + match: MatchingProjectDatum[] + + @Field(() => MatchingStats) + stats: MatchingStats +} diff --git a/src/types/Match.ts b/src/types/Recommendation.ts similarity index 86% rename from src/types/Match.ts rename to src/types/Recommendation.ts index ec646de..c4301b4 100644 --- a/src/types/Match.ts +++ b/src/types/Recommendation.ts @@ -2,7 +2,7 @@ import { ObjectType, Field } from 'type-graphql'; import { Project } from './Project'; @ObjectType() -export class Match { +export class Recommendation { @Field(() => Number) score: number diff --git a/src/types/index.ts b/src/types/index.ts index 599c335..69bd9d4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,5 +2,5 @@ export * from './Mentor'; export * from './Student'; export * from './Project'; export * from './Tag'; -export * from './Match'; +export * from './Recommendation'; export * from './Preference'; From 8a353fa6458ecdf253d59712445da1e8dbda10b3 Mon Sep 17 00:00:00 2001 From: Cobular <22972550+Cobular@users.noreply.github.com> Date: Sat, 22 Jan 2022 00:12:14 -0800 Subject: [PATCH 7/7] Updated to use MatchTuple in results to API --- src/match/matchingHelpers.ts | 18 ++++++--------- src/search/getProjectRecs.ts | 4 ++-- src/types/Matching.ts | 43 +++++++++++++----------------------- 3 files changed, 24 insertions(+), 41 deletions(-) diff --git a/src/match/matchingHelpers.ts b/src/match/matchingHelpers.ts index 5b6966e..ba9f7ae 100644 --- a/src/match/matchingHelpers.ts +++ b/src/match/matchingHelpers.ts @@ -7,7 +7,7 @@ import { StudentChoice, StudentChoices, } from './matchingTypes'; -import { MatchingProjectDatum, MatchingResult } from '../types/Matching'; +import { MatchTuple, MatchingResult } from '../types/Matching'; /* Randomize array in-place using Durstenfeld shuffle algorithm https://stackoverflow.com/a/12646864 */ function shuffleArray(array: Array): void { @@ -116,6 +116,7 @@ export function countStudentsOfChoices(studentsSelected: StudentChoices, choice: (student) => student.matched !== true, // eslint-disable-next-line arrow-body-style ) + // eslint-disable-next-line arrow-body-style .reduce((secondChoiceCounter, student) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -274,17 +275,12 @@ export function parsePrismaData(prismaData: (Project & { projectPreferences: Pro * Marshals data from internal structure to datatype required for export */ export function prepareDataForExport(matchingData: MatchingExternal): MatchingResult { - const matchingProjectData: MatchingProjectDatum[] = Object + const matchingProjectData: MatchTuple[] = Object .values(matchingData.match) - .map((value) => ({ - projectId: value.projectId, - studentsSelected: Object.values(value.studentsSelected) - .map((student) => student.studentId), - studentsMatched: Object.values(value.studentsMatched) - .map((student) => student.studentId), - projSizeRemaining: value.projSizeRemaining, - numFirstChoice: value.numFirstChoice, - })); + .flatMap((projectData) => Object.keys(projectData.studentsMatched).map((student) => ({ + studentId: student, + projectId: projectData.projectId, + }))); return { stats: matchingData.stats, match: matchingProjectData, diff --git a/src/search/getProjectRecs.ts b/src/search/getProjectRecs.ts index c2cfa37..9cdd5e4 100644 --- a/src/search/getProjectRecs.ts +++ b/src/search/getProjectRecs.ts @@ -7,7 +7,7 @@ import { PrismaClient } from '@prisma/client'; import Container from 'typedi'; import config from '../config'; import { - Student, Match, Project, Tag, + Student, Recommendation, Project, Tag, } from '../types'; import { Track, TagType } from '../enums'; import { geoToTimezone } from '../utils'; @@ -122,7 +122,7 @@ async function buildQueryFor(student: Student, tags: Tag[]): Promise { +export async function getProjectRecs(student: Student, tags: Tag[]): Promise { const prisma = Container.get(PrismaClient); const elastic = Container.get(Client); diff --git a/src/types/Matching.ts b/src/types/Matching.ts index 77c587b..e92fa5b 100644 --- a/src/types/Matching.ts +++ b/src/types/Matching.ts @@ -1,55 +1,42 @@ // eslint-disable-next-line max-classes-per-file -import { ObjectType, Field } from 'type-graphql'; -import { - MatchingStatsExternal, -} from '../match/matchingTypes'; +import { Field, ObjectType } from 'type-graphql'; +import { MatchingStatsExternal } from '../match/matchingTypes'; @ObjectType() export class MatchingStats implements MatchingStatsExternal { @Field(() => Number) - totalProjects: number + totalProjects: number; @Field(() => Number) - totalStudents: number + totalStudents: number; @Field(() => Number) - unassignedStudents: number + unassignedStudents: number; @Field(() => Number) - unfilledSlots: number + unfilledSlots: number; @Field(() => Number) - matchingScore: number + matchingScore: number; @Field(() => Number) - runtimeMs: number + runtimeMs: number; } @ObjectType() -export class MatchingProjectDatum { +export class MatchTuple { @Field(() => String) - projectId: string + studentId: string; - @Field(() => [String]) - studentsMatched: string[] - - // Below fields are pretty much just debugging data in case this is desired by the frontend. - // TODO: Confirm that this is desired on the frontend - @Field(() => Number) - numFirstChoice: number - - @Field(() => Number) - projSizeRemaining: number; - - @Field(() => [String]) - studentsSelected: string[]; + @Field(() => String) + projectId: string; } @ObjectType() export class MatchingResult { - @Field(() => [MatchingProjectDatum]) - match: MatchingProjectDatum[] + @Field(() => [MatchTuple]) + match: MatchTuple[]; @Field(() => MatchingStats) - stats: MatchingStats + stats: MatchingStats; }