Skip to content

Instantly share code, notes, and snippets.

@remydagostino
Created December 24, 2020 02:29
Show Gist options
  • Save remydagostino/1b89da9ad8c7114c3dcaaef28373be23 to your computer and use it in GitHub Desktop.
Save remydagostino/1b89da9ad8c7114c3dcaaef28373be23 to your computer and use it in GitHub Desktop.
Beverage Buddies V2

Instructions for generating the Beverage Buds pairings

At the start of the week, run the matcher, giving it the list of people in the rotation this week, and the history of all previous matches.

people-list.txt should look something like this. You can comment out people who are unavailable this week by adding a '#' before their name.

Salan Akorkin
Joe Dart
# Samuel Jackson
Potato Patatoe

history.txt should look something like this:

2020-01-01 Joe Dart -> Samuel Jackson
2020-01-01 Potato Patatoe -> Joe Dart
2020-01-08 Joe Dart -> Salan Akorkin
2020-01-08 Samuel Jackson -> Potato Patatoe

Run the matcher like so:

node beverage-buddies.js people-list.txt history.txt showPairingStamped

Tadaaa... outputted in your console will be the list of pairings for the week. Let everyone know, and once it's all settled append the output to the history.txt.

Oops!

Oh no, you've shared the list of pairings, but someone is away this week or a new person has joined and you've forgotten to take them into account.

Here are your options for fixing issues:

1. Edit people-list.txt and rerun the matcher

This works, but potentially requires people to reschedule meetings with different people which is unideal. Also, if the list becomes uneven, it's very likely that the first person on the people list will get a second meeting. Unfortuately, due to the design of the algorithm, this means that person uno on the list often gets a lot of extra bevvys.

2. Edit history.txt and rerun the matcher

Maybe you've run the matcher for the week and realized that you've organized for two people two share a bevvy who routinely share bevvys every day anyway. The algorithm will try to match each person with the person whom they've met for a bevvy least recently. By adding an entry to the history.txt for the two people who ordinarily meet frequently, you can gently encourage the algorithm to prefer different pairings.

3. Manually fix the problem without regenerating the match list

If you don't want to rerun the matcher potentially jumble the list, you can run the matcher in "stats mode". This gives you information you can use to fix problems in the list manually.

node beverage-buddies.js people-list.txt history.txt showStats

Now, in your console you will see some information you can use to help you decide how to pair off the troublesome person. For each person you will see their "instigationRatio" which is how often they have been on the left-hand side of the arrow versus the right, and you'll see their list of "preferences" which is a sorted list of people to match them with. Pick one of the highest scoring preferences and fix up your week's pairing list. All done!

