Skip to content

Commit

Permalink
Rework matchmaking (#67)
Browse files Browse the repository at this point in the history
* Replace matchmaking logic with new strategies

* Introduce minitest and deprecate rspec

* Update README to reflect the major changes
  • Loading branch information
tuxagon authored Jan 16, 2024
1 parent ecc04eb commit fd3dff4
Show file tree
Hide file tree
Showing 78 changed files with 1,239 additions and 1,099 deletions.
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,7 @@ jobs:
bundler-cache: true
- name: Set up database schema
run: bin/rake db:test:prepare
- name: Run tests
- name: Run tests (rspec)
run: bin/rspec
- name: Run tests (minitest)
run: bin/rails test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@

/app/assets/builds/*
!/app/assets/builds/.keep

.irb_history
5 changes: 4 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,20 @@ gem "bootsnap", require: false
gem "todo_or_die"

group :development, :test do
gem "debug"
gem "dotenv-rails"
gem "pry-byebug"
gem "standard"
end

group :development do
gem "foreman"
gem "rack-mini-profiler", "~> 2.0"
gem "listen", "~> 3.3"
end

group :test do
gem "minitest"
gem "mocktail"
gem "rspec"
gem "rspec-rails"
end
27 changes: 18 additions & 9 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,11 @@ GEM
bugsnag (6.24.2)
concurrent-ruby (~> 1.0)
builder (3.2.4)
byebug (11.1.3)
coderay (1.1.3)
concurrent-ruby (1.1.10)
crass (1.0.6)
debug (1.7.2)
irb (>= 1.5.0)
reline (>= 0.3.1)
diff-lcs (1.5.0)
dotenv (2.8.1)
dotenv-rails (2.8.1)
Expand All @@ -92,6 +93,7 @@ GEM
multipart-post (~> 2)
faraday-net_http (3.0.2)
ffi (1.15.5)
foreman (0.87.2)
gli (2.21.0)
globalid (1.0.0)
activesupport (>= 5.0)
Expand All @@ -107,6 +109,9 @@ GEM
importmap-rails (1.1.5)
actionpack (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.1)
irb (1.6.4)
reline (>= 0.3.0)
json (2.6.3)
language_server-protocol (3.17.0.3)
lint_roller (1.1.0)
Expand All @@ -122,6 +127,9 @@ GEM
method_source (1.0.0)
mini_mime (1.1.2)
minitest (5.16.3)
mocktail (2.0.0)
sorbet-eraser (~> 0.3.1)
sorbet-runtime (~> 0.5.9204)
msgpack (1.6.0)
multipart-post (2.2.3)
net-imap (0.3.1)
Expand All @@ -144,12 +152,6 @@ GEM
ast (~> 2.4.1)
racc
pg (1.4.4)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (>= 0.13, < 0.15)
puma (6.0.0)
nio4r (~> 2.0)
racc (1.6.0)
Expand Down Expand Up @@ -190,6 +192,8 @@ GEM
rb-inotify (0.10.1)
ffi (~> 1.0)
regexp_parser (2.8.2)
reline (0.4.1)
io-console (~> 0.5)
rexml (3.2.6)
rspec (3.12.0)
rspec-core (~> 3.12.0)
Expand Down Expand Up @@ -243,6 +247,8 @@ GEM
gli
hashie
websocket-driver
sorbet-eraser (0.3.1)
sorbet-runtime (0.5.11178)
sprockets (4.2.0)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
Expand Down Expand Up @@ -296,13 +302,16 @@ PLATFORMS
DEPENDENCIES
bootsnap
bugsnag
debug
dotenv-rails
foreman
heroicon
hotwire-rails
importmap-rails
listen (~> 3.3)
minitest
mocktail
pg
pry-byebug
puma
rack-mini-profiler (~> 2.0)
rails
Expand Down
131 changes: 9 additions & 122 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,130 +1,17 @@
# Double Up

This application is intended to be a self-hosted version of Donut.
## About

## System Requirements
Double Up started simply as a self-hosted version of a Slack app called Donut. Like Donut, Double Up's purpose is to connect individuals in a serendipitous way, but as far as I'm aware, that's where the comparison stops. At Test Double, we're a remote company. A remote consulting company, so there is another step removed for some of us as we help different clients from each other.

This application uses Ruby 3.0
We needed something that promoted getting together. The requirements were minimal, so we took a stab at doing it ourselves. It was very barebones initially, but it worked. It was set up in a way that used a designated channel as opt-in, so if you joined that channel, you'd be in the list of participants waiting for Double Up to match you. It didn't even have a name at that time either.

## Dev Setup
For awhile, it worked as expected. Take a certain number of people from a list randomly and then move onto the rest until everyone was in a group. Time revealed that basic randomness wasn't exactly what we wanted, however. We began noticing that some people were in the same group for consecutive weeks. There's nothing exactly wrong with that, conceptually, but we wanted to regularly meet up and connect with a variety of others at Test Double.

Install dependencies with `bin/bundle install` and then run the app with `bin/dev`. If you want to receive a command from Slack, `ngrok http 3000` is helpful to run first to get a domain you can provide your instance of rails via `NGROK_DOMAIN`. For example,
Eventually, the app was named Double Up officially and we've been (in short bursts of motivation and capacity) modifying it to better meet our needs and promote connectedness within our organization.

```bash
$ NGROK_DOMAIN=1fa7-63-99-55-76.ngrok.io bin/dev
```
## Further reading

## App Setup Instructions

1. Follow instructions and [create a new slack app](https://api.slack.com/authentication/basics)
2. Add the following scopes to your slack app: `users:read`, `mpim:write`, `im:write`, `chat:write`, `channels:join`, `channels:manage`, `groups:write`, `groups:read`, `mpim:read`, `im:read`, and `channels:read`.
3. Create a new app in Heroku or your hosting service of choice. Go to wherever you can add environment variables.
4. Set `MIN_GROUP_SIZE` to 3.
5. Get your secret key base and the oauth token for your newly created slack app. Store those as `SLACK_SIGNING_SECRET` and `SLACK_OAUTH_TOKEN`.
6. Create Slash Command called `doubleup` with a URL path of `/command/handle`.
7. Deploy app to Heroku (or your hosting service of choice).
8. If using Heroku, install Heroku Scheduler.
9. Schedule `rake create_groups` to run every day at a time of your choice. If you have a hosting service that will allow you to run cron jobs, you can remove the code mentioned above that checks that day and week and just use cron format. If using Heroku, just run daily and it will quit if it's not the right day 🙂
10. PROFIT!

## Matchmaking Approach

Some inspiration was taken from the following two problems

- [Stable Marriage Problem](https://en.wikipedia.org/wiki/Stable_marriage_problem)
- [Stable Roommates Problem](https://en.wikipedia.org/wiki/Stable_roommates_problem)

Some requirements this approach had to solve for

1. Individuals can join or leave a slack channel at any time to opt-in or opt-out respectively
2. Repeats for each cycle should be rare for a particular group
3. Duplicates should be rare between the various types of groups for which an individual is being matched

The approach starts with the a list of participants for a particular grouping, with the goal being that every participant is included in a single group. Each group size for a grouping can be configured, with a default of 2. For each participant, a score, starting at 0, is calculated for every other participant based upon a few factors.

1. Has this other participant been recently paired up with the current participant in this grouping?
2. Has this other participant been recently paired up with the current participant in any other grouping?

The score for the other participants is incremented by 1 for each grouping where "Yes" is the answer to those questions. That is repeated for each participant until eventually everyone has a score for every other participant.

Once scored, a threshold of 0 is set and for each participant, a random other participant is selected that has a score of 0. After running through each, the threshold is incremented by 1 if any unmatched participants exist. That repeats until eventually everyone is found within a particular group.

### Example

That was as fun to write as it was to read, so here is an example, hopefully breaking it down more practically.

Let's say we have Frodo, Sam, Pippin, and Meriadoc as participants in the grouping, Second Breakfast. In addition, let's say that we want "recent" to mean 1 group ago.

**First Time Matchmaking**

The first time the matchmaking runs, the scoring will conceptually look like

```
Frodo => {
Sam with a score of 0
Pippin with a score of 0
Meriadoc with a score of 0
}
Sam => {
Frodo with a score of 0
Pippin with a score of 0
Meriadoc with a score of 0
}
Pippin => {
Frodo with a score of 0
Sam with a score of 0
Meriadoc with a score of 0
}
Meriadoc => {
Frodo with a score of 0
Sam with a score of 0
Pippin with a score of 0
}
```

Since everyone has a score of 0 for everyone else, the matchmaking is simple. Someone is randomly selected for Frodo, so let's say, Pippin. Next up is Sam and he is detected to be ungrouped, so Meriadoc is "randomly" selected. Once we get to Pippin, we detect Pippin as being grouped, then Meriadoc finally is detected as grouped.

```
Group 1 is Frodo & Pippin
Group 2 is Sam & Meriadoc
```

**Second Time Matchmaking**

Here is where things get interesting. The scores change because the most recent group for each person will increase the score by 1.

```
Frodo => {
Sam with a score of 0
Pippin with a score of 1
Meriadoc with a score of 0
}
Sam => {
Frodo with a score of 0
Pippin with a score of 0
Meriadoc with a score of 1
}
Pippin => {
Frodo with a score of 1
Sam with a score of 0
Meriadoc with a score of 0
}
Meriadoc => {
Frodo with a score of 0
Sam with a score of 1
Pippin with a score of 0
}
```

Now when we look for a pair for Frodo, Pippin is excluded since his score is greater than the starting threshold of 0. Frodo cannot experience a repeat pair in this way, so he's paired up with Sam.

That concept continues until everyone is matched.

Once we get to the third time matchmaking, the score is reset from the first time because that was 2 times ago. If we wanted to simulate ensuring everyone gets paired up before repeating, then we could check 2 groups back, ensuring the score reset happens after everyone has been paired already, like round-robin.
- [Development setup](docs/development_setup)
- [Matchmaking strategies](docs/matchmaking_strategies)
- [Usage & setup](docs/usage_and_setup)
22 changes: 11 additions & 11 deletions app/jobs/establish_matches_for_grouping_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,20 @@ class EstablishMatchesForGroupingJob
def initialize(config: nil)
@loads_slack_channels = Slack::LoadsSlackChannels.new
@loads_slack_channel_members = Slack::LoadsSlackChannelMembers.new
@matches_participants = Matchmaking::MatchesParticipants.new(config: config)
@match_participants = Matchmaking::MatchParticipants.new(config: config)

@config = config || Rails.application.config.x.matchmaking
end

def perform(grouping:)
channel = channel_for_grouping(grouping)

matches = @matches_participants.call(
grouping: grouping,
participant_ids: @loads_slack_channel_members.call(channel: channel.id)
)
participants = @loads_slack_channel_members.call(channel: channel.id)

matches = @match_participants.call(participants, grouping)
matches.each do |match|
HistoricalMatch.create(
members: match.members,
members: match,
grouping: grouping,
matched_on: Date.today,
pending_notifications: [
Expand All @@ -32,15 +31,16 @@ def perform(grouping:)
private

def channel_for_grouping(grouping)
grouping_sym = grouping.intern

raise "No config found for grouping '#{grouping}'" unless @config.respond_to?(grouping_sym)
raise "No config found for grouping '#{grouping}'" unless @config.respond_to?(grouping.intern)

channel_name = @config.send(grouping_sym)&.channel
channel_name = @config.send(grouping)&.channel
raise "No configured channel for grouping '#{grouping}'" unless channel_name

@loads_slack_channels.call(types: "public_channel").find { |channel|
selected_channel = @loads_slack_channels.call(types: "public_channel").find { |channel|
channel.name_normalized == channel_name
}
raise "No channel found with name '#{channel_name}' for grouping '#{grouping}'" unless selected_channel

selected_channel
end
end
34 changes: 0 additions & 34 deletions app/lib/matchmaking/builds_participants.rb

This file was deleted.

Loading

0 comments on commit fd3dff4

Please sign in to comment.