perfect-strangers 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- perfect_strangers-0.1.0/.gitignore +218 -0
- perfect_strangers-0.1.0/LICENSE.txt +9 -0
- perfect_strangers-0.1.0/PKG-INFO +45 -0
- perfect_strangers-0.1.0/README.md +21 -0
- perfect_strangers-0.1.0/junk.toml +89 -0
- perfect_strangers-0.1.0/pyproject.toml +88 -0
- perfect_strangers-0.1.0/src/perfect_strangers/__about__.py +4 -0
- perfect_strangers-0.1.0/src/perfect_strangers/__init__.py +32 -0
- perfect_strangers-0.1.0/src/perfect_strangers/base_matcher.py +93 -0
- perfect_strangers-0.1.0/src/perfect_strangers/column_shift_matcher.py +72 -0
- perfect_strangers-0.1.0/src/perfect_strangers/kirkman_triple_matcher.py +174 -0
- perfect_strangers-0.1.0/src/perfect_strangers/radix_matcher.py +62 -0
- perfect_strangers-0.1.0/src/perfect_strangers/round_robin_matcher.py +24 -0
- perfect_strangers-0.1.0/src/perfect_strangers/util.py +48 -0
- perfect_strangers-0.1.0/tests/__init__.py +3 -0
- perfect_strangers-0.1.0/tests/matcher_validation.py +22 -0
- perfect_strangers-0.1.0/tests/test_column_shift_matching.py +17 -0
- perfect_strangers-0.1.0/tests/test_kirkman_triple_matching.py +20 -0
- perfect_strangers-0.1.0/tests/test_number_theory.py +20 -0
- perfect_strangers-0.1.0/tests/test_radix_matching.py +17 -0
- perfect_strangers-0.1.0/tests/test_round_robin_matching.py +19 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
# Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
# poetry.lock
|
|
109
|
+
# poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
# pdm.lock
|
|
116
|
+
# pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
# pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# Redis
|
|
135
|
+
*.rdb
|
|
136
|
+
*.aof
|
|
137
|
+
*.pid
|
|
138
|
+
|
|
139
|
+
# RabbitMQ
|
|
140
|
+
mnesia/
|
|
141
|
+
rabbitmq/
|
|
142
|
+
rabbitmq-data/
|
|
143
|
+
|
|
144
|
+
# ActiveMQ
|
|
145
|
+
activemq-data/
|
|
146
|
+
|
|
147
|
+
# SageMath parsed files
|
|
148
|
+
*.sage.py
|
|
149
|
+
|
|
150
|
+
# Environments
|
|
151
|
+
.env
|
|
152
|
+
.envrc
|
|
153
|
+
.venv
|
|
154
|
+
env/
|
|
155
|
+
venv/
|
|
156
|
+
ENV/
|
|
157
|
+
env.bak/
|
|
158
|
+
venv.bak/
|
|
159
|
+
|
|
160
|
+
# Spyder project settings
|
|
161
|
+
.spyderproject
|
|
162
|
+
.spyproject
|
|
163
|
+
|
|
164
|
+
# Rope project settings
|
|
165
|
+
.ropeproject
|
|
166
|
+
|
|
167
|
+
# mkdocs documentation
|
|
168
|
+
/site
|
|
169
|
+
|
|
170
|
+
# mypy
|
|
171
|
+
.mypy_cache/
|
|
172
|
+
.dmypy.json
|
|
173
|
+
dmypy.json
|
|
174
|
+
|
|
175
|
+
# Pyre type checker
|
|
176
|
+
.pyre/
|
|
177
|
+
|
|
178
|
+
# pytype static type analyzer
|
|
179
|
+
.pytype/
|
|
180
|
+
|
|
181
|
+
# Cython debug symbols
|
|
182
|
+
cython_debug/
|
|
183
|
+
|
|
184
|
+
# PyCharm
|
|
185
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
186
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
188
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
189
|
+
# .idea/
|
|
190
|
+
|
|
191
|
+
# Abstra
|
|
192
|
+
# Abstra is an AI-powered process automation framework.
|
|
193
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
194
|
+
# Learn more at https://abstra.io/docs
|
|
195
|
+
.abstra/
|
|
196
|
+
|
|
197
|
+
# Visual Studio Code
|
|
198
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
199
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
200
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
201
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
202
|
+
# .vscode/
|
|
203
|
+
|
|
204
|
+
# Ruff stuff:
|
|
205
|
+
.ruff_cache/
|
|
206
|
+
|
|
207
|
+
# PyPI configuration file
|
|
208
|
+
.pypirc
|
|
209
|
+
|
|
210
|
+
# Marimo
|
|
211
|
+
marimo/_static/
|
|
212
|
+
marimo/_lsp/
|
|
213
|
+
__marimo__/
|
|
214
|
+
|
|
215
|
+
# Streamlit
|
|
216
|
+
.streamlit/secrets.toml
|
|
217
|
+
|
|
218
|
+
.DS_Store
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfect-strangers
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Non-search based routines for perfect stranger matching in behavioural studies.
|
|
5
|
+
Project-URL: Documentation, https://senderby.co.uk/perfect-strangers
|
|
6
|
+
Project-URL: Issues, https://github.com/seanlikeskites/perfect-strangers/issues
|
|
7
|
+
Project-URL: Source, https://github.com/seanlikeskites/perfect-strangers
|
|
8
|
+
Author-email: Sean Enderby <sean.enderby@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
19
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: galois~=0.4
|
|
22
|
+
Requires-Dist: numpy~=2.0
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# perfect_strangers
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/perfect-strangers)
|
|
28
|
+
[](https://pypi.org/project/perfect-strangers)
|
|
29
|
+
|
|
30
|
+
-----
|
|
31
|
+
|
|
32
|
+
## Table of Contents
|
|
33
|
+
|
|
34
|
+
- [Installation](#installation)
|
|
35
|
+
- [License](#license)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
pip install perfect-strangers
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
`perfect-strangers` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# perfect_strangers
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/perfect-strangers)
|
|
4
|
+
[](https://pypi.org/project/perfect-strangers)
|
|
5
|
+
|
|
6
|
+
-----
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
- [Installation](#installation)
|
|
11
|
+
- [License](#license)
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
pip install perfect-strangers
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## License
|
|
20
|
+
|
|
21
|
+
`perfect-strangers` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "perfect-strangers"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Non-search based routines for perfect stranger matching in behavioural studies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Sean Enderby", email = "sean.enderby@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy~=2.0",
|
|
29
|
+
"galois~=0.4"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Documentation = "https://senderby.co.uk/perfect-strangers"
|
|
34
|
+
Issues = "https://github.com/seanlikeskites/perfect-strangers/issues"
|
|
35
|
+
Source = "https://github.com/seanlikeskites/perfect-strangers"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/perfect_strangers/__about__.py"
|
|
39
|
+
|
|
40
|
+
[[tool.hatch.envs.hatch-test.matrix]]
|
|
41
|
+
python = ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.envs.types]
|
|
44
|
+
extra-dependencies = [
|
|
45
|
+
"mypy>=1.0.0",
|
|
46
|
+
"pytest"
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.envs.types.scripts]
|
|
50
|
+
check = "mypy --install-types --non-interactive {args:src/perfect_strangers tests}"
|
|
51
|
+
|
|
52
|
+
[tool.coverage.run]
|
|
53
|
+
source_pkgs = ["perfect_strangers"]
|
|
54
|
+
branch = true
|
|
55
|
+
parallel = true
|
|
56
|
+
omit = [
|
|
57
|
+
"src/perfect_strangers/__about__.py",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.paths]
|
|
61
|
+
perfect_strangers = ["src/perfect_strangers", "*/perfect-strangers/src/perfect_strangers"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
exclude_lines = [
|
|
65
|
+
"no cov",
|
|
66
|
+
"if __name__ == .__main__.:",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.hatch.envs.docs]
|
|
71
|
+
dependencies = [
|
|
72
|
+
"mkdocstrings-python",
|
|
73
|
+
"zensical"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.hatch.envs.docs.scripts]
|
|
77
|
+
serve = "zensical serve"
|
|
78
|
+
build = "zensical build"
|
|
79
|
+
|
|
80
|
+
[tool.hatch.build.targets.sdist]
|
|
81
|
+
exclude = [
|
|
82
|
+
"/.github",
|
|
83
|
+
"/docs",
|
|
84
|
+
"/mkdocs.yml",
|
|
85
|
+
"/site"
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[tool.hatch.build.targets.wheel]
|
|
89
|
+
packages = ["src/perfect-strangers"]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "perfect-strangers"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Non-search based routines for perfect stranger matching in behavioural studies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = []
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "Sean Enderby", email = "sean.enderby@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Programming Language :: Python",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
25
|
+
"Programming Language :: Python :: Implementation :: PyPy",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"numpy~=2.0",
|
|
29
|
+
"galois~=0.4"
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Documentation = "https://senderby.co.uk/perfect-strangers"
|
|
34
|
+
Issues = "https://github.com/seanlikeskites/perfect-strangers/issues"
|
|
35
|
+
Source = "https://github.com/seanlikeskites/perfect-strangers"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
path = "src/perfect_strangers/__about__.py"
|
|
39
|
+
|
|
40
|
+
[[tool.hatch.envs.hatch-test.matrix]]
|
|
41
|
+
python = ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
42
|
+
|
|
43
|
+
[tool.hatch.envs.types]
|
|
44
|
+
extra-dependencies = [
|
|
45
|
+
"mypy>=1.0.0",
|
|
46
|
+
"pytest"
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.hatch.envs.types.scripts]
|
|
50
|
+
check = "mypy --install-types --non-interactive {args:src/perfect_strangers tests}"
|
|
51
|
+
|
|
52
|
+
[tool.coverage.run]
|
|
53
|
+
source_pkgs = ["perfect_strangers"]
|
|
54
|
+
branch = true
|
|
55
|
+
parallel = true
|
|
56
|
+
omit = [
|
|
57
|
+
"src/perfect_strangers/__about__.py",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
[tool.coverage.paths]
|
|
61
|
+
perfect_strangers = ["src/perfect_strangers", "*/perfect-strangers/src/perfect_strangers"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
exclude_lines = [
|
|
65
|
+
"no cov",
|
|
66
|
+
"if __name__ == .__main__.:",
|
|
67
|
+
"if TYPE_CHECKING:",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.hatch.envs.docs]
|
|
71
|
+
dependencies = [
|
|
72
|
+
"mkdocstrings-python",
|
|
73
|
+
"zensical"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.hatch.envs.docs.scripts]
|
|
77
|
+
serve = "zensical serve"
|
|
78
|
+
build = "zensical build"
|
|
79
|
+
|
|
80
|
+
[tool.hatch.build.targets.sdist]
|
|
81
|
+
exclude = [
|
|
82
|
+
"/.github",
|
|
83
|
+
"/docs",
|
|
84
|
+
"/mkdocs.yml"
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.hatch.build.targets.wheel]
|
|
88
|
+
packages = ["src/perfect_strangers"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from perfect_strangers.__about__ import __version__
|
|
6
|
+
from perfect_strangers.base_matcher import BaseMatcher
|
|
7
|
+
from perfect_strangers.column_shift_matcher import ColumnShiftMatcher
|
|
8
|
+
from perfect_strangers.kirkman_triple_matcher import KirkmanTripleMatcher
|
|
9
|
+
from perfect_strangers.round_robin_matcher import RoundRobinMatcher
|
|
10
|
+
|
|
11
|
+
__all__ = ("__version__", "create_matcher")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_matcher(groups_per_round: int, group_size: int) -> BaseMatcher:
|
|
15
|
+
"""
|
|
16
|
+
Create a groups matcher for the given experiment parameters.
|
|
17
|
+
|
|
18
|
+
:param groups_per_round: The number of groups per round of the experiment.
|
|
19
|
+
:param group_size: The number of participants in each group.
|
|
20
|
+
|
|
21
|
+
:return: A matcher object of a type which inherits from [`BaseMatcher`][perfect_strangers.BaseMatcher].
|
|
22
|
+
"""
|
|
23
|
+
if group_size == 2:
|
|
24
|
+
return RoundRobinMatcher(groups_per_round)
|
|
25
|
+
|
|
26
|
+
if group_size == 3:
|
|
27
|
+
matcher = KirkmanTripleMatcher.create_matcher(groups_per_round)
|
|
28
|
+
|
|
29
|
+
if matcher is not None:
|
|
30
|
+
return matcher
|
|
31
|
+
|
|
32
|
+
return ColumnShiftMatcher(groups_per_round, group_size)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from random import shuffle
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from perfect_strangers.util import is_round_pair_valid, is_round_valid
|
|
13
|
+
|
|
14
|
+
RoundSequence = Sequence[np.typing.NDArray]
|
|
15
|
+
|
|
16
|
+
class BaseMatcher:
|
|
17
|
+
"""
|
|
18
|
+
Base class for all group matching methods.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, groups_per_round: int, group_size: int):
|
|
21
|
+
self.groups_per_round = groups_per_round
|
|
22
|
+
self.group_size = group_size
|
|
23
|
+
self.n_participants = groups_per_round * group_size
|
|
24
|
+
|
|
25
|
+
self.group_matrices = [
|
|
26
|
+
np.arange(self.n_participants).reshape(self.groups_per_round, self.group_size)
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
self._generate_rounds()
|
|
30
|
+
self.shuffle_sequence()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def max_rounds(self) -> int:
|
|
34
|
+
"""
|
|
35
|
+
The maximum number of rounds this matcher will produce under perfect stranger matching conditions.
|
|
36
|
+
"""
|
|
37
|
+
return len(self.group_matrices)
|
|
38
|
+
|
|
39
|
+
def groups_for_next_round(self) -> list[list[int]] | None:
|
|
40
|
+
"""
|
|
41
|
+
Get the groups for the next round.
|
|
42
|
+
|
|
43
|
+
:return: A list of participants groupings for the next round, or None if there a no more rounds possible.
|
|
44
|
+
"""
|
|
45
|
+
if self.next_round >= self.max_rounds:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
g = self.group_matrices[self.next_round].tolist()
|
|
49
|
+
self.next_round += 1
|
|
50
|
+
return g
|
|
51
|
+
|
|
52
|
+
def restart(self):
|
|
53
|
+
"""
|
|
54
|
+
Reset the matcher to the first round.
|
|
55
|
+
"""
|
|
56
|
+
self.next_round = 0
|
|
57
|
+
|
|
58
|
+
def shuffle_sequence(self):
|
|
59
|
+
"""
|
|
60
|
+
Shuffle the list of rounds produced by this matcher.
|
|
61
|
+
"""
|
|
62
|
+
shuffle(self.group_matrices)
|
|
63
|
+
self.restart()
|
|
64
|
+
|
|
65
|
+
def _generate_rounds(self):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def _append_round(self, g):
|
|
69
|
+
if not is_round_valid(g, self.groups_per_round, self.group_size):
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
for r in self.group_matrices:
|
|
73
|
+
if not is_round_pair_valid(r, g):
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
self.group_matrices.append(g)
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
def validate_rounds(self) -> bool:
|
|
80
|
+
for i, current_round in enumerate(self.group_matrices):
|
|
81
|
+
# Check current round include all participants.
|
|
82
|
+
if not is_round_valid(current_round, self.groups_per_round, self.group_size):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
# Check all subsequent rounds preserve perfect stranger matching with
|
|
86
|
+
# current round.
|
|
87
|
+
for j in range(i + 1, self.max_rounds):
|
|
88
|
+
next_round = self.group_matrices[j]
|
|
89
|
+
|
|
90
|
+
if not is_round_pair_valid(current_round, next_round):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
return True
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from perfect_strangers.base_matcher import BaseMatcher, RoundSequence
|
|
12
|
+
from perfect_strangers.util import least_prime_factor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _shift_columns(base_matrix: np.typing.NDArray, stride: int) -> RoundSequence:
|
|
16
|
+
g = base_matrix.copy()
|
|
17
|
+
n_blocks = g.shape[0] // stride
|
|
18
|
+
group_size = g.shape[1]
|
|
19
|
+
|
|
20
|
+
if n_blocks < group_size:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
lpf = least_prime_factor(n_blocks) or 0
|
|
24
|
+
|
|
25
|
+
if group_size <= lpf:
|
|
26
|
+
n_shifts = n_blocks - 1
|
|
27
|
+
else:
|
|
28
|
+
# In some cases we can get away with more shifts. Need to work out a good
|
|
29
|
+
# way of calculating this.
|
|
30
|
+
n_shifts = math.ceil(n_blocks / (group_size - 1)) - 1
|
|
31
|
+
|
|
32
|
+
shifts = []
|
|
33
|
+
|
|
34
|
+
for _ in range(n_shifts):
|
|
35
|
+
for c in range(1, group_size):
|
|
36
|
+
g[:, c] = np.roll(g[:, c], c * stride)
|
|
37
|
+
|
|
38
|
+
shifts.append(g.copy())
|
|
39
|
+
|
|
40
|
+
return shifts
|
|
41
|
+
|
|
42
|
+
class ColumnShiftMatcher(BaseMatcher):
|
|
43
|
+
def __init__(self, groups_per_round: int, group_size: int):
|
|
44
|
+
super().__init__(groups_per_round, group_size)
|
|
45
|
+
|
|
46
|
+
def _generate_rounds(self):
|
|
47
|
+
# Apply initial column shifts.
|
|
48
|
+
self.group_matrices += _shift_columns(self.group_matrices[0], 1)
|
|
49
|
+
|
|
50
|
+
# Apply submatrix transposition.
|
|
51
|
+
block_size = self.groups_per_round
|
|
52
|
+
n_blocks = 1
|
|
53
|
+
|
|
54
|
+
while block_size % self.group_size == 0:
|
|
55
|
+
g = self.group_matrices[0].copy()
|
|
56
|
+
|
|
57
|
+
stride = block_size // self.group_size
|
|
58
|
+
|
|
59
|
+
for block in range(n_blocks):
|
|
60
|
+
block_start = block * block_size
|
|
61
|
+
|
|
62
|
+
for sub in range(stride):
|
|
63
|
+
start_col = block_start + sub
|
|
64
|
+
end_col = start_col + self.group_size * stride
|
|
65
|
+
g[start_col:end_col:stride, :] = g[start_col:end_col:stride, :].transpose()
|
|
66
|
+
|
|
67
|
+
self.group_matrices.append(g)
|
|
68
|
+
self.group_matrices += _shift_columns(g, block_size)
|
|
69
|
+
|
|
70
|
+
block_size = stride
|
|
71
|
+
n_blocks *= self.group_size
|
|
72
|
+
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
|
|
9
|
+
import galois
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from perfect_strangers.base_matcher import BaseMatcher, RoundSequence
|
|
13
|
+
|
|
14
|
+
ParameterFuncReturn = tuple[int, int] | None
|
|
15
|
+
RoundGenerator = Callable[[int, int], RoundSequence]
|
|
16
|
+
|
|
17
|
+
#######################################################################
|
|
18
|
+
# Theorem 5 from Ray-Chaudhuri and Wilson (1971):
|
|
19
|
+
# Where q = 6t + 1 is a prime power construct a Kirkman triple
|
|
20
|
+
# system for 3q total participants.
|
|
21
|
+
#######################################################################
|
|
22
|
+
def _get_t_from_q(q: int) -> ParameterFuncReturn:
|
|
23
|
+
"""
|
|
24
|
+
Check q is a prime power of the form 6t + 1 and return t and q if it is.
|
|
25
|
+
|
|
26
|
+
:return: Tuple of (t, q) if q is a prime power of the form 6t + 1, None otherwise.
|
|
27
|
+
"""
|
|
28
|
+
if not galois.is_prime_power(q) or q % 6 != 1:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
t = (q - 1) // 6
|
|
32
|
+
|
|
33
|
+
return t, q
|
|
34
|
+
|
|
35
|
+
def _galois_field_elements(order: int) -> tuple[list[galois.FieldArray], galois.FieldArray]:
|
|
36
|
+
gf = galois.GF(order)
|
|
37
|
+
elements = [gf(i) for i in range(order)]
|
|
38
|
+
primitive_element = gf.primitive_element
|
|
39
|
+
|
|
40
|
+
return elements, primitive_element
|
|
41
|
+
|
|
42
|
+
def _three_q_params(groups_per_round: int) -> ParameterFuncReturn:
|
|
43
|
+
return _get_t_from_q(groups_per_round)
|
|
44
|
+
|
|
45
|
+
def _three_q_rounds(t: int, q: int) -> RoundSequence:
|
|
46
|
+
labels = np.arange(3 * q).reshape(q, 3)
|
|
47
|
+
field_elements, g = _galois_field_elements(q)
|
|
48
|
+
|
|
49
|
+
def next_group(shift, i, j=None):
|
|
50
|
+
return [labels[shift + g ** (i + 2 * x * t), j if j is not None else x] for x in range(3)]
|
|
51
|
+
|
|
52
|
+
rounds = []
|
|
53
|
+
|
|
54
|
+
for shift in field_elements:
|
|
55
|
+
new_round = np.empty((q, 3))
|
|
56
|
+
new_round[0, :] = labels[shift, :]
|
|
57
|
+
group_idx = 1
|
|
58
|
+
|
|
59
|
+
for i in range(t):
|
|
60
|
+
for j in range(3):
|
|
61
|
+
new_round[group_idx, :] = next_group(shift, i, j)
|
|
62
|
+
group_idx += 1
|
|
63
|
+
|
|
64
|
+
for i in range(6 * t):
|
|
65
|
+
if (i // t) % 2 == 0:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
new_round[group_idx, :] = next_group(shift, i)
|
|
69
|
+
group_idx += 1
|
|
70
|
+
|
|
71
|
+
rounds.append(new_round)
|
|
72
|
+
|
|
73
|
+
for i in range(6 * t):
|
|
74
|
+
if (i // t) % 2 != 0:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
new_round = np.empty((q, 3))
|
|
78
|
+
|
|
79
|
+
for shift in field_elements:
|
|
80
|
+
new_round[shift, :] = next_group(shift, i)
|
|
81
|
+
|
|
82
|
+
rounds.append(new_round)
|
|
83
|
+
|
|
84
|
+
return rounds
|
|
85
|
+
|
|
86
|
+
#######################################################################
|
|
87
|
+
# Theorem 6 from Ray-Chaudhuri and Wilson (1971)
|
|
88
|
+
# Where q = 6t + 1 is a prime power construct a Kirkman triple
|
|
89
|
+
# system for 2q + 1 total participants.
|
|
90
|
+
#######################################################################
|
|
91
|
+
def _two_q_less_one_params(groups_per_round: int) -> ParameterFuncReturn:
|
|
92
|
+
if groups_per_round % 2 == 0:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
q = (3 * groups_per_round - 1) // 2
|
|
96
|
+
|
|
97
|
+
return _get_t_from_q(q)
|
|
98
|
+
|
|
99
|
+
def _two_q_less_one_rounds(t: int, q: int) -> RoundSequence:
|
|
100
|
+
groups_per_round = (2 * q + 1) // 3
|
|
101
|
+
labels = np.arange(2 * q).reshape(q, 2)
|
|
102
|
+
inf = 2 * q
|
|
103
|
+
|
|
104
|
+
field_elements, g = _galois_field_elements(q)
|
|
105
|
+
|
|
106
|
+
# Find m.
|
|
107
|
+
target = (g ** t + field_elements[1]) / field_elements[2]
|
|
108
|
+
m = target.log(g)
|
|
109
|
+
|
|
110
|
+
rounds = []
|
|
111
|
+
|
|
112
|
+
for shift in field_elements:
|
|
113
|
+
new_round = np.empty((groups_per_round, 3))
|
|
114
|
+
|
|
115
|
+
new_round[0, :] = [
|
|
116
|
+
labels[shift, 0],
|
|
117
|
+
labels[shift, 1],
|
|
118
|
+
inf
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
group_idx = 1
|
|
122
|
+
|
|
123
|
+
for i in range(t):
|
|
124
|
+
for j in range(3):
|
|
125
|
+
new_round[group_idx, :] = [
|
|
126
|
+
labels[shift + g ** (i + 2 * j * t), 0],
|
|
127
|
+
labels[shift + g ** (i + 2 * j * t + t), 0],
|
|
128
|
+
labels[shift + g ** (i + 2 * j * t + m), 1]
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
group_idx += 1
|
|
132
|
+
|
|
133
|
+
new_round[group_idx, :] = [
|
|
134
|
+
labels[shift + g ** (i + m + t), 1],
|
|
135
|
+
labels[shift + g ** (i + m + 3 * t), 1],
|
|
136
|
+
labels[shift + g ** (i + m + 5 * t), 1]
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
group_idx += 1
|
|
140
|
+
|
|
141
|
+
rounds.append(new_round)
|
|
142
|
+
|
|
143
|
+
return rounds
|
|
144
|
+
|
|
145
|
+
#######################################################################
|
|
146
|
+
# Matcher
|
|
147
|
+
#######################################################################
|
|
148
|
+
class KirkmanTripleMatcher(BaseMatcher):
|
|
149
|
+
""" Implementation as per https://math.stackexchange.com/a/4510645"""
|
|
150
|
+
def __init__(self, groups_per_round: int, t: int, q: int, round_generator: RoundGenerator):
|
|
151
|
+
self.t = t
|
|
152
|
+
self.q = q
|
|
153
|
+
self.round_generator = round_generator
|
|
154
|
+
|
|
155
|
+
super().__init__(groups_per_round, 3)
|
|
156
|
+
|
|
157
|
+
def _generate_rounds(self):
|
|
158
|
+
self.group_matrices = self.round_generator(self.t, self.q)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def create_matcher(cls, groups_per_round: int):
|
|
162
|
+
methods = [
|
|
163
|
+
(_three_q_params, _three_q_rounds),
|
|
164
|
+
(_two_q_less_one_params, _two_q_less_one_rounds)
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
for parameter_func, round_generator in methods:
|
|
168
|
+
params = parameter_func(groups_per_round)
|
|
169
|
+
|
|
170
|
+
if params is not None:
|
|
171
|
+
t, q = params
|
|
172
|
+
return KirkmanTripleMatcher(groups_per_round, t, q, round_generator)
|
|
173
|
+
|
|
174
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Generator
|
|
8
|
+
|
|
9
|
+
from perfect_strangers.base_matcher import BaseMatcher
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _round_increments(group_size, exponent) -> Generator[int]:
|
|
13
|
+
for n in range(1, exponent):
|
|
14
|
+
leading_one = group_size ** n
|
|
15
|
+
|
|
16
|
+
for i in range(leading_one):
|
|
17
|
+
yield leading_one + i
|
|
18
|
+
|
|
19
|
+
def _non_carry_add_base_s(a: int, b: int, s: int) -> int:
|
|
20
|
+
n = 0
|
|
21
|
+
c = 0
|
|
22
|
+
|
|
23
|
+
while a + b > 0:
|
|
24
|
+
c += s**n * (((a % s) + (b % s)) % s)
|
|
25
|
+
n += 1
|
|
26
|
+
a //= s
|
|
27
|
+
b //= s
|
|
28
|
+
|
|
29
|
+
return c
|
|
30
|
+
|
|
31
|
+
def _make_group(group_size: int, first_member: int, increment: int) -> list[int]:
|
|
32
|
+
m = first_member
|
|
33
|
+
remaining = group_size
|
|
34
|
+
|
|
35
|
+
group = []
|
|
36
|
+
|
|
37
|
+
while remaining > 0:
|
|
38
|
+
group.append(m)
|
|
39
|
+
m = _non_carry_add_base_s(m, increment, group_size)
|
|
40
|
+
remaining -= 1
|
|
41
|
+
|
|
42
|
+
return group
|
|
43
|
+
|
|
44
|
+
class RadixMatcher(BaseMatcher):
|
|
45
|
+
def __init__(self, group_size: int, exponent: int):
|
|
46
|
+
self.exponent = exponent
|
|
47
|
+
super().__init__(group_size ** (exponent - 1), group_size)
|
|
48
|
+
|
|
49
|
+
def _generate_rounds(self):
|
|
50
|
+
participants = set(range(self.n_participants))
|
|
51
|
+
|
|
52
|
+
for i in _round_increments(self.group_size, self.exponent):
|
|
53
|
+
allocated = set()
|
|
54
|
+
g = self.group_matrices[0].copy()
|
|
55
|
+
|
|
56
|
+
for j in range(self.groups_per_round):
|
|
57
|
+
first_member = min(participants - allocated)
|
|
58
|
+
next_group = _make_group(self.group_size, first_member, i)
|
|
59
|
+
g[j, :] = next_group
|
|
60
|
+
allocated.update(set(next_group))
|
|
61
|
+
|
|
62
|
+
self._append_round(g)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from perfect_strangers.base_matcher import BaseMatcher
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RoundRobinMatcher(BaseMatcher):
|
|
11
|
+
def __init__(self, groups_per_round: int):
|
|
12
|
+
# Round robin matching works with a group size of 2.
|
|
13
|
+
super().__init__(groups_per_round, 2)
|
|
14
|
+
|
|
15
|
+
def _generate_rounds(self):
|
|
16
|
+
def _rotate_groups(g):
|
|
17
|
+
flat = g.flatten("F")
|
|
18
|
+
flat[1:self.groups_per_round] = np.flip(flat[1:self.groups_per_round])
|
|
19
|
+
flat[1:] = np.roll(flat[1:], 1)
|
|
20
|
+
flat[1:self.groups_per_round] = np.flip(flat[1:self.groups_per_round])
|
|
21
|
+
return flat.reshape(g.shape, order="F")
|
|
22
|
+
|
|
23
|
+
for _ in range(self.n_participants - 2):
|
|
24
|
+
self.group_matrices.append(_rotate_groups(self.group_matrices[-1]))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
import numpy.typing as npt
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_round_valid(g: npt.NDArray, groups_per_round: int, group_size: int) -> bool:
|
|
15
|
+
n_groups_check = g.shape[0] == groups_per_round
|
|
16
|
+
group_size_check = g.shape[1] == group_size
|
|
17
|
+
participants_check = set(g.flatten()) == set(range(g.size))
|
|
18
|
+
|
|
19
|
+
return n_groups_check and group_size_check and participants_check
|
|
20
|
+
|
|
21
|
+
def is_round_pair_valid(r1: npt.NDArray, r2: npt.NDArray) -> bool:
|
|
22
|
+
for i in range(r1.shape[0]):
|
|
23
|
+
g1 = set(r1[i, :])
|
|
24
|
+
|
|
25
|
+
for j in range(r2.shape[0]):
|
|
26
|
+
g2 = set(r2[j, :])
|
|
27
|
+
|
|
28
|
+
if len(g1 & g2) > 1:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
def least_prime_factor(n: int) -> int | None:
|
|
34
|
+
if n < 2:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
if n % 2 == 0:
|
|
38
|
+
return 2
|
|
39
|
+
|
|
40
|
+
f = 3
|
|
41
|
+
|
|
42
|
+
while f <= math.sqrt(n):
|
|
43
|
+
if n % f == 0:
|
|
44
|
+
return f
|
|
45
|
+
|
|
46
|
+
f += 2
|
|
47
|
+
|
|
48
|
+
return n
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
def verify_n_rounds(matcher):
|
|
6
|
+
n_rounds = 0
|
|
7
|
+
|
|
8
|
+
while matcher.groups_for_next_round() is not None:
|
|
9
|
+
n_rounds += 1
|
|
10
|
+
|
|
11
|
+
assert n_rounds == matcher.max_rounds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_matcher(matcher):
|
|
15
|
+
if matcher.groups_per_round >= matcher.group_size:
|
|
16
|
+
assert matcher.max_rounds > 1
|
|
17
|
+
else:
|
|
18
|
+
assert matcher.max_rounds == 1
|
|
19
|
+
|
|
20
|
+
verify_n_rounds(matcher)
|
|
21
|
+
|
|
22
|
+
assert matcher.validate_rounds()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from perfect_strangers import ColumnShiftMatcher
|
|
8
|
+
from tests.matcher_validation import validate_matcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("group_size", range(3, 7))
|
|
12
|
+
@pytest.mark.parametrize("groups_per_round", range(2, 31))
|
|
13
|
+
def test_column_shifts(groups_per_round, group_size):
|
|
14
|
+
matcher = ColumnShiftMatcher(groups_per_round, group_size)
|
|
15
|
+
|
|
16
|
+
# Validate generated rounds
|
|
17
|
+
validate_matcher(matcher)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from perfect_strangers import KirkmanTripleMatcher
|
|
8
|
+
from tests.matcher_validation import validate_matcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("groups_per_round", range(2, 31))
|
|
12
|
+
def test_round_robin(groups_per_round):
|
|
13
|
+
matcher = KirkmanTripleMatcher.create_matcher(groups_per_round)
|
|
14
|
+
|
|
15
|
+
if matcher is not None:
|
|
16
|
+
# Kirkman triple matching should always give the maximum possible rounds.
|
|
17
|
+
assert matcher.max_rounds == (3 * groups_per_round - 1) // 2
|
|
18
|
+
|
|
19
|
+
# Validate generated rounds
|
|
20
|
+
validate_matcher(matcher)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import galois
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from perfect_strangers.util import least_prime_factor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("n", range(101))
|
|
12
|
+
def test_least_prime_factor(n):
|
|
13
|
+
test_value = least_prime_factor(n)
|
|
14
|
+
|
|
15
|
+
if n < 2:
|
|
16
|
+
assert test_value is None
|
|
17
|
+
|
|
18
|
+
else:
|
|
19
|
+
truth = min(galois.factors(n)[0])
|
|
20
|
+
assert test_value == truth
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from perfect_strangers.radix_matcher import RadixMatcher
|
|
8
|
+
from tests.matcher_validation import validate_matcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("exponent", range(2, 4))
|
|
12
|
+
@pytest.mark.parametrize("group_size", range(3, 7))
|
|
13
|
+
def test_radix_matching(group_size, exponent):
|
|
14
|
+
matcher = RadixMatcher(group_size, exponent)
|
|
15
|
+
|
|
16
|
+
# Validate generated rounds
|
|
17
|
+
validate_matcher(matcher)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from perfect_strangers import RoundRobinMatcher
|
|
8
|
+
from tests.matcher_validation import validate_matcher
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("groups_per_round", range(2, 31))
|
|
12
|
+
def test_round_robin(groups_per_round):
|
|
13
|
+
matcher = RoundRobinMatcher(groups_per_round)
|
|
14
|
+
|
|
15
|
+
# Round robin matching should always give the maximum possible rounds.
|
|
16
|
+
assert matcher.max_rounds == 2 * groups_per_round - 1
|
|
17
|
+
|
|
18
|
+
# Validate generated rounds
|
|
19
|
+
validate_matcher(matcher)
|