Skip to main content

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) whose gen()/update()-style lifecycle maps onto next_trial()/update() far more directly than onto a yield loop. 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_study calls asyncio.run() internally, which raises RuntimeError: asyncio.run() cannot be called from a running event loop inside a notebook kernel. serve_strategy is itself a coroutine, so await serve_strategy(strategy, port=8765) works directly in a notebook cell — no asyncio.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_study closes the connection without sending the protocol's done message, 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 as exc.oep_partial_summary on the exception you catch.
  • If the transport drops mid-study, you get a ConductorConnectionLost exception 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.ready handshake completes — run_study raises oep.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 a replay_transcript= scenario deliberately exercising this path). It follows the same "closes without done, re-raises, best-effort oep_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

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

oep-0.1.0.tar.gz (61.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

oep-0.1.0-py3-none-any.whl (40.6 kB view details)

Uploaded Python 3

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

Hashes for oep-0.1.0.tar.gz
Algorithm Hash digest
SHA256 496cb57b7c0be8a783ec05e33f522456aa70856c7d900a097c5f543327186f99
MD5 b04cd33492018ba12e19afeacdc1eaa8
BLAKE2b-256 2553405f46d7b296aebb08889c4b5595ecb18a012dfe4267348d3f60d7024990

See more details on using hashes here.

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

Hashes for oep-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9fff380889a07ac32166afda61b0005327939766e304f9e5d7e59fa4c907c5d2
MD5 446ae421810176fd18a360a12d145327
BLAKE2b-256 a9520ac7a1b985558b6799998928c54082e0c51a55803a6c2da3ac04e0a03eae

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page