// Usage: node beverage-buddies.js people-list.txt history.txt showPairingStamped
const { readFileSync } = require('fs');
const process = require('process');
function main(peopleFile, historyFile, command) {
const peopleLines = removeComments(readFileSync(peopleFile).toString().split('\n'));
const historyLines = readFileSync(historyFile).toString().split('\n');
const now = Date.now();
const historyPairs = historyLines.map(parseHistoryLine);
const peopleStats = generatePeopleStats(historyPairs);
const rosterStats = new Map(peopleLines.map((person) => {
const stats = getStatsWithDefault(peopleStats, person);
return [
person,
{
name: person,
instigationRatio: getInstigationRatio(stats),
preferences: getPreferences(stats, peopleLines, now)
}
];
}));
const pairs = buildPairing(Array.from(rosterStats.values()));
const orderedPairs = pairs.map((pair) => {
return pair.sort((a, b) => {
return rosterStats.get(a).instigationRatio - rosterStats.get(b).instigationRatio;
});
});
switch (command) {
case 'showPairing':
outputPairs(orderedPairs);
break;
case 'showPairingStamped':
outputPairs(orderedPairs, generateDateTimestamp(now));
break;
case 'showStats':
for (const person of rosterStats.values()) {
console.log({
name: person.name,
instigationRatio: person.instigationRatio,
preferences: person.preferences.map(
({ name, score, count }) => `${score} (${count}): ${name}`
)
});
}
break;
}
}
function removeComments(lines) {
return lines.filter((line) => line.substring(0, 1) !== '#');
}
function outputPairs(pairs, prefix) {
pairs.forEach((pair) => {
process.stdout.write([prefix, `${pair[0]} -> ${pair[1]}\n`].filter(Boolean).join(' '));
});
}
function generateDateTimestamp(time) {
const date = new Date(time);
const year = date.getFullYear();
const month = ('00' + (date.getMonth() + 1)).slice(-2);
const day = ('00' + date.getDate()).slice(-2);
return `${year}-${month}-${day}`;
}
function buildPairing(rosterStats) {
return rosterStats.reduce(
(pairs, { name, preferences }) => {
if (isPersonPaired(pairs, name)) {
return pairs;
} else {
const availablePreference = preferences
.map((preference) => preference.name)
.find((possiblePair) => !isPersonPaired(pairs, possiblePair));
if (availablePreference) {
return pairs.concat([[ name, availablePreference ]]);
} else {
// TODO: What is a better way to pick a pair for the odd person out?
// This results in the first person in the list getting chosen very often
const unavailablePreferences = getAllTopPreferences(preferences);
return pairs.concat([[ name, unavailablePreferences.winners[0] ]]);
}
}
},
[]
);
};
function getAllTopPreferences(preferences) {
return preferences.reduce(
(acc, pref) => {
if (pref.score >= acc.highScore) {
return {
highScore: pref.score,
winners: acc.winners.concat(pref.name)
};
} else {
return acc;
}
},
{ highScore: 0, winners: [] }
);
}
function isPersonPaired(pairs, person) {
return pairs.reduce((acc, [p1, p2]) => acc || p1 === person || p2 === person, false);
}
function getPreferences(stats, people, now) {
return people
.filter((person) => person != stats.name)
.map((person) => {
const meetings = stats.meetings.get(person) || [];
return {
name: person,
count: meetings.length,
// A higher score means more time between now and the last meeting
score: meetings.reduce(
(acc, meeting) => Math.min(acc, now - meeting.on.getTime()),
Infinity
)
};
})
.sort((a, b) => {
return b.score - a.score;
});
}
function getInstigationRatio(personStats) {
const meetingStats = Array.from(personStats.meetings.values()).reduce(
(acc, meetingList) => {
return {
total: acc.total + meetingList.length,
instigations: acc.instigations + meetingList.reduce((acc2, { instigator }) => acc2 + (instigator ? 1 : 0), 0)
}
},
{
total: 0,
instigations: 0
}
);
return meetingStats.instigations / meetingStats.total;
}
function getStatsWithDefault(map, id) {
return map.has(id) ? map.get(id) : { name: id, meetings: new Map() };
}
function generatePeopleStats(historyPairs) {
return historyPairs.reduce(
(acc, { date, from, to }) => {
const fromStats = getStatsWithDefault(acc, from);
const toStats = getStatsWithDefault(acc, to);
const updatedFrom = Object.assign({}, fromStats, {
meetings: new Map([
...fromStats.meetings,
[to, (fromStats.meetings.get(to) || []).concat({ on: date, instigator: true })]
])
});
const updatedTo = Object.assign({}, toStats, {
meetings: new Map([
...toStats.meetings,
[from, (toStats.meetings.get(from) || []).concat({ on: date, instigator: false })]
])
});
return new Map([
...acc,
[from, updatedFrom],
[to, updatedTo]
]);
},
new Map()
);
}
function parseHistoryLine(line, idx) {
const matcher = /^(\d{4}-\d{2}-\d{2})\s(.*?)\s->\s(.*?)$/
const result = line.match(matcher);
if (result) {
return {
date: new Date(result[1]),
from: result[2],
to: result[3]
};
} else {
throw new Error(`Could not parse history line (${idx}): ${line}`);
}
}
main(process.argv[2], process.argv[3], process.argv[4] || 'showPairing');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment