Skip to main content

notebooks/renv_process_backend_smoke.livemd

# Reproducible R Analysis With renv

```elixir
Mix.install([
  {:rx, "~> 0.1"}
])
```

## Shared Analysis Scenario

This notebook models a common workflow: an Elixir Livebook opens an R analysis
project with a pinned `renv.lock`, starts Rx inside that project, reads project
configuration from R, and runs a small analysis that depends on the packages in
the project library.

The demo uses `datasets::airquality` and `jsonlite` so it can run without
downloading a large package set. On a real team project, replace the temporary
demo path with the project directory that contains your committed `renv.lock`.

For an already-restored project, call
`:ok = Rx.renv_init("/path/to/your/project", restore: false)`.

To populate the project library from the lockfile first, call
`:ok = Rx.renv_init("/path/to/your/project", restore: true)`.

## Prepare A Demo Project

The helper below builds a disposable project that behaves like a checked-out R
project: it has a project file, an `renv.lock`, a project-local library, and a
small JSON configuration file. It reports `%{status: "skipped"}` when local R
prerequisites are missing instead of writing to a user library.

```elixir
defmodule RenvAnalysisExample do
  @moduledoc false

  def prepare do
    with {:ok, rscript} <- find_rscript(),
         :ok <- require_r_package(rscript, "renv"),
         :ok <- require_r_package(rscript, "jsonlite") do
      prepare_project(rscript)
    end
  end

  def run_analysis(%{status: "ready"} = context) do
    {result, _globals} =
      Rx.eval(
        ~S"""
        project <- Sys.getenv("RX_RENV_PROJECT")
        config <- jsonlite::fromJSON(file.path(project, "analysis_config.json"))

        observations <- stats::na.omit(
          datasets::airquality[, c("Ozone", "Solar.R", "Wind", "Temp")]
        )

        fit <- lm(Ozone ~ Solar.R + Wind + Temp, data = observations)

        prediction_days <- as.data.frame(config$prediction_days)
        prediction_days$predicted_ozone <- as.numeric(predict(fit, newdata = prediction_days))

        coefficient_matrix <- coef(summary(fit))
        coefficients <- data.frame(
          term = rownames(coefficient_matrix),
          estimate = unname(coefficient_matrix[, "Estimate"]),
          std_error = unname(coefficient_matrix[, "Std. Error"]),
          p_value = unname(coefficient_matrix[, "Pr(>|t|)"]),
          row.names = NULL
        )

        report <- list(
          status = "completed",
          project = config$project,
          rx_project = Sys.getenv("RX_RENV_PROJECT"),
          lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
          lib_paths = .libPaths(),
          jsonlite_version = as.character(utils::packageVersion("jsonlite")),
          r_version = as.character(getRversion()),
          rows_used = nrow(observations),
          model_formula = deparse(formula(fit)),
          coefficients = coefficients,
          predictions = prediction_days,
          model_summary = paste(capture.output(summary(fit)), collapse = "\n"),
          session = paste(capture.output(sessionInfo()), collapse = "\n")
        )

        as.character(
          jsonlite::toJSON(report, dataframe = "rows", auto_unbox = TRUE, digits = 5, na = "null")
        )
        """,
        %{}
      )

    result
    |> Rx.decode()
    |> Jason.decode!()
    |> Map.put("project_directory", context.project)
  end

  def run_analysis(skipped), do: skipped

  defp find_rscript do
    case System.find_executable("Rscript") do
      nil -> {:skip, "Rscript is not on PATH"}
      path -> {:ok, path}
    end
  end

  defp require_r_package(rscript, package) do
    source = ~s|cat(requireNamespace("#{package}", quietly = TRUE))|

    case System.cmd(rscript, ["--vanilla", "-e", source], stderr_to_stdout: true) do
      {"TRUE", 0} -> :ok
      {output, _status} -> {:skip, "R package #{package} is unavailable: #{String.trim(output)}"}
    end
  end

  defp prepare_project(rscript) do
    root = Path.join(System.tmp_dir!(), "rx-renv-analysis-#{System.unique_integer([:positive])}")
    project = Path.join(root, "airquality-project")
    renv_root = Path.join(root, "renv-root")
    renv_library = Path.join(project, "renv-library")
    config_path = Path.join(project, "analysis_config.json")

    File.mkdir_p!(project)

    config = %{
      "project" => "airquality-ozone-forecast",
      "prediction_days" => [
        %{"Solar.R" => 190, "Wind" => 7.4, "Temp" => 87},
        %{"Solar.R" => 250, "Wind" => 9.2, "Temp" => 92}
      ]
    }

    File.write!(config_path, Jason.encode!(config))

    env = [
      {"RENV_PATHS_ROOT", renv_root},
      {"RENV_PATHS_LIBRARY", renv_library},
      {"RENV_CONFIG_SANDBOX_ENABLED", "FALSE"}
    ]

    source = ~S"""
    args <- commandArgs(trailingOnly = TRUE)
    if (length(args) > 0L && identical(args[[1]], "--args")) args <- args[-1L]
    project <- normalizePath(args[[1]], mustWork = TRUE)

    options(renv.consent = TRUE)

    if (!requireNamespace("renv", quietly = TRUE)) {
      cat("renv unavailable\n", file = stderr())
      quit(save = "no", status = 10)
    }

    if (!requireNamespace("jsonlite", quietly = TRUE)) {
      cat("jsonlite unavailable\n", file = stderr())
      quit(save = "no", status = 11)
    }

    lockfile <- file.path(project, "renv.lock")
    writeLines(c(
      "{",
      sprintf('  "R": { "Version": "%s" },', as.character(getRversion())),
      '  "Packages": {}',
      "}"
    ), lockfile)

    renv::init(project = project, bare = TRUE, restart = FALSE, load = FALSE)

    library_path <- renv::paths$library(project = project)
    dir.create(library_path, recursive = TRUE, showWarnings = FALSE)

    source <- find.package("jsonlite")
    target <- file.path(library_path, "jsonlite")
    if (dir.exists(target)) unlink(target, recursive = TRUE)

    if (!file.copy(source, library_path, recursive = TRUE)) {
      cat("failed to copy jsonlite into isolated renv library\n", file = stderr())
      quit(save = "no", status = 12)
    }

    lock <- renv::lockfile_read(lockfile, project = project)
    lock$Packages$jsonlite <- list(
      Package = "jsonlite",
      Version = as.character(utils::packageVersion("jsonlite")),
      Source = "Repository",
      Repository = "CRAN"
    )
    renv::lockfile_write(lock, lockfile, project = project)
    """

    case System.cmd(rscript, ["--vanilla", "-e", source, "--args", project],
           env: env,
           stderr_to_stdout: true
         ) do
      {_output, 0} ->
        {:ok,
         %{
           status: "prepared",
           project: Path.expand(project),
           lockfile: Path.expand(Path.join(project, "renv.lock")),
           config_path: Path.expand(config_path),
           renv_root: Path.expand(renv_root),
           renv_library: Path.expand(renv_library),
           renv_env: env
         }}

      {output, status} ->
        {:skip, "could not prepare isolated renv project, status #{status}: #{String.trim(output)}"}
    end
  end
end
```

