Single source of project context. This document is intentionally written to be self-contained so that a person or a language model can understand the goal, the architecture, the current implementation status, and how to build/test the project without reading the source first.
NeuronIDE is a tool for designing and running EEG experiments. It has two parts:
- an editor (out of scope for this repository) that lets a researcher visually design an experiment and serializes it to a file, and
- a runtime (this repository,
Neuron-IDE-runtime) that reads that file, reconstructs the experiment as a scene, runs it, and records the data.
Running an experiment means:
- Reading EEG data from the participant's headset (the "cap") in real time.
- Rendering a graphical interface that drives the participant — e.g. telling them what to focus on, or, for SSVEP experiments, flickering on-screen objects at controlled frequencies.
- Emitting time markers for experiment events (e.g. "stimulus shown"), stamped at the exact moment the corresponding frame hits the screen.
- Persisting the EEG samples and the markers, with timestamps, to an output file for later offline analysis.
The hard requirement that shapes the whole design is temporal alignment: the EEG samples and the stimulus markers must share a common clock domain so that "this brain response happened N ms after that stimulus" is meaningful. See §4 Clock synchronization.
| Concern | Choice |
|---|---|
| Language | C++20 |
| Build system | CMake (≥ 3.25), Ninja recommended |
| Experiment file format | Protocol Buffers (proto3) — see protoFiles/neuronide.proto |
| Device config format | JSON (config.json) parsed with nlohmann/json |
| EEG acquisition | LSL — Lab Streaming Layer (liblsl) |
| Rendering / windowing | SDL2 + SDL2_image, with vsync (SDL2_ttf planned, see Components) |
| Inter-thread queues | moodycamel ConcurrentQueue (lock-free) |
| Python scripting (planned) | pybind11 (ScriptComponent) |
| Testing | GoogleTest + CTest |
| Tooling | clang-format, clang-tidy, gcovr (coverage) |
liblsl, concurrentqueue, nlohmann/json, and googletest are fetched
automatically by CMake (FetchContent). SDL2 and Protobuf are expected to be
installed on the system.
The runtime starts by parsing the experiment file into a Scene, then runs the
experiment across cooperating threads coordinated by a top-level Runtime object.
The design follows a game-engine-like component model: a Scene owns
SceneObjects, and each SceneObject owns Components that implement behavior.
flowchart LR
File[Experiment file<br/>protobuf] --> Parser
Parser -->|builds| Scene
subgraph Threads
Renderer
LSLReader
DataWriter
end
Scene --> Renderer
Cap[EEG headset<br/>LSL stream] --> LSLReader
Renderer -->|Marker| MQ[(markerQueue)]
LSLReader -->|EEGData| EQ[(eegQueue)]
EQ --> DataWriter
MQ --> DataWriter
DataWriter --> Out[Output file<br/>CSV]
- Parser reads the protobuf experiment file and produces a
Scene(objects + components). Stateless entry point:Parser::parse(path). - Renderer owns the SDL window, runs the update→render loop, and uses vsync
so it can timestamp markers at the actual moment of screen refresh. Components
emit marker names during
update; the Renderer stamps them withlsl::local_clock()right afterSDL_RenderPresentand pushesMarkers ontomarkerQueue. - LSLReader resolves and subscribes to the EEG LSL stream and continuously
pushes
EEGDatasamples ontoeegQueue. It forwards only the channels the device config enables (see §5 Configuration boundary), in config declaration order. - DataWriter drains both queues and writes them to disk via a pluggable formatting strategy (currently CSV).
The two queues (eegQueue, markerQueue) are the only shared state between
threads and are lock-free (moodycamel::ConcurrentQueue). Producers
(Renderer, LSLReader) never block on consumers (DataWriter).
classDiagram
class Scene {
-string experimentName
-vector~shared_ptr~SceneObject~~ objects
+update(Context&) void
+render(SDL_Renderer*) void
}
class SceneObject {
+string name
+bool isVisible
+Transform transform
-vector~unique_ptr~Component~~ components
+update(Context&) void
+render(SDL_Renderer*) void
}
class Component {
<<abstract>>
#weak_ptr~SceneObject~ owner
+update(Context&)* void
+render(SDL_Renderer*)* void
}
class BlinkComponent
class SpriteRenderer
class TextRenderer
class ScriptComponent
Scene *-- SceneObject
SceneObject *-- Component
Component <|-- BlinkComponent
Component <|-- SpriteRenderer
Component <|-- TextRenderer
Component <|-- ScriptComponent
Components are built from protobuf messages by a ComponentRegistry (a
singleton mapping a proto component type id → factory function). New component
types self-register via the REGISTER_COMPONENT(typeId, creatorFunc) macro, so
the parser does not need to know about concrete component classes. Each creator
also receives a ComponentBuildContext, which resolves asset paths: a relative
path in the .neuroz is taken relative to the folder holding the .neuroz, and an
absolute one is used as-is.
Self-registration runs from static initializers that nothing else references, so a
plain static link silently drops them. Executables therefore link the runtime_core
target, which pulls in the whole archive. tests/registration_tests guards this, and
every new component type gets a line there.
After construction, a component goes through two hooks, both before the first frame:
onSceneReady(Scene&), called byParseronce every object exists, to resolve and subscribe to events.onRenderTargetReady(const std::shared_ptr<SDL_Renderer>&), called by theRendererconstructor once the render target exists, to create GPU resources such as textures, and again afterSDL_RENDER_DEVICE_RESET, which loses every texture. Doing it there, not lazily on the firstrender(), keeps uploads from delaying the first stimulus frame and its marker. SDL frees a renderer's textures along with it, so a component holding textures keeps the target alive.
Images are decoded during parsing into the scene's ImageLibrary, once per file however
many sprites show it, and the library uploads them just before the components' hook
runs. The decoded pixels are kept, so a reset can rebuild the textures.
Anything that can fail on bad input (a missing image, for instance) fails during parsing, before the window opens. The window is created hidden and shown only once the scene is on the GPU, so a failed upload (an image larger than the GPU's texture limit) doesn't flash it at the participant either. A sprite that fails to draw stops the run before its frame is presented and marked.
struct EEGData { // one EEG sample
double timestamp; // in the local_clock() domain (see §4)
std::vector<double> channels; // enabled channels only, in config order (see §5)
};
struct Marker { // one experiment event
std::string eventName;
double timestamp; // local_clock() at vsync
};
struct Context { // passed to Component::update each frame
double timestamp; // per-frame delta time
std::vector<std::string>* markers; // sink: components append marker names here
};EEG analysis depends on EEG samples and stimulus markers being comparable in
time. The Renderer stamps markers with lsl::local_clock() (this machine's
clock). LSL pull_sample(), however, returns timestamps in the sender's clock
domain. If left unconverted, samples and markers would live in different clocks
and could not be aligned.
LSLReader therefore enables LSL inlet post-processing
(post_clocksync | post_dejitter | post_monotonize) so the timestamps it stores
are already mapped into the local lsl::local_clock() domain — the same clock the
Renderer uses. This is the single most important correctness property of the data
path.
The runtime is fed by two config inputs and they are not interchangeable. The split is deliberate and should be respected when adding new settings, otherwise the same experiment stops being portable between labs.
The rule: the protobuf experiment file describes what the experiment does; the device
config.jsondescribes what the hardware is. A new field belongs in protobuf if changing it changes the experiment's meaning for analysis, and in JSON if it only changes how this particular machine acquires or stores the data.
Goes in the protobuf experiment file (.neuroz) |
Goes in the device config (config.json) |
|---|---|
| Experiment name, scene objects, transforms, visibility | Device name, montage standard (10-20, …) |
| Components and their parameters (e.g. blink frequency) | LSL stream identity: name, type, source_id |
| Stimulus timing, trial structure, marker/event names | Expected stream shape: channel count, sample rate |
| Anything the editor authors and versions with the study | Channel table: index, label, enabled, unit |
| Reference / ground electrodes, impedance check thresholds | |
Participant display: monitor, fullscreen, window size (display) |
|
Output format for DataWriter (output.format) |
Consequences of the split:
- The same experiment file runs on a different cap by swapping only
config.json— no re-export from the editor. - The runtime can validate the incoming LSL stream (channel count, sample rate) before the experiment starts, because expectations are declared per device.
- Electrode-level knowledge (which channel is
Oz, which are enabled) lives in one place, soLSLReaderand, later,DataWriteragree on channel order.
The JSON is parsed 1:1 into DeviceConfig: every JSON key maps onto exactly
one field, and nesting in the file is the nesting in the struct. Keep it that
way — channels is a top-level key, so it is a top-level DeviceConfig field
(not tucked under lsl), even though LSLReader is its main consumer.
config_version, device_name, montage_standard, lsl_stream and channels
are required; reference, ground, impedance_check, display and output
default when absent. Unlike the other optional sections, each key inside display
is optional on its own ("display": { "index": 1 } is complete). If output is
present it must carry a non-empty format; whether that
format is known is decided by DataFormatStrategyFactory when the writer is
built, not by config validation.
Channels with "enabled": false stay in the config (they document the cap) but
are not acquired: LSLReader drops them from every sample.
Mapping and validation are separate jobs. ConfigParser only turns JSON into
structs — presence of keys, types, array shape. Every semantic rule (non-empty
stream identity, positive rate, channel count matching the stream, unique
in-range indices) lives on the config types themselves as validate(), because
none of those rules are about JSON and they must hold for any producer:
DeviceConfig config = ConfigParser::parse("config.json"); // already validatedDeviceConfig config; // hand-assembled: no guarantees
config.lsl.name = ...;
config.validate(); // throws std::invalid_argument on the first broken ruleA DeviceConfig returned by ConfigParser has passed validate(). One you
assemble yourself has not — call it before handing the config to a consumer.
LSLReader validates in its constructor rather than trusting its caller, since
it indexes into raw samples with the configured channel offsets.
Rules that need more context than the config carries stay with the consumer, not
in validate(): "at least one channel is enabled" is an LSLReader precondition
(a fully disabled cap is a valid config, just nothing to acquire), and
"stream shape matches the live LSL stream" can only be checked against a resolved
stream at runtime.
config_version is "MAJOR.MINOR" and is the first thing ConfigParser
validates — on an unsupported schema every later complaint would be a misleading
"missing field" message instead of "your config is newer than this runtime".
- MAJOR — breaking change: a field moved, was renamed, or changed meaning.
A runtime rejects any major other than
ConfigParser::kSupportedConfigMajor. - MINOR — additive, backward-compatible change: new optional keys. Any minor
of the supported major is accepted, and unknown keys are ignored, so a
1.7file still runs on a runtime that only knows1.0. - A version that cannot be compared (
"1","v1","1.2.3", empty) is rejected rather than assumed — an unparseable version is worse than none.
Bump MINOR when adding optional keys, MAJOR when moving or renaming any existing
one, and raise kSupportedConfigMajor in the same commit that lands the breaking
parser change.
Threads use C++20 std::jthread + std::stop_token for cooperative cancellation.
Two ownership patterns are in use:
- Self-owned thread (
LSLReader,DataWriter): the class owns itsjthread.start(...)spawns the worker (and first callsstop()so it is restartable);stop()requests stop, joins, and releases resources; the destructor callsstop(). The worker loop body takes thestop_token. - Caller-owned thread (
Renderer): the class exposesrender(stop_token)and the caller owns the thread driving it. This makes the loop trivially testable by passing an injectedstd::stop_source.
LSLReader additionally resolves its stream lazily on the worker thread (so
start() returns immediately instead of blocking the runtime while waiting for the
cap), uses a blocking pull with a finite timeout (no busy-wait, low latency,
periodic stop-token checks), and catches lsl::lost_error to re-resolve a dropped
stream rather than letting an exception terminate the process. Recovery is
deliberately ours: the inlet is created with liblsl's recover flag off, so
a lost cap surfaces as lsl::lost_error instead of being silently reconnected, and
the re-resolved stream is re-validated (channel count, sample rate) before
acquisition continues. Config errors (mismatched stream shape, no enabled channels)
stay fatal — they are logged and the worker exits instead of retrying forever.
LSLReader reports where it is as a typed AcquisitionState: Idle → Resolving
→ Streaming (set on the first queued sample, not on resolve), back to
Resolving after a lost stream, or Failed for good. waitWhileResolving(deadline, stop_token) blocks until the state changes; failure() holds the reason, and an
optional callback passed to start() fires once on the reader thread on failure.
A Failed state survives stop() so the reason can be read after joining.
Runtime builds everything that can reject the configuration (both config files,
output directory, format strategy, LSLReader) before it creates the window, so a
bad config never flashes a window at the participant. run() then:
- Acquisition gate. Starts
LSLReaderand waits for the first EEG sample, handling window events meanwhile. No stimulus is shown without EEG: a stream that fails validation, or delivers nothing within the acquisition timeout (default 30 s), throws; closing the window orrequestStop()returns without recording. - Recording. Creates
<experiment>_<YYYYMMDDTHHMMSS>[_N].<ext>in the output directory and runs the render loop. Format strategies must create the file exclusively — an existing recording is never overwritten. - Shutdown. Stops
LSLReader, thenDataWriter(producer before consumer, so the writer drains everything). If acquisition failed for good mid-experiment, the failure callback has already stopped the render loop, andrun()throws once the recording is closed.
run() is single-shot and must be called on the thread that constructed the
Runtime (SDL handles window events only there); both are enforced with
std::logic_error. The default render target opens the window (hidden until the
scene is uploaded) on display.index and refuses to run without vsync — SDL silently drops
PRESENTVSYNC when the driver can't honour it, and marker timestamps are only
correct when SDL_RenderPresent waits for the refresh.
| Area / class | Status | Notes |
|---|---|---|
Parser |
Implemented | protobuf → Scene |
Scene / SceneObject |
Implemented | component containers |
Component (base) |
Implemented | abstract update / render |
ComponentRegistry |
Implemented | proto-type → factory, macro-based self-registration |
| Scene components | Partial | 3 of the 9 MVP components have a C++ class, see Components |
Renderer |
Implemented | SDL + vsync, marker timestamping |
LSLReader |
Implemented | LSL inlet → eegQueue, clock-synced (see §4); driven by DeviceConfig, enabled channels only, re-resolves lost streams, reports AcquisitionState |
ConfigParser |
Implemented | config.json → DeviceConfig (1:1 mapping, major-version checked, see §5), nlohmann/json |
Config validate() |
Implemented | semantic rules on the config types themselves, independent of JSON (see §5) |
DataWriter |
Implemented | strategy-based; CSVFormatStrategy |
DataFormatStrategyFactory |
Implemented | output.format → format strategy; unknown formats rejected |
Runtime orchestration |
Implemented | owns SDL session, parses both config files, gates the experiment on live EEG, records to a fresh file, drives the render loop, stops workers (see §6) |
The class diagram in older docs is partly aspirational; the table above reflects the actual code.
The MVP set of components an experiment is built from, and where each one stands.
Status as of 2026-10-10, checked against main, open PRs and neuronide.proto.
Update the row in the same PR that changes it.
| Component | What it does | Proto (neuronide.proto) |
C++ (scene/components/) |
Status | Tracking |
|---|---|---|---|---|---|
| Transform | Position, size, rotation. Exactly one per object. x/y is the top-left corner in window pixels, rotation is degrees clockwise about the centre. | SceneObject.transform, a field rather than a Component |
SceneObject::Transform |
Partial: parsed onto the object and drawn by SpriteRenderer, but not required (an object without one sits at all zeros; a sprite with an image on it is rejected at parse time, as is a non-finite value) | — |
| SpriteRenderer | Draws an image: visual stimuli, photos, icons. | SpriteRenderer (renderer = 1), img_path. The runtime does not read texture_path |
SpriteRenderer, registered |
Implemented: any SDL2_image format (PNG, JPEG, …), stretched over the Transform and rotated. Decoded at parse time, so a missing or unreadable file stops the run before the window opens; uploaded to the GPU before the first frame. Objects showing the same file share one decoded image. An empty img_path draws nothing |
#17, backend#25 |
| TextRenderer | Draws text in a chosen font and size: instructions, letters, numbers. | TextRenderer (text = 2) |
— | Planned: needs SDL2_ttf, which is not a dependency yet. The message has no text color | #18, backend#25 |
| BlinkComponent | Flickers the object at a set frequency in Hz. The basis for SSVEP. | BlinkComponent (blinker = 3) |
BlinkComponent, registered and built by the parser |
In review: update() is an empty TODO on main, so nothing blinks yet. PR #34 implements it; changes requested |
#16 |
| MarkerEmitter | Emits an LSL marker on a scene event, ideally when an object appears or disappears. | MarkerEmitterComponent (marker_emitter = 5). Missing from the backend's copy |
MarkerEmitterComponent, registered |
Implemented, nothing to bind to: bindings resolve and push markers (tested against a test-only event source), but no production component exposes an event and objects have no appear/disappear events, so any binding in a real .neuroz fails at parse time |
backend#23 |
| Lifetime | Keeps the object visible for N ms, then hides it. | — | — | Not started | — |
| ResponseListener | While its object is active, records which key was pressed and the reaction time. | — | — | Not started | — |
| ScriptComponent | Custom logic in Python, outside the standard components. | ScriptComponent (script = 4) |
— | Planned: needs pybind11, which is not a dependency yet | backend#25 |
| AudioSource | Plays a sound. Possibly post-MVP. | — | — | Not started | — |
- Implemented: registered class, behaviour complete, covered by tests.
- In review: the implementation is in an open PR.
- Partial: exists, with the gaps listed in its row.
- Planned: the proto message exists, the C++ class does not. The parser drops such
components silently (
ComponentRegistry::buildreturnsnullptrandParserskips it), so a scene that uses one loads without error and the component does nothing. - Not started: neither a proto message nor a class. Adding the message comes first and
is a cross-team change (backend proto and dataclasses, launcher copy, frontend editor; see
CLAUDE.md).
- Event sources.
MarkerEmitterbinds to events that a component exposes throughgetEvent(), and nothing exposes one yet. The obvious first sources are an object appearing or disappearing, a Lifetime timeout and a ResponseListener response. - Who owns visibility. Blink, Lifetime and scripts all write
SceneObject::isVisible. If appear/disappear events follow that flag, a 15 Hz blinker emits 30 markers a second, and the 0 Hz branch in PR #34 overrides a script that hides the object. Keeping "the object is shown in this trial" (Lifetime, scripts; drives the events) separate from "flicker phase" (Blink; affects drawing only) avoids both. - Frame-locked timing. The screen only changes at vsync, and markers are stamped there.
Flicker phase and Lifetime durations should come from the frame being presented (frame
count × refresh period), not from summing
Context::timestamp. That value is a loop delta measured at the top of the loop, so it jitters, and a frame near an on/off edge can land on either side. Frequencies above half the refresh rate can't be shown at all and should be rejected. Dropped frames should be detected and logged. - Input and reaction time. The render loop polls SDL events itself and handles only
SDL_QUIT, andContextcarries no input. ResponseListener needs key events passed in, stamped with when the key was pressed, not with the vsync time the Renderer gives markers. With one poll per frame, reaction-time resolution is one frame (~16.7 ms at 60 Hz). One option is to record the response as a marker (e.g.response:<key>) with its own timestamp and compute reaction time offline against the onset marker. - Media files. Sprite, Text (fonts), Script and Audio all reference files by path. The
runtime resolves relative paths against the
.neurozfolder (see Component model), but assets can't be uploaded or exported yet (backend#25). Until they can, the launcher has to place them next to the.neurozby hand. - Transform units.
Transformis in window pixels, so a stimulus's physical size depends on the lab monitor, which comes fromconfig.json. Decide whether to stay with pixels or move to normalized or visual-angle units before more renderers land.
Neuron-IDE-runtime/ # the C++ runtime (git repo)
├── README.md # this file, code context
├── CMakeLists.txt # top-level: deps, warnings, static analysis, coverage
├── cmake/ # Dependencies / CompilerWarnings / StaticAnalysis / Coverage
├── protoFiles/
│ ├── neuronide.proto # experiment file schema
│ └── tests/ # .pbtxt fixtures + compiled .pb
├── include/ # public headers, mirrored by src/
│ ├── data_structures/ # EEGData, Marker, Context
│ ├── config/ # ConfigParser + DeviceConfig / LSLConfig / ChannelConfig / ConfigVersion
│ ├── parser/ # Parser
│ ├── scene/ # Scene, SceneObject, components/
│ ├── renderer/ # Renderer
│ ├── lslreader/ # LSLReader
│ ├── datawriter/ # DataWriter, IDataFormatStrategy, CSVFormatStrategy
│ └── Runtime.hpp
├── src/ # one CMake subdirectory (static lib) per module
└── tests/
├── unit_tests/ # CTest label: "unit"
├── component_tests/ # CTest label: "component"
└── registration_tests/ # separate binary: every component is linked in (label "component")
Each src/<module>/ builds a static library; runtime_core links them together
and the NeuronIDE executable links runtime_core.
All commands are run from the Neuron-IDE-runtime/ directory.
sudo apt update
sudo apt install cmake ninja-build clang-format clang-tidy-20 libsdl2-dev libsdl2-image-dev protobuf-compiler gcovr
# GTest, LSL, concurrentqueue and nlohmann/json are fetched automatically by CMake.Use the Ninja generator: it builds on all CPU cores by default and rebuilds only what changed.
cmake -B build -G Ninja
cmake --build build
./build/src/NeuronIDE config.json experiment.neuroz # waits for the EEG stream, then records and runs the experiment-G Ninja is needed only the first time you configure a build directory; CMake remembers it.
A build/ already configured with the default generator (Make) can't be switched, so delete it
first (rm -rf build). If you stay on Make, build with cmake --build build --parallel;
without --parallel, Make compiles one file at a time.
NeuronIDE takes the path to a device config.json (defaults to config.json in
the working directory).
By default clang-tidy runs on every file during the build and takes several times longer than
compiling it. While iterating, turn it off. The setting is remembered in build/; configure
with =ON to turn it back on.
cmake -B build -G Ninja -DNEURON_IDE_ENABLE_CLANG_TIDY=OFF -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build buildCI still runs clang-tidy and fails on any warning, so run the same check before pushing. It
reads the compile_commands.json that CMAKE_EXPORT_COMPILE_COMMANDS writes:
git ls-files 'src/*.cpp' 'tests/*.cpp' | xargs -P "$(nproc)" -n 1 clang-tidy-20 -p build --quietLines like 43669 warnings generated. count warnings in third-party headers, which
.clang-tidy filters out; only error: lines are problems.
cd build
ctest --output-on-failure # all tests
ctest -L unit --output-on-failure # unit tests only
ctest -L component --output-on-failureNote:
LSLReaderandRuntimeunit tests open local LSL streams and exercise a real outlet→inlet round-trip over loopback; they need loopback multicast to be available.
- Style:
.clang-format(Google base, 4-space indent, 100 col, aligned declarations/assignments). Checks:.clang-tidy(bugprone-*,cppcoreguidelines-*,performance-*,readability-*). - Auto-format every tracked source/header:
cmake --build build --target format
- clang-tidy runs as part of the build; see Faster builds without clang-tidy to turn it off.
cmake -B build -G Ninja -DNEURON_IDE_ENABLE_COVERAGE=ON
cmake --build build
cmake --build build --target coverage # runs tests + gcovr
xdg-open build/coverage/index.htmlAuthor a .pbtxt (see protoFiles/tests/test_scene.pbtxt), then encode it:
protoc --encode=NeuronIDE.Scene protoFiles/neuronide.proto \
< protoFiles/tests/test_scene.pbtxt > protoFiles/tests/test_scene.pb- Branches:
<type>/<description>, e.g.feat/setup-project. - Commits:
<type>(optional scope): description, e.g.feat(parser): create Parser class. - Types:
feat,fix,style(clang config),test,ci(.github).
{ "config_version": "1.1", // "MAJOR.MINOR", checked first (see below) "device_name": "OpenBCI Cyton 8ch", "montage_standard": "10-20", "lsl_stream": { // -> DeviceConfig::lsl (LSLConfig) "name": "obci_eeg1", "type": "EEG", "source_id": "cyton-a1b2c3", "expected_channel_count": 8, // must equal channels.size() "expected_sample_rate_hz": 250 }, "reference": { "label": "linked_mastoids", "scheme": "physical" }, "ground": { "label": "Fpz" }, "channels": [ // -> DeviceConfig::channels { "index": 0, "label": "Fz", "enabled": true, "unit": "microvolts" }, { "index": 1, "label": "Oz", "enabled": false, "unit": "microvolts" } // ... one entry per expected_channel_count, indices unique and in range ], "impedance_check": { "supported": true, "threshold_kohm": 5.0 }, "display": { // -> DeviceConfig::display; every key optional "index": 1, // monitor facing the participant (default 0) "fullscreen": true, // native-resolution fullscreen (default true) "width": 1280, "height": 720 // window size, used only when not fullscreen }, "output": { "format": "csv" } // -> DataFormatStrategyFactory, defaults to csv }