From f8f568b20138f3e44212e7488b2b67659b060ed6 Mon Sep 17 00:00:00 2001 From: jonathan Date: Sun, 19 Jun 2022 18:31:20 +0100 Subject: [PATCH 1/5] Update Dockerfile to install requirements first Moving the requirements installation step towards the beginning of the file means it takes less time to rebuild it when testing, improving speed --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cae56736..1c3543a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ FROM python:3.9-slim-bullseye AS parent MAINTAINER CS LGBTQ+ -COPY ./app /app COPY ./requirements.txt /requirements.txt RUN pip install -r requirements.txt +COPY ./app /app FROM parent AS web CMD ["gunicorn", "app:create_app()"] From a8c9feb3dc11948949ae2c7218f53d20f73815ba Mon Sep 17 00:00:00 2001 From: jonathan Date: Sun, 19 Jun 2022 19:09:42 +0100 Subject: [PATCH 2/5] Introduce pickling Pickle is Python's own serialisation library. It's not without its risks, but it does massively increase speed and enables passing around complex data objects - which is what we have here. It enables us to expose our "most mentees with a mentor" functionality. To enable this I've added the `connections.setter` property in `CSPerson`, to allow pickle to load the data back into the object when it deserialises it. I've added some tests, but they need expanding - there's likely something around patching the long-running matching tasks to improve visibility of how everything is working. The `run_task` route now takes a "pairing" variable, which indicates which function will be run to calculate the outcomes. The default is to use the quicker, one-round loop with an unmatched bonus of 6. --- app/classes.py | 6 +++- app/extensions.py | 7 +++-- app/main/routes.py | 62 +++++++++++++++++++-------------------- app/tasks/__init__.py | 12 ++++---- app/tasks/helpers.py | 18 ++++++++++++ app/tasks/tasks.py | 56 +++++++++++++++++++++++++---------- docker-compose.yml | 3 +- tests/test_classes.py | 10 +++++++ tests/test_integration.py | 27 ++++++----------- tests/test_task.py | 20 +++++++++---- 10 files changed, 142 insertions(+), 79 deletions(-) create mode 100644 app/tasks/helpers.py diff --git a/app/classes.py b/app/classes.py index 927662cc..d6d59dea 100644 --- a/app/classes.py +++ b/app/classes.py @@ -44,10 +44,14 @@ def val_grade_to_str(cls, grade: int): def connections(self) -> list["CSPerson"]: return self._connections + @connections.setter + def connections(self, new_connections): + self._connections = new_connections + @staticmethod def map_input_to_model(data: dict): data["role"] = data["job title"] - data["email"] = data["email address"] + data["email"] = data.get("email address", data.get("email")) data["grade"] = CSPerson.str_grade_to_val(data.get("grade", "")) def map_model_to_output(self, data: dict): diff --git a/app/extensions.py b/app/extensions.py index be71a20f..1a88b625 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -2,9 +2,12 @@ from celery import Celery -celery = Celery( - "app", +celery_app = Celery( + "celery_app", backend=os.environ["REDIS_URL"], broker=os.environ["REDIS_URL"], include=["app.tasks.tasks"], + accept_content=["pickle", "json"], + task_serializer="pickle", + result_serializer="pickle", ) diff --git a/app/main/routes.py b/app/main/routes.py index f51cece6..2164703e 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -15,14 +15,17 @@ ) import pathlib import shutil - from werkzeug.utils import secure_filename, redirect -from app.classes import CSMentor, CSMentee, CSParticipantFactory -from app.extensions import celery +from app.classes import CSMentor, CSMentee +from app.extensions import celery_app from app.main import main_bp from app.helpers import valid_files, random_string -from app.tasks.tasks import async_process_data, delete_mailing_lists_after_period +from app.tasks.tasks import ( + async_process_data, + delete_mailing_lists_after_period, +) +from app.tasks.helpers import most_mentees_with_at_least_one_mentor from matching.process import create_participant_list_from_path, create_mailing_list @@ -107,8 +110,15 @@ def delete_files(response): @main_bp.route("/tasks", methods=["POST"]) def run_task(): + """ + This route only accepts JSON encoded requests + """ current_app.logger.debug(request.get_json()) data_folder = request.get_json()["data_folder"] + try: + optimise_for_pairing = request.get_json()["pairing"] + except KeyError: + optimise_for_pairing = False folder = pathlib.Path( os.path.join(current_app.config["UPLOAD_FOLDER"], data_folder) ) @@ -118,23 +128,18 @@ def delete_upload(response): shutil.rmtree(folder) return response - mentors = [ - mentor.to_dict() - for mentor in create_participant_list_from_path( - CSMentor, - path_to_data=folder, - ) - ] - mentees = [ - mentee.to_dict() - for mentee in create_participant_list_from_path( - CSMentee, - path_to_data=folder, - ) - ] - task = async_process_data.delay( - (mentors, mentees), + mentors = create_participant_list_from_path( + CSMentor, + path_to_data=folder, + ) + mentees = create_participant_list_from_path( + CSMentee, + path_to_data=folder, ) + if optimise_for_pairing: + task = most_mentees_with_at_least_one_mentor(mentors, mentees) + else: + task = async_process_data.delay(mentors, mentees) return jsonify(task_id=task.id), 202 @@ -145,19 +150,15 @@ def get_status(task_id): matched participants as JSON formatted data. This data is then fed into the `create_mailing_list` function, where the mailing lists are saved to a folder that corresponds to the `task_id`. """ - task_result = celery.AsyncResult(task_id) + task_result = celery_app.AsyncResult(task_id) result = { "task_id": task_id, "task_status": task_result.status, "task_result": "processing", } - if task_result.status == "SUCCESS": + if task_result.ready(): outputs = {} - for matched_participant_list in task_result.result: - participants = [ - CSParticipantFactory.create_from_dict(participant_dict) - for participant_dict in matched_participant_list - ] + for participants in task_result.result[:2]: participant_class = participants[0].class_name() connections = [len(p.connections) for p in participants] outputs[participant_class] = { @@ -169,10 +170,9 @@ def get_status(task_id): os.path.join(current_app.config["UPLOAD_FOLDER"], task_id) ), ) - result["task_result"] = url_for( - "main.download", task_id=task_id, count_data=json.dumps(outputs) - ) - current_app.logger.debug(outputs) + result["task_result"] = url_for( + "main.download", task_id=task_id, count_data=json.dumps(outputs) + ) return result, 200 diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py index 63c31bbb..b56c85a1 100644 --- a/app/tasks/__init__.py +++ b/app/tasks/__init__.py @@ -1,14 +1,14 @@ -from app.extensions import celery +from app.extensions import celery_app def make_celery(app): - celery.conf.update(app.config) - celery.conf.imports = ("app.tasks.tasks",) + celery_app.conf.update(app.config) + celery_app.conf.imports = ("app.tasks.tasks",) - class ContextTask(celery.Task): + class ContextTask(celery_app.Task): def __call__(self, *args, **kwargs): with app.app_context(): return self.run(*args, **kwargs) - celery.Task = ContextTask - return celery + celery_app.Task = ContextTask + return celery_app diff --git a/app/tasks/helpers.py b/app/tasks/helpers.py new file mode 100644 index 00000000..9ba535a4 --- /dev/null +++ b/app/tasks/helpers.py @@ -0,0 +1,18 @@ +from copy import deepcopy + +import celery +from celery.result import AsyncResult +from app.tasks.tasks import async_process_data, find_best_output +from app.classes import CSMentor, CSMentee +from app.helpers import base_rules + + +def most_mentees_with_at_least_one_mentor( + mentors: list[CSMentor], mentees: list[CSMentee] +) -> AsyncResult: + max_score = sum(rule.results.get(True) for rule in base_rules()) + copies = ((deepcopy(mentors), deepcopy(mentees), i) for i in range(max_score)) + task = celery.chord( + (async_process_data.si(*data) for data in copies), find_best_output.s() + )() + return task diff --git a/app/tasks/tasks.py b/app/tasks/tasks.py index ded74490..42f74a0c 100644 --- a/app/tasks/tasks.py +++ b/app/tasks/tasks.py @@ -1,32 +1,58 @@ +import functools import os - +import sys import requests -from typing import Tuple, List -from app.extensions import celery +from typing import Tuple, List, Sequence + +from app.classes import CSMentor, CSMentee +from app.extensions import celery_app as celery_app from matching import process -from app.classes import CSParticipantFactory from app.helpers import base_rules +from matching.rules.rule import UnmatchedBonus + +sys.setrecursionlimit(10000) -@celery.task(name="async_process_data", bind=True) +@celery_app.task(name="async_process_data", bind=True) def async_process_data( self, - data_to_process: Tuple[List[dict], List[dict]], -) -> Tuple[List[dict], List[dict]]: - mentor_data, mentee_data = data_to_process - mentors = map(CSParticipantFactory.create_from_dict, mentor_data) - mentees = map(CSParticipantFactory.create_from_dict, mentee_data) + mentors, + mentees, + unmatched_bonus: int = 6, +) -> Tuple[List[CSMentor], List[CSMentee], int]: all_rules = [base_rules() for _ in range(3)] + for ruleset in all_rules: + ruleset.append(UnmatchedBonus(unmatched_bonus)) matched_mentors, matched_mentees = process.process_data( list(mentors), list(mentees), all_rules=all_rules ) - matched_as_dict = [participant.to_dict() for participant in matched_mentors], [ - participant.to_dict() for participant in matched_mentees - ] - return matched_as_dict + return matched_mentors, matched_mentees, unmatched_bonus + + +@celery_app.task +def find_best_output( + group_result: Sequence[tuple[list[CSMentor], list[CSMentee], int]] +) -> tuple[list[CSMentor], list[CSMentee], int]: + highest_count = (0, 0) + best_outcome = group_result[0] + for participant_tuple in group_result: + mentors, mentees, unmatched_bonus = participant_tuple + one_connection_min_func = functools.partial( + map, lambda participant: len(participant.connections) > 0 + ) + current_count = ( + sum(one_connection_min_func(mentors)), + sum(one_connection_min_func(mentees)), + ) + if all( + current > highest for current, highest in zip(current_count, highest_count) # type: ignore + ): + best_outcome = participant_tuple + highest_count = current_count # type: ignore + return best_outcome -@celery.task(name="delete_mailing_lists_after_period", bind=True) +@celery_app.task(name="delete_mailing_lists_after_period", bind=True) def delete_mailing_lists_after_period(self, task_id: str): url = f"{os.environ.get('SERVICE_URL', 'http://app:5000')}/tasks/{task_id}" return requests.delete(url).status_code diff --git a/docker-compose.yml b/docker-compose.yml index 14a51ed2..fc9bd8b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,10 +26,11 @@ services: command: flask run -h 0.0.0.0 worker: + user: nobody build: context: "." target: worker - command: celery --app=app.extensions.celery worker --loglevel=info + command: celery --app=app.extensions.celery_app worker --loglevel=info environment: - FLASK_ENV=development diff --git a/tests/test_classes.py b/tests/test_classes.py index efd807b1..b55ecfe5 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -3,6 +3,7 @@ from app.classes import CSMentor, CSMentee, CSParticipantFactory from matching.rules import rule as rl from matching.match import Match +import pickle @pytest.mark.unit @@ -61,3 +62,12 @@ def test_export(base_mentor, base_mentee, base_mentor_data): "mentor only": "yes", "number of matches": 2, } + + +@pytest.mark.unit +def test_class_is_picklable(base_mentor, base_mentee): + base_mentor.mentees = base_mentee + base_mentee.mentors = base_mentor + pickled = pickle.dumps(base_mentor) + unpickled_mentor = pickle.loads(pickled) + assert unpickled_mentor == base_mentor diff --git a/tests/test_integration.py b/tests/test_integration.py index 01f3b1c6..e9ee8f7b 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -49,22 +49,18 @@ def test_input_data( def test_process_data(self, celery_app, celery_worker, known_file, test_data_path): known_file(test_data_path, "mentee", 50) known_file(test_data_path, "mentor", 50) - mentees = [ - mentee.to_dict() - for mentee in create_participant_list_from_path(CSMentee, test_data_path) - ] - mentors = [ - mentor.to_dict() - for mentor in create_participant_list_from_path(CSMentor, test_data_path) - ] - task = async_process_data.delay((mentors, mentees)) - while not task.state == "SUCCESS": - time.sleep(1) - assert len(task.result[0]) == 50 + mentors, mentees, unmatched_bonus = async_process_data( + create_participant_list_from_path(CSMentor, test_data_path), + create_participant_list_from_path(CSMentee, test_data_path), + ) + + assert len(mentors) == 50 and len(mentees) == 50 + @pytest.mark.parametrize("testing_value", (True, False)) @pytest.mark.parametrize(["test_task", "output"], [("small", 10), ("large", 100)]) def test_create_mailing_list( self, + testing_value, celery_app, celery_worker, known_file, @@ -76,16 +72,11 @@ def test_create_mailing_list( known_file(pathlib.Path(test_data_path, test_task), "mentee", output) known_file(pathlib.Path(test_data_path, test_task), "mentor", output) processing_id = client.post( - "/tasks", json={"data_folder": test_task} + "/tasks", json={"data_folder": test_task, "pairing": testing_value} ).get_json()["task_id"] resp = client.get(url_for("main.get_status", task_id=processing_id)) content = resp.get_json() - assert content == { - "task_id": processing_id, - "task_status": "PENDING", - "task_result": "processing", - } assert resp.status_code == 200 current_app.config["UPLOAD_FOLDER"] = test_data_path while content["task_status"] == "PENDING": diff --git a/tests/test_task.py b/tests/test_task.py index 3984855e..a70c4c8b 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -3,7 +3,10 @@ import pytest from flask import url_for, session -from app.tasks.tasks import delete_mailing_lists_after_period, async_process_data +from app.tasks.tasks import ( + delete_mailing_lists_after_period, + find_best_output, +) @pytest.mark.unit @@ -15,7 +18,9 @@ def test_when_processing_uploaded_data_deleted(client, test_data_path, write_tes mock_task = Mock() mock_task.id = 1 with patch("app.main.routes.async_process_data.delay", return_value=mock_task): - client.post(url_for("main.run_task"), json={"data_folder": "12345"}) + client.post( + url_for("main.run_task"), json={"data_folder": "12345", "pairing": False} + ) assert not pathlib.Path(test_data_path, "12345").exists() @@ -30,6 +35,11 @@ def test_delete_calls_correct_path(): @pytest.mark.unit -def test_async_process_data(base_mentee_data, base_mentor_data): - test_data = ([{"csmentor": base_mentor_data}], [{"csmentee": base_mentee_data}]) - assert async_process_data(data_to_process=test_data) +def test_find_best_outcome(base_mentor, base_mentee): + base_mentor.mentees = base_mentee + base_mentee.mentors = base_mentor + assert find_best_output([([base_mentor], [base_mentee], 0)]) == ( + [base_mentor], + [base_mentee], + 0, + ) From a9437f646cff51030c8c52af72ca20b616a6081a Mon Sep 17 00:00:00 2001 From: jonathan Date: Sun, 19 Jun 2022 19:12:16 +0100 Subject: [PATCH 3/5] Make it easier to test locally Various fixes here that make it easier to test the system locally --- app/classes.py | 5 +++-- app/config.py | 2 +- app/helpers.py | 10 +++++----- tests/conftest.py | 39 +++++++++++++++++++++++++++++++++++++-- tests/test_classes.py | 1 - 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/app/classes.py b/app/classes.py index d6d59dea..64dddee0 100644 --- a/app/classes.py +++ b/app/classes.py @@ -28,7 +28,7 @@ class CSPerson(Person): def __init__(self, **kwargs): self.biography = kwargs.get("biography") self.both = True if kwargs.get("both mentor and mentee") == "yes" else False - self.map_input_to_model(kwargs) + kwargs = self.map_input_to_model(kwargs) super(CSPerson, self).__init__(**kwargs) self._connections: list[CSPerson] = [] @@ -53,13 +53,14 @@ def map_input_to_model(data: dict): data["role"] = data["job title"] data["email"] = data.get("email address", data.get("email")) data["grade"] = CSPerson.str_grade_to_val(data.get("grade", "")) + return data def map_model_to_output(self, data: dict): data["job title"] = data.pop("role") - data["email address"] = data.pop("email") data["grade"] = self.val_grade_to_str(int(data.get("grade", "0"))) data["biography"] = self.biography data["both mentor and mentee"] = "yes" if self.both else "no" + return data def to_dict_for_output(self, depth=1) -> dict: return { diff --git a/app/config.py b/app/config.py index e1ce8b23..47779caa 100644 --- a/app/config.py +++ b/app/config.py @@ -16,4 +16,4 @@ class TestConfig(Config): "interval_max": 0.5, } if os.environ.get("REDIS_URL") is None: - os.environ["REDIS_URL"] = "redis@redis" + os.environ["REDIS_URL"] = "redis://localhost:6379/0" diff --git a/app/helpers.py b/app/helpers.py index 1a20b361..0333cd7f 100644 --- a/app/helpers.py +++ b/app/helpers.py @@ -1,10 +1,11 @@ import csv +import functools import math import operator import pathlib import string import random -from matching.rules.rule import AbstractRule + import matching.rules.rule as rl @@ -51,6 +52,7 @@ def random_string(): return "".join(random.choice(string.ascii_lowercase) for _ in range(10)) +@functools.lru_cache def known_file(path_to_file, role_type: str, quantity=50): padding_size = int(math.log10(quantity)) + 1 pathlib.Path(path_to_file).mkdir(parents=True, exist_ok=True) @@ -77,12 +79,11 @@ def known_data(role_type: str): "grade": "EO" if role_type == "mentor" else "AA", "organisation": f"Department of {role_type.capitalize()}s", "biography": "Test biography", + "profession": "Policy", } if role_type == "mentor": - data["profession"] = "Policy" data["characteristics"] = "bisexual, transgender" elif role_type == "mentee": - data["target profession"] = "Policy" data["match with similar identity"] = "yes" data["identity to match"] = "bisexual" else: @@ -172,7 +173,7 @@ def random_file(role_type: str, quantity: int = 50): file_writer.writerows(rows) -def base_rules() -> list[AbstractRule]: +def base_rules() -> list[rl.Rule]: return [ rl.Disqualify( lambda match: match.mentee.organisation == match.mentor.organisation @@ -192,5 +193,4 @@ def base_rules() -> list[AbstractRule]: lambda match: match.mentee.characteristic in match.mentor.characteristics and match.mentee.characteristic != "", ), - rl.UnmatchedBonus(6), ] diff --git a/tests/conftest.py b/tests/conftest.py index 2e8725ae..6a2ee205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,16 @@ from app import create_app from app.config import TestConfig from matching.process import create_participant_list_from_path -from app.classes import CSMentee, CSMentor +from app.classes import CSMentee, CSMentor, CSParticipantFactory from app.helpers import known_file as kf +from app.helpers import known_data @pytest.fixture(scope="function") def base_data() -> dict: + """ + This is a `dict` representation of the data in the CSV file presented to the system + """ return { "first name": "Test", "last name": "Data", @@ -34,7 +38,6 @@ def base_mentor_data(base_data): @pytest.fixture def base_mentee_data(base_data): - # base_data["target profession"] = "Policy" base_data["match with similar identity"] = "yes" base_data["identity to match"] = "bisexual" return base_data @@ -98,3 +101,35 @@ def _write_test_file(filename): f.close() return _write_test_file + + +@pytest.fixture(scope="session") +def celery_config(): + return { + "broker_url": os.environ.get("REDIS_URL", "redis://localhost:6379/0"), + "result_backend": os.environ.get("REDIS_URL", "redis://localhost:6379/0"), + "accept_content": ["pickle", "json"], + "task_serializer": "pickle", + "result_serializer": "pickle", + } + + +@pytest.fixture(scope="session") +def celery_worker_parameters(): + return {"perform_ping_check": False} + + +@pytest.fixture(scope="session") +def known_participants(): + def _known_participants(quantity=50) -> tuple[list[CSMentor], list[CSMentee]]: + mentors = [ + CSParticipantFactory.create_from_dict({"mentor": known_data("mentor")}) + for i in range(quantity) + ] + mentees = [ + CSParticipantFactory.create_from_dict({"mentee": known_data("mentee")}) + for i in range(quantity) + ] + return mentors, mentees + + return _known_participants diff --git a/tests/test_classes.py b/tests/test_classes.py index b55ecfe5..98932735 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -16,7 +16,6 @@ def test_CSMentor_to_dict(base_data): @pytest.mark.unit def test_CSMentee_to_dict(base_data): - base_data["target profession"] = base_data.get("profession") mentor = CSMentee(**base_data) for key, value in base_data.items(): assert value in mentor.to_dict()[mentor.class_name()].values() From 4caf9bf01afa2c555ee44ce1aba3780c4313fa60 Mon Sep 17 00:00:00 2001 From: jonathan Date: Sun, 19 Jun 2022 19:13:04 +0100 Subject: [PATCH 4/5] =?UTF-8?q?Bump=20version:=202.2.0=20=E2=86=92=202.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1341d68f..beff0d4f 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 2.2.0 +current_version = 2.3.0 commit = True tag = True diff --git a/setup.py b/setup.py index c04f99e3..eaaba615 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ # This call to setup() does all the work setup( name="mentor-match", - version="2.2.0", + version="2.3.0", description="A web interface for the mentor-match-package", long_description=README, long_description_content_type="text/markdown", From 5aaaa6b8143bb7ffe78d253cf60ab0f6348470ef Mon Sep 17 00:00:00 2001 From: jonathan Date: Sun, 19 Jun 2022 19:12:58 +0100 Subject: [PATCH 5/5] Update CHANGELOG.md --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe766b73..3efdc9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Users can now edit the weightings, but only for specific attributes, and for the pre-existing calculation +## [2.3.0] - 2022-06-19 + +### Changed +- The system now uses pickle under the hood, so please be careful - if you've not secured the connections between + the machines you're running this on you could really get yourself into trouble. +- However, it has made it significantly faster - a single matching exercise is now down to just 40s, from a best of + 97s when we were using JSON to serialize data. + +### Added + +- This is the big one. We've added functionality that will creep up an `UnmatchedBonus`. This functionality is + useful if you want to ensure everyone gets at least one mentor. It calculates a lot of values - one client is + calculating 37 different iterations of a three-round program, requiring 111 rounds of matching - so it takes a bit + longer to calculate. Exposing this functionality in the front end will be patched very soon, but in the meantime + dig around in the routes section or add a `"pairing": True` key-value pair to your JSON call to the appropriate + endpoint. +- Given the huge amount of processing happening, this functionality takes a lot longer than you're expecting. It's + enough time to make several cups of tea - on my hardware, it's clocking in at around 7 or 8 minutes. That's a long + time to stare at the same screen. We'll be updating the frontend to give more feedback soon, but for the moment, + either check the logs from celery or accept that you'll be here a little while. +- A note about the approach: I could have built a system that iterated over potential outcomes sequentially, + stopping when it got to the approach that scored above a specific threshold. I see two problems with this. First, + assuming that each matching process takes _n_ seconds, in the worst case iterating upwards takes _Mn_ seconds. In + the best case, of course, it takes _n_ seconds! +- My approach batches up the number of approaches into chunks of ten (_M_/10) that are done simultaneously (_Mn_/10). + This is therefore generally faster, although not in the case where the first outcome is the one we want. Given + that I can't predict things will be perfect every time, I've opted for the apparently longer approach. + ## [2.2.0] - 2022-05-26 ### Changed