Open Experiment Protocol — conductor-mastered experiment client library
Project description
oep — the Open Experiment Protocol conductor client
oep is a small, pure-Python library for writing the conductor side of an adaptive,
conductor-mastered OEP experiment: the external process that decides each trial's
parameters (an up-down staircase, a Bayesian optimizer, an AEPsych Strategy, whatever
you write) and hands them to a runner over a WebSocket, while OEP itself handles
presentation, response collection, timing, and logging.
If you already have AEPsych in your toolkit, you almost certainly want the
AEPsych cookbook
once you've read this page — it's the handoff document for plugging a real Strategy
into the seam described below.
Protocol details (envelopes, sequencing, the single-flight rule, error codes) are
normative in
spec/schema/extensions/oep-draft-network.md.
This README only covers what you need to write a study; you never have to touch the
wire format yourself.
Install
pip install -e 'python/oep[dev]'
(That's the from-source, editable-install form used inside this repo; once oep is
published to PyPI, pip install oep will work the same way. The only runtime
dependency is websockets>=12; Python 3.10+.)
Hello world
The walkthrough below drives a real runner, so it needs an OEP repo checkout (the
webxr-runner app plus the example experiment file) alongside oep — a plain
pip install oep gives you the conductor side (oep.conductor) only, which is enough to
write and unit-test a study() generator against a fake/mocked runner, just not to see
one play out end to end.
The example this README is written against is
examples/network-conductor/brainard-color-threshold.oep.json
— an odd-one-out color-threshold task. Each trial, the conductor sends a reference and a
comparison RGB triple; the participant presses Up/Left/Right for which patch looks
different; the conductor gets the outcome back and decides the next trial.
Here is the entire conductor side — a generator function that yields trial
parameters and receives a result back each time:
from oep.conductor import run_study
def study():
ref = [0.5, 0.42, 0.31]
step = 0.12
for _ in range(8):
comp = list(ref)
comp[1] = min(1.0, comp[1] + step)
result = yield {"refRGB": ref, "compRGB": comp}
step = min(0.45, step + 0.02) if not result.correct else max(0.01, step - 0.02)
summary = run_study(study, port=8765)
print(f"{summary.status}: {summary.trials_completed} trials completed")
Save that as my_study.py and run it:
python3 my_study.py
run_study blocks, listening on ws://127.0.0.1:8765, until one runner connects and
finishes a session.
The runner side
In a second terminal, start the webxr runner's dev server (from the repo root):
cd apps/webxr-runner
npm run dev
This is a Vite dev server on https://localhost:5173 with a self-signed cert —
accept the browser's certificate warning once. Open:
https://localhost:5173/?conductorWs=ws://localhost:8765
?conductorWs= is the runner's shorthand for binding the experiment's "conductor"
channel to a concrete WebSocket URL (it never comes from the experiment document
itself). You'll land on the drop zone — drag
examples/network-conductor/brainard-color-threshold.oep.json onto the page (or use
Browse Folder). The experiment loads, connects to your study() process, and starts
exchanging trials the moment you click through the instructions and press Space.
Respond with the Up / Left / Right arrow keys — the experiment's responseMap
matches on the lowercased key name (arrowup/arrowleft/arrowright; the runner
lowercases every KeyboardEvent.key before matching, so the physical arrow keys are all
you need to press).
Chrome / Edge / Firefox treat ws://localhost as trustworthy from an https://
page, so the plain ws:// URL above works out of the box. Safari blocks it
(mixed-content) even for loopback — either drive the preview in Chrome, or serve wss://
(see LAN & headset use below). Full walkthrough and a captured wire
transcript:
examples/network-conductor/README.md.
The TrialResult field contract
Whatever your study() generator receives back after each yield is a TrialResult —
a read-only view over the experiment's outbound-mapping contract. The fields it
carries are whatever that specific experiment's extensions["oep-draft-network"].outbound
block declares, not anything oep invents. For the Brainard example:
"outbound": {
"trialResult": {
"fields": {
"trialId": { "$state": "net.trialId" },
"correct": { "$event": "/correct" },
"rt": { "$event": "/reactionTimeMs" },
"oddLocation": { "$state": "oddPos" }
}
}
}
| Field | Access | Meaning |
|---|---|---|
trialId |
result.trialId |
Echo of the id oep assigned this trial (trial-001, trial-002, ... — assigned by the package, not by you) |
correct |
result.correct |
Whether the response matched correctResponse |
rt |
result.rt |
Reaction time in ms |
oddLocation |
result.oddLocation |
Which position ("top"/"left"/"right") was actually odd this trial |
Both attribute access (result.correct) and mapping access (result["correct"],
result.get("confidence")) work; mapping access is the only way to reach a field that
happens to collide with the three names oep reserves for itself — raw, meta, get.
result.raw is the full payload (read-only); result.meta carries trial_number
(1-based), seq (the wire sequence number), and received_at (a UTC datetime) —
protocol bookkeeping, not experiment data. A missing field raises AttributeError/
KeyError naming the fields that do exist, so a typo fails loud, not with None.
The payload is copied once at construction and is immutable from then on — nothing your
study code does to a TrialResult can change what got logged.
Breaks
yield a Break() (or the ready-made BREAK instance) instead of a trial-params dict
to run a break/resume cycle — the runner shows its break screen and waits for the
participant (or you) to resume:
from oep.conductor import BREAK, run_study
def study():
for i in range(1, 21):
yield {"refRGB": [0.5, 0.42, 0.31], "compRGB": [0.5, 0.5, 0.31]}
if i % 10 == 0:
yield BREAK # runner pauses; resumes when the participant is ready
run_study(study)
update() is never called for a break — it's reserved for real trial results — so your
generator gets None back after a break resumes, not a TrialResult.
The class API (TrialStrategy)
run_study's generator seam is the convenient default, but it isn't the only front
door. oep.conductor.serve_strategy drives the exact same protocol session over
anything that implements — never subclasses, since TrialStrategy is a
@runtime_checkable Protocol and this is structural typing, no base class involved —
the two-method TrialStrategy shape:
import asyncio
from typing import Optional
from oep.conductor import Break, TrialParams, TrialResult, serve_strategy
class MyStrategy:
def next_trial(self) -> Optional[TrialParams | Break]:
... # return a trial-params mapping, BREAK, or None to end the session
def update(self, result: TrialResult) -> None:
... # incorporate a completed trial's result
asyncio.run(serve_strategy(MyStrategy(), port=8765))
next_trial() and update() mean exactly what they mean for the generator seam —
serve_strategy is in fact what run_study calls internally, after wrapping your
generator in a private TrialStrategy adapter. Everything else — the TrialResult
field contract, breaks, SessionSummary, TLS, transcript recording — is identical
between the two front doors.
Reach for the class API instead of a generator when:
- The integration already owns its own state, and doesn't fit a generator's
single-function shape — the motivating case is a real adaptive-procedure library
(e.g. an AEPsych
Strategy) whosegen()/update()-style lifecycle maps ontonext_trial()/update()far more directly than onto ayieldloop. See the AEPsych cookbook for that seam in full. - You're driving a session from a Jupyter notebook (or any code that already has an
event loop running).
run_studycallsasyncio.run()internally, which raisesRuntimeError: asyncio.run() cannot be called from a running event loopinside a notebook kernel.serve_strategyis itself a coroutine, soawait serve_strategy(strategy, port=8765)works directly in a notebook cell — noasyncio.run()involved.
SessionSummary and failure semantics
run_study returns a SessionSummary (status, trials_completed, transcript_path,
strategy_result — your generator's return value) only when the study actually
finished: status="completed". This is deliberate, and it's a feature, not an
inconvenience: a crashed study can never look like a completed one.
- If your
study()code raises,run_studycloses the connection without sending the protocol'sdonemessage, and then re-raises your exception — so a bug in your staircase surfaces as a Python traceback in your process, and the runner sees a required-channel loss rather than a clean end. Nothing downstream (a saved transcript, a data file, a runner log) can be misread as "the study finished" when it didn't. If you need the partial accounting anyway, it's attached best-effort asexc.oep_partial_summaryon the exception you catch. - If the transport drops mid-study, you get a
ConductorConnectionLostexception instead of a return value — its.partial_summary(status"connection_lost") tells you how far the session got. - If the wire protocol itself is violated — a sequence gap, or a message arriving
before the
oep.setup/oep.readyhandshake completes —run_studyraisesoep.conductor.ProtocolError. Unlike the two cases above, this isn't a bug in your study code or a dropped connection: it means the conductor and the runner disagreed about where they were in the exchange (a version mismatch, a runner bug, or areplay_transcript=scenario deliberately exercising this path). It follows the same "closes withoutdone, re-raises, best-effortoep_partial_summary" shape as the study-code-raises case above.
Either way, wrap run_study in a try/except if you want to inspect what happened
on a non-completed run; a bare call that lets the exception propagate is the right
default for a script you're watching interactively.
LAN & headset use
By default run_study listens on 127.0.0.1 only — nothing outside your machine can
reach it. To let a phone-tethered headset (or any device on your LAN) connect, bind
explicitly:
run_study(study, host="0.0.0.0", port=8765)
Then point the headset's ?conductorWs= at your machine's LAN IP instead of
localhost, e.g. ws://192.168.1.100:8765.
For anything beyond quick desktop-Chrome testing you'll also want TLS — most on-headset
browsers, and Safari everywhere, refuse a plain ws:// connection from an https://
page (see the mixed-content note above). Pass a cert/key pair and run_study serves
wss:// automatically:
run_study(
study,
host="0.0.0.0",
ssl_cert="path/to/cert.pem",
ssl_key="path/to/key.pem",
)
(ssl_cert/ssl_key must be given together; pass a fully-configured ssl_context=
instead if you need more control — the two are mutually exclusive.)
TLS alone is not enough. A self-signed certificate makes the connection encrypted,
but the device still has to trust that certificate for the hostname it's actually
dialing — otherwise the browser refuses the connection exactly like it would refuse
plain ws://. In practice that means either accepting a security-warning click-through
each session, or (better, for repeated testing) generating a certificate with
mkcert and installing its CA on the headset,
the same approach the webxr-runner's own
PREVIEW-TESTING-GUIDE.md
recommends for the runner's own dev-server cert. "Necessary but not sufficient" is the
rule to remember: TLS is the wire encryption, trust is a separate, per-device step.
Roadmap
oep ships one surface today, oep.conductor. oep.data and oep.assets are reserved
names for future package surfaces (reading OEP-format data/streams, and working with
experiment assets, from Python) — they exist only as a naming decision right now, with
no code shipped yet.
See also
examples/network-conductor/README.md— the flagship worked example this README pairs with: node graph, timing, a captured wire transcript walked line by line, and the CLI demo (conductor.py) that's now a thin wrapper over this package.docs/cookbook-aepsych.md— plugging a real AEPsychStrategyinto thestudy()seam above, and the class-API alternative.spec/schema/extensions/oep-draft-network.md— the normative protocol this package implements.
License
Apache License 2.0 — see LICENSE.
This license covers the oep Python package only; other components of the Open
Experiment Protocol repository (the Builder application and experiment runners) are
not open source at this time.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file oep-0.1.0.tar.gz.
File metadata
- Download URL: oep-0.1.0.tar.gz
- Upload date:
- Size: 61.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
496cb57b7c0be8a783ec05e33f522456aa70856c7d900a097c5f543327186f99
|
|
| MD5 |
b04cd33492018ba12e19afeacdc1eaa8
|
|
| BLAKE2b-256 |
2553405f46d7b296aebb08889c4b5595ecb18a012dfe4267348d3f60d7024990
|
File details
Details for the file oep-0.1.0-py3-none-any.whl.
File metadata
- Download URL: oep-0.1.0-py3-none-any.whl
- Upload date:
- Size: 40.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9fff380889a07ac32166afda61b0005327939766e304f9e5d7e59fa4c907c5d2
|
|
| MD5 |
446ae421810176fd18a360a12d145327
|
|
| BLAKE2b-256 |
a9520ac7a1b985558b6799998928c54082e0c51a55803a6c2da3ac04e0a03eae
|