gyX

Gyx

Native Elixir reinforcement learning. Environments follow a Gymnasium-shaped contract (make / reset / step / render) and trainers stay on the BEAM. Drive them from IEx, the Mix CLI, or the optional playground.

This repository is three layers:

Layer Where Shipped with Hex?
Library lib/, priv/ yes
CLI mix gyx.* yes (Mix tasks in the library)
Playground ui/ no — separate Phoenix app

The library does not depend on Phoenix. The UI depends on Gyx ({:gyx, path: ".."}) and only talks to it through Gyx.Session and Gyx.Experiment. The old OpenAI Gym erlport stack is under legacy/ and is not compiled.

Gymnasium interaction for CSHRL synthesis lives in synthex. Gyx can score a couple of classic envs without starting Python; see Synthex.

Requirements

Optional:

Classic control, Blackjack, FrozenLake, and the approximate tree/ suite run with no Python.

Install

# mix.exs
{:gyx, "~> 0.2.0"}

From this repo:

mix deps.get
mix test

The Hex package includes lib, priv (MJCF assets, the Python bridge, static images), images, config, and Mix tasks. It does not include ui/ or legacy/.

Quick start

{:ok, env} = Gyx.make("CartPole-v1")
{env, obs, info} = Gyx.reset(env, seed: 0)
{:ok, env, exp} = Gyx.step(env, 1)
exp.next_observation
exp.reward
exp.terminated
exp.truncated
exp.info

exp is a Gyx.Core.Exp: one transition {observation, action, next_observation, reward, terminated, truncated, info}. The episode is done when terminated or truncated (Gyx.Core.Exp.done?/1).

Gyx.envs()
Gyx.spec("CartPole-v1")
Gyx.observe(env)
Gyx.params(env)
{:ok, svg} = Gyx.render(env, :svg)

Pass server: true to Gyx.make/2 to wrap the struct in a Gyx.Env.Server process (named, shareable — what a LiveView would use). Gyx.reset/2, step/2, observe/1, and render/2 accept either a struct or a pid.

Environments

Gyx.make/2 looks up a registered id. The environment is Hopper-v4; a prefix names the implementation.

Prefix Example Engine Python?
(none) Hopper-v4 Official Farama MJCF + Gyx.Physics.Mj (Nx/EXLA) no
tree/ tree/Hopper-v4 Approximate Gyx.Physics.Tree no
gymnasium/ gymnasium/Hopper-v4 Real MuJoCo C via a Python Port yes
gymnasium/ALE/ gymnasium/ALE/Pong-v5 ALE/Stella via the same Port yes

Unprefixed Farama ids aim at the same observation and reward contract as gymnasium.make("Hopper-v4"). Gold checks live in test/envs/farama_test.exs — those tolerances are the guarantee, not the Python wrap.

The first step of each body/dof shape compiles an XLA kernel (a few seconds). Later steps reuse it. Set config :gyx, :mj_backend, :elixir to use the scalar BEAM integrator instead of EXLA.

Short aliases work for the classics (:cartpole, "cartpole", "CartPole-v0"CartPole-v1).

Classic control and toys

All native Elixir. Discrete or 1-D Box observations as float tuples.

Id Observation Action Notes
CartPole-v1 {x, x_dot, theta, theta_dot} 0 / 1 truncates at 500
MountainCar-v0 {position, velocity} 0 / 1 / 2
Acrobot-v1 6-D link kinematics 0 / 1 / 2
Pendulum-v1 {cos θ, sin θ, θdot} torque Box trainers discretize to -2 / 0 / +2
FrozenLake-v1 tile index 0..n-1 0..3 is_slippery, map_name "4x4" / "8x8"
Blackjack-v1 {player, dealer, usable_ace} stick / hit
{:ok, env} = Gyx.make("FrozenLake-v1", is_slippery: false, map_name: "8x8")
env = Gyx.configure(env, is_slippery: true)
Gyx.params(env)
# => [%{key: :is_slippery, ...}, %{key: :map_name, ...}]

Farama MuJoCo (unprefixed)

Official XML from priv/mjcf/ (Apache-2.0, vendored from Gymnasium) plus the native rigid-body engine. Observation is a float tuple; action is a float tuple sized to the actuator count.

Id obs act episode cap
InvertedPendulum-v4 4 1 1000
InvertedDoublePendulum-v4 11 1 1000
Reacher-v4 11 2 50
Swimmer-v4 8 2 1000
Hopper-v4 11 3 1000
Walker2d-v4 17 6 1000
HalfCheetah-v4 17 6 1000
Ant-v4 27 8 1000
{:ok, env} = Gyx.make("Hopper-v4")
{env, obs, info} = Gyx.reset(env, seed: 0)
{:ok, env, exp} = Gyx.step(env, {0.1, 0.0, -0.1})

