APT-RLa library of gaits as the action space

Kang, Park et al. Agile perceptive multi-skill locomotion for quadrupedal robots in the wild. Science Robotics 2026. No official code. Written from scratch.

APT-RL doesn't let the policy drive the joints directly. It optimises thousands of trot and bound cycles offline, compresses them into a small latent space, and makes the policy act in that space: pick a gait twice a second, steer the latent, add small corrections. I rebuilt the whole chain for a 14.5 kg A1, which is a much weaker robot than the paper's. In simulation the A1 now walks on the prior: switch the prior off and the walking stops. In October it got its eyes, a real trot and a library that reaches 3 m/s. The best teacher so far succeeds on 83.5% of the paper’s obstacle courses in simulation. But it picks trot almost every time, and it still won’t jump a gap.

Reached: Isaac sim. In progress.
Isaac Sim, the best teacher so far (Oct 7) climbing stairs at level 5 of 10 at a 1 m/s command, trotting on the motion-library prior. It succeeds on 83.5% of the paper’s obstacle courses.

The idea

Most locomotion policies output joint position targets and learn gaits from scratch, shaped by reward terms. APT-RL moves the prior into the action space instead. First, a trajectory optimiser generates a large set of physically consistent gait cycles. It uses a single-rigid-body model (SRBD): the robot is treated as one rigid body pushed by ground forces, in the sagittal plane only. The paper's set is 180,000 trajectories, 90,000 trot and 90,000 bound, from −2 to 7 m/s. That's about 15.5 hours of motion, generated in 8 minutes.

A transformer VAE is trained on that set. Its encoder maps a short window of robot state to a 16-dimensional latent. Separate decoders, one per gait, map the latent back to joint torques. After training, the encoder is thrown away and the decoders are frozen. The RL policy then outputs three things. A gait bit, chosen at 2 Hz (trot below 0.5, bound above). A 16-dim latent and 12 auxiliary joint offsets, both at 100 Hz. What reaches the motors is the decoder's torque plus a PD term around the default pose, shifted by the auxiliary action:

τ = Dec_gait(z) + kp·(q_default − q + a_scale·a_aux) − kd·q̇

The prior shapes which motions are cheap, not which are allowed; whatever the 2D library can't express, like hip abduction or a jump, comes from the auxiliary channel. The paper's key check zeroes the decoder torque in a trained policy: success drops from 94.6% to 2.9%. I call that the ff=0 test and ran it on every policy.

The paper also has a perception half: depth plus a 2D lidar distilled from privileged height maps into a student policy. I haven't got there. Everything on this page is about the action space.

What I built, and on what

There's no official code, so the library half is all mine: the SRBD generator, the A1 feasibility filter, the TVAE and the MuJoCo replay harness. Where the paper only said "following [59]", I filled in the details from the two MIT Cheetah 2 papers it defers to. The A1 constants (14.5 kg all-in, gains, joint order) came from measurements on my own robot in the parkour project.

The RL side went through three bases, and they overlap in time:

  • apt-a1, Aug 7–8. The library, the TVAE, and a first RL environment in MuJoCo. From late morning on Aug 7, the RL moved onto my Isaac Lab Extreme Parkour stack, with the library wired in as a new action term. That line ended on the afternoon of Aug 8.
  • apt-rl, from Aug 8. A clean rebuild on Isaac Lab's own manager-based velocity task, with no parkour code in it. I scaffolded it at 14:00 on Aug 8, the same afternoon I stopped the parkour runs. It uses apt-a1's exported decoders and is the current line.
  • a1-parkour-gait, Aug 9. A side branch. I put the pieces that had held up back onto the parkour scaffold, to see if the prior helped where parkour already worked.

Then I stopped for seven weeks to work on DreamWaQ, HIMLoco, PIE and MOVE. I came back on Sep 29 with a second read of the paper, and both repos moved together from there: library fixes in apt-a1, RL in apt-rl.

The motion library, at A1 scale

