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 +13 -0
- oep/conductor/__init__.py +53 -0
- oep/conductor/errors.py +84 -0
- oep/conductor/generator.py +416 -0
- oep/conductor/protocol.py +323 -0
- oep/conductor/results.py +139 -0
- oep/conductor/server.py +444 -0
- oep/conductor/strategy.py +60 -0
- oep/conductor/transcript.py +226 -0
- oep-0.1.0.dist-info/METADATA +535 -0
- oep-0.1.0.dist-info/RECORD +13 -0
- oep-0.1.0.dist-info/WHEEL +4 -0
- oep-0.1.0.dist-info/licenses/LICENSE +201 -0
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
|
+
]
|
oep/conductor/errors.py
ADDED
|
@@ -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
|
+
)
|