Declaring a perturbation

Perturbations go in scenarios.yaml, on the scenario set. Every scenario the set generates carries them.

scenario_sets:
  - id: weak-grip
    scene: libero-spatial
    generator: refractal.generators:linspace_grid
    generator_seed: 0
    params:
      init_state_index: {range: [0, 4], steps: 5}
    perturbations:
      - at_step: 0
        type: scale_actuator
        target: gripper0_gripper_finger_joint1
        args: {factor: 0.1}

That is one perturbation, firing at the start of every episode in the set, which cuts the gripper's torque limit to a tenth of what the model declares.

Five fields:

field
at_step when it fires, counted from the episode's first step
until_step optional: when it ends. Absent means it lasts to the end
type which effect to run
target a name your adapter resolves — an actuator or a body
args that effect's parameters

target is whatever the scene calls the thing. The names above are LIBERO's, and refractal build records the ones your scene actually has, so a typo is caught when the plan is made rather than mid-episode.

Effects

type what it does args the adapter must supply
scale_actuator multiplies an actuator's torque limit by factor factor scale_actuator, get_actuator_limit
displace_body moves a body once, by a fixed offset delta, a list get_body_pose, set_body_pose
apply_force applies a wrench to a body wrench, a list apply_force, get_applied_wrench
drop_observation blanks a field of the observation fill transform_observation
substitute_observation replaces a field with another representation of the same thing kind, name transform_observation, alternative

Two points of precision on each.

scale_actuator is a torque limit, not a current limit. MuJoCo has no current. You are scaling the force range the actuator is allowed to exert, and the mapping from that to motor current depends on the motor constant, which the simulator does not model. Call it torque.

It multiplies the current limit, not the originally declared one. Two perturbations on the same actuator therefore compose multiplicatively — 0.5 then 0.6 leaves 0.3 of what it started with — and each step's resulting value is recorded, so composition is visible in the results rather than only in your head.

apply_force is sustained, not an impulse. The wrench persists until something changes it, because that is what the underlying field does. It is named for what it does. A true one-off impulse — a single instantaneous change of momentum — is not implemented.

Perturbing the observation

The first two effects change the world. The last two change what the policy is shown of it — same physics, different picture.

substitute_observation is the interesting one, and it exists for a specific class of problem: configuration that silently changes what the policy sees.

A proprioceptive state vector encodes the end-effector's rotation in some convention. Pick a different convention and the vector is still the right length, still the right units, still the same field — and describes a rotation the policy was not trained to read. Nothing downstream can tell. The published measurement of that class of mistake is 55 points of success rate, which is larger than most differences anyone is trying to measure between checkpoints.

Today that is a setting somebody chose once. As a perturbation it is declared, hashed, and joined on a base like any other axis:

    perturbations:
      - at_step: 0
        type: substitute_observation
        target: states
        args: {kind: state, name: robosuite}

target names the field to replace. kind and name say what to put there — what sort of thing is wanted, and which one. Which names exist is your scene's business; the refusal lists them when you get one wrong.

It is categorical, not sweepable. A representation is this one or that one; there is nothing in between and no ordering. So it is a matched comparison at each value rather than a curve, which compare already does.

Dosing: probability

The example above substitutes on every step. Add a rate and it applies on a declared fraction of them instead:

    perturbations:
      - at_step: 0
        probability: 0.08
        type: drop_observation
        target: states
        args: {fill: zeros}

This is the field that lets you ask what a policy tolerates, and it is worth being clear about why the obvious alternative does not.

You might expect to sweep the effect and read how much of it the episode received. The trouble is that for many transforms, how much lands is decided by the trajectory — and the trajectory is the policy's. Two rotation conventions differ only over part of the space of orientations, so an episode is perturbed only on the steps it spends there. Measured on LIBERO, the trajectory moves that fraction about six times more than the start state does.

So at one declared setting you get a spread of realized exposures across episodes, and the spread is mostly the policy's own doing. Worse, it can run backwards: a policy already going wrong may wander into the region where the perturbation bites, raising its own exposure. High exposure is then a symptom of failing rather than a cause of it, and a curve drawn through those points reads the wrong way round.

probability has none of that. The coin does not know what the policy did, so the dose cannot be caused by the outcome. A result reads "fails above 8% corrupted observations" — a property of the policy, which transfers to any perturbation that corrupts observations at some rate, rather than a property of this scene's geometry.

Three things follow.

The rate is the level a curve groups on. Declare it and it becomes the sweep axis, whatever the effect's own arguments are called.

It is drawn from the episode's seed, so the same episode is perturbed on the same steps every run, and different episodes get different draws. A dose that moved between runs would be unreproducible in exactly the way everything here exists to prevent.