The paper's optimiser applies no torque, friction or joint limits; it leaves feasibility to RL and a powerful robot. I kept its method and added a filter against the A1's measured limits. Getting the generator right took a day of silent bugs:

  • The paper's unknowns don't converge here. Solving for initial height, pitch and pitch rate converged 0% of the time: with a COM-relative moment arm, pitch can't change the net angular impulse. Solving for the front/rear load split instead converged 100%, residuals around 1e-15.
  • Friction belongs in the model. Shaping horizontal force separately from vertical force broke the friction cone at touchdown and liftoff, causing 62% of all rejections. Writing the horizontal force as a fraction of the vertical one brought those rejections to zero.
  • The leg is a pendulum. HOUND's timings, Froude-scaled, gave about 3.4 Hz. The A1's leg has a natural period of 1.050 s, so that swung it 3.4 times faster than gravity would. Slowing trot to 1.3–1.8 Hz and capping speed at 2 m/s raised the keep rate from 52% to 69% (trot) and 54% to 72% (bound). On the A1, joint velocity binds, not torque.
  • The torque sign. I computed torque as Jᵀf, but the actuator torque is −Jᵀf. The filter only checked |τ|, so it never noticed. Replaying the library in MuJoCo caught it: the body sank faster than free fall, then got thrown to 0.56 m and flipped.

Replay also showed that at 1.75–2.05 m/s the stored torques alone hit the 33.5 N·m ceiling in 92% of runs, leaving nothing for feedback. So the filter now keeps 25% of the torque budget in reserve. The corrected library, lib_v2, holds 47,174 trajectories: 19,389 trot and 27,785 bound.

The sagittal gait viewer, built on Aug 7 from an early pass of the generator, before the gait-timing fix. Its gaits are faster than the final library's. Legs are drawn by forward kinematics from the stored joint angles, coloured by torque.

MuJoCo, no policy: library trajectories played back with stored torque plus PD at kp 40. A 0.5 m/s trot holds for all six cycles; a 0.5 m/s bound pitches over at 4.3 cycles. There's no body-level feedback, so this is a correctness check, not a quality score.

The VAE

A latent-size sweep showed four dimensions are enough for this library: torque fit was R² 0.9943 at both 4 and 16. That fits a library set by about six numbers. I kept 16 to match the paper.

On Sep 29 an audit showed I'd trained it wrong. I had fitted encoder and decoders jointly on torque. The paper trains in two stages: first a VAE on state alone, then decoders on top of the frozen encoder. Refitting that way gave R² 0.9685, with 9 active latent dimensions spread like a unit Gaussian, and that mattered a lot for RL. Two later library fixes came from RL failures: a four-contact trot (library_v3, Sep 30) and a fast bound to 3 m/s (library_v4, Oct 1).

Phase one: the library inside the parkour stack

The first 36 hours were mostly about how many ways a training curve can lie.

A 35% win, retracted twice

In MuJoCo, at 78.6M steps, the library policy beat a no-prior policy on speed tracking by 35%. Then I measured where the torque came from: the auxiliary channel was supplying 94% of it. Against a fairer control, a frozen random decoder with the same authority, the library lost: 0.482 m/s mean error against 0.283. I retracted the claim the same morning.

The reward blackout

On the parkour stack, four of five motion-library runs collapsed to zero reward for good. The stack clips each step's reward at zero, and the paper's gait-diversity term at −15 costs up to 0.075 per step against at most 0.042 of positive reward. Reward was exactly zero, whatever the action. At −1 it trained, but finished at terrain level 1.885 against 2.103 for plain position control, with the decoder down to 15% of the torque.

"it just doesn't move. just stands there."

That was my reaction to the unclipped −15 run, right after I had presented its 44.5% gait mixing as evidence that the architecture worked. It was mixing gaits at 0.006 m/s. The gait bit can be flipped without moving. Next video: "it walked to the edge of the gap and just stood there." The foot-collision term read height in world coordinates, so a foot standing on a 20 cm step drew the largest penalty in the table.

Seven changes at once, six standing runs

Since the last config that walked, I had changed seven things. Six configurations in a row stood still at 0.004–0.056 m/s, and none of it could be attributed. I pinned the working config back and kept only the three real bug fixes. That walked at 0.537 m/s against a 0.55 command (98% tracking), versus 56% for the baseline at the same commands. The baseline still climbed about 9% more terrain.

