These docs describe unreleased changes on main. See the latest release.

C

Serve environments, write policies, and run evaluations from C11 with rlmesh.h.

rlmesh.h is a C11 interface to the RLMesh runtime. Environments and policies are tables of callbacks, everything else is an opaque handle, and every fallible call returns an RLMeshStatus. The header also compiles warning-free as C++98, so it fits into older C++ codebases too; for modern C++, see the C++ page.

Install

Build from source

The library is Rust underneath, so building it needs a Rust toolchain (1.99 or newer). From a clone of the repository:

git clone https://github.com/ArenaX-Labs/rlmesh && cd rlmesh
RLMESH_RELEASE_BUILD=1 cargo build --release -p rlmesh-capi

That produces target/release/librlmesh_capi.so (shared) and librlmesh_capi.a (static). The header is crates/rlmesh-capi/include/rlmesh.h. Link the shared library plus the system libraries the runtime uses:

cc -std=c11 -I rlmesh/crates/rlmesh-capi/include app.c \
  -L rlmesh/target/release -lrlmesh_capi -lpthread -ldl -lm -o app

RLMESH_RELEASE_BUILD=1 makes the library speak the released workflow edition. Without it, a source build only connects to peers built from the identical source tree, and a session with anything else (such as the rlmesh Python package) fails with no mutual workflow edition.

Use the CMake or pkg-config package

mise run release:cxx:package in the repository assembles a relocatable package, target/capi-pkg/rlmesh-<version>-<triple>.tar.gz. It holds both headers, the shared and static libraries, and CMake and pkg-config metadata, so consumers need no Rust toolchain. Released versions attach the same package (Linux x86_64, glibc 2.34 or newer) to each GitHub Release, with a .sha256 file.

Unpack it anywhere and use find_package:

find_package(rlmesh 5 CONFIG REQUIRED)  # -Drlmesh_DIR=<unpacked>/lib/cmake/rlmesh
target_link_libraries(my_sim PRIVATE rlmesh::rlmesh)

Or fetch it at configure time:

