Skip to content

Commit

Permalink
Initial dry-run implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
SamChou19815 committed Oct 30, 2021
1 parent 8ef85d7 commit 86798b9
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 11 deletions.
4 changes: 4 additions & 0 deletions src/firebase-admin-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export const selectableRequirementChoicesCollection = db
.collection('user-selectable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppSelectableRequirementChoices>());

export const overriddenFulfillmentChoicesCollection = db
.collection('user-overridden-fulfillment-choices')
.withConverter(getTypedFirestoreDataConverter<FirestoreOverriddenFulfillmentChoices>());

export const subjectColorsCollection = db
.collection('user-subject-colors')
.withConverter(getTypedFirestoreDataConverter<Readonly<Record<string, string>>>());
Expand Down
4 changes: 4 additions & 0 deletions src/firebase-frontend-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ export const selectableRequirementChoicesCollection = db
.collection('user-selectable-requirement-choices')
.withConverter(getTypedFirestoreDataConverter<AppSelectableRequirementChoices>());

export const overriddenFulfillmentChoicesCollection = db
.collection('user-overridden-fulfillment-choices')
.withConverter(getTypedFirestoreDataConverter<FirestoreOverriddenFulfillmentChoices>());

export const subjectColorsCollection = db
.collection('user-subject-colors')
.withConverter(getTypedFirestoreDataConverter<Readonly<Record<string, string>>>());
Expand Down
62 changes: 62 additions & 0 deletions src/requirements/admin/opt-out-data-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* eslint-disable no-console */

import { usernameCollection } from '../../firebase-admin-config';
import { getFirestoreCourseOptInOptOutChoicesBuilder } from '../requirement-graph-builder-from-user-data';
import { getUserDataOnAdmin } from './requirement-graph-admin-utils';

/** Compute opt-out choices for given user using their existing choices. */
async function runOnUser(userEmail: string) {
const {
courses,
onboardingData,
toggleableRequirementChoices,
selectableRequirementChoices,
} = await getUserDataOnAdmin(userEmail);

const builder = getFirestoreCourseOptInOptOutChoicesBuilder(
courses,
onboardingData,
toggleableRequirementChoices,
selectableRequirementChoices,
{}
);

console.log(userEmail);
courses.forEach(course => {
const choices = builder(course);

console.log(`${course.code} (${course.uniqueId}):`);
console.log(
`- selected requirement: ${selectableRequirementChoices[course.uniqueId] || 'None'}`
);
console.log(`- optOut = [${choices.optOut.join(', ')}]`);
console.log(
`- acknowledgedCheckerWarningOptIn = [${choices.acknowledgedCheckerWarningOptIn.join(', ')}]`
);
});

console.log('\n');
}

async function main() {
let userEmail = process.argv[2];
if (userEmail != null) {
await runOnUser(userEmail);
return;
}
const collection = await usernameCollection.get();
const userEmails = collection.docs.map(it => it.id);
for (userEmail of userEmails) {
console.group(`Running on ${userEmail}...`);
try {
// Intentionally await in a loop to have no interleaved console logs.
// eslint-disable-next-line no-await-in-loop
await runOnUser(userEmail);
} catch (e) {
console.log(e);
}
console.groupEnd();
}
}

main();
23 changes: 19 additions & 4 deletions src/requirements/admin/requirement-graph-admin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@ import { getCourseCodesArray } from '../requirement-frontend-computation';
import buildRequirementFulfillmentGraphFromUserData from '../requirement-graph-builder-from-user-data';
import RequirementFulfillmentGraph from '../requirement-graph';

interface UserRequirementDataOnAdmin {
interface UserDataOnAdmin {
readonly courses: readonly CourseTaken[];
readonly onboardingData: AppOnboardingData;
readonly toggleableRequirementChoices: Readonly<Record<string, string>>;
readonly selectableRequirementChoices: Readonly<Record<string, string>>;
}

interface UserRequirementDataOnAdmin extends UserDataOnAdmin {
readonly userRequirements: readonly RequirementWithIDSourceType[];
readonly requirementFulfillmentGraph: RequirementFulfillmentGraph<string, CourseTaken>;
}

export default async function getUserRequirementDataOnAdmin(
userEmail: string
): Promise<UserRequirementDataOnAdmin> {
export async function getUserDataOnAdmin(userEmail: string): Promise<UserDataOnAdmin> {
const [
semesters,
toggleableRequirementChoices,
Expand All @@ -47,6 +48,20 @@ export default async function getUserRequirementDataOnAdmin(
]);

const courses = getCourseCodesArray(semesters, onboardingData);

return { courses, onboardingData, toggleableRequirementChoices, selectableRequirementChoices };
}

export default async function getUserRequirementDataOnAdmin(
userEmail: string
): Promise<UserRequirementDataOnAdmin> {
const {
courses,
onboardingData,
toggleableRequirementChoices,
selectableRequirementChoices,
} = await getUserDataOnAdmin(userEmail);

const {
userRequirements,
requirementFulfillmentGraph,
Expand Down
123 changes: 118 additions & 5 deletions src/requirements/requirement-graph-builder-from-user-data.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { CREDITS_COURSE_ID } from './data/constants';
import { getUserRequirements } from './requirement-frontend-utils';
import { courseIsAPIB, getUserRequirements } from './requirement-frontend-utils';
import RequirementFulfillmentGraph from './requirement-graph';
import buildRequirementFulfillmentGraph from './requirement-graph-builder';
import buildRequirementFulfillmentGraph, {
BuildRequirementFulfillmentGraphParameters,
} from './requirement-graph-builder';

/**
* Removes all AP/IB equivalent course credit if it's a duplicate crseId.
Expand Down Expand Up @@ -31,7 +33,9 @@ export default function buildRequirementFulfillmentGraphFromUserData(
onboardingData: AppOnboardingData,
toggleableRequirementChoices: AppToggleableRequirementChoices,
selectableRequirementChoices: AppSelectableRequirementChoices,
overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices
overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices,
/** A flag for data migration. Prod code should never use this */
keepCoursesWithoutDoubleCountingEliminationChoice = false
): {
readonly userRequirements: readonly RequirementWithIDSourceType[];
readonly userRequirementsMap: Readonly<Record<string, RequirementWithIDSourceType>>;
Expand All @@ -40,7 +44,10 @@ export default function buildRequirementFulfillmentGraphFromUserData(
const userRequirements = getUserRequirements(onboardingData);
const userRequirementsMap = Object.fromEntries(userRequirements.map(it => [it.id, it]));

const requirementFulfillmentGraph = buildRequirementFulfillmentGraph<string, CourseTaken>({
const requirementGraphBuilderParameters: BuildRequirementFulfillmentGraphParameters<
string,
CourseTaken
> = {
requirements: userRequirements.map(it => it.id),
userCourses: forfeitTransferCredit(coursesTaken),
userChoiceOnFulfillmentStrategy: Object.fromEntries(
Expand Down Expand Up @@ -99,7 +106,113 @@ export default function buildRequirementFulfillmentGraphFromUserData(
},
allowDoubleCounting: requirementID =>
userRequirementsMap[requirementID].allowCourseDoubleCounting || false,
});
};
const requirementFulfillmentGraph = buildRequirementFulfillmentGraph(
requirementGraphBuilderParameters,
keepCoursesWithoutDoubleCountingEliminationChoice
);

return { userRequirements, userRequirementsMap, requirementFulfillmentGraph };
}

export type FirestoreCourseOptInOptOutChoicesBuilder = (
c: CourseTaken
) => FirestoreCourseOptInOptOutChoices;

/**
* @returns a function that given a course, return a `FirestoreCourseOptInOptOutChoices`.
*
* Frontend code can use the returned function to compute the equivalent opt-out data,
* and the data migration code can use the returned function to compute opt-out choice for all courses.
* This function should be deleted once we fully finish the migration.
*/
export function getFirestoreCourseOptInOptOutChoicesBuilder(
coursesTaken: readonly CourseTaken[],
onboardingData: AppOnboardingData,
toggleableRequirementChoices: AppToggleableRequirementChoices,
selectableRequirementChoices: AppSelectableRequirementChoices,
overriddenFulfillmentChoices: AppOverriddenFulfillmentChoices
): FirestoreCourseOptInOptOutChoicesBuilder {
// In this graph, each course is connected to all requirements that course can be used to satisfy.
const {
requirementFulfillmentGraph: graphWithoutDoubleCountingAccounted,
userRequirementsMap,
} = buildRequirementFulfillmentGraphFromUserData(
coursesTaken,
onboardingData,
toggleableRequirementChoices,
{}, // Provide no double counting choices, so all the edges will be kept
overriddenFulfillmentChoices,
/* keepCoursesWithoutDoubleCountingEliminationChoice */ true
);

// In this graph, each course is only connected to the selected requirement and the requirements
// that allow double counting.
const graphWithDoubleCountingAccounted = buildRequirementFulfillmentGraphFromUserData(
coursesTaken,
onboardingData,
toggleableRequirementChoices,
selectableRequirementChoices,
overriddenFulfillmentChoices,
/* keepCoursesWithoutDoubleCountingEliminationChoice */ false
).requirementFulfillmentGraph;

/**
* The table below summerizes the type of all possible requirement-course edges before
* double-counting elimination, and where they will go in the new format.
*
* -------------------------------------------------------------------------------------------------
* | Requirement Type | edge exists after double-counting elim | where is it in the new format |
* | ---------------- | -------------------------------------- | --------------------------------- |
* | selected | True | implicit (connected by default) |
* | (no warning) | | |
* | | | |
* | selected | True | `acknowledgedCheckerWarningOptIn` |
* | (has warning) | | |
* | | | |
* | allow double | True | implicit (connected by default) |
* | counting | | |
* | | | |
* | not connected | False | `optOut` |
* | (no warning) | | |
* | | | |
* | not connected | False | implicit (unconnected by default) |
* | (has warning) | | |
*/
return function builder(course) {
const connectedRequirementsWithDoubleCountingAccounted = graphWithDoubleCountingAccounted.getConnectedRequirementsFromCourse(
course
);
const connectedRequirementsWithoutDoubleCountingAccounted = graphWithoutDoubleCountingAccounted.getConnectedRequirementsFromCourse(
course
);
/**
* complementary == All unconnected requirement after double counting elimination
*
* It's true that
* ```
* union(
* selectableRequirementChoices[course],
* requirements that allow double counting and the given course can be used to satisfy,
* complementary,
* ) == all requirements that course can be used to satisfy
* ```
*/
const complementary = new Set(connectedRequirementsWithoutDoubleCountingAccounted);
connectedRequirementsWithDoubleCountingAccounted.forEach(r => complementary.delete(r));

// We only need to explicitly opt-out of requirements without checker warnings, since requirement
// with checker warnings need to be explicitly opt-in.
const optOut = Array.from(complementary).filter(
it => userRequirementsMap[it].checkerWarning == null
);
// Find requirements with checker warnings that needs to be explictly opt-in.
const acknowledgedCheckerWarningOptIn = courseIsAPIB(course)
? []
: connectedRequirementsWithDoubleCountingAccounted.filter(
it => userRequirementsMap[it].checkerWarning != null
);

return { optOut, arbitraryOptIn: {}, acknowledgedCheckerWarningOptIn };
};
}
2 changes: 1 addition & 1 deletion src/requirements/requirement-graph-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ interface CourseForRequirementGraph extends CourseWithUniqueId {
readonly courseId: number;
}

type BuildRequirementFulfillmentGraphParameters<
export type BuildRequirementFulfillmentGraphParameters<
Requirement extends string,
Course extends CourseForRequirementGraph
> = {
Expand Down
23 changes: 22 additions & 1 deletion src/user-data.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ type FirestoreOnboardingUserData = {
readonly tookSwim: 'yes' | 'no';
};

type FirestoreCourseOptInOptOutChoices = {
/** A list of requirements to opt-out */
readonly optOut: readonly string[];
/** It is for opting-in requirements that has a checker warning. */
readonly acknowledgedCheckerWarningOptIn: readonly string[];
/**
* A list of requirement and their slots to opt-in arbitrarily.
* It's for attaching completely unknown courses to a requirement
* (e.g. opt-in CS 2112 for history requirement).
*/
readonly arbitraryOptIn: readonly { readonly [requirement: string]: readonly string[] };
};
type FirestoreOverriddenFulfillmentChoices = {
readonly [courseUniqueId: string]: FirestoreCourseOptInOptOutChoices;
};

type FirestoreUserData = {
readonly name: FirestoreUserName;
readonly semesters: readonlyFirestoreSemester[];
Expand All @@ -65,6 +81,7 @@ type FirestoreUserData = {
readonly subjectColors: { readonly [subject: string]: string };
readonly uniqueIncrementer: number;
readonly userData: FirestoreOnboardingUserData;
// TODO: add overriddenFulfillmentChoices once we connect new requirement flow to prod.
};

interface CornellCourseRosterCourse {
Expand Down Expand Up @@ -150,7 +167,11 @@ type AppToggleableRequirementChoices = Readonly<Record<string, string>>;
/** Map from course's unique ID to requirement ID */
type AppSelectableRequirementChoices = Readonly<Record<string, string>>;

/** Map from course's unique ID to override options */
/**
* @deprecated replaced by `FirestoreOverriddenFulfillmentChoices`
*
* Map from course's unique ID to override options.
*/
type AppOverriddenFulfillmentChoices = Readonly<
Record<
string,
Expand Down

0 comments on commit 86798b9

Please sign in to comment.