Tree (approximate)

Same body names under tree/…. Faster to hack on. Not a physics clone of Farama — do not treat tree/Hopper-v4 as transfer-equivalent to Hopper-v4.

Gymnasium wrap (Python)

gymnasium/Hopper-v4 and gymnasium/Hopper-v5 (and the rest of the Farama locomotion suite, v4 and v5) start a long-lived Port (priv/python/gymnasium_bridge.py) that owns a real gymnasium.make env. Use this to evaluate a Gyx policy on the C engine, or to bench against it.

Requires python3 with gymnasium and mujoco. If those are missing, Gyx.make("gymnasium/Hopper-v4") fails at start; the id is still listed.

Atari wrap (Python)

Not a native emulator. Ids exist only as gymnasium/ALE/…:

Observations are uint8 Nx tensors {210, 160, 3}. Actions are Discrete. Requires gymnasium, ale-py, and the ALE ROMs. There is no unprefixed or tree/ Atari id.

{:ok, env} = Gyx.make("gymnasium/ALE/Pong-v5")
{env, obs, _info} = Gyx.reset(env, seed: 0)
Nx.shape(obs)
{:ok, env, exp} = Gyx.step(env, 0)

A2C/PPO downsample frames to 84×84 grayscale (Gyx.Nx.Pixels) and train a small CNN. Tabular Q-learning and SARSA have no pixel preset.

Spaces

Gyx.Core.Spaces:

Gyx.Core.Spaces.contains?/2 is what step uses for :invalid_action.

Session

Interactive handle used by mix gyx.interact and the playground. Tracks observation, episode return, and termination so callers do not thread env / exp by hand.

{:ok, session} = Gyx.Session.start("CartPole-v1", seed: 0)
{:ok, session} = Gyx.Session.step(session, 1)
session.obs
session.return
session.terminated
Gyx.Session.done?(session)
{:ok, svg} = Gyx.Session.render(session, :svg)
session = Gyx.Session.reset(session, seed: 1)

start/2 rescues Gymnasium Port failures into {:error, reason}.

Training

Gyx.Agent is the protocol (act, learn, finish_episode, eval). Gyx.Trainers.Episodic runs any agent against an env id:

alias Gyx.{Agents.QLearning, Encode, Trainers.Episodic}
agent = QLearning.new(alpha: 0.2, gamma: 0.99, epsilon: 0.2)
%{agent: agent, returns: returns} =
Episodic.train("FrozenLake-v1", agent, episodes: 1500, env: [is_slippery: false])
Episodic.evaluate("FrozenLake-v1", agent, env: [is_slippery: false])

Presets pair an agent with horizon, encoding, and (where needed) discrete action sets:

{agent, opts} = Gyx.Trainers.Presets.q_learning("CartPole-v1")
%{agent: agent} = Episodic.train("CartPole-v1", agent, opts)
{agent, opts} = Gyx.Trainers.Presets.a2c("CartPole-v1")
%{agent: agent} = Episodic.train("CartPole-v1", agent, opts)
Algo Module Observation Notes
q_learning Gyx.Agents.QLearning discrete or binned Box ε-greedy table
sarsa Gyx.Agents.Sarsa same as Q on-policy table
reinforce Gyx.Agents.Reinforce vector (one-hot or raw) softmax policy
a2c Gyx.Agents.ActorCritic vector MLP or Atari CNN Axon + Nx
ppo same, algo: :ppo same fewer default episodes

Continuous Box actuators (Pendulum, Hopper, …) are discretized for these discrete policies (Gyx.Envs.Hopper.discrete_actions/0 and friends). MountainCar training uses potential-based shaping; evaluation still uses the true −1-per-step reward.

Gyx.Encode.for_env/1 buckets a continuous box into an integer tuple so the Q-table stays finite. Encode.one_hot/1 is used for FrozenLake under REINFORCE / A2C.

