Rx Tour
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:
-
Rscripton 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=1for C orRX_BUILD_RUST_NIF=1for 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.
:ok = Rx.init()
For reproducible process-backend package environments, call Rx.renv_init/2
before the first eval:
# :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.
{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.
# Logical (boolean)
{r, _} = Rx.eval("TRUE", %{})
Rx.decode(r)
# Integer (32-bit, L suffix in R)
{r, _} = Rx.eval("42L", %{})
Rx.decode(r)
# Double
{r, _} = Rx.eval("3.14", %{})
Rx.decode(r)
# Character (string)
{r, _} = Rx.eval(~s[paste("hello", "from R")], %{})
Rx.decode(r)
# NULL decodes to nil
{r, _} = Rx.eval("NULL", %{})
Rx.decode(r)
4. Atomic Vectors
Flat homogeneous vectors decode as Elixir lists.
{r, _} = Rx.eval("c(1.0, 2.0, 3.0)", %{})
Rx.decode(r)
{r, _} = Rx.eval("c(TRUE, FALSE, TRUE)", %{})
Rx.decode(r)
{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}.
{r, _} = Rx.eval("NA_real_", %{})
Rx.decode(r)
# 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.
{result, globals} =
Rx.eval(
"""
y <- x * 2
y + 1
""",
%{"x" => 10}
)
Rx.decode(result)
# globals contains all variables that exist in the eval environment
# after the expression completes
Rx.decode(globals["y"])
# 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.
{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.
{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.
{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.
{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.
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.
[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)
}
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.
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
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.
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.
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
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.
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.
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
# 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.
{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))
}
{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.
{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.
printed = Rx.print(lm_result)
IO.puts(printed)
With capture: true, print messages and warnings are returned alongside stdout.
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.
{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
{:ok, rows} = Rx.DataFrame.from_r(plain_df, engine: :no_arrow, as: :rows)
rows
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.
arrow_available? =
try do
{available, _} = Rx.eval("base::requireNamespace('arrow', quietly = TRUE)", %{})
Rx.decode(available)
rescue
_ -> false
end
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
if explorer_section.skipped do
explorer_section
else
Explorer.DataFrame.names(explorer_section.explorer_df)
end
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.
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.
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
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.
# 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.
{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
}
{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.
%{
current_backend: Rx.backend(),
rx_backend_env: System.get_env("RX_BACKEND") || "(unset)"
}
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:
RX_BUILD_NIF=1 livebook server
RX_BUILD_RUST_NIF=1 livebook server
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-backendrenvworkflows are supported through explicitRx.renv_init/2. Nativerenvactivation is not supported. -
Multiple R workers: Not implemented;
Rx.Runtimeserializes 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.