oep 0.1.0__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.
oep/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """``oep`` — the Open Experiment Protocol researcher-facing package.
2
+
3
+ Today this ships one surface, :mod:`oep.conductor` (a conductor-mastered
4
+ client for the ``oep-draft-network`` extension protocol). ``oep.data`` and
5
+ ``oep.assets`` are reserved names for future surfaces (documented only —
6
+ no stub code ships here yet).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["__version__"]
@@ -0,0 +1,53 @@
1
+ """``oep.conductor`` — conductor-mastered client for the ``oep-draft-network``
2
+ extension protocol.
3
+
4
+ Re-exports the full researcher-facing surface: the ``run_study`` generator
5
+ front door (TLS and ``replay_transcript=`` supported), the class-API
6
+ ``serve_strategy``/``serve_replay`` front doors and the ``TrialStrategy``
7
+ Protocol they drive, the ``Break``/``BREAK`` sentinel, the
8
+ ``TrialResult``/``TrialResultMeta``/``SessionSummary`` result types, and the
9
+ package's public exception types (``errors`` itself is also re-exported as a
10
+ submodule, so ``oep.conductor.errors.SomeError`` always works even for a
11
+ name this module doesn't re-export directly).
12
+
13
+ Protocol/transcript internals (the envelope codec, the session/sequencing
14
+ state machine, transcript record/replay) are deliberately NOT part of this
15
+ top-level surface — import them from ``oep.conductor.protocol`` /
16
+ ``oep.conductor.transcript`` directly if you need them.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from . import errors
22
+ from .errors import (
23
+ ConductorConnectionLost,
24
+ ProtocolError,
25
+ SingleFlightViolation,
26
+ TranscriptMismatchError,
27
+ TrialSpecError,
28
+ )
29
+ from .generator import run_study
30
+ from .protocol import PROTOCOL_VERSION
31
+ from .results import SessionSummary, TrialResult, TrialResultMeta
32
+ from .server import serve_replay, serve_strategy
33
+ from .strategy import BREAK, Break, TrialParams, TrialStrategy
34
+
35
+ __all__ = [
36
+ "run_study",
37
+ "serve_strategy",
38
+ "serve_replay",
39
+ "Break",
40
+ "BREAK",
41
+ "TrialParams",
42
+ "TrialStrategy",
43
+ "TrialResult",
44
+ "TrialResultMeta",
45
+ "SessionSummary",
46
+ "PROTOCOL_VERSION",
47
+ "TrialSpecError",
48
+ "ConductorConnectionLost",
49
+ "TranscriptMismatchError",
50
+ "SingleFlightViolation",
51
+ "ProtocolError",
52
+ "errors",
53
+ ]
@@ -0,0 +1,84 @@
1
+ """Exceptions raised by :mod:`oep.conductor`.
2
+
3
+ All conductor-facing exceptions live here — every other module (the
4
+ generator bridge, the ``server``/``strategy``/``protocol`` layers) imports
5
+ from this module rather than defining its own; this is the single source of
6
+ truth for the package's error contract, re-exported at ``oep.conductor``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ __all__ = [
12
+ "TrialSpecError",
13
+ "ConductorConnectionLost",
14
+ "TranscriptMismatchError",
15
+ "SingleFlightViolation",
16
+ "ProtocolError",
17
+ ]
18
+
19
+
20
+ class TrialSpecError(Exception):
21
+ """Raised when a value yielded by a study generator (or returned by a
22
+ :class:`~oep.conductor.strategy.TrialStrategy`) fails validation at the
23
+ yield site.
24
+
25
+ Per the package design's yield contract, every yielded value must be a
26
+ ``Break`` or a JSON-safe mapping with string keys and finite values
27
+ (NumPy scalars/arrays are narrowed first; anything else is rejected).
28
+ The message names the offending key path and type so a researcher can
29
+ find the bad value in their own study code without instrumenting it.
30
+ """
31
+
32
+
33
+ class ConductorConnectionLost(Exception):
34
+ """Raised when the transport is lost mid-study (the runner disconnects,
35
+ or the socket errors out, before a ``done``/close was sent).
36
+
37
+ Guaranteed to carry a ``partial_summary`` attribute — a
38
+ ``SessionSummary`` with ``status="connection_lost"`` reflecting how far
39
+ the session got before the transport went away — so callers can recover
40
+ trial-completion accounting even from an abnormal end.
41
+ """
42
+
43
+ partial_summary: object
44
+
45
+
46
+ class TranscriptMismatchError(Exception):
47
+ """Raised during ``replay_transcript=`` playback when a runner's
48
+ outbound message diverges from the transcript's next expectation (by
49
+ type or trial id — never timing/RT, which are expected to vary run to
50
+ run).
51
+
52
+ The message names the transcript line, what was expected, and what was
53
+ actually received.
54
+ """
55
+
56
+
57
+ class SingleFlightViolation(RuntimeError):
58
+ """Raised by :meth:`~oep.conductor.protocol.ConductorSession.send_trial_spec_payload`
59
+ when a caller tries to send a new ``trialSpec`` before the preceding
60
+ one's ``trialResult`` has been received and accepted.
61
+
62
+ This is the sending-side enforcement of the extension spec's normative
63
+ Single-Flight Conductor Profile MUST. It signals a bug in the calling
64
+ strategy/loop, not a wire-level condition — a well-behaved conductor
65
+ should never trigger this in practice.
66
+ """
67
+
68
+
69
+ class ProtocolError(RuntimeError):
70
+ """A protocol-level violation (seq gap, premature message, bad handshake).
71
+
72
+ Per the extension spec, any of these ends the run with reason ``error``
73
+ on this conductor's side too — there is no recovery path for them in
74
+ v0.1 (transparent reconnect is explicitly deferred).
75
+
76
+ This is public and researcher-facing: it escapes
77
+ :func:`~oep.conductor.generator.run_study` (and
78
+ :func:`~oep.conductor.server.serve_strategy`/
79
+ :func:`~oep.conductor.server.serve_replay`) whenever
80
+ :class:`~oep.conductor.protocol.ConductorSession` detects a sequence gap
81
+ or a premature/malformed handshake message -- see the package README's
82
+ failure-semantics section for what a researcher should do when they see
83
+ one.
84
+ """
@@ -0,0 +1,416 @@
1
+ """``run_study`` — the generator front door, and the strict trial-spec
2
+ normalization it enforces at the yield site (package design's §run_study,
3
+ §Yield contract, §Break, §Terminal-state semantics).
4
+
5
+ This module has three parts:
6
+
7
+ - :func:`normalize_trial_params` — a pure, socket-free function implementing
8
+ the yield contract's strict-JSON + NumPy-narrowing rules exactly.
9
+ - :class:`_GeneratorStrategy` — the private bridge adapting a zero-argument
10
+ generator *factory* (the researcher's ``study`` callable) onto the
11
+ :class:`~oep.conductor.strategy.TrialStrategy` shape consumed by
12
+ :func:`~oep.conductor.server.serve_strategy`.
13
+ - :func:`run_study` — composes the bridge with ``serve_strategy``, plus the
14
+ kwarg validation documented in the package design's ``run_study`` signature
15
+ block.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import inspect
22
+ import logging
23
+ import math
24
+ import ssl
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Generator, Mapping, Optional
27
+
28
+ from .errors import TrialSpecError
29
+ from .results import SessionSummary, TrialResult
30
+ from .server import serve_replay, serve_strategy
31
+ from .strategy import Break, TrialParams
32
+
33
+ __all__ = ["run_study", "normalize_trial_params"]
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Yield contract: strict-JSON + NumPy narrowing (pure, socket-free)
38
+ # ---------------------------------------------------------------------------
39
+
40
+ #: The four numpy ABC family names we recognize by walking a value's class
41
+ #: hierarchy (``type(x).__mro__``) — chosen to mirror numpy's own abstract
42
+ #: base classes (``numpy.integer``, ``numpy.floating``, ``numpy.bool_``,
43
+ #: ``numpy.ndarray``) WITHOUT importing numpy and WITHOUT the generic
44
+ #: ``hasattr(x, "tolist")`` duck-typing the spec explicitly rules out: origin
45
+ #: is decided by ``__module__``, kind is decided by class *name* ancestry.
46
+ #: ``bool_`` covers numpy <2.0's class name; numpy >=2.0 renamed the scalar
47
+ #: type's own ``__name__`` to plain ``bool`` (``numpy.bool_`` is now just an
48
+ #: alias) — both are checked so this works across numpy major versions.
49
+ _NUMPY_NDARRAY = "ndarray"
50
+ _NUMPY_BOOL_NAMES = frozenset({"bool_", "bool"})
51
+ _NUMPY_INTEGER = "integer"
52
+ _NUMPY_FLOATING = "floating"
53
+
54
+
55
+ #: Returned by :func:`_numpy_kind` for a NumPy-origin value whose class
56
+ #: hierarchy doesn't match any of the four recognized kinds (e.g. a complex
57
+ #: or structured scalar) -- distinct from ``None``, which means "not
58
+ #: NumPy-origin at all" and falls through to the ordinary JSON-type checks.
59
+ _NUMPY_UNKNOWN = "unknown"
60
+
61
+
62
+ def _numpy_kind(value: Any) -> Optional[str]:
63
+ """Classify a NumPy-origin value's kind (one of ``"ndarray"``,
64
+ ``"bool_"``, ``"integer"``, ``"floating"``, or :data:`_NUMPY_UNKNOWN`),
65
+ or ``None`` if ``value`` isn't NumPy-origin at all (see module docstring
66
+ for the detection strategy)."""
67
+ origin_type = type(value)
68
+ if not origin_type.__module__.startswith("numpy"):
69
+ return None
70
+ ancestor_names = {klass.__name__ for klass in origin_type.__mro__}
71
+ if _NUMPY_NDARRAY in ancestor_names:
72
+ return _NUMPY_NDARRAY
73
+ if ancestor_names & _NUMPY_BOOL_NAMES:
74
+ return "bool_"
75
+ if _NUMPY_INTEGER in ancestor_names:
76
+ return _NUMPY_INTEGER
77
+ if _NUMPY_FLOATING in ancestor_names:
78
+ return _NUMPY_FLOATING
79
+ return _NUMPY_UNKNOWN # NumPy-origin but not one of the four recognized kinds
80
+
81
+
82
+ def _check_finite(value: float, path: str) -> None:
83
+ if math.isnan(value) or math.isinf(value):
84
+ raise TrialSpecError(
85
+ f"{path}: non-finite float ({value!r}) is not JSON-safe "
86
+ "(NaN/Infinity are rejected by the yield contract's strict-JSON rule)"
87
+ )
88
+
89
+
90
+ def _normalize_value(value: Any, *, path: str) -> Any:
91
+ kind = _numpy_kind(value)
92
+ if kind == _NUMPY_NDARRAY:
93
+ # .tolist() then the RESULT is recursively re-validated -- so an
94
+ # object-dtype array with an unsupported element fails element-by-
95
+ # element with a clear path, rather than passing opaquely.
96
+ return _normalize_value(value.tolist(), path=path)
97
+ if kind == "bool_":
98
+ return bool(value)
99
+ if kind == _NUMPY_INTEGER:
100
+ return int(value)
101
+ if kind == _NUMPY_FLOATING:
102
+ converted = float(value)
103
+ _check_finite(converted, path)
104
+ return converted
105
+ if kind is not None:
106
+ # NumPy-origin but not integer/floating/bool_/ndarray (e.g. a
107
+ # complex or structured scalar) -- no safe narrowing rule for it.
108
+ raise TrialSpecError(
109
+ f"{path}: unsupported NumPy type {type(value).__name__!r} "
110
+ "(only integer/floating/bool_/ndarray are narrowed)"
111
+ )
112
+
113
+ # bool MUST be checked before int -- bool is an int subclass in Python.
114
+ if isinstance(value, bool):
115
+ return value
116
+ if isinstance(value, int):
117
+ return value
118
+ if isinstance(value, float):
119
+ _check_finite(value, path)
120
+ return value
121
+ if isinstance(value, str) or value is None:
122
+ return value
123
+ if isinstance(value, Mapping):
124
+ return normalize_trial_params(value, path=path)
125
+ if isinstance(value, (list, tuple)):
126
+ # Tuples normalize to lists per the yield contract.
127
+ return [_normalize_value(item, path=f"{path}[{i}]") for i, item in enumerate(value)]
128
+
129
+ raise TrialSpecError(f"{path}: unsupported type {type(value).__name__!r} is not JSON-safe")
130
+
131
+
132
+ def normalize_trial_params(mapping: Mapping[str, Any], *, path: str = "") -> dict:
133
+ """Validate and normalize one yielded trial-params mapping, per the
134
+ package design's §Yield contract, EXACTLY:
135
+
136
+ - NumPy-origin values are narrowed (`np.integer` -> ``int``,
137
+ `np.floating` -> ``float``, `np.bool_` -> ``bool``, `np.ndarray` ->
138
+ ``.tolist()`` then recursively re-validated) without ever importing
139
+ numpy.
140
+ - Strict JSON: non-finite floats (``nan``/``inf``/``-inf``) are
141
+ rejected; mapping keys must be ``str``; tuples normalize to lists;
142
+ any other type raises :class:`~oep.conductor.errors.TrialSpecError`
143
+ naming the offending key path and type.
144
+
145
+ Returns a **brand-new** ``dict``/``list`` tree built entirely from
146
+ immutable leaves (``str``/``int``/``float``/``bool``/``None``) -- this
147
+ is already the deep-copied snapshot the spec requires (later mutation
148
+ of the researcher's original mapping, or of anything they hold onto,
149
+ cannot reach the normalized values transmitted here).
150
+
151
+ Raises at the call site (no partial results): validation failures
152
+ propagate as :class:`~oep.conductor.errors.TrialSpecError`, letting the
153
+ generator bridge's ``next_trial()`` surface them as a study failure
154
+ (terminal-state "failed" path).
155
+ """
156
+ if not isinstance(mapping, Mapping):
157
+ raise TrialSpecError(
158
+ f"{path or '<trial>'}: expected a mapping, got {type(mapping).__name__!r}"
159
+ )
160
+
161
+ result: dict[str, Any] = {}
162
+ for key, value in mapping.items():
163
+ if not isinstance(key, str):
164
+ key_path = f"{path}.<{key!r}>" if path else f"<{key!r}>"
165
+ raise TrialSpecError(
166
+ f"{key_path}: mapping keys must be str, got {type(key).__name__!r}"
167
+ )
168
+ child_path = f"{path}.{key}" if path else key
169
+ result[key] = _normalize_value(value, path=child_path)
170
+ return result
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # _GeneratorStrategy: factory -> TrialStrategy bridge
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def _reject_if_already_started(study: Any) -> None:
179
+ """Factory-only enforcement, shared by :func:`run_study`'s up-front
180
+ validation and :class:`_GeneratorStrategy`'s own constructor (so the
181
+ bridge is safe even if constructed directly, not just via
182
+ ``run_study``)."""
183
+ if inspect.isgenerator(study):
184
+ raise TypeError(
185
+ "run_study expects the generator FUNCTION itself, not an "
186
+ "already-started generator -- call run_study(study), not "
187
+ "run_study(study()). study is a zero-argument callable that "
188
+ "returns a fresh generator each time it is invoked."
189
+ )
190
+
191
+
192
+ class _GeneratorStrategy:
193
+ """Bridges a zero-argument generator *factory* (the researcher's
194
+ ``study`` callable) onto the
195
+ :class:`~oep.conductor.strategy.TrialStrategy` shape, implementing it
196
+ structurally (``next_trial``/``update``) -- no inheritance from the
197
+ ``Protocol`` is required or used.
198
+
199
+ - The generator is instantiated **inside the session**, i.e. lazily on
200
+ the first :meth:`next_trial` call, not in ``__init__`` -- so calling
201
+ ``run_study`` never runs a byte of the researcher's generator body
202
+ before a runner has actually connected. Each :func:`run_study` call
203
+ constructs a fresh ``_GeneratorStrategy``, so a fresh generator (fresh
204
+ state) results.
205
+ - **Send contract:** after a trial yield, the next resume sends the
206
+ ``TrialResult`` captured via :meth:`update`. After a :class:`Break`
207
+ yield, ``update`` is never called (matching the class-API break
208
+ lifecycle) -- so the next resume instead sends ``None``, per the
209
+ spec's break-resume rule.
210
+ - **Yield validation happens here, at the yield site:** every mapping
211
+ value the generator yields is passed through
212
+ :func:`normalize_trial_params` before being returned to
213
+ ``serve_strategy`` for transmission; a
214
+ :class:`~oep.conductor.errors.TrialSpecError` propagates straight out
215
+ of :meth:`next_trial`, which ``serve_strategy`` treats like any other
216
+ strategy failure (terminal-state "failed": close without ``done``,
217
+ re-raise).
218
+ - **Terminal capture:** the generator's ``StopIteration.value`` is
219
+ captured and exposed via the :meth:`strategy_result` method -- the
220
+ additive seam :func:`~oep.conductor.server.serve_strategy` looks for
221
+ (see that module's ``_capture_strategy_result`` helper) to populate
222
+ ``SessionSummary.strategy_result``. It stays ``None`` if the session
223
+ never reaches normal completion (failure/connection-loss paths).
224
+ """
225
+
226
+ def __init__(
227
+ self, study: Optional[Callable[[], Generator[Any, Any, Any]]]
228
+ ) -> None:
229
+ _reject_if_already_started(study)
230
+ if study is None or not callable(study):
231
+ raise TypeError(
232
+ "study must be a zero-argument callable returning a generator "
233
+ f"(a generator FUNCTION), got {study!r}"
234
+ )
235
+ self._factory = study
236
+ self._gen: Optional[Generator[Any, Any, Any]] = None
237
+ self._started = False
238
+ self._pending_send: Any = None
239
+ self._result: Any = None
240
+
241
+ def strategy_result(self) -> Any:
242
+ """The generator's ``StopIteration.value`` (``None`` unless/until
243
+ the session completes normally) -- the additive seam
244
+ ``serve_strategy`` reads for ``SessionSummary.strategy_result``."""
245
+ return self._result
246
+
247
+ def next_trial(self) -> TrialParams | Break | None:
248
+ if self._gen is None:
249
+ self._gen = self._factory()
250
+ if not inspect.isgenerator(self._gen):
251
+ raise TypeError(
252
+ "study() must return a generator (study must be a generator "
253
+ f"function, i.e. contain a `yield`) -- got "
254
+ f"{type(self._gen).__name__!r}"
255
+ )
256
+
257
+ try:
258
+ if not self._started:
259
+ self._started = True
260
+ value = next(self._gen)
261
+ else:
262
+ value = self._gen.send(self._pending_send)
263
+ except StopIteration as stop:
264
+ self._result = stop.value
265
+ return None
266
+
267
+ if isinstance(value, Break):
268
+ # update() is never called for a break -- the next resume must
269
+ # send None, per the spec's break-resume rule.
270
+ self._pending_send = None
271
+ return value
272
+
273
+ return normalize_trial_params(value)
274
+
275
+ def update(self, result: TrialResult) -> None:
276
+ self._pending_send = result
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # run_study
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def _validate_run_study_kwargs(
285
+ *,
286
+ study: Any,
287
+ ssl_context: Optional[ssl.SSLContext],
288
+ ssl_cert: str | Path | None,
289
+ ssl_key: str | Path | None,
290
+ record_transcript: str | Path | None,
291
+ replay_transcript: str | Path | None,
292
+ ) -> None:
293
+ """All ``ValueError``/``TypeError`` validation for :func:`run_study`'s
294
+ kwargs, run before any socket opens."""
295
+ if record_transcript is not None and replay_transcript is not None:
296
+ raise ValueError(
297
+ "record_transcript= and replay_transcript= are mutually exclusive "
298
+ "-- a session is either recorded or replayed, never both."
299
+ )
300
+ if ssl_cert is not None and ssl_key is None:
301
+ raise ValueError("ssl_cert= requires ssl_key= (they must be supplied together).")
302
+ if ssl_key is not None and ssl_cert is None:
303
+ raise ValueError("ssl_key= requires ssl_cert= (they must be supplied together).")
304
+ if ssl_context is not None and (ssl_cert is not None or ssl_key is not None):
305
+ raise ValueError(
306
+ "ssl_context= is mutually exclusive with ssl_cert=/ssl_key= -- pass "
307
+ "full control via ssl_context=, or the beginner pair, never both."
308
+ )
309
+ if study is None and replay_transcript is None:
310
+ raise TypeError(
311
+ "run_study(study=None) is legal only together with replay_transcript= "
312
+ "(replay never executes a study) -- pass a zero-argument generator "
313
+ "function, e.g. run_study(my_study)."
314
+ )
315
+ if study is not None:
316
+ _reject_if_already_started(study)
317
+
318
+
319
+ def _resolve_ssl_context(
320
+ ssl_context: Optional[ssl.SSLContext],
321
+ ssl_cert: str | Path | None,
322
+ ssl_key: str | Path | None,
323
+ ) -> Optional[ssl.SSLContext]:
324
+ """Resolve ``run_study``'s TLS kwargs into a single ``ssl.SSLContext``
325
+ (or ``None``), per the package design's §run_study: ``ssl_context`` is
326
+ used verbatim; ``ssl_cert``+``ssl_key`` (already validated as a pair by
327
+ :func:`_validate_run_study_kwargs`) build a default
328
+ ``ssl.PROTOCOL_TLS_SERVER`` context via ``load_cert_chain``. Serving
329
+ with either one flips the logged/summarized scheme to ``wss://`` (see
330
+ :func:`~oep.conductor.server.serve_strategy`/:func:`~oep.conductor.server.serve_replay`)."""
331
+ if ssl_context is not None:
332
+ return ssl_context
333
+ if ssl_cert is not None and ssl_key is not None:
334
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
335
+ context.load_cert_chain(certfile=str(ssl_cert), keyfile=str(ssl_key))
336
+ return context
337
+ return None
338
+
339
+
340
+ def run_study(
341
+ study: Optional[Callable[[], Generator[TrialParams | Break, Optional[TrialResult], Any]]],
342
+ *,
343
+ host: str = "127.0.0.1",
344
+ port: int = 8765,
345
+ ssl_context: Optional[ssl.SSLContext] = None,
346
+ ssl_cert: str | Path | None = None,
347
+ ssl_key: str | Path | None = None,
348
+ record_transcript: str | Path | None = None,
349
+ replay_transcript: str | Path | None = None,
350
+ logger: Optional[logging.Logger] = None,
351
+ ) -> SessionSummary:
352
+ """Run a researcher's ``study`` generator as one conductor-mastered
353
+ ``oep-draft-network`` session -- the package's ~15-line hello world
354
+ (package design §run_study).
355
+
356
+ ``study`` is a **zero-argument factory** returning a fresh generator
357
+ (never an already-started generator -- see the factory-only
358
+ ``TypeError`` below); it yields :data:`~oep.conductor.strategy.TrialParams`
359
+ mappings (validated and normalized per :func:`normalize_trial_params`)
360
+ or a :class:`~oep.conductor.strategy.Break`, and receives back a
361
+ :class:`~oep.conductor.results.TrialResult` (or ``None``, after a
362
+ break completes). ``study=None`` is legal ONLY together with
363
+ ``replay_transcript=`` (replay never executes a study); every other
364
+ combination with a missing ``study`` is a ``TypeError`` at call time,
365
+ raised before any socket is opened.
366
+
367
+ Kwarg validation (raised before any socket opens): ``record_transcript``/
368
+ ``replay_transcript`` are mutually exclusive; ``ssl_cert``/``ssl_key``
369
+ must be supplied together and are mutually exclusive with
370
+ ``ssl_context`` -- all as ``ValueError``.
371
+
372
+ TLS: ``ssl_context``, if given, is used verbatim; ``ssl_cert``+
373
+ ``ssl_key`` together build a default ``ssl.PROTOCOL_TLS_SERVER`` context
374
+ (see :func:`_resolve_ssl_context`). Either one serves ``wss://`` instead
375
+ of ``ws://`` (reflected in the served scheme's logs/summary).
376
+
377
+ Replay: ``replay_transcript=`` serves a REAL socket session driven by a
378
+ canned ``.jsonl`` transcript instead of ``study`` -- see
379
+ :func:`~oep.conductor.server.serve_replay` for the full contract
380
+ (package design §Replay contract). Notably this is the one path where
381
+ ``study=None`` is legal, and it is dispatched before
382
+ :class:`_GeneratorStrategy` is ever constructed.
383
+ """
384
+ _validate_run_study_kwargs(
385
+ study=study,
386
+ ssl_context=ssl_context,
387
+ ssl_cert=ssl_cert,
388
+ ssl_key=ssl_key,
389
+ record_transcript=record_transcript,
390
+ replay_transcript=replay_transcript,
391
+ )
392
+
393
+ resolved_ssl_context = _resolve_ssl_context(ssl_context, ssl_cert, ssl_key)
394
+
395
+ if replay_transcript is not None:
396
+ return asyncio.run(
397
+ serve_replay(
398
+ replay_transcript,
399
+ host=host,
400
+ port=port,
401
+ ssl_context=resolved_ssl_context,
402
+ logger=logger,
403
+ )
404
+ )
405
+
406
+ strategy = _GeneratorStrategy(study)
407
+ return asyncio.run(
408
+ serve_strategy(
409
+ strategy,
410
+ host=host,
411
+ port=port,
412
+ ssl_context=resolved_ssl_context,
413
+ record_transcript=record_transcript,
414
+ logger=logger,
415
+ )
416
+ )