The walking video was a face-plant

I had published the overnight report with a clip captioned as walking. "it doesn't walk. it just face plants and stays there." It was the wrong checkpoint rendered under the wrong play task, and I hadn't watched it. Since then, no clip goes out unless I've read its frames.

Neither trot nor bound

A contact probe showed the walker matched no library gait: no leg pair's stance overlap above 0.47, an arrhythmic crawl. Gait diversity back at −15 woke the selector (p(bound) 0.568), at the cost of 80% tracking and a near-dragging rear leg.

The line ends

The paper anneals its gait terms over 50,000 epochs; my runs were 1,500. A 15,000-iteration run kept collapsing and recovering. Then: "screw it, lets focus on the fresh new implementation... free both gpus."

The clip I first published as "walking". It's iteration ~100 of an earlier run, rendered under a play task whose commands it never trained on. It lies on its belly the whole time.
The real final checkpoint (iteration 1499) under the matching play task, commands 0.3–0.8 m/s. It walks, but the gait is the arrhythmic crawl: the decoder supplies about 30% of the torque.

Phase two: a clean rebuild

apt-rl starts from Isaac Lab's velocity task, which walks an A1 in minutes, with the motion-library action and the paper's observation, reward and PPO tables swapped in. An equation audit from page renders found eight mismatches in my first port; one, an action penalty summed over all 29 dimensions instead of 12, broke training on its own.

Early life is net-negative

The shakedown runs, SD1 onward, kept failing the same way. Under the paper's weights, living costs more than it earns, so the policy finds the cheapest way out. That was death (SD1: Isaac Lab multiplies rewards by dt, so the −1.5 death penalty actually delivered −0.015), a joint-limit exit, kneeling, or standing still.

The prior can't lift a foot

Open loop, no policy, latents driven as sinusoids: 0.004 m/s and zero contact changes. What matters is decoder torque against PD stiffness, roughly 0.5–1 rad of deflection on HOUND and 0.05 rad on the A1. Dropping kp to 20 and tripling decoder gain walked 0.511 m/s open loop. Under PPO, the policy then steered the latent to where the decoder goes quiet, and froze it in time. A stock Isaac Lab A1 task reached 0.91 tracking by iteration 205. The same task with only my action space swapped in reached 0.14.

Phase is the gait

Random-phase sinusoids only rocked the body. Real latents from 24 library trot cycles stepped all four feet at trot cadence. I cloned the actor from those, then found PPO was paid to take the gait apart: here the gait is action motion, and an action-rate penalty the paper doesn't have charged for it. The fourth run, bankwarm4, moved forward at iteration 400.

"you call that a walk?"

It wasn't one. Three legs at duty 0.72–0.76 and one at 0.30. The latent was pinned at its clamp, the decoder was putting out 181 N·m per step (ten times library level), and it fell twice in 500 steps. I downgraded the claim to "locomotes" and wrote a walk bar into the diagnostics: all duties 0.45–0.75, zero falls, diagonal sync above lateral by 0.15, decoder torque under 36 N·m per step. No APT-RL checkpoint has passed it.

A verdict, later withdrawn

A 0.35 rad error in the default pose had been cancelling the prior's stance torque. Once I fixed it, I ran matched arms. Without the prior: tracking 0.267 at iteration 200, the best cold learner yet. With it: 0.005, falling constantly. I concluded the prior was a disturbance, not a scaffold, on this robot.

bankwarm4 at iteration 400, flat ground, 5 s. The first mean policy that moved forward, and the one I meant with "you call that a walk?".

The same day, on the side branch, I put the library action and the pose fix onto the parkour scaffold with its own rewards and clip. The prior arm kept pace with a no-prior control (terrain 4.24 at iteration 951, against 4.40 at 901) and by 600 stepped with all duties in band. A later no-prior arm there passed the duty bar while skimming, with swing peaks of 2–33 mm, so I retracted it and added a 40 mm swing-height check. That branch became the rear-drag investigation on the Extreme Parkour teacher.