Q-learning and SARSA are not available on Atari pixels. Selecting them for gymnasium/ALE/* has no preset (Experiment.new/1 raises). Use A2C or PPO.

Experiments

Gyx.Experiment is a named run: env, algorithm, optional episode overrides. The CLI and the UI both call this module.

exp = Gyx.Experiment.new(name: "cart", env: "CartPole-v1", algo: "a2c")
exp = Gyx.Experiment.run(exp, episodes: 20, on_progress: fn info -> IO.inspect(info.return) end)
exp.eval_return
exp.returns
:ok = Gyx.Experiment.save(exp) # experiments/cart.json
{:ok, exp} = Gyx.Experiment.load("cart")
Gyx.Experiment.list()

JSON stores the spec and the recorded returns. It does not persist the trained agent (Nx params). episodes / max_steps left nil mean “use the preset at run/2 time” — new/1 does not build the agent, so creating an Atari experiment does not compile the CNN.

Default directory is experiments/ (gitignored). Override with --dir on the Mix tasks.

CLI

All tasks start the Gyx application. They are the supported interface for scripts; the playground wraps the same functions.

List or inspect environments

mix gyx.envs
mix gyx.envs CartPole-v1
mix gyx.envs gymnasium/ALE/Pong-v5

Prints id, observation space, action space, and max episode steps.

InteractGyx.Session, random or scripted actions

mix gyx.interact CartPole-v1
mix gyx.interact CartPole-v1 --seed 0 --steps 8
mix gyx.interact CartPole-v1 --action 1 --action 0 --render ansi
mix gyx.interact Hopper-v4 --action 0.1,0.0,-0.1 --steps 3
Flag Meaning
--seed N reset seed
--steps N how many steps (default 5, or the action list length)
--action A repeatable; integers, floats, or comma tuples
--render MODE ansi, svg, … after the loop

Train — one-shot Gyx.Experiment.run/2

mix gyx.train CartPole-v1
mix gyx.train CartPole-v1 --algo q_learning --episodes 50
mix gyx.train gymnasium/ALE/Pong-v5 --algo a2c --name pong
Flag Meaning
--algo q_learning / sarsa / reinforce / a2c (default) / ppo
--episodes, --max_steps, --seed override the preset
--name, --dir write DIR/NAME.json after the run

Named experiments

mix gyx.experiment new cart --env CartPole-v1 --algo a2c
mix gyx.experiment list
mix gyx.experiment show cart
mix gyx.experiment run cart --episodes 20

run reloads the JSON, trains, evaluates, and writes returns back. The agent is still in-memory only.

Bench — wall-clock reset/step, no rendering, random actions

mix gyx.bench
mix gyx.bench --steps 4000 --warmup 200
mix gyx.bench --id Hopper-v4 --id Ant-v4

Compares native Farama, tree/, gymnasium/ wrap, and in-process Python gymnasium.make. Wrap/Python columns need python3 + gymnasium + mujoco.

Synthex probe (no Python)

mix gyx.synthex
mix gyx.synthex --env MountainCar-v0

CartPole and MountainCar only.

Playground pointer — does not start Phoenix

mix gyx.playground
# prints: cd ui && mix deps.get && mix phx.server

Playground

ui/ is its own Mix project (:gyx_ui). It is not compiled when you mix compile Gyx and is not in the Hex files list.

cd ui
mix deps.get
mix phx.server

Open http://127.0.0.1:4000

The LiveView catalog groups Classic · Elixir, Native Farama · Elixir MJCF, Tree · approximate, Gymnasium · C MuJoCo, and Gymnasium · Atari. Reset / step / render go through Gyx.Session. Train goes through Gyx.Experiment.run/2. Atari gets a pixel stage and a d-pad built from get_action_meanings; choosing Q-learning on an Atari id falls back to A2C.

Rendering

Gyx.render(env, mode):

Mode Classic Farama / tree Gymnasium / Atari
:svg 2-D drawing projected MJCF scene PNG-in-SVG for pixels
:ansi / :text terminal sketch inspect q / obs inspect obs
:scene camera + geoms for the UI
:rgb raw frame from the Port

Gyx.Render.Png encodes an HxWx3 uint8 tensor. Default mode is :svg.

Configuration

config/config.exs:

config :gyx, :mj_backend, :nx # or :elixir
config :nx, default_backend: EXLA.Backend
config :nx, :default_defn_options, compiler: EXLA, client: :host

Gyx.Application starts Gyx.Registry and sets the EXLA backend when EXLA is loaded. There is no Endpoint in this OTP app.

Testing

mix test

Gymnasium and Atari tests are tagged :gymnasium and :atari. test/test_helper.exs skips those tags unless python3 can import the extras (and, for Atari, actually gym.make("ALE/Pong-v5")).

mix test --only gymnasium
mix test --only atari

cd ui && mix test exercises the LiveView against the library dependency.

Synthex

mix gyx.synthex and Gyx.Synthex.Probe use Synthex.Gym.Oracle. This repo already lists Synthex as a GitHub :dev / :test dependency (Hex will not publish git deps). The scorer itself needs no Synthex:

scorer = Gyx.Synthex.Scorer.new("CartPole-v1")
scorer.(%{"cmd" => "collect_states", "default" => 0, "seeds" => [0, 1]})
mix gyx.synthex
mix gyx.synthex --env MountainCar-v0

This is collect → features → score. It does not run CEGAR.

Layout

License

BSD-2-Clause. MJCF assets under priv/mjcf/ are Apache-2.0 from Farama Gymnasium.