Powered by AppSignal & Oban Pro

Rx Tour

notebooks/rx_tour.livemd

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:

  • 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.

: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-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.