Conditional Next Trial¶
psychscanner-primal keeps only feedback-scored tasks (see the Demonstration Suite) — tasks where a trial has a real correct/incorrect signal. That signal makes adaptive designs a natural fit: retry a trial when the response doesn't parse, raise or lower difficulty based on accuracy, or insert a follow-up probe — all decided at run time from the model's actual response, not fixed in the task card ahead of time.
next_trial_fn is the hook for this. After every trial, it's asked whether a new
trial should run before the task card's own next one.
How it works¶
- Set
next_trial=Trueand provide anext_trial_fnclass on the card. - After each trial, the scanner calls
next_trial_fn.next_trial(trial, response). - Returning a trial dict (same shape as a task JSON item:
trcode/stimulus, optionalfb/tools/parser) runs it immediately. ReturningNonemoves on to the task card's next trial. - If the handler proposes the exact same
stimulusmore thanmax_repeattimes in a row (default 3), the runner stops asking and resumes the task card's own sequence — this bounds a handler that would otherwise retry forever.
Retry once on an unparseable answer¶
Dry-run this against mock-llm first — no API key needed:
from pathlib import Path
from psychscanner import ExpCard, ExpCardInit, NextTrialBase, ScannerModel, to_csv
class RetryOnce(NextTrialBase):
def __init__(self):
super().__init__()
self.retried = set()
def next_trial(self, trial, response):
trcode = trial["trcode"]
if trcode not in self.retried and "retry" not in trcode:
self.retried.add(trcode)
return {"trcode": trcode + "_retry", "stimulus": "Please answer again, briefly."}
return None
task = {
"tasktype": "sc",
"taskname": "my_first_task",
"instructions": {"definition": ["Answer each question briefly."]},
"contexts": ["general"],
"contexts_id": ["Q"],
"context_present": False,
"items": {"Q_1": [{"trcode": "Q_1", "stimulus": "What is 2 + 2?", "corrAns": "4"}]},
"chain_type": "task", # required: the retry needs to see the original question
"parser": None,
}
card = ExpCardInit(
model="mock-llm",
family="mock-llm",
task_file=task,
memory="Convo", # required: same reason as chain_type="task"
chain_type="task",
cogtype="no",
nsim=1,
proj_dir=Path("./results"),
projectname="my_first_task_nt",
next_trial=True,
next_trial_fn=RetryOnce,
)
scanner = ScannerModel(expcard=ExpCard(card))
results = scanner.run()
to_csv(scanner, path=card.proj_dir)
results[0] now has two rows — Q_1 and Q_1_retry — instead of one.
Adaptive difficulty from corrAns¶
Since primal's tasks carry corrAns, a handler can score the response itself and
branch on correctness — no separate FeedbackBase needed unless you also want text
injected into the conversation:
class Staircase(NextTrialBase):
def __init__(self):
super().__init__()
self.level = 1
def next_trial(self, trial, response):
correct = str(response.get("content", "")).strip() == str(trial.get("corrAns"))
new_level = min(self.level + 1, 5) if correct else max(self.level - 1, 1)
if new_level == self.level:
return None # no change, let the task card continue
self.level = new_level
return {"trcode": f"stair_{self.level}", "stimulus": f"Difficulty level {self.level} item"}
Like FeedbackBase, the handler is instantiated once per participant simulation, so
__init__ state (self.level, self.retried) is safe to keep across trials.
Required card settings¶
| Parameter | Required value |
|---|---|
next_trial |
True |
next_trial_fn |
Your NextTrialBase subclass (class, not instance) |
Inserted trials inherit context_present/context_item/tasktype from the trial
they follow unless you override them explicitly in the returned dict.
See also¶
- Write and run your own task — the task-card basics this page builds on
- The equivalent mechanism for injecting text feedback rather than branching the trial
sequence is
FeedbackBase— see theon_response/inject_feedbackdocstrings inpsychscanner.feedback.feedback_base