Investigate a regression with RLM
Source:vignettes/articles/tutorial-rlm-dsprrr.Rmd
tutorial-rlm-dsprrr.RmdYou will build a 40,000-row session table and a 200-row change log, ask an RLM why checkout conversion fell after a release, and check its answer against a calculation that does not involve the model. RLM suits this kind of question: the answer is buried in a large object, and you do not know in advance which slice of it to look at. The model inspects the data with R code and only sees the results it prints.
The data are built without random numbers, so the right answer is fixed:
release: 2.4.0
cohort: platform=mobile / plan=pro
before_rate: 0.92
after_rate: 0.61
drop_pp: 31
change_id: CHG-1842
evidence: Mobile Pro token refresh: retry budget changed from 3 to 0 and timeout from 8 s to 800 ms.
The model has to discover which categorical columns define the
affected cohort, calculate the drop, and find the change record that
best explains it. Of the session table (about 3.6 MB as row-oriented
JSON), only a short str() preview goes into the prompt. Check the answer
independently computes the same answer without a model.
Where RLM fits
The task needs exact aggregation over a table too large to paste into
a prompt, a grouping the question does not name, and a judgment about
one short piece of text. module() or
chain_of_thought() would have to receive the data in the
prompt. program_of_thought() fits when you already know
which calculation to run. Once an investigation like this becomes
routine, a plain R function is the better tool. RLM covers the
exploratory middle: the model decides what to inspect, R does the
arithmetic, and the model revises its plan from the results.
Build the incident data
Every cohort has exactly 5,000 sessions per release. Only mobile Pro users on release 2.4.0 convert less often:
n <- 5000L
sessions <- expand.grid(
release = c("2.3.9", "2.4.0"),
platform = c("desktop", "mobile"),
plan = c("free", "pro"),
within_group = seq_len(n),
KEEP.OUT.ATTRS = FALSE,
stringsAsFactors = FALSE
)
base_rate <- c(
"desktop.free" = 0.88,
"mobile.free" = 0.84,
"desktop.pro" = 0.94,
"mobile.pro" = 0.92
)
cohort_key <- paste(sessions$platform, sessions$plan, sep = ".")
conversion_rate <- unname(base_rate[cohort_key])
conversion_rate[
sessions$release == "2.4.0" & cohort_key == "mobile.pro"
] <- 0.61
sessions$converted <-
sessions$within_group <= n * conversion_rateMost change records are routine. One describes a checkout-authentication change for the affected release and cohort:
changes <- data.frame(
change_id = sprintf("CHG-%04d", 1701:1900),
release = rep(c("2.3.9", "2.4.0"), each = 100),
component = "miscellaneous",
note = "Routine maintenance with no expected checkout impact."
)
target <- which(changes$change_id == "CHG-1842")
changes$component[target] <- "checkout-auth"
changes$note[target] <- paste(
"Mobile Pro token refresh:",
"retry budget changed from 3 to 0 and timeout from 8 s to 800 ms."
)The question does not reveal the affected dimensions:
question <- paste(
"Checkout conversion fell after release 2.4.0.",
"Find the finest low-cardinality categorical cohort with the largest",
"before/after drop; do not stop at a marginal roll-up that dilutes the",
"change. Identify its dimensions and values, quantify the rates, and cite",
"the change-log record that is the strongest candidate explanation."
)Define the investigation
The output signature makes the answer checkable.
SUBMIT() must provide every field with a compatible
type.
library(dsprrr)
incident_signature <- signature(
paste(
"sessions, changes, question ->",
"release: string, cohort: string,",
"before_rate: number, after_rate: number,",
"drop_pp: number, change_id: string, evidence: string"
),
instructions = paste(
"Inspect the schema and low-cardinality categorical fields; exclude the",
"release, outcome, and row-index fields from candidate cohort dimensions.",
"Find the finest low-cardinality cohort with the largest before/after",
"drop; do not stop at a marginal roll-up that dilutes the change.",
"Quantify it and cite the strongest matching change record.",
"Format cohort in source-column order as '<dimension>=<value> / ...'.",
"Copy the selected change note verbatim into evidence. Treat that record as",
"evidence, not proof of causation."
)
)
investigator <- rlm_module(
incident_signature,
interpreter_factory = function() {
r_code_runner(timeout = 30, persistent = TRUE)
},
max_iterations = 8,
max_llm_calls = 0L,
max_output_chars = 10000
)This example uses r_code_runner(persistent = TRUE)
because the input is a large R data frame and both the data and the
generated code are treated as trusted. The callr process is a separate R
session, not a security sandbox: it has your file, network and
environment access. Do not use this configuration for input you do not
control. The factory creates one runner per call, keeps its state
between steps, and shuts it down when the call ends.
max_llm_calls = 0L turns off recursive queries, because
R aggregation and one change record are enough here. When generated code
must read text that R cannot classify, such as vague change notes,
llm_query() asks a model about that slice; How RLM works explains
how those calls run.
Run the investigation
Request the structured result so the answer and its trajectory stay together:
result <- run(
investigator,
sessions = sessions,
changes = changes,
question = question,
.llm = ellmer::chat_openai(model = "gpt-6-luna"),
.return_format = "structured"
)
result$outputThis page does not show a recorded model run. The generated code and the number of steps vary from run to run; the fixed answer at the top is what a successful run must recover.
What a successful trajectory looks like
The exact code will differ, but the useful work falls into four steps.
2. Calculate cohort-level changes
R does the aggregation exactly:
rates <- aggregate(
converted ~ release + platform + plan,
data = .context$sessions,
FUN = mean
)
before <- subset(rates, release == "2.3.9")
after <- subset(rates, release == "2.4.0")
deltas <- merge(
before,
after,
by = c("platform", "plan"),
suffixes = c("_before", "_after")
)
deltas$drop_pp <-
100 * (deltas$converted_before - deltas$converted_after)
winner <- deltas[which.max(deltas$drop_pp), ]
deltas[order(-deltas$drop_pp), ]Only this four-row table, not the 40,000 sessions, goes into the next prompt.
3. Find the matching change records
The model cannot search for words it has not seen yet, so it starts from what step 2 found: 2.4.0 records that mention the affected platform and plan.
4. Submit typed evidence
The last step returns the calculated values and the matching record:
SUBMIT(
release = after$release[[1L]],
cohort = paste0(
"platform=", winner$platform,
" / plan=", winner$plan
),
before_rate = winner$converted_before,
after_rate = winner$converted_after,
drop_pp = winner$drop_pp,
change_id = candidate$change_id[[1L]],
evidence = candidate$note[[1L]]
)If a value is missing or has the wrong type, the RLM receives the validation error and can correct the submission on a later step. Run in order in a real runner, these four blocks produce exactly the answer at the top of the page.
Check the answer independently
The structured result carries the trajectory of this call:
trajectory <- result$metadata$repl_history
vapply(trajectory, function(step) step$code, character(1))
vapply(trajectory, function(step) step$success, logical(1))Do not trust the model’s prose. Compute the expected answer from the data without it:
rates <- aggregate(
converted ~ release + platform + plan,
data = sessions,
FUN = mean
)
before <- subset(rates, release == "2.3.9")
after <- subset(rates, release == "2.4.0")
comparison <- merge(
before,
after,
by = c("platform", "plan"),
suffixes = c("_before", "_after")
)
comparison$drop_pp <-
100 * (comparison$converted_before - comparison$converted_after)
oracle <- comparison[which.max(comparison$drop_pp), ]
oracle
#> platform plan release_before converted_before release_after converted_after
#> 4 mobile pro 2.3.9 0.92 2.4.0 0.61
#> drop_pp
#> 4 31The change lookup starts from the computed cohort, not from words in the expected note:
mentions <- function(value) {
grepl(paste0("\\b", value, "\\b"), changes$note, ignore.case = TRUE)
}
expected_change <- changes[
changes$release == "2.4.0" &
mentions(oracle$platform) &
mentions(oracle$plan),
]
expected_change[, c("change_id", "component")]
#> change_id component
#> 142 CHG-1842 checkout-authThis lookup relies on a property of the fixture: exactly one 2.4.0 record names the affected platform and plan. In a real change log, linking a cohort to a change is a judgment call, which is why the signature asks for the note as evidence rather than proof.
Then compare the model’s answer with both:
stopifnot(
identical(result$output$release, "2.4.0"),
identical(
result$output$cohort,
paste0("platform=", oracle$platform, " / plan=", oracle$plan)
),
isTRUE(all.equal(result$output$before_rate, oracle$converted_before)),
isTRUE(all.equal(result$output$after_rate, oracle$converted_after)),
isTRUE(all.equal(result$output$drop_pp, oracle$drop_pp)),
identical(result$output$change_id, expected_change$change_id),
identical(result$output$evidence, expected_change$note)
)Choose the runner
The one-call rlm() helper runs in a fresh
mcp_repl_runner() sandbox by default. That sandbox suits
compact inputs you do not control, but every input is sent with every
step, so this 40,000-row table is far too large for it. See How RLM works for the sandbox’s
size limits and safety model. For a compact document, the one-call form
is enough:
answer <- rlm(
"document, question -> answer",
document = "Owner: team-a\nCommitment: publish the audit by Friday",
question = "Which commitments have no owner?",
.llm = ellmer::chat_openai(model = "gpt-6-luna"),
.max_iterations = 4L,
.max_llm_calls = 0L
)Turn discovery into a program
RLM is a poor default for a known report. If several investigations aggregate the same columns and join the same records, write that path in R or as a dsprrr pipeline. If the best implementation is itself the thing to search for, evaluate candidates over labeled cases with Flex. Use RLM to discover the path, then replace it with code once you know it.