Only transforms can carry one. A state mutation persists, so applying it on a fraction of steps would compound rather than dose — it is refused with the list of effects that can.

The declared rate is not the realized one, and the gap is not small

A rate is a coin per step, so what an episode actually receives varies around it — more so the shorter the episode. Measured through the real gate, 200 episodes per cell:

declared over 100 steps over 220 steps
5% 2.0% – 9.0% 2.7% – 7.3%
10% 5.0% – 15.0% 7.3% – 13.2%
25% 18.0% – 33.0% 20.9% – 29.5%

(central 90% of episodes; LIBERO-Spatial caps at 220 steps)

At 5% over 100 steps an episode receives anywhere from 0.4× to 1.8× the declared rate. Do not read a per-episode declared rate as exact. The receipt's realized exposure is what says what that episode got.

Group on the declared rate anyway. The spread is per episode; the mean over a cell converges to the declared value, so it is within-cell noise that episode count handles — the ordinary reason to run more episodes, not a reason to change the axis.

Grouping on realized exposure instead is worse, and not obviously so. Episode length is itself an outcome — a task that succeeds stops early — and a shorter episode has a wider spread of realized rates. So the extreme bins fill with short episodes and the middle with long ones. Simulating a case where exposure has no effect at all and success is a coin flip, binning by realized exposure produces an apparent swing from 72% to 96%. Every point of it is artifact.

That is a different confound from the one probability was introduced to remove, arriving by a different route, and it is why the declared rate is the axis and the measured one is the check.

Check the realized rate against the declared one in the receipt. For an effect that changes the observation every time it applies, the two should agree within that spread; where they do not, the difference is the geometry described above, and that gap is the thing worth knowing.

What the receipt says for these

An observation effect applies on every step it is active, so its receipt is one entry per window rather than one per step, carrying:

  • a digest of the observation before and after, from the first application
  • how many steps it applied on
  • on how many of those the observation actually changed

The last is the one that matters, and the first is deliberately not a verdict. A representation swap is frequently identity: two rotation conventions agree exactly over half the orientations there are and differ by a full turn over the rest, so the same correctly applied effect changes nothing on some steps and everything on others.

Measured on LIBERO, that is not a rare edge. The wrist sits within a few ten-thousandths of the boundary between the two at the pose every episode starts from, and 19 of 50 episodes begin on the side where they agree. A receipt judging the effect by its first application would call a working perturbation a no-op on roughly two episodes in five.

Being that close to a discontinuity is reproducible, not random: repeated resets give identical values and changing the seed does not move them, because the pose comes from the benchmark's pinned start states. What it is not is meaningful — nothing anyone configured decides it, and an unrelated change to the simulator could move it.

So: zero changes across the whole window is the failure, and it is refused at the write. Fewer changes than applications is ordinary.

Sustained perturbations

Add until_step and the perturbation is in force for a window rather than the rest of the episode:

    perturbations:
      - at_step: 50
        until_step: 100
        type: scale_actuator
        target: gripper0_gripper_finger_joint1
        args: {factor: 0.1}

That is the realistic version of a weak gripper — a servo that browns out and recovers — rather than one that is weak from the moment it matters onward.

Ending applies the effect's inverse, not a remembered value. The distinction decides whether sustained perturbations compose. If one perturbation scales an actuator by 0.5 and another by 0.6, the first one ending must leave 0.6 of the original. Putting back the value it read before the second existed would clobber the second entirely.

So the end is × 1/factor, and the arithmetic works whatever order things start and finish in.

Which effects can be sustained

effect how it ends
scale_actuator × 1/factor yes
displace_body − delta yes
apply_force — no
drop_observation you stop applying it yes
substitute_observation you stop applying it yes

The two observation effects end for free, and for a reason worth stating: they change nothing that persists. A world mutation has to be undone because the simulator is still holding the change. An observation is built fresh every step, so ceasing to transform it is the undo.

That inverts which case is which. For a world effect, at_step is the instant it fires and it stays in force by itself. For an observation effect, the window is the whole point — leaving until_step off means every observation from at_step to the end, and a single transformed frame has to be asked for explicitly with until_step: at_step + 1.

apply_force assigns its wrench rather than adding to it, so undoing it would mean restoring a previous value and clobbering anything applied since. It is refused rather than given semantics that only hold when nothing else is happening:

error: scenario_set 'brownout' gives 'apply_force' an until_step, but that
effect cannot be ended: undoing it would mean restoring a value rather than
applying an inverse, which clobbers anything else acting on the same target.
Effects that can be sustained: ['displace_body', 'scale_actuator'].

Drop until_step to have it last to the end of the episode.

