# Rx Tour
```elixir
Mix.install([
{:rx, "~> 0.1"},
{:kino, "~> 0.19.0"},
{:explorer, "~> 0.11"},
{:plotly_ex, "~> 0.1.0"}
])
```
## What Is Rx?
Rx runs R from Elixir. By default, a persistent `Rscript` process starts
alongside your application; you send R source code in and get typed Elixir
values back. The BEAM cannot be crashed by R in this mode because they are
separate OS processes. An embedded native backend also exists for experimental,
opt-in workflows.
**Requirements:**
* `Rscript` on PATH for the default process backend
* Linux and macOS/arm64 are validated for the native harness surface
* R package `jsonlite` (all scalar/vector exchange)
* R package `arrow` (Explorer and raw Arrow data frame exchange)
* R package `ggplot2` (optional ggplot source and examples)
* R package `plotly` (optional interactive Plotly bridge examples)
* Native runs require exactly one implementation gate: `RX_BUILD_NIF=1` for C
or `RX_BUILD_RUST_NIF=1` for Rust
---
## 1. Initialization
`Rx.init/1` is idempotent. Call it once at the start of a notebook session or
rely on `Rx.eval/3` calling it automatically.
```elixir
:ok = Rx.init()
```
For reproducible process-backend package environments, call `Rx.renv_init/2`
before the first eval:
```elixir
# :ok = Rx.renv_init("path/to/project")
# :ok = Rx.renv_init("path/to/project", restore: true)
```
---
## 2. Basic Arithmetic
`Rx.eval/3` takes R source code, a globals map, and options. Normally it
returns `{result, globals}` where both sides contain `%Rx.Object{}` handles.
With `capture: true`, it returns an `%Rx.EvalResult{}` struct instead.
`Rx.decode/1` converts supported handles back to Elixir terms. It currently
decodes primitives, vectors, typed `NA` values, fully named plain R lists, and
unnamed, partially named, or duplicate-name plain R lists. R `table` values
decode to `%Rx.Table{}`. Other classed R objects such as fitted models stay
opaque and can be passed back to R.
```elixir
{result, _globals} = Rx.eval("1 + 1", %{})
Rx.decode(result)
```
> R's numeric literals are doubles. `1 + 1` returns `2.0`, not `2`. Use `1L + 1L`
> for integers.
---
## 3. Scalar Types
R has four atomic scalar types. All round-trip through `eval` + `decode`.
```elixir
# Logical (boolean)
{r, _} = Rx.eval("TRUE", %{})
Rx.decode(r)
```
```elixir
# Integer (32-bit, L suffix in R)
{r, _} = Rx.eval("42L", %{})
Rx.decode(r)
```
```elixir
# Double
{r, _} = Rx.eval("3.14", %{})
Rx.decode(r)
```
```elixir
# Character (string)
{r, _} = Rx.eval(~s[paste("hello", "from R")], %{})
Rx.decode(r)
```
```elixir
# NULL decodes to nil
{r, _} = Rx.eval("NULL", %{})
Rx.decode(r)
```
---
## 4. Atomic Vectors
Flat homogeneous vectors decode as Elixir lists.
```elixir
{r, _} = Rx.eval("c(1.0, 2.0, 3.0)", %{})
Rx.decode(r)
```
```elixir
{r, _} = Rx.eval("c(TRUE, FALSE, TRUE)", %{})
Rx.decode(r)
```
```elixir
{r, _} = Rx.eval(~s[c("one", "two", "three")], %{})
Rx.decode(r)
```
---
## 5. R Missing Values (NA)
R's typed `NA` values decode to `%Rx.NA{type: type}`.
```elixir
{r, _} = Rx.eval("NA_real_", %{})
Rx.decode(r)
```
```elixir
# NA in a vector appears inline alongside normal values
{r, _} = Rx.eval("c(1L, NA_integer_, 3L)", %{})
Rx.decode(r)
```
---
## 6. Passing Globals
Globals can be raw scalar values (`nil`, booleans, finite numbers, and strings),
`%Rx.Object{}` handles from previous `eval` calls, primitive handles created
with `Rx.encode!/1`, or string-key Elixir maps encoded as R named lists.
```elixir
{result, globals} =
Rx.eval(
"""
y <- x * 2
y + 1
""",
%{"x" => 10}
)
Rx.decode(result)
```
```elixir
# globals contains all variables that exist in the eval environment
# after the expression completes
Rx.decode(globals["y"])
```
```elixir
# Use Rx.encode!/1 when you want an explicit reusable primitive handle.
numbers = Rx.encode!([1, 2, 3])
{total, _} = Rx.eval("sum(numbers)", %{"numbers" => numbers})
Rx.decode(total)
```
### Named Lists And Map Globals
Fully named plain R lists decode to Elixir maps.
```elixir
{person, _} =
Rx.eval(
~S|list(name = "Alice", age = 30L, active = TRUE, scores = c(91.5, 88.0))|,
%{}
)
Rx.decode(person)
```
Plain R lists that are unnamed, partially named, or have duplicate names decode
to `%Rx.RList{}` so positional information is not lost.
```elixir
{mixed, _} = Rx.eval("list(1.0, b = 2.0, b = 3.0)", %{})
Rx.decode(mixed)
```
String-key Elixir maps can be passed as globals and used from R as named lists.
```elixir
{label, _} =
Rx.eval("paste(customer$name, sum(customer$scores), sep = ': ')", %{
"customer" => %{"name" => "Alice", "scores" => [91.5, 88.0]}
})
Rx.decode(label)
```
### Chaining Multiple Evals
Object handles from one eval can be threaded into the next, building up a
computation across calls.
```elixir
{a, _} = Rx.eval("seq_len(10L)", %{})
{b, _} = Rx.eval("cumsum(a)", %{"a" => a})
{result, _} = Rx.eval("tail(b, 1L)", %{"b" => b})
Rx.decode(result)
```
---
## 7. Capture Mode
With `capture: true`, stdout, messages, and warnings are returned in an
`%Rx.EvalResult{}` struct instead of being routed to IO.
```elixir
captured =
Rx.eval(
"""
print("hello from R")
message("this is a message")
warning("this is a warning")
answer <- 42L
answer
""",
%{},
capture: true
)
%{
value: Rx.decode(captured.result),
globals: Map.keys(captured.globals),
stdout: captured.stdout,
messages: captured.messages,
warnings: captured.warnings
}
```
---
## 8. Plot Capture
`Rx.plot/3` evaluates R source with a temporary PNG graphics device on the
process/PortArrow backend or the experimental native backend. It returns every
PNG page produced during the evaluation.
```elixir
[plot] =
Rx.plot(
"""
plot(1:5, (1:5)^2, type = "b", main = "Rx plot")
""",
%{},
width: 640,
height: 420
)
%{
format: plot.format,
mime_type: plot.mime_type,
page: plot.page,
size: {plot.width, plot.height},
bytes: byte_size(plot.data)
}
```
```elixir
Rx.Kino.image(plot)
```
Visible ggplot2 objects returned by top-level expressions are captured without
an explicit `print(p)` call. This cell skips itself when the R `ggplot2` package
is not installed.
```elixir
ggplot2_available? =
Rx.eval("requireNamespace('ggplot2', quietly = TRUE)", %{}, capture: true)
|> then(fn result -> Rx.decode(result.result) end)
ggplot2_plot =
if ggplot2_available? do
[plot] =
Rx.plot(
"""
library(ggplot2)
ggplot(mtcars, aes(wt, mpg)) + geom_point()
""",
%{},
width: 640,
height: 420
)
plot
end
```
```elixir
case ggplot2_plot do
nil -> %{skipped: true, reason: "R package `ggplot2` is not installed"}
plot -> Rx.Kino.image(plot)
end
```
With `capture: true`, plot output is returned with the plots instead of routed
to the configured IO devices.
```elixir
captured_plot =
Rx.plot(
"""
cat("plot stdout\n")
message("plot message")
warning("plot warning")
plot(1:3)
""",
%{},
capture: true,
width: 360,
height: 240
)
%{
pages: length(captured_plot.plots),
stdout: captured_plot.stdout,
messages: captured_plot.messages,
warnings: captured_plot.warnings
}
```
The initial supported format is `:png`. Plot options include `width`, `height`,
`res`, `pointsize`, `max_pages`, and `max_bytes`. The page and byte limits guard
the captured response after R renders the plot. The process and native backends
return the same public plot result shapes.
---
## 9. Interactive Plotly Bridge
R `plotly` objects are htmlwidget objects, so `Rx.decode/1` keeps them
opaque. `Rx.Plotly.from_r/1` serializes the R object to Plotly.js JSON and
wraps it in a `plotly_ex` `%Plotly.Figure{}` when the R `plotly` package and
the optional Elixir `plotly_ex` dependency are available. The bridge uses the
active backend, so create the R plotly object after selecting the backend that
will serialize it.
```elixir
plotly_available? =
Rx.eval("requireNamespace('plotly', quietly = TRUE)", %{}, capture: true)
|> then(fn result -> Rx.decode(result.result) end)
r_plotly_figure =
if plotly_available? do
{r_plotly, _} =
Rx.eval(
"""
plotly::plot_ly(
x = c(1, 2, 3, 4),
y = c(2, 4, 8, 16),
type = "scatter",
mode = "lines+markers"
)
""",
%{}
)
{:ok, figure} = Rx.Plotly.from_r(r_plotly)
figure
end
```
```elixir
case r_plotly_figure do
nil -> %{skipped: true, reason: "R package `plotly` is not installed"}
figure -> Plotly.show(figure)
end
```
Raw JSON is available when you need to inspect or store the Plotly.js payload.
Render it by wrapping it with `Rx.Plotly.from_json/1`, not by passing the
string directly to `Plotly.show/1`.
The raw JSON is unchanged; figures created with `from_json/1` or `from_r/1`
omit only the deprecated Plotly Chart Studio config keys `plotlyServerURL`,
`showLink`, `linkText`, `showEditInChartStudio`, `showSendToCloud`, and
`sendData` before rendering. Other Plotly config values are preserved.
```elixir
if plotly_available? do
{r_plotly, _} =
Rx.eval(
"""
plotly::plot_ly(x = c("a", "b"), y = c(5, 9), type = "bar")
""",
%{}
)
{:ok, json} = Rx.Plotly.json_from_r(r_plotly)
{:ok, figure} = Rx.Plotly.from_json(json)
%{
bytes: byte_size(json),
traces: length(figure.data),
layout_keys: Map.keys(figure.layout)
}
else
%{skipped: true, reason: "R package `plotly` is not installed"}
end
```
---
## 10. R Errors
R errors become `%Rx.Error{}` exceptions. The BEAM process stays alive.
```elixir
try do
Rx.eval("missing_var + 1", %{})
rescue
e in Rx.Error ->
IO.puts("Caught R error: #{Exception.message(e)}")
IO.inspect(e.r_class, label: "r_class")
end
```
```elixir
# Parse errors are caught too
try do
Rx.eval("function(", %{})
rescue
e in Rx.Error -> IO.puts("Parse error: #{Exception.message(e)}")
end
```
---
## 11. Opaque Objects
Classed R objects, functions, environments, and other semantic R objects are
returned as `%Rx.Object{}` handles unless they have a feature-specific decoder.
Plain lists can decode as maps or `%Rx.RList{}`, R `table` values decode as
`%Rx.Table{}`, but classed lists such as `lm` results stay opaque.
```elixir
{lm_result, _} =
Rx.eval(
"""
x <- c(1, 2, 3, 4, 5)
y <- c(2.1, 4.0, 6.2, 7.9, 10.1)
m <- lm(y ~ x)
m
""",
%{}
)
%{
object: lm_result,
decoded_is_still_opaque?: match?(%Rx.Object{}, Rx.decode(lm_result))
}
```
```elixir
{coef_result, _} = Rx.eval("coef(model)[[2]]", %{"model" => lm_result})
Rx.decode(coef_result)
```
To get structured model values, query the opaque object in R and return a plain
named list.
```elixir
{model_stats, _} =
Rx.eval(
"""
s <- summary(model)
list(
coefficients = as.list(coef(model)),
r_squared = s$r.squared,
sigma = s$sigma
)
""",
%{"model" => lm_result}
)
Rx.decode(model_stats)
```
For the R console-style display, use `Rx.print/2`. Decoding remains for
semantic value conversion; print methods are a separate textual display path.
```elixir
printed = Rx.print(lm_result)
IO.puts(printed)
```
With `capture: true`, print messages and warnings are returned alongside stdout.
```elixir
captured_print = Rx.print(lm_result, capture: true, width: 80)
%{
stdout: captured_print.stdout,
messages: captured_print.messages,
warnings: captured_print.warnings,
decoded_is_still_opaque?: match?(%Rx.Object{}, Rx.decode(lm_result))
}
```
## 12. Rx.DataFrame Without Arrow
`Rx.DataFrame` converts simple R `data.frame` values without the R `arrow`
package. Use `engine: :no_arrow` when portability matters or when Arrow is not
installed.
```elixir
{plain_df, _} =
Rx.eval("""
data.frame(
x = c(1L, NA_integer_, 3L),
label = c("a", NA_character_, "c"),
stringsAsFactors = FALSE,
check.names = FALSE
)
""", %{})
{:ok, rx_df} = Rx.DataFrame.from_r(plain_df, engine: :no_arrow)
rx_df
```
```elixir
{:ok, rows} = Rx.DataFrame.from_r(plain_df, engine: :no_arrow, as: :rows)
rows
```
```elixir
typed =
%Rx.DataFrame{
names: ["x"],
columns: %{"x" => [1, %Rx.NA{type: :integer}]},
types: %{"x" => :integer},
n_rows: 2
}
{:ok, typed_r} = Rx.DataFrame.to_r(typed, engine: :no_arrow)
Rx.eval("sum(is.na(df$x))", %{"df" => typed_r}) |> elem(0) |> Rx.decode()
```
## 13. Explorer Data Frames
`Rx.Explorer` converts between R `data.frame` objects and
`Explorer.DataFrame` values. It uses Arrow IPC under the hood, so this section
requires the Elixir `explorer` package and the R `arrow` package.
This tour uses the default PortArrow backend.
On validated native platforms, public Arrow dataframe encode/decode uses the
active native backend for native-owned objects when native is initialized.
PortArrow-owned dataframe handles are not valid native eval globals.
```elixir
arrow_available? =
try do
{available, _} = Rx.eval("base::requireNamespace('arrow', quietly = TRUE)", %{})
Rx.decode(available)
rescue
_ -> false
end
```
```elixir
explorer_section =
if arrow_available? do
{r_df, _} =
Rx.eval(
"""
data.frame(
name = c("alice", "bob", "carol"),
score = c(91.5, 78.0, 88.3),
pass = c(TRUE, FALSE, TRUE)
)
""",
%{}
)
{:ok, explorer_df} = Rx.Explorer.from_r(r_df)
%{skipped: false, r_df: r_df, explorer_df: explorer_df}
else
%{skipped: true, reason: "Explorer/Arrow examples skipped: R package arrow is not installed"}
end
if explorer_section.skipped, do: explorer_section, else: explorer_section.explorer_df
```
```elixir
if explorer_section.skipped do
explorer_section
else
Explorer.DataFrame.names(explorer_section.explorer_df)
end
```
```elixir
if explorer_section.skipped do
explorer_section
else
Explorer.DataFrame.n_rows(explorer_section.explorer_df)
end
```
### Send an Explorer DataFrame to R
`to_r/1` stores the Explorer data frame in the R object store and returns an
opaque `%Rx.Object{}` handle. Pass that handle into `eval/3` as a global.
```elixir
sales_section =
if arrow_available? do
sales =
Explorer.DataFrame.new(%{
"region" => ["north", "south", "west", "north"],
"revenue" => [120.5, 98.0, 140.25, 130.75],
"units" => [10, 8, 12, 11]
})
{:ok, sales_r} = Rx.Explorer.to_r(sales)
{summary, _} =
Rx.eval(
"""
data.frame(
rows = nrow(sales),
total_revenue = sum(sales$revenue),
mean_units = mean(sales$units)
)
""",
%{"sales" => sales_r}
)
{:ok, summary_df} = Rx.Explorer.from_r(summary)
%{skipped: false, sales: sales, sales_r: sales_r, summary_df: summary_df}
else
%{skipped: true, reason: "Explorer/Arrow examples skipped: R package arrow is not installed"}
end
if sales_section.skipped, do: sales_section, else: sales_section.summary_df
```
### Round Trip Through R
R can transform an Explorer-originated data frame and return the new data frame
back to Explorer.
```elixir
discounted_section =
if sales_section.skipped do
sales_section
else
{discounted, _} =
Rx.eval(
"""
sales$discounted_revenue <- round(sales$revenue * 0.9, 2)
sales
""",
%{"sales" => sales_section.sales_r}
)
{:ok, discounted_df} = Rx.Explorer.from_r(discounted)
%{skipped: false, discounted_df: discounted_df}
end
if discounted_section.skipped, do: discounted_section, else: discounted_section.discounted_df
```
```elixir
if arrow_available? do
case Rx.Explorer.from_r(elem(Rx.eval("list(a = 1, b = 2)", %{}), 0)) do
{:ok, df} -> df
{:error, reason} -> {:expected_error, reason}
end
else
%{skipped: true, reason: "Explorer/Arrow examples skipped: R package arrow is not installed"}
end
```
---
## 14. Raw Arrow IPC Data Frames
When the R `arrow` package is installed, data frames can be exchanged as Apache
Arrow IPC stream bytes. The bytes can be consumed by any Arrow-capable library.
```elixir
# This section requires: Rscript -e 'requireNamespace("arrow")'
if arrow_available? do
{df_obj, _} =
Rx.eval(
"""
data.frame(
name = c("alice", "bob", "carol"),
score = c(91.5, 78.0, 88.3),
pass = c(TRUE, FALSE, TRUE)
)
""",
%{}
)
case Rx.decode_arrow(df_obj) do
{:ok, arrow_bytes} ->
IO.puts("Arrow IPC bytes: #{byte_size(arrow_bytes)} bytes")
IO.puts("Pass to Explorer.DataFrame.from_ipc_stream/1 or any Arrow library")
{:error, reason} ->
IO.puts("Arrow not available: #{inspect(reason)}")
end
else
%{skipped: true, reason: "Raw Arrow IPC example skipped: R package arrow is not installed"}
end
```
---
## 15. Table Values And DataFrame Options
Two supported shapes are easy to miss in a quick tour: R `table` values decode
to `%Rx.Table{}`, and `Rx.DataFrame.from_r/2` can return structs, columns, or
rows. The no-Arrow path also accepts `max_rows` as a safety guard that rejects
unexpectedly large frames instead of truncating them.
```elixir
{table_result, _} =
Rx.eval(
"""
table(
group = c("control", "control", "treatment", "treatment", "treatment"),
passed = c(TRUE, FALSE, TRUE, TRUE, FALSE)
)
""",
%{}
)
decoded_table = Rx.decode(table_result)
%{
counts: decoded_table.counts,
dim: decoded_table.dim,
dimnames: decoded_table.dimnames
}
```
```elixir
{option_df, _} =
Rx.eval(
"""
data.frame(
id = 1:5,
label = paste0("row-", 1:5),
score = c(9.5, 8.0, 7.25, 9.0, 8.75),
stringsAsFactors = FALSE
)
""",
%{}
)
{:ok, all_rows} = Rx.DataFrame.from_r(option_df, engine: :no_arrow, as: :rows)
{:ok, columns} = Rx.DataFrame.from_r(option_df, engine: :no_arrow, as: :columns)
{:ok, auto_dataframe} = Rx.DataFrame.from_r(option_df, engine: :auto)
preview_rows = all_rows |> Enum.take(3)
max_rows_guard =
case Rx.DataFrame.from_r(option_df, engine: :no_arrow, as: :rows, max_rows: 3) do
{:ok, _rows} -> :within_limit
{:error, {:dataframe_too_large, 5, 3}} -> {:expected_error, {:dataframe_too_large, 5, 3}}
{:error, reason} -> {:unexpected_error, reason}
end
%{
preview_rows: preview_rows,
max_rows_guard: max_rows_guard,
column_names: Map.keys(columns),
auto_engine_rows: auto_dataframe.n_rows
}
```
---
## 16. Backend Selection And Process Reset
The process backend is the default and production-preferred backend. Public
auto-init chooses it unless `RX_BACKEND` is set to `native` or
`native_fallback`. `Rx.use_backend(:process, opts)` can reset the separate
Rscript process with new process options. That invalidates handles owned by the
old process, so recreate objects after switching.
```elixir
%{
current_backend: Rx.backend(),
rx_backend_env: System.get_env("RX_BACKEND") || "(unset)"
}
```
```elixir
case Rx.backend() do
:native ->
%{
skipped: true,
reason: "already on native; restart the Livebook runtime before switching away"
}
_ ->
:ok = Rx.use_backend(:process)
{result, _} = Rx.eval(~s[paste("backend", "process", sep = ":")], %{})
%{
backend: Rx.backend(),
decoded: Rx.decode(result)
}
end
```
---
## 17. Optional Native Switch And No-Arrow Round Trip
Run this section last. Once embedded native R initializes, it lives inside the
current BEAM process and cannot be shut down cleanly in place. Restart the
Livebook runtime before switching back to the process backend or changing
native options.
Start Livebook with exactly one native implementation gate before running this
cell:
```bash
RX_BUILD_NIF=1 livebook server
RX_BUILD_RUST_NIF=1 livebook server
```
```elixir
native_gate =
case {System.get_env("RX_BUILD_NIF") == "1", System.get_env("RX_BUILD_RUST_NIF") == "1"} do
{true, false} -> {:ok, :c}
{false, true} -> {:ok, :rust}
{false, false} -> :disabled
{true, true} -> {:error, "set exactly one of RX_BUILD_NIF=1 or RX_BUILD_RUST_NIF=1"}
end
discover_native_paths = fn ->
try do
with {r_home_output, 0} <- System.cmd("R", ["RHOME"], stderr_to_stdout: true),
r_home = String.trim(r_home_output),
true <- r_home != "",
{:ok, lib_r_path} <- Rx.Native.Paths.lib_r_path(r_home) do
{:ok, r_home, lib_r_path}
else
{output, status} ->
{:error, "R RHOME failed with status #{status}: #{String.trim(output)}"}
false ->
{:error, "R RHOME returned an empty path"}
{:error, reason} ->
{:error, reason}
end
rescue
error -> {:error, Exception.message(error)}
end
end
run_no_arrow_round_trip = fn ->
source =
%Rx.DataFrame{
names: ["label", "count", "score"],
columns: %{
"label" => ["a", "b", "c"],
"count" => [1, 2, 3],
"score" => [10.0, 20.5, 30.25]
},
types: %{"label" => :character, "count" => :integer, "score" => :double},
n_rows: 3
}
{:ok, r_df} = Rx.DataFrame.to_r(source, engine: :no_arrow)
{transformed, _} =
Rx.eval(
"""
df$total <- df$count * df$score
df
""",
%{"df" => r_df}
)
{:ok, rows} = Rx.DataFrame.from_r(transformed, engine: :no_arrow, as: :rows)
%{
backend: Rx.backend(),
rows: rows
}
end
case {Rx.backend(), native_gate} do
{:native, _gate} ->
run_no_arrow_round_trip.()
{_backend, {:ok, implementation}} ->
case discover_native_paths.() do
{:ok, r_home, lib_r_path} ->
try do
:ok = Rx.use_backend(:native, r_home: r_home, lib_r_path: lib_r_path, lib_paths: [])
run_no_arrow_round_trip.()
|> Map.put(:native_gate, implementation)
rescue
error ->
%{skipped: true, native_gate: implementation, reason: Exception.message(error)}
end
{:error, reason} ->
%{skipped: true, native_gate: implementation, reason: reason}
end
{_backend, :disabled} ->
%{
skipped: true,
reason: "restart Livebook with RX_BUILD_NIF=1 or RX_BUILD_RUST_NIF=1 to run native"
}
{_backend, {:error, reason}} ->
%{skipped: true, reason: reason}
end
```
---
## Remaining Gaps
* **Production default:** The external process backend remains the default and
production-preferred backend because it keeps R outside the BEAM process.
* **Native backend:** Native C and Rust gates are validated on the
representative Linux and macOS/arm64 harness surface, including eval, print,
capture, plot, no-Arrow data frames, and direct native Arrow on validated
native platforms. Native remains experimental, opt-in, and not
production-hardened.
* **Native restart/switching:** Embedded R has no in-BEAM shutdown/restart
path. Restart the BEAM or Livebook runtime before switching away from native
or changing native options after native has initialized.
* **`renv`:** Process-backend `renv` workflows are supported through explicit
`Rx.renv_init/2`. Native `renv` activation is not supported.
* **Multiple R workers:** Not implemented; `Rx.Runtime` serializes calls
through one active backend.
* **Kino / Livebook helpers:** PNG plot rendering and Plotly handoff are
implemented. Broader UI helpers remain future work.
* **Windows:** Not implemented.
---
## Architecture Summary
```
Elixir caller
│
▼
Rx.eval / Rx.plot / Rx.print / Rx.decode / dataframe APIs
│
▼
Rx.Runtime (GenServer)
│
├─ process / PortArrow backend
│ │
│ ▼ binary framed protocol: JSON header + optional Arrow body
│ Rx.Backends.PortArrow
│ │ stdin/stdout pipes
│ ▼
│ Rscript --vanilla priv/rx_backend.R
│
└─ native backend
│
▼
Rx.Backends.Native
│ one native owner thread
▼
Rx.Native NIF -> embedded libR
```
R runs in a separate OS process. A BEAM crash in R does not kill the VM. If
R crashes, the `PortArrow` GenServer stops (transient); outstanding callers
receive `RuntimeError: "R backend crashed"` and the next call restarts R.
Calling `Rx.use_backend(:process, opts)` with a different process config
also stops the old Rscript process and starts a fresh one. Opaque object handles
from the stopped process are stale and must be recreated.
The native backend is not an R OS process. It loads embedded R into the BEAM
through the NIF and keeps one native owner thread for R C API calls. Build it
explicitly with exactly one implementation gate: `RX_BUILD_NIF=1` for the C NIF
or `RX_BUILD_RUST_NIF=1` for the Rust NIF; both implementations load as
`priv/rx_nif.so`. Once native has initialized, restart the BEAM or Livebook runtime
before switching away from native or changing native options.
`RX_BACKEND` controls public auto-init: unset, empty, `process`, `port_arrow`,
or `port` selects the process backend; `native` selects the native backend
strictly; `native_fallback` tries native first and falls back to the process
backend only when native is unavailable before embedded R starts. Explicit
`Rx.system_init/1` remains strict after initialization, while
`Rx.use_backend/2` is the public selector/reset API.
Data frames have two public exchange paths. `Rx.Explorer` and
`Rx.decode_arrow/1` use Apache Arrow IPC and require the optional Elixir
Explorer dependency plus the R `arrow` package. `Rx.DataFrame` can use Arrow,
no-Arrow conversion, or `engine: :auto`, which falls back only for missing
optional Arrow dependencies. Ownership still matters: PortArrow-owned handles
must be used with the process backend, and native-owned handles must be used
with the active native backend.