Seven weeks later: the verdict was confounded

On Sep 29 I reread the paper and the Aug 9 runs. Moving the spawn pose to the library's crouch had kept the 0.42 m spawn height, so every "with prior" episode began with a 15 cm drop; its 35–67-step episodes were just the time to fall. It had also run only 200–260 iterations.

The bigger finding was a framework default. The paper trained on legged_gym, where only_positive_rewards clips the summed reward at zero and adds the termination penalty after the clip. Isaac Lab never clips. That difference accounts for the net-negative early life in August. So I turned the clip on, ramped penalties in over 1,000 iterations, and moved to a deploy-matched actuator (kp 37, kd 0.9, gains randomised over 34–40, 5–10 ms delay). The policy moved, but by slamming the latent between its limits, and stood once the smoothness penalty reached full weight. A low-pass filter on the latent paid that bill; the joint-acceleration penalty took over.

What finally worked was the paper-spec two-stage VAE plus a second audit's fixes. Those included the PPO regularisers, which had been running as an extra optimiser step outside the loss. I also defined gates, sized against the paper's own training curve:

GatePass criterion (sim)Result
G1 no retreatTracking not falling after the penalty ramp completesPass, Sep 30
G2 walkerSuccess ≥ 0.8, tracking ≥ 0.5, ff=0 tracking under a thirdPass, Sep 30 (bound only)
G3 gait useBoth forced gaits ≥ 0.7; p(bound) rises with speedFail, Oct 1
G4 terrain teacherCurriculum level ≥ 6 of 10 on obstaclesNot reached
G5 studentTeacher–student gap ≤ 10% successNot reached

G1: the gait is the prior

At iteration 2000: success 0.71, tracking 0.728. With the decoder zeroed, success stayed at 0.99, but tracking fell to 0.027: it stood. That's the paper's ablation, reproduced. But it bounded at every speed.

The trot library could only run

Both arms avoided the trot decoder. My two-contact projection can't express double support, so every library trot had a flight phase. A four-contact trot (library_v3, 37% with double support) fixed it: forced-trot tracking now fell from 0.47 to 0.15 with ff=0.

G2 at 10,000 iterations

Success 0.865, tracking 0.782. With ff=0: 0.891 and 0.023. It handled rough ground and slopes well. Down-stairs fell from 0.93 success on flat rows to 0.12 at levels 7–10. Every one of those steps was a bound.

The v3 gait arm at 10,000 iterations, trot forced, 0.6 m/s command, level 1. Diagonal legs move together (diagonal sync 0.49 against 0.24 lateral). One rear leg is still off, at duty 0.30.
Same checkpoint, bound forced, 1.2 m/s. Same policy, same weights; only the gait bit differs.
The G2 walker (bound only), 1.0 m/s, level 1. It passes the gate's numbers and fails my walk bar: all four duties sit at 0.27–0.41.

Gait choice: where it stands

G3 asks whether the policy picks the gait that suits the situation. It doesn't, yet. At the end of the gait-term anneal, on the paper's obstacle terrain, p(bound) sat at 0.52–0.53 in every speed bin and at 0.46–0.56 on every terrain type. It switched 0.79 times a second, which satisfies the diversity term without meaning anything. Looking for why turned up three bugs of my own:

  • A ±1 m/s command cap. My task never overrode Isaac Lab's default command ranges, so every run trained at −1 to 1 m/s and my 0–2 m/s evaluations were extrapolation. Below 2 m/s, over my scaled obstacles, trot and bound did equally well, so selection had nothing to earn. I made the obstacles taller (hurdles 0.40 m, steps 0.45 m, gaps 0.8 m) and extended bound to 3 m/s.
  • No curriculum. Swapping in the paper's terrain generator silently turned curriculum mode off. Every tile got a random type and difficulty. That made every per-level table on obstacle terrain from before Oct 1 meaningless.
  • a_scale taken word for word. The paper's 0.2 at kp 80 gives 16 N·m per unit of auxiliary action. At my kp 37 it gives 7.4 N·m, against 12–20 N·m of decoder torque, so the auxiliary channel had 2–4 times the paper's relative authority. My best obstacle policy (success 0.759) scored identically with the decoder zeroed. It was walking on the auxiliary channel alone, so the gait bit selected nothing. At a_scale 0.07–0.1 the decoders carry about 70% of tracking again.