The results record the start and the end as two events, each with the values either side of it, and the end marked as an end — so a receipt showing a limit going down and then back up says which was which rather than showing two indistinguishable entries.

An episode that ends inside the window leaves the end unfired, with a reason, exactly as an unfired start does.

at_step: 0, and why it is usually what you want

This is a correctness matter, not a preference.

An episode that ends before its trigger never experienced the perturbation. It succeeded unperturbed. Counting that success at the declared level credits the policy with robustness it never demonstrated.

The bias is not random. Fast episodes escape a late trigger more often, so leaving those episodes in favours whichever checkpoint finishes sooner. A quicker checkpoint looks more torque-robust purely by outrunning the perturbation — and that confound moves with the very quantity you are comparing.

Worse, such an episode is unusable in both directions. Its scenario identity covers the perturbation, so it cannot join the unperturbed baseline either. It is not misfiled and cannot be reclassified; it measured baseline behaviour under a perturbed identity. The only fix is not creating it.

So: for anything measuring robustness at a level, fire at step 0. Every episode is then perturbed before it can finish, and the gap between assigned and experienced closes.

A mid-episode trigger is for experiments where when is the variable — measuring how recovery depends on the moment a disturbance arrives. There, the distinction is the point rather than a nuisance, and the results record both the step you asked for and the step it actually fired at.

compare reports how many episodes were excluded for never experiencing their level, and refuses outright a comparison in which none did.

What gets refused

All three are checked when the plan is made. They cost nothing and they happen before any container starts.

An actuator the scene does not have.

error: scenario_set 'weak-grip' perturbs actuator 'gripper0_thumb', which scene
'libero-spatial' does not have. It has ['gripper0_gripper_finger_joint1',
'gripper0_gripper_finger_joint2', 'robot0_torq_j1', ...]; did you mean
'gripper0_gripper_finger_joint1'?

Fix the name. The list is what the scene actually reports.

An actuator with no torque limit to scale.

error: scenario_set 'weak-grip' scales the torque limit of 'robot0_torq_j2' on
scene 'libero-spatial', but that actuator has no limit to scale -- MuJoCo's
forcerange [0, 0] means unlimited, and scaling unlimited by any factor leaves it
unlimited. The episode would run, the log would say the perturbation fired, and
nothing would have changed.

This is the one that catches people. On LIBERO's Panda, the two gripper actuators declare a limit of 20 N; all seven arm joints declare [0, 0], which MuJoCo reads as unlimited. Scaling unlimited by 0.3 is still unlimited — the call succeeds, the episode completes, and nothing happened. Perturb an actuator that has a limit, or give that one an absolute limit in the scene.

A scene whose capabilities were never recorded.

error: scenario_set 'weak-grip' scales the torque limit of
'gripper0_gripper_finger_joint1' on scene 'libero-spatial', but nothing has
recorded which of that scene's actuators have a torque limit. Run 'refractal
build' where the benchmark is importable.

Run refractal build with your probe. This refuses rather than assuming, because nothing looked and it said yes must not be the same answer when the failure is silent and costs a whole run.

Identity: what changes

A perturbed episode is a different experiment from its unperturbed counterpart, so its scenario identity covers the perturbation. Scale 0.5 and scale 0.3 are different scenarios and will not join as though they were the same.

Every episode also carries a base identity covering only the unperturbed scenario. That is what a sweep joins on: hold the base fixed, vary the level, get a curve.

The consequence worth stating plainly:

Results you have already recorded become the baseline end of any future sweep.

The base of an unperturbed scenario is just its ordinary scenario identity, so a run written before perturbations existed at all joins a curve measured later. This is measured rather than asserted — a sweep joined against runs that predate the base column entirely, all five base scenarios matching.

One more thing the results carry, because it decides how a sweep is grouped:

the episode has its level
no perturbations none — it is the baseline, and belongs on the curve at its top
one sweepable perturbation its level, which is what the curve groups by
one categorical perturbation none, correctly — see below
two or more none — no single level, so it is off any single axis

The count is recorded alongside, because three of those four have no level and mean different things.

Not every perturbation has a level

Torque scale is continuous, so a sweep is natural: 1.0, 0.1, 0.05, and a curve through them.

Some perturbations are categorical. A state source is correct, wrong, or some third thing — there is no ordering and nothing between the values. You cannot sweep it, and an axis with two points is a comparison rather than a curve. That is fine: a matched comparison at each value is what compare already does, and it needs no sweep machinery at all.

Which kind an effect is, the effect declares. It is not inferred from what its arguments are called — an effect naming its level something unexpected would otherwise silently become categorical, losing the column a curve groups by with nothing to say so.

scale_actuator is sweepable, with factor as its level. displace_body and apply_force are not.