Adapter contract¶
An adapter wraps a simulator so Refractal can drive it. It is a class with five methods, loaded by import string from your catalog.
scenes:
- id: bench-v1
external:
provider: my_package.adapter:BenchAdapter
A worked implementation is in adapter-example.md.
The five methods¶
reset(task) -> raw_observation¶
Called once per episode. Build or reconfigure the environment, apply the scenario, return the first raw observation.
task is a dict. The scenario's parameters are in it under the names your
catalog used.
- Store the environment on
self; the next four methods will need it. - Rebuild only when you must.
resetis called for every episode, and rebuilding a simulator each time is usually the difference between minutes and hours. - Do not mutate the scenario dict. It is hashed; a mutated copy is a different scenario from the one recorded.
step(action) -> StepResult¶
Apply one action. Return StepResult(obs, reward, done, info).
action is whatever the model server sent. Convert it here, including any
discretisation your environment needs.
make_obs(raw_obs, task) -> Observation¶
Convert your environment's observation into the wire format the model server expects.
{"images": {"front": array}, "states": array, "task_description": str}
The keys must match what the server was configured to read. A mismatch here is silent: the policy receives zeros or defaults, scores badly, and nothing reports an error.
get_step_result(step_result) -> EpisodeResult¶
Called once, on the final step. Return {"success": bool} and any metrics worth
keeping.
Whatever you return is the outcome. Nothing downstream re-derives it or second-guesses it.
check_done(step_result) -> bool¶
Whether the episode should stop. Defaults to step_result.done; override if your
environment signals termination differently.
What your adapter owes¶
The scenario dict is the vocabulary. Refractal never looks inside it. The
names are whatever your catalog's params used, and nothing checks that your
adapter reads the same ones. If the catalog says cup_x and the adapter reads
cube_x, every episode starts from your default, the comparison is between two
identical conditions, and no error is raised anywhere.
Anything you vary that is not in the scenario is invisible. Randomising a
distractor's position inside reset means two episodes with the same
scenario_hash were not the same episode. Put it in the scenario, or seed it
from something already there.
Record what you want to keep. Fields you do not pass to record_step are not
recoverable later. The episode ran and the value is gone.
Count from zero. If your adapter selects from a fixed array of start states,
the index it receives runs 0, 1, 2, … per task. A catalog whose indices start
elsewhere is refused by refractal run rather than recorded.
Registering it¶
The provider is an import string resolved at run time, so the adapter does not
need to be installed where you run refractal plan.
external:
provider: my_package.adapter:BenchAdapter
ref: {suite: kitchen} # which scene
params: {send_state: true, num_steps_wait: 10} # constructor arguments
params is passed to your __init__. ref is not; it identifies the scene.
Both are hashed into the scene's identity.
Checking it works¶
$ refractal build catalog --probe my_package.probe:MyProbe
$ refractal plan catalog --hardware laptop -o plan.json
$ refractal run plan.json -o results --backend vla-eval --server baseline=ws://localhost:8000
If episodes run but every one fails, check the observation keys first and the scenario parameter names second. Those are the two failures that produce a complete run and a meaningless result.
terminal_phase and phase_outcomes¶
Two columns an adapter fills that Refractal never interprets.
terminal_phase is how the episode ended, as one string, in the adapter's
own vocabulary. The set is open and Refractal declares none of it. Nothing
here enumerates, validates or maps these strings, because any list written into
the core would be one simulator's and the next adapter would either be
constrained by it or quietly ignore it.
One string is the whole constraint, and it has a consequence worth stating: a simulator that can end an episode two ways at once has to decide which one this is. Refuse rather than pick. A priority order invented to resolve a case nobody has seen is a guess that reads as a fact ever after, and if the refusal never fires nothing was lost.
It is not a substitute for success and not a rate. It is the reason where
success is the verdict. "Failed by crashing" and "failed by running out of
time" are different findings, and a boolean cannot separate them.
phase_outcomes is map<string, bool>: named assertions evaluated while
the episode runs. Whether a safety margin held, whether a sub-goal was
reached, whether a limit was exceeded.
The argument for evaluating in-loop rather than reconstructing afterwards is cost and possibility, not taste. A contact check against live simulator state is nearly free where the state already exists, and expensive or impossible from a recorded trajectory: safety is usually a property of the relationship between the agent and the scene, and an ego trajectory alone cannot express it. Two identical paths can be safe or catastrophic depending on what else was there.
Both columns were declared long before anything filled them. The first adapter to populate either was a driving one, which is the shape of evidence that they are general rather than manipulation-specific.