Watching the final a_scale 0.07 checkpoint terrain by terrain showed a steady trot on rough ground. On stairs, the one real switch to bound came during a descent (the clip below). Stepping stones were messy. On gaps, it walks to the edge, hesitates and falls in, ten times in 8 s, and never jumps. On Oct 2, prompted by the videos, I fed it a fake flat-ground height scan. Success went from 0.684 to 0.682: it had been ignoring the scan. Isaac Lab's default scan sees about 0.8 m ahead, 0.3–0.8 s at these speeds, so reacting blind was cheaper. Two runs started that day with the paper's geometry, a longer scan plus a lidar-like strip to 5 m. Their gate at 3,000 iterations: the fake scan has to hurt.

Isaac Sim, Oct 2: stairs at level 9 of 10, the final checkpoint of the a_scale 0.07 run (Oct 2). It switches to a bound during a roughly 30° pyramid descent, between 2.5 and 3.5 s, then goes back to trotting on the flat. It resets twice in 8 s. This is the only clear terrain-driven gait switch I've seen so far.
Gap terrain, level 4, same checkpoint as the stair clip. It trots to the moat edge, hesitates and falls in. It bounds 1% of the time and resets 10 times in 8 s.

October: eyes, a real trot, and a speed ceiling

Everything here is Isaac Lab simulation. Success rates use the paper’s protocol: all obstacle terrains, levels 0–10, commands of 0–2 m/s.

The obstacle policies were blind

Watching the clips, I suspected the policy wasn’t looking. Replacing its height scan with fake flat ground took success from 0.684 to 0.682: it wasn’t. Isaac Lab’s default scan sees about 0.8 m ahead, 0.3–0.8 s at these speeds, so reacting blind was cheaper. With the paper’s geometry, a 2.3 m scan plus a lidar-like strip out to 5 m, the fake scan finally hurt, most on the hardest rows. The same day I found that Isaac Lab’s “inverted pyramid” is the one the robot climbs, so every earlier “down-stairs” number was really up-stairs.

Vision for drops, not footholds

A per-terrain blind test showed where it looked: descending stairs fell from 0.91 to 0.16 without vision and high steps from 0.93 to 0.39, but stepping stones didn’t change. The legs were brushing obstacles on 2.4 of every 10 control steps, unpenalised, so feeling its way was cheap. Restoring the paper’s leg-collision termination made climbing depend on vision for the first time: 0.91 with it, 0.00 without. I also added the paper’s concurrent estimator and terrain encoder, swapped the scan for a patch matched to what this robot’s D435 and Mid-360 can see (the sensor head), and set a 0.15 m minimum gap, since narrower gaps fell between scan rays.

Half the episodes died at step 0

The new stack looked broken: 0.17 success. It wasn’t. The leg-collision check read the contact sensor’s force history, which still held forces from before the reset, so half of all episodes ended on their first step, in training as well. A three-step grace period fixed it, and the same checkpoint re-scored 0.546.

From a shuffle to a trot

“Why such small strides?” Every policy since the first walker had shuffled: 0.15 m per cycle at 5–7 Hz, against the library trot’s 0.56 m at 1.7 Hz. Nothing tied the policy’s timing to the library’s rhythm. An air-time reward only nudged it. A contact-schedule reward at the library cadence did it: a clean diagonal trot at 2 Hz, 0.34 m per cycle at 1 m/s, body level, and 0.785 success at 3,000 iterations.

Both gaits on command, and a ceiling at 2 m/s

A warm start with commanded gaits got both to work when asked: 0.735 success forced to trot and 0.718 forced to bound. Left to itself, it still never picked bound. I also found that the paper anneals its gait-diversity term over 50,000 epochs; I had used 12,000. And both gaits refused 2.5 m/s: they turned and stepped in place.

