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.
Files changed (44) hide show
  1. pairing_core/__init__.py +161 -0
  2. pairing_core/adapters/__init__.py +43 -0
  3. pairing_core/adapters/_process.py +210 -0
  4. pairing_core/adapters/bbp.py +179 -0
  5. pairing_core/adapters/javafo.py +160 -0
  6. pairing_core/adapters/trf.py +496 -0
  7. pairing_core/api.py +408 -0
  8. pairing_core/bracket.py +376 -0
  9. pairing_core/bye.py +164 -0
  10. pairing_core/canonical.py +326 -0
  11. pairing_core/color.py +249 -0
  12. pairing_core/controls.py +84 -0
  13. pairing_core/engine.py +489 -0
  14. pairing_core/envelope.py +272 -0
  15. pairing_core/errors.py +130 -0
  16. pairing_core/exchange.py +217 -0
  17. pairing_core/explain.py +145 -0
  18. pairing_core/fide2026/__init__.py +38 -0
  19. pairing_core/fide2026/api.py +52 -0
  20. pairing_core/fide2026/baku.py +63 -0
  21. pairing_core/fide2026/burstein.py +465 -0
  22. pairing_core/fide2026/common.py +222 -0
  23. pairing_core/fide2026/double_team.py +454 -0
  24. pairing_core/fide2026/dubov.py +520 -0
  25. pairing_core/fide2026/dutch.py +595 -0
  26. pairing_core/fide2026/lim.py +817 -0
  27. pairing_core/fide2026/models.py +340 -0
  28. pairing_core/fide2026/olympiad.py +522 -0
  29. pairing_core/floats.py +284 -0
  30. pairing_core/harness/__init__.py +36 -0
  31. pairing_core/harness/compare.py +144 -0
  32. pairing_core/harness/corpus.py +102 -0
  33. pairing_core/models.py +458 -0
  34. pairing_core/pairer.py +712 -0
  35. pairing_core/provider.py +159 -0
  36. pairing_core/registry.py +111 -0
  37. pairing_core/roundrobin.py +114 -0
  38. pairing_core/rulesets.py +154 -0
  39. pairing_core/validator.py +668 -0
  40. pairing_core-0.4.1.dist-info/METADATA +486 -0
  41. pairing_core-0.4.1.dist-info/RECORD +44 -0
  42. pairing_core-0.4.1.dist-info/WHEEL +5 -0
  43. pairing_core-0.4.1.dist-info/licenses/LICENSE +21 -0
  44. pairing_core-0.4.1.dist-info/top_level.txt +1 -0
@@ -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