Runs a session up to an injection point once, then continues the full remainder of the session (every later question and turn, and the final summary) once per arm, so that every arm shares each message before the injection. Comparing the arms then compares sessions that differ only in what was injected and in what followed from it.
Usage
run_focus_group_arms(
topic,
config,
arms,
n_participants = 6,
guide = c(Opening = 1, Icebreaker = 1, Engagement = 2, Exploration = 3, Closing = 1),
demographics = NULL,
survey_responses = NULL,
flow = "desire_based",
seed = NULL,
message_mode = c("roleflip", "flat"),
verbose = TRUE,
max_participant_responses = NULL,
max_calls = 120L,
confirm = FALSE,
max_tokens_config = NULL,
reasoning_effort = NULL,
on_desire_failure = c("error", "random"),
probes = NULL,
moderator_verbatim = FALSE,
call_on_silent = c("opening", "midpoint", "end"),
call_on_text = NULL,
handles = NULL,
final_summary = TRUE,
.runner = NULL
)Arguments
- topic
Character. Focus group topic. Or a recorded session to continue, a
FocusGroupor afocus_group_result; see "Continuing a recorded session".- config
An explicit
LLMR::llm_configused by all agents and group-level model tasks.- arms
A named list of at least two arms, as described in Details.
- n_participants
Integer. Number of participants, excluding the moderator.
- guide
A named numeric vector or named list. The names are phases, among
"Opening","Icebreaker","Engagement","Exploration", and"Closing", each at most once and in that order. Numeric values select that many moderator instructions from the phase banks. Character vectors supply the ordered instructions directly. The default is a compact session (one opening, one icebreaker, two engagement and three exploration questions, one closing); with the default flow, participants, response limit, and call-ons it plans at most 111 model outputs, inside the defaultmax_callsof 120. Larger guides multiply quickly: each question round costs one output per response plus, under"desire_based", one scoring output per participant.NULLruns a minimal script: an opening, one open discussion oftopic, and a closing.- demographics
Optional participant demographics, one row per participant. With this,
survey_responses, and personas all absent, the participants are drawn from the bundled ANES 2024 records.- survey_responses
Optional participant survey answers, one row per participant.
- flow
Character. One of
"round_robin","probabilistic", or"desire_based".- seed
Optional whole number (a fraction such as 1.7 is an error) governing in-package sampling: the respondents drawn from the bundled records and the random choices of the turn-taking flow, such as the speaker draws of
"probabilistic"and the tie-breaks of"desire_based". The random waits of a provider's retries do not draw from this stream. The caller's random-number state is restored when the function returns. The optionfocusgroup.seed, when set, fixes the respondent draw instead.- message_mode
Character.
"roleflip"or"flat".- verbose
Logical. Print session progress.
- max_participant_responses
Optional integer maximum number of participant responses per moderator question.
NULL, the default, uses the optionfocusgroup.max_participant_responses, which is 2 unless you set it.- max_calls
Integer. Maximum estimated live model outputs allowed without confirmation; a finite whole number.
- confirm
Logical. Permit a live run whose estimate exceeds
max_calls.- max_tokens_config
Optional named list of token limits by call type, as for
FocusGroup$new():utterance,moderator,desire,desire_retry,summary, andinterim_summary. A model that reasons before it answers needsdesireanddesire_retrylarge enough to hold its reasoning and the score.- reasoning_effort
Optional reasoning-effort setting passed to the provider: one string, such as
"low", for every call, or a named vector by call type ("moderator","participant","desire","summary","probe"). It is written over thereasoning_effortofconfigfor those calls;NULLleavesconfigas it is. LLMR forwards it on OpenAI-compatible providers.- on_desire_failure
Character. Under
"desire_based", what happens when a participant's desire score cannot be obtained after its retry."error"(the default) stops the session with an error of classfocusgroup_desire_failure, returned as a partial session as described in Details."random"chooses the speaker among the participants with valid scores by the usual rule, and uniformly among the eligible participants only when none has a valid score; the failure is recorded in the selected message'smetadata$desire_scoring_failure.- probes
Optional
fg_probe()or list of probes. A probe at or before the branch point is asked once, in the prefix; a later one is asked in every arm.- moderator_verbatim
FALSE(the default) has the moderator's model render each guide item as a line of its own.TRUEdelivers every guide item exactly as written, with no model call, and a vector of item numbers delivers those items so. A verbatim message carriesmetadata$verbatim = TRUE. The opening and closing instructions of the numeric phase banks are instructions to the moderator, so verbatim delivery suits a guide written as the lines to be said.- call_on_silent
Where the moderator calls on the participants who have not spoken since the previous such point, as described in Details: any of
"opening","midpoint", and"end"(the default is all three), guide item numbers, or"none"(orFALSE) for no call-ons.- call_on_text
The call-on line. It must contain
{participant}, which is replaced by the participant's handle;NULLuses "{participant}, I'd like to hear from you as well. What are your thoughts?".- handles
The labels under which the agents see each other:
NULL(the default) for"P1","P2", ... and"Moderator","ids"for the agent IDs, or a character vector of handles named by agent ID, as forFocusGroup$new().- final_summary
Logical.
TRUE(the default) ends the session with a model-written summary;FALSEmakes no summary call and leavessummaryempty.- .runner
Optional function used instead of live model calls. It receives a data frame with
configandmessageslist-columns and returns those rows with at leastresponse_text. Token columns such assent_tokens,rec_tokens, andtotal_tokensare optional; when they are absent, usage is unknown.
Value
A focus_group_arms object, a list with:
- arms
A named list of
focus_group_resultobjects, one per arm, each a complete session as returned byrun_focus_group(). Its transcript, calls, and records begin with the shared prefix; itsusagecounts the whole session, prefix included; and itsmetadata$branchrecords the arm, the branch point, the number of prefix messages and calls, andprefix_hash, a hash of the prefix transcript that is the same in every arm.- prefix
The shared prefix as a
focus_group_resultwithmetadata$completeset toFALSEand no summary.- branch_point
The branch point, as written, such as
"fg_after_item(4)".- injections
A data frame of every arm's injections, as in
run_focus_group().- probes
A data frame of every probe answer: those asked in the prefix once, with
armset toNA, and those asked in each arm.- usage
The token usage of all the calls made: the prefix once and each arm's remainder. Adding up the arms' own
usagewould count the prefix once per arm.- metadata
A list with
topic,branch_point,arms(the arm names),estimated_calls,seed,prefix_messages,prefix_hash,complete, andfailure.
When a call fails, the run stops with an error of class
focusgroup_incomplete_arms whose partial field holds the
focus_group_arms object built from what was completed, with the arm
that failed, if any, as a partial session; its arm field names that arm
and its parent field holds the original error.
Details
Each arm is NULL, which continues the prefix with nothing injected, one
fg_injection(), or a list of injections. The first injection of every
arm that has one must name the same point; that point is where the
session branches. An arm's later injections may name later points. A
neutral injection at the branch point, rather than NULL, gives a control
arm that also receives a message there.
The prefix is run once, with its probes, and a copy of the whole session
(agents, turn-taking state, records, and token counters) is taken at the
branch point; each arm continues its own copy. Each arm also starts from
the random-number state at the branch point, so the random draws that
follow, such as tie-breaks and probabilistic speaker choices, use the same
stream in every arm; a provider's retries, which wait a random time, do
not draw from it. With seed, the whole run is reproducible; without it,
the arms still share the stream from the branch point on.
A branch point must lie within the guide: fg_after_item(k) needs k no
larger than the number of guide items, and fg_before_turn(t) needs t
no larger than the number of participant turns the guide allows. A point
the session still does not reach, as when a custom flow chooses no
speaker, stops the run with an error of class
focusgroup_injection_unreached.
The estimate checked against max_calls is that of one session times the
number of arms, an upper bound, since the prefix is paid once.
Continuing a recorded session
When topic is a recorded session, a FocusGroup or a
focus_group_result (for example one read from a session file by
focus_group_from_transcript()), no prefix is run: each arm continues the
record from a participant turn. The branch point must be
fg_before_turn(t), where turn t is the t-th message by a
participant. The messages before it are the shared prefix, taken from the
record as they are, and the record's turn t and everything after it are
replaced by each arm's continuation. An arm fg_injection(text, at = fg_before_turn(t), from = "P2") has P2 say text as turn t (see
fg_injection()); a NULL arm regenerates turn t and the rest; an
outside voice or a moderator line can also be injected there.
The session is rebuilt as it stood just before turn t: the guide item
and its stage, the turns already given on it, the call-on queue, a
participant the moderator's last line addressed, the question in force,
the interim summary in force, and the turn-taking rule's state, with the
record's personas, handles, guide, settings, templates, standing rules,
and message construction. config and .runner are given to every agent;
reasoning_effort and max_tokens_config, when given, replace the
record's. The arguments that the record fixes (n_participants, guide,
demographics, survey_responses, flow, message_mode,
max_participant_responses, on_desire_failure, moderator_verbatim,
call_on_silent, call_on_text, and handles) must be left out. A probe
at the branch point itself is asked once, on the rebuilt session, before
the arms, so that every arm shares its answers, as a probe there is asked
in the prefix of a new session. A probe or an injection at a point the
record has already passed is an error, and so is a transcript without
moderator lines, which has no guide item to continue, as
are a custom turn-taking flow, whose state cannot be rebuilt, a record
without a topic, and a participant who has only the placeholder persona of
an imported transcript; give personas with focus_group_from_transcript(x, personas = ...). Everything is checked before any call. Under round robin
with a model that replies the same way to the same messages, a NULL arm
repeats the record. Each arm's calls and usage hold the record's calls
before the cut and the arm's own after it. With seed, the caller's
random-number state is restored afterwards.
A continuation of a human transcript is a model's continuation of a human discussion: the later moderator lines are the recorded ones, delivered as written, and the participants' turns are generated from the personas given. Arms are comparable with one another, not with the people who took part.
Examples
cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b",
api_key = LLMR::llm_api_key_env("GROQ_API_KEY"))
scripted <- function(experiments, ...) {
experiments$response_text <- "Evening hours matter most to people who work."
experiments
}
guide <- list(Opening = "Welcome the participants.",
Engagement = c("Which hours matter most?",
"Who is left out by the current hours?"),
Exploration = "What would you change first?",
Closing = "Thank the participants and close.")
arms <- list(
objection = fg_injection(
"Extended hours would cost the branch two weekday programs.",
at = fg_after_item(3), from = "Guest expert"),
control = NULL)
out <- run_focus_group_arms(
"Library hours", cfg, arms = arms, n_participants = 2, guide = guide,
flow = "round_robin", verbose = FALSE,
probes = fg_probe("From 1 to 7, how strongly do you favor longer hours?",
at = list(fg_after_item(3), "end")),
.runner = scripted)
out
#> <focus_group_arms>
#> Topic: Library hours
#> Branch point: fg_after_item(3) after 6 shared message(s)
#> objection: 13 messages
#> control: 12 messages
#> Tokens: unknown
#> Token usage is incomplete for 25 call(s).
out$probes[, c("arm", "point", "agent_id", "answer")]
#> arm point agent_id
#> 1 <NA> fg_after_item(3) P1
#> 2 <NA> fg_after_item(3) P2
#> 3 objection end P1
#> 4 objection end P2
#> 5 control end P1
#> 6 control end P2
#> answer
#> 1 Evening hours matter most to people who work.
#> 2 Evening hours matter most to people who work.
#> 3 Evening hours matter most to people who work.
#> 4 Evening hours matter most to people who work.
#> 5 Evening hours matter most to people who work.
#> 6 Evening hours matter most to people who work.
# Continue a recorded session from its third participant turn: as recorded,
# and with P2 saying something else there.
session <- run_focus_group("Library hours", cfg, n_participants = 2,
guide = guide, flow = "round_robin",
verbose = FALSE, .runner = scripted)
path <- tempfile(fileext = ".fg.jsonl")
writeLines(format(session), path)
again <- run_focus_group_arms(
focus_group_from_transcript(path), cfg,
arms = list(regenerated = NULL,
changed = fg_injection("Mornings matter more to me.",
at = fg_before_turn(3), from = "P2")),
verbose = FALSE, .runner = scripted)
again$arms$changed$transcript[, c("speaker_id", "text")]
#> speaker_id text
#> 1 System Participants: P1, P2; Moderator: MOD
#> 2 MOD Evening hours matter most to people who work.
#> 3 MOD Evening hours matter most to people who work.
#> 4 P1 Evening hours matter most to people who work.
#> 5 P2 Evening hours matter most to people who work.
#> 6 MOD Evening hours matter most to people who work.
#> 7 P2 Mornings matter more to me.
#> 8 P1 Evening hours matter most to people who work.
#> 9 MOD Evening hours matter most to people who work.
#> 10 P2 Evening hours matter most to people who work.
#> 11 P1 Evening hours matter most to people who work.
#> 12 MOD Evening hours matter most to people who work.