include(FetchContent)
FetchContent_Declare(rlmesh
  URL https://github.com/ArenaX-Labs/rlmesh/releases/download/v<version>/rlmesh-<version>-x86_64-unknown-linux-gnu.tar.gz
  URL_HASH SHA256=<the release's .sha256>
  DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
FetchContent_MakeAvailable(rlmesh)

target_link_libraries(my_sim PRIVATE rlmesh::rlmesh)

Without CMake, PKG_CONFIG_PATH=<unpacked>/lib/pkgconfig pkg-config --cflags --libs rlmesh prints the flags.

The CMake package version is the ABI generation, so find_package(rlmesh 5) refuses a package of another generation. rlmesh::rlmesh links the shared library, librlmesh_capi.so.5, whose name also carries the generation. rlmesh::rlmesh_static is for fully static targets that link no other Rust library.

If you load the library with dlopen or otherwise bypass its versioned name, check the generation at startup:

if (!rlmesh_abi_check()) {
  fprintf(stderr, "librlmesh_capi is ABI %u; this program was built for ABI %d\n",
          rlmesh_abi_version(), RLMESH_ABI_VERSION);
  return 1;
}

Quickstart

Two programs: one serves an environment, the other connects a model to it and runs three episodes. They talk over gRPC, so either side could be replaced by a Python, Rust, or C++ program.

Serve an environment

An environment is an RLMeshEnvVtable of callbacks plus RLMeshEnvAttributes (its spaces and id). rlmesh_env_new wraps them in a handle, and rlmesh_env_server_new binds a server to it.

#include <rlmesh.h>
#include <stdio.h>

/* A three-step counter: the observation is the step index, every action
 * earns 1.0, and the episode terminates after the third step. */
typedef struct {
  int64_t step;
} Counter;

static RLMeshStatus counter_reset(void* user_data, const int64_t* seed, const RLMeshValue* options,
                                  RLMeshValue** out_observation, RLMeshValue** out_info) {
  Counter* env = (Counter*)user_data;
  (void)seed;
  (void)options;
  (void)out_info; /* left NULL: an empty info dict */
  env->step = 0;
  return rlmesh_value_int(env->step, out_observation);
}

static RLMeshStatus counter_step(void* user_data, const RLMeshValue* action,
                                 RLMeshValue** out_observation, double* out_reward,
                                 bool* out_terminated, bool* out_truncated,
                                 RLMeshValue** out_info) {
  Counter* env = (Counter*)user_data;
  (void)action;
  (void)out_info;
  env->step += 1;
  *out_reward = 1.0;
  *out_terminated = env->step >= 3;
  *out_truncated = false;
  return rlmesh_value_int(env->step, out_observation);
}

int main(void) {
  static Counter state = {0};
  RLMeshSpace* observation_space = NULL;
  RLMeshSpace* action_space = NULL;
  RLMeshEnv* env = NULL;
  RLMeshEnvServer* server = NULL;
  RLMeshBytes address = {NULL, 0, 0};
  RLMeshStatus status;

  if ((status = rlmesh_discrete_new(4, NULL, &observation_space)) != RLMESH_OK) goto done;
  if ((status = rlmesh_discrete_new(2, NULL, &action_space)) != RLMESH_OK) goto done;

  RLMeshEnvVtable vtable = RLMESH_ENV_VTABLE_INIT;
  vtable.reset = counter_reset;
  vtable.step = counter_step;
  RLMeshEnvAttributes attributes = RLMESH_ENV_ATTRIBUTES_INIT;
  attributes.observation_space = observation_space;
  attributes.action_space = action_space;
  attributes.id = "CounterEnv-v0";
  if ((status = rlmesh_env_new(&vtable, &state, &attributes, &env)) != RLMESH_OK) goto done;

  RLMeshEnvServerOptions options = RLMESH_ENV_SERVER_OPTIONS_INIT;
  options.address = "tcp://127.0.0.1:5555";
  if ((status = rlmesh_env_server_new(&env, 1, &options, &server)) != RLMESH_OK) goto done;
  if ((status = rlmesh_env_server_address(server, &address)) != RLMESH_OK) goto done;
  printf("serving on %.*s\n", (int)address.len, (const char*)address.data);
  fflush(stdout);
  /* Blocks until Ctrl-C or a shutdown, then drains and closes the env. */
  status = rlmesh_env_server_serve(server);
  if (status == RLMESH_ERR_CANCELLED) status = RLMESH_OK;

done:
  if (status != RLMESH_OK) fprintf(stderr, "%s\n", rlmesh_last_error_message());
  rlmesh_bytes_free(address);
  rlmesh_env_server_free(server); /* free the server before the env it borrows */
  rlmesh_env_free(env);
  rlmesh_space_free(action_space);
  rlmesh_space_free(observation_space);
  return status == RLMESH_OK ? 0 : 1;
}

rlmesh_env_server_serve runs lane zero on the calling thread. rlmesh_env_server_start serves on library threads instead; follow it with rlmesh_env_server_wait and rlmesh_env_server_shutdown.

Run a model against it

A policy is an RLMeshModelVtable with a predict callback. rlmesh_model_run plays whole episodes and returns an RLMeshRunResult:

#include <rlmesh.h>
#include <stdio.h>

/* Always takes action 0. */
static RLMeshStatus predict(void* user_data, const RLMeshValue* observation,
                            const RLMeshPredictContext* context, RLMeshValue** out_action) {
  (void)user_data;
  (void)observation;
  (void)context; /* NULL unless context_corners asks for it */
  return rlmesh_value_int(0, out_action);
}

int main(void) {
  RLMeshModel* model = NULL;
  RLMeshRunResult* result = NULL;
  RLMeshStatus status;

  RLMeshModelVtable vtable = RLMESH_MODEL_VTABLE_INIT;
  vtable.predict = predict;
  if ((status = rlmesh_model_new(&vtable, NULL, NULL, &model)) != RLMESH_OK) goto done;

  int64_t seeds[] = {0, 1, 2};
  RLMeshRunOptions options = RLMESH_RUN_OPTIONS_INIT;
  options.has_episodes = true;
  options.episodes = 3;
  options.seeds = seeds;
  options.seeds_len = 3;
  status = rlmesh_model_run(model, rlmesh_address_target("tcp://127.0.0.1:5555"), &options,
                            &result);
  if (status != RLMESH_OK) goto done;

  printf("%zu episodes, %lld steps, mean reward %.2f\n", rlmesh_run_result_num_episodes(result),
         (long long)rlmesh_run_result_total_steps(result), rlmesh_run_result_mean_reward(result));

done:
  if (status != RLMESH_OK) fprintf(stderr, "%s\n", rlmesh_last_error_message());
  rlmesh_run_result_free(result);
  rlmesh_model_free(model); /* NULL is a no-op for every free */
  return status == RLMESH_OK ? 0 : 1;
}
3 episodes, 9 steps, mean reward 3.00

The repository’s crates/rlmesh-capi/examples/ directory has fuller programs, including c_model.c (a model against any Box or Discrete environment) and per-area examples such as c_run.c, c_remote.c, and c_adapters.c.

Conventions

Sized structs and _INIT macros

Every options struct, vtable, attributes record, and output struct starts with a size_t struct_size field and has a matching RLMESH_<NAME>_INIT macro. Always start from the macro and then set the fields you need:

RLMeshRemoteEnvOptions connect = RLMESH_REMOTE_ENV_OPTIONS_INIT;
connect.address = "tcp://127.0.0.1:5555";

Passing NULL for an options pointer means “all defaults”. A zeroed struct (= {0}) is not a default: its struct_size is below the minimum, so the call fails with RLMESH_ERR_INVALID_ARGUMENT. The size field is what lets the library grow a struct within an ABI generation: newer fields are appended, and programs built against an older header keep working.

Optional scalars come with a flag, so a supplied zero differs from “not set”: has_episodes / episodes, has_port / port, has_connect_timeout_seconds / connect_timeout_seconds. Optional pointers use NULL (for example seeds == NULL means no seeds, while a non-NULL pointer with seeds_len == 0 is an empty list). Durations are double seconds, and a timeout of zero means none. Enums reserve the value 0 as *_INVALID, and input bool fields must hold 0 or 1.

Status codes and the last error

Each nonzero RLMeshStatus names an error class: RLMESH_ERR_INVALID_ARGUMENT (ValueError), RLMESH_ERR_TYPE, RLMESH_ERR_RUNTIME (misuse, such as I/O on a closed handle), RLMESH_ERR_CONNECTION, RLMESH_ERR_TIMEOUT, RLMESH_ERR_PROTOCOL, RLMESH_ERR_ENVIRONMENT, RLMESH_ERR_MODEL, RLMESH_ERR_OS, RLMESH_ERR_CANCELLED, and RLMESH_ERR_INTERNAL. After a failure, the details are kept per thread and stay valid until your next RLMesh call on that thread:

Function Returns
rlmesh_last_error_message() the error text
rlmesh_last_error_class() the exact class, including ones without their own status (RECOVERABLE_ENVIRONMENT_EXCEPTION, ADAPTER_RESOLUTION_ERROR)
rlmesh_last_error_is_recoverable() nonzero when an environment or model failure left the session usable
rlmesh_last_error_code(&code) the environment or model error code, with rlmesh_last_error_code_str()
rlmesh_last_error_status() the status, for calls that return a pointer and signal failure with NULL

rlmesh_error_class_is_subclass answers “is this a kind of that”, for example whether a CONNECTION_ERROR is an OS_ERROR (it is):

RLMeshRemoteEnvOptions connect = RLMESH_REMOTE_ENV_OPTIONS_INIT;
connect.address = "tcp://127.0.0.1:5999";
connect.has_connect_timeout_seconds = true;
connect.connect_timeout_seconds = 2.0;
RLMeshRemoteEnv* env = NULL;
if (rlmesh_remote_env_new(&connect, &env) != RLMESH_OK) {
  RLMeshErrorClass cls = rlmesh_last_error_class();
  fprintf(stderr, "%s: %s\n", rlmesh_error_class_name(cls), rlmesh_last_error_message());
  if (rlmesh_error_class_is_subclass(cls, RLMESH_ERROR_CLASS_OS_ERROR)) {
    fprintf(stderr, "transport problem: check the address\n");
  }
  return 1;
}

Your own callbacks report failure the same way in reverse: call rlmesh_callback_set_error (a message and a recoverable flag) or rlmesh_callback_set_error_class (an exact class), then return a nonzero status. A recoverable environment failure fails one request and keeps the client’s session:

if (action == NULL) {
  rlmesh_callback_set_error("step needs an action", true);
  return RLMESH_ERR_ENVIRONMENT;
}

Ownership

  • Every handle you receive, you free. Each type has rlmesh_<type>_free; freeing NULL is a no-op. Types with a close operation also have rlmesh_<type>_close, which is idempotent; closing never frees, so call both.
  • Output slots are written, never read. A successful call does not read or free what a slot held before, so free an owned value before reusing its variable.
  • Owned text and bytes are RLMeshBytes (data, len, not NUL-terminated); release them with rlmesh_bytes_free. Input strings are either NUL-terminated const char* or length-bearing RLMeshStr (rlmesh_str("key") wraps a C string).
  • Borrowed pointers (const results such as rlmesh_remote_env_action_space, or rlmesh_value_dict_get) stay valid while the handle they came from lives. Don’t free them.
  • Numeric arrays use (out, cap, out_len) triples. Pass NULL / 0 first to query the length.
  • user_data passes to the library only when the constructor returns RLMESH_OK; its free_user_data then runs once, after the last library reference is gone.
  • Composite values retain their children, so a list or dict does not take over the items you built it from. Free your own handles too.
  • Borrowing objects are freed first. A server or session borrows the environments and models you give it; free it before them.

RLMeshValue, RLMeshTensor, spaces, and models are reference-counted: _clone shares the object rather than copying it.

Threads

Callbacks may run on library threads, so their state must be safe to use from another thread. An environment’s callbacks are called one at a time per handle. Creating a model or environment handle sets SIGPIPE to ignored on POSIX systems, unless your program already installed a handler, so a peer disconnecting mid-write cannot kill the process.

C-specific extras

Functions and types with no counterpart in the other languages carry the rlmesh_ext_ / RLMeshExt prefix: cancel tokens (rlmesh_ext_cancel_token_new, _cancel), token-protected clients (rlmesh_ext_remote_env_new_with_token), server options for bearer tokens and the describe envelope (RLMeshExtEnvServerOptions), the trusted entrypoint registry, and helpers such as rlmesh_ext_dtype_size.

Environments

  • rlmesh_env_new copies the vtable and attributes. reset and step are required; render, close, and free_user_data are optional. reset receives seed as a pointer (NULL for none) and options as a dict value or NULL; a NULL info output means an empty dict.
  • Freeing an environment does not call its close; rlmesh_env_close does, at most once.
  • rlmesh_env_server_new serves a list of single environments, one per lane. rlmesh_vector_env_new with rlmesh_env_server_new_vector serves a natively batched simulator.
  • RLMeshEnvServerOptions holds the address (or host / port / path / transport), adapter tags, RLMeshServeOptions (timeouts, workflow_edition, remote shutdown), close_env_on_shutdown (default true), and the ext record.
  • RLMeshEnvFactory serves families of environments with tag parameters and variant catalogs; see examples/c_factory.c in the repository.

See Serve an Environment for addresses, authentication, and supervision.

Spaces and values

RLMeshSpace is a space with its sampler. Build one with rlmesh_box_new, rlmesh_discrete_new, or rlmesh_dict_new (the experimental MultiBinary, MultiDiscrete, Text, and Tuple kinds have constructors too). rlmesh_space_seed, rlmesh_space_sample, and rlmesh_space_contains work on every kind. Box bounds are typed with rlmesh_bound_float, rlmesh_bound_int, and friends.

RLMeshValue is an immutable handle holding one of ten kinds: None, Bool, Int, Float, Text, Bytes, Tensor, List, Tuple, or Dict. A Discrete action is an Int, a Box observation is a Tensor, and info dicts are Dict values with insertion order. Readers are strict: rlmesh_value_as_int on a Float returns RLMESH_ERR_TYPE, and a missing dict key succeeds with NULL. Values sent across the boundary must match their space’s structure: a Dict value with a missing or extra key fails with RLMESH_ERR_INVALID_ARGUMENT before it is sent (the message names the path, as in $.position: missing key 'x'), and a received value with one is rejected before it reaches your code.

int64_t shape[] = {3};
RLMeshSpace* box = NULL;
rlmesh_box_new(rlmesh_bound_float(-1.0), rlmesh_bound_float(1.0), shape, 1, NULL, &box);

float xyz[3] = {0.0f, 0.5f, -0.5f};
size_t dims[] = {3};
RLMeshTensor* tensor = NULL;
RLMeshValue* position = NULL;
RLMeshValue* mode = NULL;
rlmesh_tensor_new(xyz, sizeof xyz, dims, 1, RLMESH_DTYPE_FLOAT32, &tensor); /* copies xyz */
rlmesh_value_tensor(tensor, &position);
rlmesh_value_int(2, &mode);
bool inside = rlmesh_space_contains(box, position);

RLMeshStr keys[] = {rlmesh_str("position"), rlmesh_str("mode")};
const RLMeshValue* values[] = {position, mode};
RLMeshValue* observation = NULL;
rlmesh_value_dict(keys, values, 2, &observation);

/* ...and later: */
rlmesh_value_free(observation);
rlmesh_value_free(mode);
rlmesh_value_free(position);
rlmesh_tensor_free(tensor);
rlmesh_space_free(box);

(Status checks are omitted here for brevity.) rlmesh_tensor_from_dlpack and rlmesh_tensor_to_dlpack exchange CPU tensors with other frameworks through DLPack 1.x. See Spaces and Values.

Models and policies

RLMeshModelVtable has a slot for each prediction method: predict, predict_chunk, predict_batch, and predict_chunk_batch. Fill the ones your policy has and leave the rest NULL. Set a slot’s bit in context_corners (for example RLMESH_CORNER_PREDICT) to receive an RLMeshPredictContext with the episode id, seeds, predict index, and a per-episode state store (rlmesh_episode_state_get / _set). Other slots cover load, on_episode_end, and close, and static declarations such as the adapter spec and workflow_edition.

  • rlmesh_model_new builds a model; rlmesh_model_from_config checks the declared load keys and calls load with a config dict.
  • rlmesh_model_serve(model, "tcp://0.0.0.0:50051", NULL) serves it to remote runtimes.
  • Pass RLMESH_SPEC_KIND_NO_ADAPTER as the spec for a policy that adapts its own observations.

A predict callback writes an owned value into its output slot. See Build a Model for the prediction methods.

Runs and sessions

Runs and sessions take targets: rlmesh_model_target(model), rlmesh_remote_model_target(remote), or RLMESH_RANDOM_SAMPLE for the model, and rlmesh_address_target("tcp://..."), rlmesh_remote_env_target(env), rlmesh_env_server_target(server), rlmesh_env_target(env), rlmesh_vector_env_target, or rlmesh_env_factory_target for the environment. A local environment target runs in-process.

  • rlmesh_run and rlmesh_model_run take RLMeshRunOptions: episodes, seeds, max_episode_steps, max_episode_seconds, hooks, instruction, close_env, execution_horizon, view, cancel. With neither episodes nor seeds, a run plays one episode. A run never closes the model and closes the environment only with close_env.
  • RLMeshRunHooks (built with rlmesh_run_hooks_new from a vtable) observes a run on the calling thread.
  • RLMeshRunResult has rlmesh_run_result_num_episodes, _total_steps, _mean_reward, _success_rate, and per-episode rlmesh_run_result_episodes_get.

A session drives the loop yourself:

RLMeshSession* session = NULL;
RLMeshValue* observation = NULL;
RLMeshValue* info = NULL;
if ((status = rlmesh_model_session(model, rlmesh_remote_env_target(env), NULL, &session)) !=
    RLMESH_OK)
  goto done;

int64_t seed = 0;
if ((status = rlmesh_session_reset(session, &seed, NULL, &observation, &info)) != RLMESH_OK)
  goto done;
for (;;) {
  RLMeshValue* action = NULL;
  RLMeshValue* next = NULL;
  double reward = 0;
  bool terminated = false, truncated = false;
  if ((status = rlmesh_session_predict(session, observation, &action)) != RLMESH_OK) goto done;
  rlmesh_value_free(info);
  info = NULL;
  status = rlmesh_session_step(session, action, &next, &reward, &terminated, &truncated, &info);
  rlmesh_value_free(action);
  if (status != RLMESH_OK) goto done;
  rlmesh_value_free(observation); /* release the old value before reusing its slot */
  observation = next;
  if (terminated || truncated) break;
}
status = rlmesh_session_close(session);

done:
  rlmesh_value_free(info);
  rlmesh_value_free(observation);
  rlmesh_session_free(session); /* before the model and env it borrows */

rlmesh_session_reset takes an optional seed and trial index (each NULL for none). Sessions connect on first use. See Run an Evaluation.

Remote clients

rlmesh_remote_env_new and rlmesh_remote_vector_env_new take RLMeshRemoteEnvOptions and connect before returning, so an unreachable address fails right away. rlmesh_remote_model_new only validates its options; its clones can open concurrent sessions. Reset and step return their results through output parameters in the usual order: observation and info, or observation, reward, terminated, truncated, and info.

A client’s I/O calls are exclusive; accessors and cancellation may overlap them. After rlmesh_remote_env_close, further I/O fails with RLMESH_ERR_RUNTIME, and the handle still needs rlmesh_remote_env_free. See Connect a Client.

Adapters

RLMeshEnvTags and RLMeshModelSpec describe the two sides of an adapter, built from typed RLMeshObsNode / RLMeshInputNode trees or from their dict and JSON forms. Pass tags through RLMeshEnvServerOptions.tags (or attach them with rlmesh_adapters_tag) and the spec through RLMeshModelOptions.spec or the vtable; runs and sessions resolve them on connect. rlmesh_adapters_resolve and rlmesh_adapter_transform_obs / _transform_action expose resolution directly. See examples/c_adapters.c and the Adapters guide.

View and record

RLMeshView (RLMESH_VIEW_INIT, or rlmesh_view_parse with term, http:PORT, or both) streams frames live; set it on session options, or on run options with a remote model. rlmesh_recorder_new records finished runs with rlmesh_recorder_add, records live runs through the hooks returned by rlmesh_recorder_capture, and writes a folder or ZIP bundle with rlmesh_recorder_export.

Reference