pairing-core 0.4.1__py3-none-any.whl
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.
- pairing_core/__init__.py +161 -0
- pairing_core/adapters/__init__.py +43 -0
- pairing_core/adapters/_process.py +210 -0
- pairing_core/adapters/bbp.py +179 -0
- pairing_core/adapters/javafo.py +160 -0
- pairing_core/adapters/trf.py +496 -0
- pairing_core/api.py +408 -0
- pairing_core/bracket.py +376 -0
- pairing_core/bye.py +164 -0
- pairing_core/canonical.py +326 -0
- pairing_core/color.py +249 -0
- pairing_core/controls.py +84 -0
- pairing_core/engine.py +489 -0
- pairing_core/envelope.py +272 -0
- pairing_core/errors.py +130 -0
- pairing_core/exchange.py +217 -0
- pairing_core/explain.py +145 -0
- pairing_core/fide2026/__init__.py +38 -0
- pairing_core/fide2026/api.py +52 -0
- pairing_core/fide2026/baku.py +63 -0
- pairing_core/fide2026/burstein.py +465 -0
- pairing_core/fide2026/common.py +222 -0
- pairing_core/fide2026/double_team.py +454 -0
- pairing_core/fide2026/dubov.py +520 -0
- pairing_core/fide2026/dutch.py +595 -0
- pairing_core/fide2026/lim.py +817 -0
- pairing_core/fide2026/models.py +340 -0
- pairing_core/fide2026/olympiad.py +522 -0
- pairing_core/floats.py +284 -0
- pairing_core/harness/__init__.py +36 -0
- pairing_core/harness/compare.py +144 -0
- pairing_core/harness/corpus.py +102 -0
- pairing_core/models.py +458 -0
- pairing_core/pairer.py +712 -0
- pairing_core/provider.py +159 -0
- pairing_core/registry.py +111 -0
- pairing_core/roundrobin.py +114 -0
- pairing_core/rulesets.py +154 -0
- pairing_core/validator.py +668 -0
- pairing_core-0.4.1.dist-info/METADATA +486 -0
- pairing_core-0.4.1.dist-info/RECORD +44 -0
- pairing_core-0.4.1.dist-info/WHEEL +5 -0
- pairing_core-0.4.1.dist-info/licenses/LICENSE +21 -0
- pairing_core-0.4.1.dist-info/top_level.txt +1 -0
pairing_core/__init__.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pairing-core — deterministic chess pairing library.
|
|
3
|
+
|
|
4
|
+
Pairing systems: frozen ``dutch-till2026-compat`` Dutch kernel, the 2026
|
|
5
|
+
family (``pairing_core.fide2026``: dutch/dubov/burstein/lim/double/team/
|
|
6
|
+
olympiad + baku modifier), and Berger round robin. External engines
|
|
7
|
+
(BBP, JaVaFo) are bring-your-own-binary adapters at
|
|
8
|
+
``pairing_core.adapters`` (execution + TRF interchange; no provider
|
|
9
|
+
integration, nothing bundled). No persistence, no network.
|
|
10
|
+
|
|
11
|
+
Historical provenance: the frozen Dutch kernel was extracted
|
|
12
|
+
behavior-preserving from 20kevit/chess-manager ``domain/pairing/``
|
|
13
|
+
(v0.1.0 goldens pin it). Current capability authority:
|
|
14
|
+
``docs/CAPABILITY.md``; conformance standing (explicit interpretations,
|
|
15
|
+
no FIDE endorsement claimed):
|
|
16
|
+
``docs/audit/FIDE_CONFORMANCE_MATRIX.md``.
|
|
17
|
+
|
|
18
|
+
Public contract:
|
|
19
|
+
pair_canonical(...), CanonicalPlayer, CanonicalRequest,
|
|
20
|
+
pair(...), pair_detailed(...), pair_via(...), EngineRequest,
|
|
21
|
+
EngineProvider, Registry, RulesetId, ExecutionBudgets, CancelToken,
|
|
22
|
+
PlayerData, PairingCard, RoundResult, validate_round(...), ...
|
|
23
|
+
|
|
24
|
+
Canonical engine abstraction:
|
|
25
|
+
PairingEngine.pair(request) -> RoundResult
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from pairing_core.models import PlayerData, PairingCard, RoundResult
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from pairing_core.models import PlayerSnapshot
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
PlayerSnapshot = PlayerData # type: ignore
|
|
34
|
+
|
|
35
|
+
from pairing_core.engine import pair_round, SwissEngine
|
|
36
|
+
from pairing_core.validator import validate_round, ValidationReport, Finding
|
|
37
|
+
from pairing_core.api import PairingRequest, PairingEngine, NativeDutchEngine
|
|
38
|
+
from pairing_core.api import (
|
|
39
|
+
EngineRequest,
|
|
40
|
+
pair,
|
|
41
|
+
pair_detailed,
|
|
42
|
+
pair_via,
|
|
43
|
+
validate_request,
|
|
44
|
+
versions,
|
|
45
|
+
)
|
|
46
|
+
from pairing_core.errors import (
|
|
47
|
+
CancelledError,
|
|
48
|
+
DuplicatePlayerIdError,
|
|
49
|
+
EngineTimeoutError,
|
|
50
|
+
EngineUnavailableError,
|
|
51
|
+
ImpossiblePairingError,
|
|
52
|
+
InternalError,
|
|
53
|
+
InvalidPlayerError,
|
|
54
|
+
InvalidRequestError,
|
|
55
|
+
PairingError,
|
|
56
|
+
UnsupportedCapabilityError,
|
|
57
|
+
UnsupportedRulesetError,
|
|
58
|
+
VersionMismatchError,
|
|
59
|
+
)
|
|
60
|
+
from pairing_core.rulesets import (
|
|
61
|
+
DUTCH_TILL2026_COMPAT,
|
|
62
|
+
ConstraintSet,
|
|
63
|
+
RulesetId,
|
|
64
|
+
resolve_ruleset,
|
|
65
|
+
)
|
|
66
|
+
from pairing_core.envelope import Pairing, RoundPairing
|
|
67
|
+
from pairing_core.controls import CancelToken, ExecutionBudgets
|
|
68
|
+
from pairing_core.provider import (
|
|
69
|
+
Capability,
|
|
70
|
+
EngineMetadata,
|
|
71
|
+
EngineProvider,
|
|
72
|
+
NativeDutchProvider,
|
|
73
|
+
)
|
|
74
|
+
from pairing_core.registry import Registry, create_default_registry
|
|
75
|
+
from pairing_core.roundrobin import round_robin
|
|
76
|
+
from pairing_core.explain import (
|
|
77
|
+
BoardExplanation,
|
|
78
|
+
ByeExplanation,
|
|
79
|
+
Explanation,
|
|
80
|
+
explain,
|
|
81
|
+
)
|
|
82
|
+
from pairing_core.canonical import (
|
|
83
|
+
CANONICAL_REQUEST_SCHEMA,
|
|
84
|
+
KNOWN_SYSTEMS,
|
|
85
|
+
SYSTEM_DUTCH,
|
|
86
|
+
SYSTEM_ROUND_ROBIN,
|
|
87
|
+
CanonicalPlayer,
|
|
88
|
+
CanonicalRequest,
|
|
89
|
+
canonical_json,
|
|
90
|
+
describe_systems,
|
|
91
|
+
pair_canonical,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
__version__ = "0.4.1"
|
|
95
|
+
# Frozen-kernel reference marker (historical donor claim for the
|
|
96
|
+
# pre-2026 Dutch formulation; kept byte-stable for consumers).
|
|
97
|
+
# Current evidence posture: docs/CAPABILITY.md + conformance matrix.
|
|
98
|
+
__fide_reference__ = "C.04.2 + C.04.3 (effective 1 July 2025)"
|
|
99
|
+
|
|
100
|
+
__all__ = [
|
|
101
|
+
"PlayerData",
|
|
102
|
+
"PlayerSnapshot",
|
|
103
|
+
"PairingCard",
|
|
104
|
+
"RoundResult",
|
|
105
|
+
"PairingRequest",
|
|
106
|
+
"PairingEngine",
|
|
107
|
+
"NativeDutchEngine",
|
|
108
|
+
"SwissEngine",
|
|
109
|
+
"pair_round",
|
|
110
|
+
"validate_round",
|
|
111
|
+
"ValidationReport",
|
|
112
|
+
"Finding",
|
|
113
|
+
"EngineRequest",
|
|
114
|
+
"pair",
|
|
115
|
+
"pair_detailed",
|
|
116
|
+
"pair_via",
|
|
117
|
+
"validate_request",
|
|
118
|
+
"versions",
|
|
119
|
+
"RulesetId",
|
|
120
|
+
"ConstraintSet",
|
|
121
|
+
"resolve_ruleset",
|
|
122
|
+
"DUTCH_TILL2026_COMPAT",
|
|
123
|
+
"Pairing",
|
|
124
|
+
"RoundPairing",
|
|
125
|
+
"ExecutionBudgets",
|
|
126
|
+
"CancelToken",
|
|
127
|
+
"EngineProvider",
|
|
128
|
+
"NativeDutchProvider",
|
|
129
|
+
"Capability",
|
|
130
|
+
"EngineMetadata",
|
|
131
|
+
"Registry",
|
|
132
|
+
"create_default_registry",
|
|
133
|
+
"round_robin",
|
|
134
|
+
"explain",
|
|
135
|
+
"Explanation",
|
|
136
|
+
"BoardExplanation",
|
|
137
|
+
"ByeExplanation",
|
|
138
|
+
"CanonicalPlayer",
|
|
139
|
+
"CanonicalRequest",
|
|
140
|
+
"pair_canonical",
|
|
141
|
+
"canonical_json",
|
|
142
|
+
"describe_systems",
|
|
143
|
+
"CANONICAL_REQUEST_SCHEMA",
|
|
144
|
+
"KNOWN_SYSTEMS",
|
|
145
|
+
"SYSTEM_DUTCH",
|
|
146
|
+
"SYSTEM_ROUND_ROBIN",
|
|
147
|
+
"PairingError",
|
|
148
|
+
"InvalidRequestError",
|
|
149
|
+
"InvalidPlayerError",
|
|
150
|
+
"DuplicatePlayerIdError",
|
|
151
|
+
"ImpossiblePairingError",
|
|
152
|
+
"EngineTimeoutError",
|
|
153
|
+
"CancelledError",
|
|
154
|
+
"EngineUnavailableError",
|
|
155
|
+
"UnsupportedCapabilityError",
|
|
156
|
+
"UnsupportedRulesetError",
|
|
157
|
+
"VersionMismatchError",
|
|
158
|
+
"InternalError",
|
|
159
|
+
"__version__",
|
|
160
|
+
"__fide_reference__",
|
|
161
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""External-engine adapters (adapter edge, NOT core domain).
|
|
2
|
+
|
|
3
|
+
Never imported by pairing-core core modules (import-lint enforced):
|
|
4
|
+
TRF types, subprocess execution, and vendor-specific conventions live
|
|
5
|
+
exclusively here. Adapters are bring-your-own-binary (O04/O06); absence of a
|
|
6
|
+
binary is a typed failure, never a silent fallback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pairing_core.adapters.trf import (
|
|
10
|
+
TournamentInput,
|
|
11
|
+
TrfPlayer,
|
|
12
|
+
TrfRound,
|
|
13
|
+
build_trf,
|
|
14
|
+
from_engine_request,
|
|
15
|
+
parse_pairing_output,
|
|
16
|
+
parse_trf,
|
|
17
|
+
validate_external_pairs,
|
|
18
|
+
)
|
|
19
|
+
from pairing_core.adapters import bbp, javafo, trf
|
|
20
|
+
from pairing_core.adapters.bbp import (
|
|
21
|
+
BBPConfig,
|
|
22
|
+
BBPPairing,
|
|
23
|
+
pair_tournament,
|
|
24
|
+
probe_version,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"bbp",
|
|
29
|
+
"javafo",
|
|
30
|
+
"trf",
|
|
31
|
+
"TournamentInput",
|
|
32
|
+
"TrfPlayer",
|
|
33
|
+
"TrfRound",
|
|
34
|
+
"build_trf",
|
|
35
|
+
"from_engine_request",
|
|
36
|
+
"parse_pairing_output",
|
|
37
|
+
"parse_trf",
|
|
38
|
+
"validate_external_pairs",
|
|
39
|
+
"BBPConfig",
|
|
40
|
+
"BBPPairing",
|
|
41
|
+
"pair_tournament",
|
|
42
|
+
"probe_version",
|
|
43
|
+
]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Supervised subprocess execution for engine adapters (INTERNAL).
|
|
2
|
+
|
|
3
|
+
Not exported from pairing_core.adapters. Rules (SECURITY.md posture,
|
|
4
|
+
blueprint H, O10):
|
|
5
|
+
|
|
6
|
+
- argv lists only, never shell strings (no shell=True, no unsanitized
|
|
7
|
+
interpolation). Timeouts kill after a grace period and reap (no zombies).
|
|
8
|
+
- Bounded stdout/stderr capture (10 MiB each; beyond -> InternalError, not
|
|
9
|
+
memory exhaustion). The bound is enforced DURING collection by reader
|
|
10
|
+
threads that kill the child as soon as either stream exceeds the cap, so
|
|
11
|
+
a hostile executable cannot grow parent memory by writing unlimited
|
|
12
|
+
output. Strict UTF-8 decode; undecodable bytes -> InternalError.
|
|
13
|
+
- Args, executable path, and working directory are caller-supplied and
|
|
14
|
+
explicit (BYO binaries). No PATH searching, no downloading, no guessing.
|
|
15
|
+
- Returns CompletedRun; interpretation (exit-code mapping) belongs to the
|
|
16
|
+
per-engine adapter, which has the authoritative manual.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import BinaryIO, List, Optional, Sequence
|
|
28
|
+
|
|
29
|
+
from pairing_core.errors import EngineTimeoutError, InternalError
|
|
30
|
+
|
|
31
|
+
OUTPUT_CAP = 10 * 1024 * 1024
|
|
32
|
+
KILL_GRACE_SECONDS = 5.0
|
|
33
|
+
_READ_CHUNK = 65536
|
|
34
|
+
_POLL_INTERVAL_SECONDS = 0.05
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class CompletedRun:
|
|
39
|
+
"""INTERNAL. Captured outcome of one supervised execution."""
|
|
40
|
+
argv: tuple
|
|
41
|
+
returncode: int
|
|
42
|
+
stdout: str
|
|
43
|
+
stderr: str
|
|
44
|
+
elapsed_seconds: float
|
|
45
|
+
timed_out: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _pump_pipe(stream: BinaryIO, buf: bytearray, lock: threading.Lock,
|
|
49
|
+
over: List[bool], over_event: threading.Event) -> None:
|
|
50
|
+
"""INTERNAL. Drain one child pipe in chunks, enforcing OUTPUT_CAP.
|
|
51
|
+
|
|
52
|
+
Runs on a daemon reader thread so both pipes drain concurrently (no
|
|
53
|
+
deadlock when the child fills one pipe while writing the other).
|
|
54
|
+
Sets over/over_event the moment this stream exceeds the cap; the
|
|
55
|
+
main thread then kills the child. Parent memory per stream never
|
|
56
|
+
exceeds OUTPUT_CAP + one chunk, regardless of child output volume.
|
|
57
|
+
A closed/torn-down pipe reads as EOF (ValueError/OSError tolerated).
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
chunk = stream.read(_READ_CHUNK)
|
|
62
|
+
if not chunk:
|
|
63
|
+
return
|
|
64
|
+
with lock:
|
|
65
|
+
buf += chunk
|
|
66
|
+
if len(buf) > OUTPUT_CAP and not over[0]:
|
|
67
|
+
over[0] = True
|
|
68
|
+
over_event.set()
|
|
69
|
+
return
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _finish_readers(proc: subprocess.Popen,
|
|
75
|
+
readers: List[threading.Thread]) -> None:
|
|
76
|
+
"""INTERNAL. Bounded drain of reader threads, then close pipe fds.
|
|
77
|
+
|
|
78
|
+
Readers normally finish on child EOF. If a thread is still blocked
|
|
79
|
+
(e.g. a grandchild inherited the pipe), closing the fds unblocks it:
|
|
80
|
+
the pump treats the resulting ValueError as EOF. Threads are daemon,
|
|
81
|
+
so nothing here can hang interpreter shutdown.
|
|
82
|
+
"""
|
|
83
|
+
for thread in readers:
|
|
84
|
+
thread.join(timeout=KILL_GRACE_SECONDS)
|
|
85
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
86
|
+
try:
|
|
87
|
+
if pipe is not None:
|
|
88
|
+
pipe.close()
|
|
89
|
+
except (OSError, ValueError):
|
|
90
|
+
pass
|
|
91
|
+
for thread in readers:
|
|
92
|
+
thread.join(timeout=KILL_GRACE_SECONDS)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _kill_quietly(proc: subprocess.Popen) -> None:
|
|
96
|
+
"""INTERNAL. SIGKILL that tolerates an already-exited child."""
|
|
97
|
+
try:
|
|
98
|
+
proc.kill()
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_command(argv: Sequence[str], *, timeout_seconds: float,
|
|
104
|
+
workdir: Optional[str] = None) -> CompletedRun:
|
|
105
|
+
"""Run argv (no shell) with timeout; kill-after-grace; bounded capture.
|
|
106
|
+
|
|
107
|
+
Timeout -> EngineTimeoutError (after terminating + reaping the child).
|
|
108
|
+
Either stream exceeding 10 MiB -> InternalError, raised promptly while
|
|
109
|
+
the child is still producing output (the child is killed first).
|
|
110
|
+
Spawn failures (missing/unexecutable) -> InternalError here; adapters
|
|
111
|
+
pre-check paths and raise EngineUnavailableError with better context.
|
|
112
|
+
"""
|
|
113
|
+
if timeout_seconds is None or not timeout_seconds > 0:
|
|
114
|
+
raise InternalError("run_command requires timeout_seconds > 0.")
|
|
115
|
+
start = time.monotonic()
|
|
116
|
+
try:
|
|
117
|
+
proc = subprocess.Popen(
|
|
118
|
+
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
119
|
+
cwd=workdir, shell=False)
|
|
120
|
+
except (OSError, ValueError) as exc:
|
|
121
|
+
raise InternalError(f"cannot spawn {argv[0]!r}: {exc}") from exc
|
|
122
|
+
out_buf = bytearray()
|
|
123
|
+
err_buf = bytearray()
|
|
124
|
+
lock = threading.Lock()
|
|
125
|
+
over = [False]
|
|
126
|
+
over_event = threading.Event()
|
|
127
|
+
readers = [
|
|
128
|
+
threading.Thread(target=_pump_pipe,
|
|
129
|
+
args=(proc.stdout, out_buf, lock, over, over_event),
|
|
130
|
+
name="pairing-core-stdout-pump", daemon=True),
|
|
131
|
+
threading.Thread(target=_pump_pipe,
|
|
132
|
+
args=(proc.stderr, err_buf, lock, over, over_event),
|
|
133
|
+
name="pairing-core-stderr-pump", daemon=True),
|
|
134
|
+
]
|
|
135
|
+
for thread in readers:
|
|
136
|
+
thread.start()
|
|
137
|
+
try:
|
|
138
|
+
deadline = start + timeout_seconds
|
|
139
|
+
timed_out = False
|
|
140
|
+
while True:
|
|
141
|
+
if over_event.is_set():
|
|
142
|
+
_kill_quietly(proc) # no-op if the child already exited
|
|
143
|
+
break
|
|
144
|
+
if proc.poll() is not None:
|
|
145
|
+
break
|
|
146
|
+
remaining = deadline - time.monotonic()
|
|
147
|
+
if remaining <= 0:
|
|
148
|
+
timed_out = True
|
|
149
|
+
_kill_quietly(proc)
|
|
150
|
+
break
|
|
151
|
+
over_event.wait(timeout=min(_POLL_INTERVAL_SECONDS, remaining))
|
|
152
|
+
if timed_out:
|
|
153
|
+
_finish_readers(proc, readers)
|
|
154
|
+
elapsed = time.monotonic() - start
|
|
155
|
+
raise EngineTimeoutError(
|
|
156
|
+
f"engine exceeded {timeout_seconds}s wall-clock budget "
|
|
157
|
+
f"(killed after {elapsed:.1f}s).") from None
|
|
158
|
+
if over_event.is_set():
|
|
159
|
+
_finish_readers(proc, readers)
|
|
160
|
+
raise InternalError(
|
|
161
|
+
"engine output exceeded 10 MiB capture cap.")
|
|
162
|
+
# Normal exit: bounded drain of already-buffered pipe data.
|
|
163
|
+
_finish_readers(proc, readers)
|
|
164
|
+
out = bytes(out_buf)
|
|
165
|
+
err = bytes(err_buf)
|
|
166
|
+
finally:
|
|
167
|
+
# Absolute cleanup on every path: no zombies, no leaked fds.
|
|
168
|
+
try:
|
|
169
|
+
if proc.poll() is None:
|
|
170
|
+
proc.kill()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
try:
|
|
174
|
+
proc.wait(timeout=KILL_GRACE_SECONDS)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
proc.kill()
|
|
177
|
+
proc.wait()
|
|
178
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
179
|
+
try:
|
|
180
|
+
if pipe is not None:
|
|
181
|
+
pipe.close()
|
|
182
|
+
except (OSError, ValueError):
|
|
183
|
+
pass
|
|
184
|
+
elapsed = time.monotonic() - start
|
|
185
|
+
try:
|
|
186
|
+
stdout = out.decode("utf-8")
|
|
187
|
+
stderr = err.decode("utf-8")
|
|
188
|
+
except UnicodeDecodeError as exc:
|
|
189
|
+
raise InternalError(
|
|
190
|
+
f"engine output is not UTF-8: {exc}") from exc
|
|
191
|
+
return CompletedRun(argv=tuple(argv), returncode=proc.returncode,
|
|
192
|
+
stdout=stdout, stderr=stderr,
|
|
193
|
+
elapsed_seconds=elapsed, timed_out=False)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_executable(path: str, what: str) -> str:
|
|
197
|
+
"""INTERNAL. Validate an explicit binary path (exists + executable)."""
|
|
198
|
+
from pairing_core.errors import EngineUnavailableError
|
|
199
|
+
|
|
200
|
+
if not isinstance(path, str) or not path:
|
|
201
|
+
raise EngineUnavailableError(f"{what}: explicit path required.")
|
|
202
|
+
if not os.path.isfile(path) or not os.access(path, os.X_OK):
|
|
203
|
+
raise EngineUnavailableError(
|
|
204
|
+
f"{what}: not an executable file: {path!r}.")
|
|
205
|
+
return path
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def temp_workdir() -> tempfile.TemporaryDirectory:
|
|
209
|
+
"""INTERNAL. Scratch dir for one adapter run (caller cleans up)."""
|
|
210
|
+
return tempfile.TemporaryDirectory(prefix="pairing-core-")
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""BBP external reference adapter (W3 foundation).
|
|
2
|
+
|
|
3
|
+
Bring-your-own-binary; never bundled, never auto-discovered (O04/O06).
|
|
4
|
+
No BBP structures leak into core: conversion happens here, at the adapter
|
|
5
|
+
boundary, via the TRF subset (adapters/trf.py). No EngineProvider is shipped
|
|
6
|
+
yet — provider integration waits until request histories can carry
|
|
7
|
+
per-round data (later phase); this module is the execution + conversion
|
|
8
|
+
layer the harness and a future provider will share.
|
|
9
|
+
|
|
10
|
+
Authoritative behavior source: BBP README (primary, retrieved in full —
|
|
11
|
+
CLI ``--dutch/--burstein -p/-c/-g/-r/-s``, TRF-2026 + TRF(bx), exit codes
|
|
12
|
+
0-5, checklist + RTG, Apache-2.0). Only the Dutch pairing path is wrapped;
|
|
13
|
+
Burstein is self-declared flawed upstream and NOT wrapped (refusing, not
|
|
14
|
+
misrepresenting).
|
|
15
|
+
|
|
16
|
+
Exit-code mapping (per BBP manual):
|
|
17
|
+
0 ok | 1 no valid pairing -> ImpossiblePairingError
|
|
18
|
+
2 unexpected -> InternalError | 3 invalid request -> InvalidRequestError
|
|
19
|
+
4 unhandleable size -> InternalError (resource exhaustion, captured)
|
|
20
|
+
5 file access -> EngineUnavailableError | other -> InternalError.
|
|
21
|
+
Timeout (ours, wall-clock) -> EngineTimeoutError with termination.
|
|
22
|
+
Missing/unexecutable binary or failed version probe -> EngineUnavailableError.
|
|
23
|
+
Malformed pairing output -> InternalError with captured excerpt.
|
|
24
|
+
|
|
25
|
+
Live-binary verification is PENDING (no binary in this environment); the
|
|
26
|
+
execution path is tested against fixture stub executables.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import os
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
from pairing_core.adapters._process import (
|
|
36
|
+
CompletedRun,
|
|
37
|
+
check_executable,
|
|
38
|
+
run_command,
|
|
39
|
+
temp_workdir,
|
|
40
|
+
)
|
|
41
|
+
from pairing_core.adapters.trf import (
|
|
42
|
+
TournamentInput,
|
|
43
|
+
build_trf,
|
|
44
|
+
parse_pairing_output,
|
|
45
|
+
validate_external_pairs,
|
|
46
|
+
)
|
|
47
|
+
from pairing_core.errors import (
|
|
48
|
+
EngineTimeoutError,
|
|
49
|
+
EngineUnavailableError,
|
|
50
|
+
ImpossiblePairingError,
|
|
51
|
+
InternalError,
|
|
52
|
+
InvalidRequestError,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
BBP_SYSTEM_DUTCH = "--dutch"
|
|
56
|
+
EXIT_DESCRIPTIONS = {
|
|
57
|
+
1: "no valid pairing exists for the current round",
|
|
58
|
+
2: "unexpected engine error",
|
|
59
|
+
3: "invalid request (e.g. malformed input file)",
|
|
60
|
+
4: "unhandleable data size / out of memory",
|
|
61
|
+
5: "file access error",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class BBPConfig:
|
|
67
|
+
"""PUBLIC. Explicit BYO-binary configuration (no discovery)."""
|
|
68
|
+
executable: str
|
|
69
|
+
timeout_seconds: float = 60.0
|
|
70
|
+
system: str = BBP_SYSTEM_DUTCH
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if self.system != BBP_SYSTEM_DUTCH:
|
|
74
|
+
raise InvalidRequestError(
|
|
75
|
+
"only --dutch is wrapped (Burstein upstream is "
|
|
76
|
+
"self-declared flawed; refusing to wrap it).")
|
|
77
|
+
if not isinstance(self.timeout_seconds, (int, float)) or \
|
|
78
|
+
isinstance(self.timeout_seconds, bool) or \
|
|
79
|
+
not self.timeout_seconds > 0:
|
|
80
|
+
raise InvalidRequestError("timeout_seconds must be a number > 0.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class BBPPairing:
|
|
85
|
+
"""PUBLIC. Captured outcome of one BBP Dutch pairing run."""
|
|
86
|
+
pairs: Tuple[Tuple[int, Optional[int]], ...]
|
|
87
|
+
engine_version: str
|
|
88
|
+
elapsed_seconds: float
|
|
89
|
+
command: Tuple[str, ...] = ()
|
|
90
|
+
stdout: str = ""
|
|
91
|
+
stderr: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def probe_version(executable: str,
|
|
95
|
+
timeout_seconds: float = 30.0) -> str:
|
|
96
|
+
"""Run `<exe> -r`; return the release/build line.
|
|
97
|
+
|
|
98
|
+
Failure (any) -> EngineUnavailableError: version identity is required
|
|
99
|
+
for reproducibility metadata, never guessed.
|
|
100
|
+
"""
|
|
101
|
+
exe = check_executable(executable, "BBP executable")
|
|
102
|
+
try:
|
|
103
|
+
run = run_command([exe, "-r"], timeout_seconds=timeout_seconds)
|
|
104
|
+
except (InternalError, EngineTimeoutError) as exc:
|
|
105
|
+
raise EngineUnavailableError(
|
|
106
|
+
f"BBP version probe failed for {exe!r}: {exc}") from exc
|
|
107
|
+
first = run.stdout.strip().splitlines()
|
|
108
|
+
if run.returncode != 0 or not first:
|
|
109
|
+
raise EngineUnavailableError(
|
|
110
|
+
f"BBP version probe failed for {exe!r}: exit "
|
|
111
|
+
f"{run.returncode}, stderr={run.stderr[:500]!r}.")
|
|
112
|
+
return first[0].strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def pair_tournament(config: BBPConfig,
|
|
116
|
+
tournament: TournamentInput) -> BBPPairing:
|
|
117
|
+
"""Run BBP Dutch pairing over a TRF input; return captured pairs.
|
|
118
|
+
|
|
119
|
+
Raises per the exit-code mapping above. stdout/stderr/command/version
|
|
120
|
+
are captured for harness diagnostics and reproducibility.
|
|
121
|
+
"""
|
|
122
|
+
exe = check_executable(config.executable, "BBP executable")
|
|
123
|
+
version = probe_version(exe)
|
|
124
|
+
text = build_trf(tournament)
|
|
125
|
+
with temp_workdir() as workdir:
|
|
126
|
+
src = os.path.join(workdir, "input.trf")
|
|
127
|
+
out = os.path.join(workdir, "output.txt")
|
|
128
|
+
try:
|
|
129
|
+
with open(src, "w", encoding="utf-8") as fh:
|
|
130
|
+
fh.write(text)
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
raise InternalError(
|
|
133
|
+
f"cannot write BBP input file: {exc}") from exc
|
|
134
|
+
argv = [exe, config.system, src, "-p", out]
|
|
135
|
+
try:
|
|
136
|
+
run = run_command(argv, timeout_seconds=config.timeout_seconds,
|
|
137
|
+
workdir=workdir)
|
|
138
|
+
except EngineTimeoutError:
|
|
139
|
+
raise
|
|
140
|
+
_check_exit(run, exe)
|
|
141
|
+
pairs = _read_output(out, exe)
|
|
142
|
+
validate_external_pairs(
|
|
143
|
+
pairs,
|
|
144
|
+
tuple(p.pairing_id for p in tournament.players),
|
|
145
|
+
absent_ids=tuple(tournament.absent_ids),
|
|
146
|
+
engine=f"BBP {exe!r}")
|
|
147
|
+
return BBPPairing(pairs=tuple(pairs), engine_version=version,
|
|
148
|
+
elapsed_seconds=run.elapsed_seconds,
|
|
149
|
+
command=tuple(argv), stdout=run.stdout,
|
|
150
|
+
stderr=run.stderr)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_exit(run: CompletedRun, exe: str) -> None:
|
|
154
|
+
code = run.returncode
|
|
155
|
+
if code == 0:
|
|
156
|
+
return
|
|
157
|
+
detail = EXIT_DESCRIPTIONS.get(code, f"unknown exit code {code}")
|
|
158
|
+
context = f"BBP {exe!r} exit {code} ({detail}); stderr={run.stderr[:500]!r}."
|
|
159
|
+
if code == 1:
|
|
160
|
+
raise ImpossiblePairingError(context)
|
|
161
|
+
if code == 3:
|
|
162
|
+
raise InvalidRequestError(context)
|
|
163
|
+
if code == 5:
|
|
164
|
+
raise EngineUnavailableError(context)
|
|
165
|
+
raise InternalError(context)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_output(path: str, exe: str) -> List[Tuple[int, Optional[int]]]:
|
|
169
|
+
try:
|
|
170
|
+
with open(path, encoding="utf-8") as fh:
|
|
171
|
+
text = fh.read()
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
raise InternalError(
|
|
174
|
+
f"BBP {exe!r} produced no readable output file: {exc}") from exc
|
|
175
|
+
try:
|
|
176
|
+
return parse_pairing_output(text)
|
|
177
|
+
except InternalError as exc:
|
|
178
|
+
raise InternalError(f"BBP {exe!r} malformed pairing output: "
|
|
179
|
+
f"{exc}") from exc
|