Isaac Sim, Oct 4: the contact-schedule policy trotting on rough ground at a 2 m/s command, averaging about 1.5 m/s with long strides at the library’s 2 Hz rhythm.
Isaac Sim, Oct 6: after the library fix, bounding at a 3 m/s command, at about 2.2 m/s. The render ends just before it falls.

The library didn’t know the motor

Three causes. The library’s trot stopped at 2 m/s, with only 476 bounds above 2.5. My generator checked torque and joint speed as separate limits, but the A1’s motor delivers less torque the faster it spins, about 33.5 × (1 − speed/21) N·m; by that test 54–86% of the fast bounds were infeasible. And the gait clock was fixed. Library v6 uses the exact motor curve: about 189,000 trajectories, 87,215 trot and 101,835 bound. Nothing is feasible above 3 m/s, and 2.5–3 m/s needs a low swing. The VAE fits it with R² 0.963.

The ceiling is gone, and so is terrain success

A run with commanded gaits tracked 0.91–0.93 of commands from 2.1 to 3.2 m/s, reaching 2.43 m/s at the top band, where earlier runs managed 0.05. But its obstacle success fell to about 0.48. A warm start recovered it only partly, to 0.55, while keeping the speed.

The best teacher so far, and what it gave up

A 50,000-iteration run with the policy choosing its own gait and the paper’s full anneal reached 0.835 success, the best yet. On the hardest stair rows it scores 0.78–0.84, high steps 0.93, hurdles 0.81, rough ground 0.98. It gave up three things. It trots 93% of the time at every speed and terrain. Its speed ceiling came back: at a 2.6–3.2 m/s command on flat ground it does 1.15 m/s, probably having learned that a high command on hard terrain means slow down. And stepping stones fail above level 4. The velocity estimator doesn’t matter: feeding it ground truth instead moves success from 0.841 to 0.846.

Why it won’t cross a gap

Its gap “successes” are it standing at the edge. A gap-only run showed why: a trotting foot drops into a 0.15 m slot about half the time, the calf brushes the wall, and the episode ends. Every early attempt to cross died, so it learned that a gap means stop. Two runs are now training with leg contact on gap tiles as a penalty instead of a termination, plus the paper’s command coupling: slower on high stairs, no sideways or turning commands above 2 m/s.

Isaac Sim, the Oct 7 warm start: rough ground at a 2.8 m/s command, running about 2.5 m/s. This policy keeps the speed but only reaches 0.55 on obstacles.
The best teacher at a gap: it walks up to the edge, stops, and backs off.

Departures from the paper

DepartureWhat forced it
Speed to 2 m/s (bound to 3), slower trot timingThe A1's 1.050 s leg pendulum; joint velocity rejected nearly half of all samples
Library filtered to A1 limits, 25% torque in reserveStored torques alone hit the ceiling in 92% of fast replays
Load split in place of initial pitchThe paper's stated unknowns gave a singular solve here
Four-contact trotRL avoided a trot decoder that only knew running trots
kp 37 / kd 0.9 (paper 80 / 2), a_scale 0.07–0.1The A1's deployable gains; the auxiliary channel's authority has to stay relative to decoder torque
legged_gym-style reward clip, penalty rampWithout them early life is net-negative and PPO dies or stands
Gait anneal compressed to the run length50,000 epochs is beyond my budget
Foot-collision by contact directionHeight-based versions punished standing on obstacles

What's still wrong

  • Gait choice collapsed: the best teacher trots 93% of the time, on every terrain and at every speed.
  • Gaps: it stops at the edge and never jumps. The runs that change how gaps are penalised are still training.
  • The best teacher lost the top speed that the commanded runs had. No single policy yet has both 0.8+ obstacle success and 2.4 m/s.
  • Stepping stones fail above level 4. Vision is used for drops and steps, much less for precise footholds.
  • Still missing compared with the paper: the depth and lidar student distilled from this teacher.
  • All of this is in simulation. None of the Isaac Lab policies has been checked in MuJoCo or run on the robot. The decoder emits torques, and my A1 deploy stack has so far only run position control.

Lab reports

These are the reports I wrote during the work, unedited, as they were at the time. Several were later corrected or retracted. The tags say which.