Implement a history-based matching algorythm
The bot will attempt to keep producing groups with entirely unique matches based on the full history of matches until it can't. It'll then step forward and ignore a week of history and try again, ignoring more history until no history is left
This commit is contained in:
parent
ed2375386b
commit
874a24dd1d
8 changed files with 388 additions and 104 deletions
154
matching.py
154
matching.py
|
@ -1,27 +1,145 @@
|
|||
"""Utility functions for matchy"""
|
||||
import random
|
||||
from typing import Protocol
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Protocol, runtime_checkable
|
||||
import history
|
||||
|
||||
|
||||
# Number of days to step forward from the start of history for each match attempt
|
||||
_ATTEMPT_RELEVANCY_STEP = timedelta(days=7)
|
||||
|
||||
# Attempts for each of those time periods
|
||||
_ATTEMPTS_PER_TIME = 3
|
||||
|
||||
# Mamum attempts worth taking
|
||||
_MAX_ATTEMPTS = _ATTEMPTS_PER_TIME*10
|
||||
|
||||
logger = logging.getLogger("matching")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Member(Protocol):
|
||||
@property
|
||||
def mention(self) -> str:
|
||||
pass
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
pass
|
||||
|
||||
|
||||
def members_to_groups(matchees: list[Member],
|
||||
per_group: int) -> list[list[Member]]:
|
||||
"""Generate the groups from the set of matchees"""
|
||||
random.shuffle(matchees)
|
||||
num_groups = max(len(matchees)//per_group, 1)
|
||||
@runtime_checkable
|
||||
class Role(Protocol):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Guild(Protocol):
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
pass
|
||||
|
||||
|
||||
def members_to_groups_simple(matchees: list[Member], num_groups: int) -> tuple[bool, list[list[Member]]]:
|
||||
"""Super simple group matching, literally no logic"""
|
||||
return [matchees[i::num_groups] for i in range(num_groups)]
|
||||
|
||||
|
||||
class Member(Protocol):
|
||||
"""Protocol for the type of Member"""
|
||||
@property
|
||||
def mention(self) -> str:
|
||||
pass
|
||||
def circular_iterator(lst, start_index):
|
||||
for i in range(start_index, len(lst)):
|
||||
yield i, lst[i]
|
||||
for i in range(0, start_index):
|
||||
yield i, lst[i]
|
||||
|
||||
|
||||
def attempt_create_groups(matchees: list[Member],
|
||||
hist: history.History,
|
||||
oldest_relevant_ts: datetime,
|
||||
num_groups: int) -> tuple[bool, list[list[Member]]]:
|
||||
"""History aware group matching"""
|
||||
|
||||
# Set up the groups in place
|
||||
groups = list([] for _ in range(num_groups))
|
||||
|
||||
matchees_left = matchees.copy()
|
||||
|
||||
# Sequentially try and fit each matchy into groups one by one
|
||||
current_group = 0
|
||||
while matchees_left:
|
||||
# Get the next matchee to place
|
||||
matchee = matchees_left.pop()
|
||||
matchee_matches = hist.matchees.get(
|
||||
str(matchee.id), {}).get("matches", {})
|
||||
relevant_matches = list(int(id) for id, ts in matchee_matches.items()
|
||||
if history.ts_to_datetime(ts) >= oldest_relevant_ts)
|
||||
|
||||
# Try every single group from the current group onwards
|
||||
# Progressing through the groups like this ensures we slowly fill them up with compatible people
|
||||
added = False
|
||||
for idx, group in circular_iterator(groups, current_group):
|
||||
current_group = idx # Track the current group
|
||||
|
||||
# Current compatibilty is simply whether or not the group has any members with previous matches in it
|
||||
if not any(m.id in relevant_matches for m in group):
|
||||
group.append(matchee)
|
||||
added = True
|
||||
break
|
||||
|
||||
# If we failed to add this matchee, bail on the group creation as it could not be done
|
||||
if not added:
|
||||
return None
|
||||
|
||||
# Move on to the next group
|
||||
current_group += 1
|
||||
if current_group >= len(groups):
|
||||
current_group = 0
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def members_to_groups(matchees: list[Member],
|
||||
hist: history.History = history.History(),
|
||||
per_group: int = 3, max_attempts: int = _MAX_ATTEMPTS) -> list[list[Member]]:
|
||||
"""Generate the groups from the set of matchees"""
|
||||
num_groups = max(len(matchees)//per_group, 1)
|
||||
attempts = max_attempts
|
||||
|
||||
# Only both with the complicated matching if we have a history
|
||||
# TODO: When matching takes into account more than history this should change
|
||||
if not hist.history:
|
||||
logger.info("No history so matched groups with simple method")
|
||||
return members_to_groups_simple(matchees, num_groups)
|
||||
|
||||
# Grab the oldest timestamp
|
||||
oldest_relevant_datetime = hist.oldest()
|
||||
|
||||
# Loop until we find a valid set of groups
|
||||
while attempts:
|
||||
attempts -= 1
|
||||
|
||||
groups = attempt_create_groups(
|
||||
matchees, hist, oldest_relevant_datetime, num_groups)
|
||||
|
||||
if groups:
|
||||
logger.info("Matched groups after %s attempt(s)",
|
||||
_MAX_ATTEMPTS - attempts)
|
||||
return groups
|
||||
|
||||
# In case we still don't have groups we should progress and
|
||||
# walk the oldest relevant timestamp forward a week
|
||||
# Stop bothering if we've gone beyond today
|
||||
if attempts % _ATTEMPTS_PER_TIME == 0:
|
||||
oldest_relevant_datetime += _ATTEMPT_RELEVANCY_STEP
|
||||
if oldest_relevant_datetime > datetime.now():
|
||||
break
|
||||
|
||||
# If we've still failed, just use the simple method
|
||||
logger.info("Fell back to simple groups after %s attempt(s)",
|
||||
_MAX_ATTEMPTS - attempts)
|
||||
return members_to_groups_simple(matchees, num_groups)
|
||||
|
||||
|
||||
def group_to_message(group: list[Member]) -> str:
|
||||
|
@ -34,18 +152,6 @@ def group_to_message(group: list[Member]) -> str:
|
|||
return f"Matched up {mentions}!"
|
||||
|
||||
|
||||
class Role(Protocol):
|
||||
@property
|
||||
def name(self) -> str:
|
||||
pass
|
||||
|
||||
|
||||
class Guild(Protocol):
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
pass
|
||||
|
||||
|
||||
def get_role_from_guild(guild: Guild, role: str) -> Role:
|
||||
"""Find a role in a guild"""
|
||||
return next((r for r in guild.roles if r.name == role), None)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue