# Fresh-look changes - 12 Aug 2026

An outside pass over the whole platform, with everything measured before it
was changed. Scripts and the full report are in the session record; the
short version of what was found and what was done:

- The v19 balance bake was a 100 Hz bang-bang limit cycle: wheels railed at
  +-1.4 Nm 66% of every episode (1.171 Nm mean holding still, where 0.06
  suffices), because no stage score charged for effort. Every stage score
  now charges EFFORT_RATE * mean|u| per second (SCORE_VERSION 20), and the
  balance seed is the quiet family found by re-searching under that charge:
  [3.65, 2.91, 1.02, 0.52] - 4.88 on the old objective, 0.058 Nm standing.
- stop's baked search had run one round; its brake clamp (0.20 rad) was the
  binding constraint - the score rises monotonically to ~7.3/10 at 0.60 at
  every gain tried. The seed clamp is now 0.45.
- serve.py's demo and drive mode NEGATED the foot-shift reflex relative to
  the scored law (the same inversion fixed in the trainer during the 11 Aug
  audit), and the demo read only 8 of recover's 10 slots, dropping the
  brace. The single-implementation refactor below is done: controller.py
  owns the laws, everyone imports it.
- Drive mode's extra suspects for the standing limit cycle are addressed by
  construction: reflex/trim/yaw now computed at the full 100 Hz (was ~33),
  measured speed low-passed before the lean target (v_tau 0.10), and the
  turn differential reserves headroom (1 - |u|) as assist_lab always did.
- Body model: leg servos are bounded by PEAK torque (11.48 Nm at the joint,
  was the 5.74 continuous rating - sustained load is priced by the reward's
  torque term, not fenced by the model), DR now scales forcerange with gain
  so strength varies both ways (wheel cap 1.19-1.61), the PPO torque
  penalty charges only the actuators the policy commands, and action_rate
  dropped 0.02 -> 0.005 (it was 27x the torque term at full swing).
- All v19 bakes quarantined (*.v19.stale.json); every stage relearns under
  v20. Accept now always records names + score_version; load refuses a
  stale layout; check_stages flags score-version mismatches.
- The bench grew a live signal scope (lean vs target, wheel torque vs the
  1.4 Nm rails, pitch gyro, effort + saturation) - every fault above was
  invisible until the right signal was plotted, so the right signals are
  now always on screen.

# Learning-flow audit — 11 Aug 2026

Compared our flow against standard practice for policy search / staged
curriculum learning. Findings, each with the evidence that proved it.

## Where we diverged from known-good workflow

| practice | state before audit | state now |
|---|---|---|
| version control before experiments | none — a broken hour was unrecoverable | git, snapshot + fix commits |
| score a known-good policy before searching | skipped once: hand-tuned controller scored 0.33 in a search space that could not express it | rule: every new stage scores its seed first |
| null / control case in every score | recover had no unshoved case; base pipeline fell at 5s unnoticed | null case first in the case list |
| verify the disturbance reaches the robot | both shove hits landed after the robot had already fallen; every sweep measured an unshoved robot | hits constrained to first 60% of episode |
| treat flat scores as harness bugs | flat-across-conditions read as "robust" twice | check_stages.py flags FLAT thinking |
| thresholds set from measured noise | shove detector threshold 3.0 vs measured self-noise 17 — fired every step | filtered (tau 0.10s), threshold 5.0, both from measurement |
| one implementation of the control law | three copies (serve demo, learn_balance episodes, assist_lab) + duplicated defs in one file | duplicates removed; single-implementation refactor still TODO |
| layout-versioned artifacts | stop's brake gain loaded into recover's brace slot (25x overdose) | check_stages flags STALE layouts; stale recover bake quarantined |
| scorer versioning | scores compared across incompatible scorer versions | SCORE_VERSION constant; gate compares like with like |

## Root causes of the recover collapse (in order found)

1. `STAGES["stop"]` registry entry deleted by de-duplication → KeyError.
2. `_recover_seed` wrote stop's brake gain (0.5) into the brace slot (seed
   0.02) → fell in 0.7s.
3. Shove detector fired on the balancer's own noise (threshold below the
   noise floor) → brace became a random 12° lean injection.
4. Foot-shift sign inverted (feet fled the fall) → unstable pitch loop with
   fast legs; fell in 5s unshoved.
5. Shove hit-times drawn from the whole episode → with seed 0 both landed
   after the fall; shove strength changed nothing and the flat scores
   masked faults 3 and 4.

All five fixed. Current per-case recover scores are monotonic in shove
strength (null 7.79, then 3.13 / 2.34 / 1.63) — an honest objective for the
search to climb. The brace gain is left for the search to settle.

## Standing items

- ALL stages: relearn in the UI under score v20 (every v19 bake quarantined;
  the effort charge changes the objective everywhere)
- slope (fore-aft gradient): withdrawn — tilted floor produces ~1° of
  required-lean shift where geometry predicts 5.7°; understand before rebuild
- drive: now calls the shared law (controller.py) with the 33 Hz sampling,
  missing speed filter, missing headroom reserve and inverted reflex all
  fixed - re-measure the standing limit cycle before trusting it gone
- single-controller refactor: DONE 12 Aug (trainer/controller.py)

## Run after every change

    cd trainer && python check_stages.py