## Start Rx In That Project

`Rx.renv_init/2` validates the lockfile, checks that `renv` and `jsonlite` are
available to the selected project, and starts the process backend with
`RX_RENV_PROJECT` and `RX_RENV_LOCKFILE` set for the R process.

```elixir
example =
  case RenvAnalysisExample.prepare() do
    {:ok, context} ->
      project = context.project

      :ok =
        Rx.renv_init(project,
          restore: false,
          renv_env: context.renv_env
        )

      context
      |> Map.put(:status, "ready")
      |> Map.put(:backend, Rx.backend())

    {:skip, reason} ->
      %{status: "skipped", reason: reason}
  end

Map.drop(example, [:renv_env])
```

## Inspect The R Environment

This is the quick sanity check you would usually run before a notebook analysis:
confirm the project path, lockfile path, project library, and package version R
actually sees.

```elixir
renv_environment =
  case example do
    %{status: "ready"} ->
      {result, _globals} =
        Rx.eval(
          ~S"""
          list(
            project = Sys.getenv("RX_RENV_PROJECT"),
            lockfile = Sys.getenv("RX_RENV_LOCKFILE"),
            lib_paths = .libPaths(),
            jsonlite_version = as.character(utils::packageVersion("jsonlite"))
          )
          """,
          %{}
        )

      Rx.decode(result)

    skipped ->
      skipped
  end

renv_environment
```

## Run The Analysis

The R code reads the project configuration with `jsonlite::fromJSON()`, fits a
small ozone model against `datasets::airquality`, predicts for the configured
days, and returns a JSON report to Elixir.

```elixir
analysis_report = RenvAnalysisExample.run_analysis(example)

case analysis_report do
  %{"status" => "completed"} = report ->
    %{
      project: report["project"],
      rows_used: report["rows_used"],
      formula: report["model_formula"],
      jsonlite_version: report["jsonlite_version"],
      predictions: report["predictions"],
      coefficient_terms: Enum.map(report["coefficients"], & &1["term"])
    }

  skipped ->
    skipped
end
```

## Keep The Full Report

The previous cell keeps the display compact. The full report still includes the
project paths, `.libPaths()`, model summary, and `sessionInfo()` output so the
notebook result can be audited later.

```elixir
analysis_report
```

## What Changes Between Projects

Rx treats the selected project, lockfile path, lockfile content, resolved
library paths, and selected `renv` environment as part of the backend identity.
If any of those change, call `Rx.renv_init/2` again and recreate R object
handles in the new session before passing them back to R.

For example, repoint the process backend at another checked-out project with
`:ok = Rx.renv_init("/path/to/another/project", restore: false)`.