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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"license": "ISC",
"dependencies": {
"@boklisten/bl-email": "^1.8.1",
"@boklisten/bl-model": "^0.25.37",
"@boklisten/bl-model": "^0.25.41",
"@boklisten/bl-post-office": "^0.5.56",
"@napi-rs/image": "^1.8.0",
"@sendgrid/mail": "^7.7.0",
Expand Down
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
19 changes: 7 additions & 12 deletions src/collections/match/match.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,19 @@ const userMatchSchema = {
ref: BlCollectionName.Items,
default: undefined,
},
// customerItems owned by sender which have been given to anyone. May differ from receivedCustomerItems
// unique items owned by sender which have been given to anyone. May differ from receivedBlIds
// when a book is borrowed and handed over to someone other than the technical owner's match
deliveredCustomerItems: {
type: [ObjectId],
ref: BlCollectionName.CustomerItems,
default: undefined,
},
// items which have been received by the receiver from anyone
receivedCustomerItems: {
type: [ObjectId],
ref: BlCollectionName.CustomerItems,
default: undefined,
},
deliveredBlIds: [String],
// unique items which have been received by the receiver from anyone
receivedBlIds: [String],
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
// if true, disallow handing the items out or in at a stand, only allow match exchange
itemsLockedToMatch: {
type: Boolean,
default: undefined,
},
// if receiver items have overrides, the generated customer items will
// get the deadline specified in the override instead of using the branch defined deadline
deadlineOverrides: [{ item: String, deadline: Date }],
AdrianAndersen marked this conversation as resolved.
Show resolved Hide resolved
};

/** @see StandMatch */
Expand Down
Loading
Loading