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.
@@ -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
+ [![PyPI - Version](https://img.shields.io/pypi/v/perfect-strangers.svg)](https://pypi.org/project/perfect-strangers)
28
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/perfect-strangers.svg)](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
+ [![PyPI - Version](https://img.shields.io/pypi/v/perfect-strangers.svg)](https://pypi.org/project/perfect-strangers)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/perfect-strangers.svg)](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,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.1.0"
@@ -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,3 @@
1
+ # SPDX-FileCopyrightText: 2025-present Sean Enderby <sean.enderby@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
@@ -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)