Skip to content
This repository has been archived by the owner on Dec 18, 2024. It is now read-only.

Update Match Algorithm #512

Merged
merged 8 commits into from
Jun 11, 2024
57 changes: 40 additions & 17 deletions src/collections/match/helpers/match-finder-2/match-finder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
groupMatchesByUser,
seededRandom,
shuffler,
createMatchableUsersWithIdPrefix,
} from "./match-testing-utils";

chai.use(chaiAsPromised);
Expand All @@ -32,6 +33,7 @@ const andrine = createFakeMatchableUser("andrine", "book1", "book2", "book3");
const beate = createFakeMatchableUser("beate", "book1", "book2", "book3");

const monika = createFakeMatchableUser("monika", "book4");
const mons = createFakeMatchableUser("mons", "book4");

const mathias = createFakeMatchableUser(
"mathias",
Expand All @@ -40,6 +42,13 @@ const mathias = createFakeMatchableUser(
"book3",
"book4",
);
const mathea = createFakeMatchableUser(
"mathea",
"book1",
"book2",
"book3",
"book4",
);

describe("Full User Match", () => {
it("should be able to full match with other user", () => {
Expand Down Expand Up @@ -87,7 +96,7 @@ describe("Full User Match", () => {
it("should be able to create multiple full matches with overlapping books", () => {
const matchFinder = new MatchFinder(
[monika, mathias],
[mathias, monika, mathias],
[mathea, mons, mathea],
);
matchFinder.generateMatches();
expect(
Expand All @@ -101,6 +110,18 @@ describe("Full User Match", () => {
).length,
).to.equal(0);
});

it("should not be able to match users with themselves", () => {
const matchFinder = new MatchFinder([andrine], [andrine]);
const matches = matchFinder.generateMatches();

const andrineXstand = createFakeStandMatch(
andrine,
andrine.items,
andrine.items,
);
assert.deepEqual(matches, [andrineXstand]);
});
});

describe("Partly User Match", () => {
Expand Down Expand Up @@ -290,50 +311,52 @@ describe("Large User Groups", () => {
it("can sufficiently match realistic user data with itself", () => {
const shuffle = shuffler(seededRandom(12345));
const rawData = ullern_test_users;
const test_users: MatchableUser[] = rawData.map(({ id, items }) => ({
id,
items: new Set(items.map((item) => item["$numberLong"])),
}));
const test_senders = createMatchableUsersWithIdPrefix(rawData, "_sender");
const test_receivers = createMatchableUsersWithIdPrefix(
rawData,
"_receiver",
);

const matchFinder = new MatchFinder(
shuffle(test_users.slice()),
shuffle(test_users.slice()),
shuffle(test_senders.slice()),
shuffle(test_receivers.slice()),
);

const matches = Array.from(matchFinder.generateMatches());

const numberOfMatchesPerType = calculateNumberOfMatchesPerType(matches);

expect(numberOfMatchesPerType.userMatches).to.be.lessThan(
test_users.length * 1.4,
test_senders.length * 1.4,
);
expect(numberOfMatchesPerType.standMatches).to.be.lessThanOrEqual(
test_users.length * 0.1,
test_senders.length * 0.1,
);
});

it("can sufficiently match realistic user data with a modified version of itself", () => {
const shuffle = shuffler(seededRandom(123454332));
const rawData = ullern_test_users;
const test_users: MatchableUser[] = rawData.map(({ id, items }) => ({
id,
items: new Set(items.map((item) => item["$numberLong"])),
}));
const test_senders = createMatchableUsersWithIdPrefix(rawData, "_sender");
const test_receivers = createMatchableUsersWithIdPrefix(
rawData,
"_receiver",
);

const matchFinder = new MatchFinder(
shuffle(test_users.slice()).slice(33),
shuffle(test_users.slice()).slice(20),
shuffle(test_senders.slice()).slice(33),
shuffle(test_receivers.slice()).slice(20),
);

const matches = Array.from(matchFinder.generateMatches());

const numberOfMatchesPerType = calculateNumberOfMatchesPerType(matches);

expect(numberOfMatchesPerType.userMatches).to.be.lessThan(
test_users.flat().length * 1.4,
test_senders.flat().length * 1.4,
);
expect(numberOfMatchesPerType.standMatches).to.be.lessThanOrEqual(
test_users.flat().length * 0.2,
test_senders.flat().length * 0.2,
);
});

Expand Down
11 changes: 9 additions & 2 deletions src/collections/match/helpers/match-finder-2/match-finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class MatchFinder {
);
if (sender) {
if (hasDifference(intersect(sentItems, sender.items), sentItems)) {
throw "Sender cannot give away more books than they have!";
throw new Error("Sender cannot give away more books than they have!");
}
sender.items = difference(sender.items, sentItems);
originalSenders = removeFullyMatchedUsers(originalSenders);
Expand All @@ -146,11 +146,18 @@ export class MatchFinder {
if (
hasDifference(intersect(receivedItems, receiver.items), receivedItems)
) {
throw "Receiver cannot receive more books than they want!";
throw new Error("Receiver cannot receive more books than they want!");
}
receiver.items = difference(receiver.items, receivedItems);
originalReceivers = removeFullyMatchedUsers(originalReceivers);
}

if (
senderId === receiverId &&
match.variant === CandidateMatchVariant.UserMatch
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
) {
throw new Error("Receiver and sender cannot be the same person");
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
}
}

if (originalSenders.length > 0 || originalReceivers.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,16 @@ export const shuffler =
return list;
};

export function createMatchableUsersWithIdPrefix(
rawData: { id: string; items: { $numberLong: string }[] }[],
idPrefix: string,
): MatchableUser[] {
return rawData.map(({ id, items }) => ({
id: id + idPrefix,
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
items: new Set(items.map((item) => item["$numberLong"])),
}));
}

/**
* Utility method to print some stats about the matching
* so that one can evaluate the performance of the matcher
Expand Down
7 changes: 6 additions & 1 deletion src/collections/match/helpers/match-finder-2/match-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ export function tryFindOneWayMatch(
receivers: MatchableUser[],
): MatchableUser | null {
return receivers.reduce((best: MatchableUser | null, next) => {
return !hasDifference(sender.items, next.items) &&
return sender.id !== next.id &&
!hasDifference(sender.items, next.items) &&
(best === null || next.items.size < best.items.size)
? next
: best;
Expand All @@ -135,6 +136,7 @@ export function tryFindTwoWayMatch(
return (
receivers.find(
(receiver) =>
sender.id !== receiver.id &&
!hasDifference(sender.items, receiver.items) &&
!hasDifference(receiver.items, sender.items),
) ?? null
Expand All @@ -153,6 +155,9 @@ export function tryFindPartialMatch(
): MatchableUser | null {
let bestReceiver: MatchableUser | null = null;
for (const receiver of receivers) {
if (sender.id === receiver.id) {
continue;
}
const matchableItems = intersect(sender.items, receiver.items);
const bestMatchableItems = intersect(
sender.items,
Expand Down
97 changes: 48 additions & 49 deletions src/collections/match/operations/match-generate-operation-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@boklisten/bl-model";
import { ObjectId } from "mongodb";

import { isBoolean } from "../../../helper/typescript-helpers";
import { BlDocumentStorage } from "../../../storage/blDocumentStorage";
import {
CandidateMatchVariant,
Expand All @@ -25,6 +26,7 @@ export interface MatcherSpec {
startTime: string;
deadlineBefore: string;
matchMeetingDurationInMS: number;
includeSenderItemsFromOtherBranches: boolean;
}

export function candidateMatchToMatch(candidate: MatchWithMeetingInfo): Match {
Expand All @@ -51,17 +53,26 @@ export function candidateMatchToMatch(candidate: MatchWithMeetingInfo): Match {
*
* @param branchIds The IDs of branches to search for users and items
* @param deadlineBefore Select customer items that have a deadlineBefore between now() and this deadlineBefore
* @param includeSenderItemsFromOtherBranches whether the remainder of the items that a customer has in possession should be added to the match, even though they were not handed out at the specified branchIds
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
* @param customerItemStorage
*/
export async function getMatchableSenders(
branchIds: string[],
deadlineBefore: string,
includeSenderItemsFromOtherBranches: boolean,
customerItemStorage: BlDocumentStorage<CustomerItem>,
): Promise<MatchableUser[]> {
const branchCustomerItems = await customerItemStorage.aggregate([
const groupByCustomerStep = {
$group: {
_id: "$customer",
id: { $first: "$customer" },
items: { $push: "$item" },
},
};

const matchableUsers = (await customerItemStorage.aggregate([
{
$match: {
// TODO: Check that the book is going to be returned this match session/semester
returned: false,
buyout: false,
cancel: false,
Expand All @@ -73,13 +84,26 @@ export async function getMatchableSenders(
deadline: { $gt: new Date(), $lte: new Date(deadlineBefore) },
},
},
]);
groupByCustomerStep,
])) as MatchableUser[];

return groupItemsByUser(
branchCustomerItems,
(customerItem) => customerItem.customer.toString(),
(customerItem) => [customerItem.item.toString()],
);
if (includeSenderItemsFromOtherBranches) {
return (await customerItemStorage.aggregate([
{
$match: {
customer: { $in: matchableUsers.map((mu) => mu.id) },
returned: false,
buyout: false,
cancel: false,
buyback: false,
deadline: { $gt: new Date(), $lte: new Date(deadlineBefore) },
},
},
groupByCustomerStep,
])) as MatchableUser[];
}

return matchableUsers;
}

/**
Expand All @@ -92,7 +116,7 @@ export async function getMatchableReceivers(
branchIds: string[],
orderStorage: BlDocumentStorage<Order>,
): Promise<MatchableUser[]> {
const branchOrders = await orderStorage.aggregate([
const branchOrders = (await orderStorage.aggregate([
{
$match: {
placed: true,
Expand Down Expand Up @@ -129,7 +153,7 @@ export async function getMatchableReceivers(
},
},
},
]);
])) as Order[];
return groupItemsByUser(
branchOrders,
(order) => order.customer.toString(),
Expand All @@ -143,54 +167,29 @@ export function verifyMatcherSpec(
const m = matcherSpec as Record<string, unknown>;
return (
m &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Array.isArray(m.branches) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Array.isArray(m.userMatchLocations) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
m.branches.every(
Array.isArray(m["branches"]) &&
Array.isArray(m["userMatchLocations"]) &&
m["branches"].every(
(branchId) => typeof branchId === "string" && branchId.length === 24,
) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
typeof m.standLocation === "string" &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
m.standLocation.length > 0 &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
m.userMatchLocations.every(
typeof m["standLocation"] === "string" &&
m["standLocation"].length > 0 &&
m["userMatchLocations"].every(
(location) =>
typeof location.name === "string" &&
location.name.length > 0 &&
(location.simultaneousMatchLimit === undefined ||
(Number.isInteger(location.simultaneousMatchLimit) &&
location.simultaneousMatchLimit > 0)),
) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
typeof m.startTime === "string" &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
!isNaN(new Date(m.startTime).getTime()) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
typeof m.deadlineBefore === "string" &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
!isNaN(new Date(m.deadlineBefore).getTime()) &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
new Date(m.deadlineBefore).getTime() > new Date().getTime() &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
typeof m.matchMeetingDurationInMS === "number" &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
!isNaN(m.matchMeetingDurationInMS)
typeof m["startTime"] === "string" &&
!isNaN(new Date(m["startTime"]).getTime()) &&
typeof m["deadlineBefore"] === "string" &&
!isNaN(new Date(m["deadlineBefore"]).getTime()) &&
new Date(m["deadlineBefore"]).getTime() > new Date().getTime() &&
typeof m["matchMeetingDurationInMS"] === "number" &&
!isNaN(m["matchMeetingDurationInMS"]) &&
isBoolean(m["includeSenderItemsFromOtherBranches"])
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export class MatchGenerateOperation implements Operation {
getMatchableSenders(
matcherSpec.branches,
matcherSpec.deadlineBefore,
matcherSpec.includeSenderItemsFromOtherBranches,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.customerItemStorage,
Expand Down
2 changes: 1 addition & 1 deletion src/storage/blDocumentStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class BlDocumentStorage<T extends BlDocument>
});
}

aggregate(aggregation: PipelineStage[]): Promise<T[]> {
aggregate(aggregation: PipelineStage[]): Promise<unknown[]> {
return this.mongoDbHandler.aggregate(aggregation);
}

Expand Down
2 changes: 1 addition & 1 deletion src/storage/blStorageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface BlStorageHandler<T extends BlDocument> {

removeMany(ids: string[]): Promise<T[]>;

aggregate(aggregation: PipelineStage[]): Promise<T[]>;
aggregate(aggregation: PipelineStage[]): Promise<unknown[]>;

exists(id: string): Promise<boolean>;
}