File size: 28,067 Bytes
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
822fa6d
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8318a56
 
 
 
 
 
 
2eeee5a
 
 
 
aca7f23
 
 
 
 
 
a64a1f1
aca7f23
822fa6d
 
 
 
 
 
 
 
 
 
2eeee5a
 
 
 
 
 
 
 
 
 
8318a56
 
 
 
 
 
7ff03af
 
 
 
 
 
0f34fff
7ff03af
 
822fa6d
 
 
 
 
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
822fa6d
 
 
 
7ff03af
 
 
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
a5a834a
 
 
 
 
2eeee5a
 
 
 
 
822fa6d
 
 
 
d0cdd50
 
 
 
 
 
822fa6d
 
 
2eeee5a
 
 
 
 
 
 
 
 
 
 
7ff03af
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a64a1f1
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8318a56
2eeee5a
 
8318a56
 
2eeee5a
 
8318a56
 
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
"""Snow locomotion environment (Step 4).

Wraps MuJoCo Playground's G1 joystick env. Three changes, nothing else:

1. **Snow forces.** Each step, the per-environment depth field is sampled at both feet and a
   sinkage + ploughing force is written into `data.xfrc_applied`. The MuJoCo ground stays a
   flat plane representing the hard substrate -- snow is never geometry (see terrain.py).
2. **Observations.** The 8 estimator values plus a 3-step history are appended to the actor's
   `state` observation. The critic's `privileged_state` additionally receives true depth and
   true bearing per foot -- training only, discarded at deployment.
3. **Rewards.** Three terms on top of Playground's stock G1 terms, all scaled by confidence.

The design rule, from the brief: **the reward reads the estimate, never the truth.** Truth is
permitted only as the corruption function's input, in the critic, and in physics-consequence
terms (stumble). If the clearance target read true depth, the policy could succeed while
ignoring the sensor entirely and the experiment would prove nothing.

Terrain is stored as a shared BANK of pre-generated fields with a per-environment index,
rather than a field per environment. A field in `state.info` would be carried through every
rollout transition -- 160x40 floats x 8192 envs x unroll 20 is several GB. The bank costs
~1.6 MB total and an int per environment.
"""
from __future__ import annotations

from typing import Any

import jax
import jax.numpy as jnp
import numpy as np
from ml_collections import config_dict
from mujoco import mjx
from mujoco_playground._src import mjx_env
from mujoco_playground._src.locomotion.g1 import joystick

from snow_rl import belief, estimator, sinkage, support
from snow_rl.terrain import DEFAULT_CONFIG as TERRAIN_CFG
from snow_rl.terrain import SnowField, batched_generate, sample

TERRAIN_BANK_SIZE = 64
HISTORY_STEPS = 3
CLEARANCE_MARGIN_M = 0.05
CLEARANCE_TOLERANCE = 0.01      # Gaussian width on the clearance target
FLAT_DEPTH_THRESHOLD_M = 0.03   # below this the estimate is calling the ground firm
FLAT_NOMINAL_CLEARANCE_M = 0.07
# Absolute plough threshold, not a ratio. The brief specified "horizontal force > 2x
# vertical", which is right for CONTACT forces where a dragging foot can exceed vertical
# load. In this model vertical support is a stiff spring and ploughing is a small tangential
# drag: swept over the whole parameter space the ratio maxes out at 0.076 against a threshold
# of 2.0, so the term could never fire and was identically 0.000 across a 60M-step run.
# ~15 N of drag on a swinging foot means it is meaningfully submerged.
STUMBLE_PLOUGH_N = 15.0
# A foot is "in contact" when snow carries this much load -- ~6% of body weight. Floor
# contact alone is not enough once snow is a force rather than geometry (see
# _stepped_with_snow_contact).
SNOW_CONTACT_FORCE_N = 20.0
# MJX constraint-row budget. mujoco_warp's default heuristic is
#   njmax = max(nv*2.26*(nhfield>0)*18 + 53, ...)
# which collapses to 53 -> rounded to 64 for any model without a heightfield. The G1 needs
# 93-94 rows once the gait is dynamic (standing is only 61, which is why this appears mid-run
# and not at reset). Past the limit MuJoCo SILENTLY DROPS contact constraints -- feet lose
# grip or sink through -- while printing "nefc overflow" and continuing to train.
# Playground's stock value is 90. 128 is the next valid size above the observed 93-94 peak.
NJMAX = 128
# Slope. Everything before this was level-ground walking, which is a real gap for a
# mountaineering project: on the flat, losing a foothold means falling over; on a slope it
# means sliding. The source project's terrain generator had a slope term (+/-0.25) that was
# dropped when the field was ported to JAX, because our snow is a force field over a flat
# MuJoCo plane rather than geometry.
#
# Rather than tilt the ground mesh, tilt GRAVITY. A uniform slope is exactly equivalent to
# rotating the gravity vector, it costs nothing in the physics, and it keeps the snow force
# field (which is defined against a flat substrate) valid without reworking terrain.py.
SLOPE_RAD = 0.0
# Uncertainty the belief map attaches to one contact observation. The estimator's own
# ensemble spread is only weakly informative (corr with true error +0.35), so a fixed
# conservative value is more honest than pretending the spread is calibrated.
MAP_OBS_STD_N = 70.0
MAP_OBS_STD_M = 0.05


def default_config() -> config_dict.ConfigDict:
    """Playground's G1 config plus the snow terms. Stock values are untouched."""
    cfg = joystick.default_config()
    # Measured on the first 60M-step run, the snow terms were 6.1% of total episode reward
    # (clearance 6.8%, flat_efficiency 0.1%, overload 0.6%) -- Playground's stock objective
    # accounted for the other 94%, so the policy had almost no gradient pressure to change
    # its gait for snow. Scaled up ~3.5x to reach roughly a quarter of total reward: enough
    # to steer, short of drowning velocity tracking (whose failure mode is a policy that
    # stands still or creeps).
    # Back to the brief's original weights. Five tuning cycles (0.8 -> 3 -> 5 on clearance,
    # 0.4 -> 1.5 -> 3.0 on overload) never beat them: measured velocity tracking went
    # 18% -> -5% -> -8% while survival rose, because not walking maximises survival. The
    # bottleneck was never the reward weights -- it is that a flat-ground policy is thrown
    # into 200 mm snow from step 0, which the curriculum now addresses instead.
    cfg.reward_config.scales.snow_clearance = 0.8
    cfg.reward_config.scales.snow_stumble = -1.0
    cfg.reward_config.scales.snow_flat_efficiency = -0.5
    cfg.reward_config.scales.snow_overload = -0.4
    # Playground's stock njmax is 90 constraint rows; a dynamic G1 gait needs 93-94 and
    # MuJoCo SILENTLY DROPS contacts past the limit. This line has been deleted twice by
    # index-based edits to the reward block above -- if you rewrite those weights, check it
    # survived. `make_snow_env()._config.njmax` should read 128, not 90.
    cfg.njmax = NJMAX
    cfg.snow = config_dict.create(
        enable_forces=True,
        # Zeroing this blanks the estimator block in the observation while leaving snow
        # physics and rewards intact. This is the Step 6 ablation that isolates the sensor
        # channel -- see docs/PIPELINE.md section 8.
        estimator_channel=1.0,
        # Separate flags so each half's contribution is measurable on its own. everest's
        # benchmark could not separate its map from its support gate; this can.
        map_channel=1.0,
        support_channel=1.0,
        false_safe_rate=-1.0,   # <0 means "use the calibrated point-estimate rate"
        terrain_bank_size=TERRAIN_BANK_SIZE,
        terrain_seed=0,
        # Uphill slope in radians. 0.20 rad ~ 11.5 deg, a realistic snow slope; avalanche
        # terrain starts around 0.5 rad (30 deg). Applied by rotating gravity, so positive
        # values mean the robot walks uphill along +x.
        slope_rad=SLOPE_RAD,
        # Curriculum: 1.0 is full difficulty (0-25 cm). Lower values shrink every regime's
        # depth range so an early stage trains on snow the policy can actually cope with.
        depth_scale=1.0,
    )
    return cfg


class SnowJoystick(joystick.Joystick):
    """G1 joystick locomotion over variable-depth snow."""

    def __init__(
        self,
        task: str = "flat_terrain",
        config: config_dict.ConfigDict | None = None,
        config_overrides: dict[str, Any] | None = None,
    ):
        # Playground downloads mujoco_menagerie inside `locomotion.load()`. Constructing
        # Joystick directly bypasses that, so a fresh machine has no robot meshes and model
        # compilation fails on a missing STL. This does not reproduce anywhere menagerie is
        # already cached -- which is every development machine, and no fresh container.
        mjx_env.ensure_menagerie_exists()
        super().__init__(task=task, config=config or default_config(),
                         config_overrides=config_overrides)

    def _post_init(self) -> None:
        super()._post_init()
        slope = float(self._config.snow.slope_rad)
        if slope != 0.0:
            # Rotate gravity about +y: the robot walking along +x is then walking uphill.
            g = float(np.linalg.norm(self._mj_model.opt.gravity))
            # NEGATIVE x component: gravity pulls the robot BACK along -x while the
            # walking command drives +x, so positive slope_rad means walking UPHILL.
            # The sign was inverted first time round, which made gravity assist the command
            # -- passive drift measured +1.02 m downhill at 17 deg, and "slope performance"
            # improved with steepness because the robot was being pushed along.
            tilted = np.array([-g * np.sin(slope), 0.0, -g * np.cos(slope)])
            self._mj_model.opt.gravity[:] = tilted
            self._mjx_model = self._mjx_model.replace(opt=self._mjx_model.opt.replace(
                gravity=jnp.asarray(tilted)))
        self._terrain_cfg = TERRAIN_CFG
        rate = self._config.snow.false_safe_rate
        self._est_params = estimator.load_params(
            false_safe_rate=None if rate < 0 else rate
        )
        # Feet bodies carry the snow force; site ids come from the parent env.
        self._feet_body_id = jnp.asarray(self._mj_model.site_bodyid[self._feet_site_id])
        self._terrain_bank: SnowField = batched_generate(
            jax.random.split(jax.random.key(self._config.snow.terrain_seed),
                             self._config.snow.terrain_bank_size),
            self._terrain_cfg,
            float(self._config.snow.depth_scale),
        )

    # ---------------- terrain ----------------

    def _field_at(self, index: jax.Array) -> SnowField:
        return jax.tree_util.tree_map(lambda leaf: leaf[index], self._terrain_bank)

    def _sample_feet(self, field: SnowField, feet_xyz: jax.Array):
        """Snow state under each foot. Returns (depth, bearing, stiffness, damping, void)."""
        return sample(field, self._terrain_cfg, feet_xyz[:, :2])

    # ---------------- episode ----------------

    def reset(self, rng: jax.Array) -> mjx_env.State:
        rng, terrain_rng, est_rng = jax.random.split(rng, 3)
        state = super().reset(rng)

        index = jax.random.randint(terrain_rng, (), 0, self._config.snow.terrain_bank_size)
        est_state = estimator.init_state(est_rng, self._est_params)
        feet_xyz = state.data.site_xpos[self._feet_site_id]
        field = self._field_at(index)
        depth, bearing, _, _, void = self._sample_feet(field, feet_xyz)

        obs8, est_state = estimator.estimate(
            est_rng, est_state, depth, bearing, void > 0.5,
            jnp.ones((2,), dtype=bool), self._est_params,
        )
        belief_map = belief.init()
        state.info["snow_belief"] = belief_map
        state.info["snow_map_readout"] = belief.readout(belief_map, feet_xyz[:, :2])
        state.info["snow_support_obs"] = support.observation(
            support.evaluate(bearing * 0.0 + support.G1_WEIGHT_N,
                             jnp.zeros(2), jnp.zeros(2))
        )
        state.info["snow_overload"] = jnp.zeros(())
        state.info["snow_terrain_index"] = index
        state.info["snow_obs"] = obs8
        state.info["snow_history"] = jnp.tile(obs8, HISTORY_STEPS)
        state.info["snow_truth"] = jnp.concatenate([depth, bearing])
        state.info["snow_force"] = jnp.zeros((2, 3))
        state.info["snow_last_feet_pos"] = feet_xyz
        state.info["snow_est_last_obs"] = est_state.last_obs
        state.info["snow_est_false_safe"] = est_state.false_safe
        state.info["snow_est_noise_scale"] = est_state.noise_scale

        state = state.replace(obs=self._get_obs(state.data, state.info,
                                                state.info["last_contact"]))
        for key in ("snow_clearance", "snow_stumble", "snow_flat_efficiency",
                    "snow_overload"):
            state.metrics[f"reward/{key}"] = jnp.zeros(())
        state.metrics["snow_depth_under_feet"] = jnp.mean(depth)
        state.metrics["snow_contact_fraction"] = jnp.zeros(())
        return state

    def step(self, state: mjx_env.State, action: jax.Array) -> mjx_env.State:
        field = self._field_at(state.info["snow_terrain_index"])
        feet_xyz = state.data.site_xpos[self._feet_site_id]
        # Finite-difference foot velocity; MJX site velocities are not directly exposed.
        feet_vel = (feet_xyz - state.info["snow_last_feet_pos"]) / self.dt
        depth, bearing, stiffness, damping, void = self._sample_feet(field, feet_xyz)
        void_b = void > 0.5

        # Force for the estimator/reward at this control instant. The force actually applied
        # to the physics is recomputed each substep inside _physics_with_snow.
        force = sinkage.foot_force_world_n(
            feet_xyz[:, 2], feet_vel, jnp.zeros((2,)), depth, stiffness, damping, void_b
        )
        force = force * self._config.snow.enable_forces

        # A foot that has just landed starts a new stance -- the false-safe latch resamples
        # here rather than every tick, which would inflate the per-footstep rate ~20x.
        state.info["rng"], est_rng = jax.random.split(state.info["rng"])
        new_stance = self._bearing_load(force) & ~state.info["last_contact"].astype(bool)
        est_state = estimator.EstimatorState(
            last_obs=state.info["snow_est_last_obs"],
            false_safe=state.info["snow_est_false_safe"],
            noise_scale=state.info["snow_est_noise_scale"],
        )
        obs8, est_state = estimator.estimate(
            est_rng, est_state, depth, bearing, void_b, new_stance, self._est_params
        )

        # Fuse each foot's estimate into the spatial memory. Reads the ESTIMATE, not truth.
        est = obs8.reshape(estimator.N_FEET, estimator.N_CHANNELS)
        support_est_n = est[:, 1] * estimator.SUPPORT_NORM_N
        implied = estimator.implied_depth_m(est, self._est_params)
        belief_map = state.info["snow_belief"]
        for foot in range(estimator.N_FEET):
            belief_map = belief.update(
                belief_map, feet_xyz[foot, :2], support_est_n[foot],
                jnp.asarray(MAP_OBS_STD_N), implied[foot], jnp.asarray(MAP_OBS_STD_M),
            )
        state.info["snow_belief"] = belief_map
        state.info["snow_map_readout"] = belief.readout(belief_map, feet_xyz[:, :2])

        sup = support.evaluate(support_est_n, jnp.full((2,), MAP_OBS_STD_N), force[:, 2])
        state.info["snow_support_obs"] = support.observation(sup)
        state.info["snow_overload"] = support.overload_cost(
            sup, self._bearing_load(force)
        )

        state.info["snow_obs"] = obs8
        state.info["snow_history"] = jnp.concatenate(
            [obs8, state.info["snow_history"][: estimator.OBS_SIZE * (HISTORY_STEPS - 1)]]
        )
        state.info["snow_truth"] = jnp.concatenate([depth, bearing])
        state.info["snow_force"] = force
        state.info["snow_last_feet_pos"] = feet_xyz
        state.info["snow_est_last_obs"] = est_state.last_obs
        state.info["snow_est_false_safe"] = est_state.false_safe
        state.info["snow_est_noise_scale"] = est_state.noise_scale

        state = self._stepped_with_snow_contact(state, action, field)
        state.metrics["snow_depth_under_feet"] = jnp.mean(depth)
        return state

    def _snow_force_at(self, data, prev_feet_xyz, field, dt: float):
        """Snow force on both feet given the current physics state."""
        feet_xyz = data.site_xpos[self._feet_site_id]
        feet_vel = (feet_xyz - prev_feet_xyz) / dt
        depth, _, stiffness, damping, void = self._sample_feet(field, feet_xyz)
        force = sinkage.foot_force_world_n(
            feet_xyz[:, 2], feet_vel, jnp.zeros((2,)), depth, stiffness, damping, void > 0.5
        )
        return force * self._config.snow.enable_forces, feet_xyz

    def _physics_with_snow(self, data, motor_targets: jax.Array, field):
        """Substep the physics, recomputing the snow force at EVERY substep.

        Playground's `mjx_env.step` holds `xfrc_applied` constant across all 10 substeps.
        For a stiff spring applied as an external force that zero-order hold is an explicit
        integrator, and it is unstable above k ~ m/ctrl_dt^2 ~ 5000 N/m with a ~2 kg effective
        foot mass. The terrain generates stiffness up to 45000 N/m, so most of the range
        diverged: measured growth 4.0x at k=12000 and 9.1x at k=45000, which showed up in
        rollouts as the force pinned at its 2000 N clamp and swing feet launched to 0.67 m.

        Recomputing inside the substep loop moves the limit to m/sim_dt^2 ~ 500000 N/m,
        comfortably above the terrain's maximum. Measured growth is 1.0x at every stiffness.

        The estimator, belief map and reward stay at the 50 Hz control rate -- those are
        sensor-rate quantities and nothing about them is unstable.
        """
        def substep(carry, _):
            d, prev = carry
            force, feet_xyz = self._snow_force_at(d, prev, field, self.sim_dt)
            xfrc = jnp.zeros_like(d.xfrc_applied).at[self._feet_body_id, :3].set(force)
            d = d.replace(ctrl=motor_targets, xfrc_applied=xfrc)
            d = mjx.step(self.mjx_model, d)
            return (d, feet_xyz), force

        feet0 = data.site_xpos[self._feet_site_id]
        (data, _), forces = jax.lax.scan(
            substep, (data, feet0), None, length=self.n_substeps
        )
        # Report the last substep's force: what the feet are feeling as control resumes.
        return data, forces[-1]

    def _bearing_load(self, snow_force: jax.Array) -> jax.Array:
        """Feet whose vertical snow force is carrying meaningful load."""
        return snow_force[:, 2] > SNOW_CONTACT_FORCE_N

    def _stepped_with_snow_contact(self, state: mjx_env.State, action: jax.Array,
                                   field) -> mjx_env.State:
        """Playground's Joystick.step with ONE change: what counts as foot contact.

        Playground reads a floor-contact sensor at z=0. Once snow is a force rather than
        geometry that sensor stops meaning "the foot is standing on something": measured on
        deep terrain it fired on 2 of 35 steps, because the snow held the foot at z~0.156 m
        and it never reached the plane. Everything keyed to it broke -- `first_contact` never
        fired so the clearance reward was dead exactly where the signal lives, `swing_peak`
        never reset, and `feet_air_time` grew without bound.

        Contact here means BEARING LOAD: the floor sensor OR snow carrying real weight.

        This mirrors the upstream step body; re-check it when bumping mujoco_playground.
        """
        state.info["rng"], push1_rng, push2_rng = jax.random.split(state.info["rng"], 3)
        push_theta = jax.random.uniform(push1_rng, maxval=2 * jnp.pi)
        push_magnitude = jax.random.uniform(
            push2_rng,
            minval=self._config.push_config.magnitude_range[0],
            maxval=self._config.push_config.magnitude_range[1],
        )
        push = jnp.array([jnp.cos(push_theta), jnp.sin(push_theta)])
        push *= (
            jnp.mod(state.info["push_step"] + 1, state.info["push_interval_steps"]) == 0
        )
        push *= self._config.push_config.enable
        qvel = state.data.qvel
        qvel = qvel.at[:2].set(push * push_magnitude + qvel[:2])
        state = state.replace(data=state.data.replace(qvel=qvel))

        motor_targets = self._default_pose + action * self._config.action_scale
        data, applied_force = self._physics_with_snow(state.data, motor_targets, field)
        state.info["motor_targets"] = motor_targets
        state.info["snow_force"] = applied_force

        floor_contact = jnp.array([
            data.sensordata[self._mj_model.sensor_adr[sensorid]] > 0
            for sensorid in self._feet_floor_found_sensor
        ])
        # >>> the one deviation from upstream <<<
        contact = floor_contact | self._bearing_load(state.info["snow_force"])

        contact_filt = contact | state.info["last_contact"]
        first_contact = (state.info["feet_air_time"] > 0.0) * contact_filt
        state.info["feet_air_time"] += self.dt
        p_fz = data.site_xpos[self._feet_site_id][..., -1]
        state.info["swing_peak"] = jnp.maximum(state.info["swing_peak"], p_fz)

        obs = self._get_obs(data, state.info, contact)
        done = self._get_termination(data)

        rewards = self._get_reward(
            data, action, state.info, state.metrics, done, first_contact, contact
        )
        rewards = {k: v * self._config.reward_config.scales[k] for k, v in rewards.items()}
        reward = sum(rewards.values()) * self.dt

        state.info["push"] = push
        state.info["step"] += 1
        state.info["push_step"] += 1
        phase_tp1 = state.info["phase"] + state.info["phase_dt"]
        state.info["phase"] = jnp.fmod(phase_tp1 + jnp.pi, 2 * jnp.pi) - jnp.pi
        state.info["last_last_act"] = state.info["last_act"]
        state.info["last_act"] = action
        state.info["rng"], cmd_rng = jax.random.split(state.info["rng"])
        state.info["command"] = jnp.where(
            state.info["step"] > 500, self.sample_command(cmd_rng), state.info["command"]
        )
        state.info["step"] = jnp.where(done | (state.info["step"] > 500), 0,
                                       state.info["step"])
        state.info["feet_air_time"] *= ~contact
        state.info["last_contact"] = contact
        state.info["swing_peak"] *= ~contact
        for k, v in rewards.items():
            state.metrics[f"reward/{k}"] = v
        state.metrics["swing_peak"] = jnp.mean(state.info["swing_peak"])
        state.metrics["snow_contact_fraction"] = jnp.mean(contact.astype(float))

        done = done.astype(reward.dtype)
        return state.replace(data=data, obs=obs, reward=reward, done=done)

    # ---------------- observations ----------------

    def _snow_block(self, info: dict[str, Any]) -> jax.Array:
        # The parent's reset() calls _get_obs before this env has populated its info keys.
        # reset() recomputes the observation once they exist, so zeros here are transient.
        history = info.get("snow_history")
        if history is None:
            return jnp.zeros(estimator.OBS_SIZE * HISTORY_STEPS)
        return history * self._config.snow.estimator_channel

    def _assurance_block(self, info: dict[str, Any]) -> jax.Array:
        """everest's half: spatial memory plus bilateral support reserve."""
        readout = info.get("snow_map_readout")
        if readout is None:
            readout = jnp.zeros(belief.READOUT_SIZE)
        sup = info.get("snow_support_obs")
        if sup is None:
            sup = jnp.zeros(5)
        return jnp.hstack([
            readout * self._config.snow.map_channel,
            sup * self._config.snow.support_channel,
        ])

    def _get_obs(self, data, info: dict[str, Any], contact: jax.Array):
        obs = super()._get_obs(data, info, contact)
        block = jnp.hstack([self._snow_block(info), self._assurance_block(info)])
        return {
            "state": jnp.hstack([obs["state"], block]),
            # Privileged: the critic sees ground truth, the actor never does.
            "privileged_state": jnp.hstack([
                obs["privileged_state"], block,
                info.get("snow_truth", jnp.zeros(2 * estimator.N_FEET)),
            ]),
        }

    # ---------------- rewards ----------------

    def _get_reward(self, data, action, info, metrics, done, first_contact, contact):
        rewards = super()._get_reward(data, action, info, metrics, done,
                                      first_contact, contact)
        obs8 = info["snow_obs"].reshape(estimator.N_FEET, estimator.N_CHANNELS)
        implied_depth = estimator.implied_depth_m(obs8, self._est_params)
        confidence = obs8[:, 2]

        rewards["snow_clearance"] = self._reward_snow_clearance(
            info["swing_peak"], implied_depth, confidence, first_contact
        )
        rewards["snow_stumble"] = self._cost_snow_stumble(info["snow_force"], contact)
        rewards["snow_flat_efficiency"] = self._cost_snow_flat_efficiency(
            info["swing_peak"], implied_depth, confidence, first_contact
        )
        # everest's bilateral gate. It cannot halt a locomotion policy, so it becomes a cost
        # for standing on a foothold the estimate says will not hold.
        rewards["snow_overload"] = info["snow_overload"]
        return rewards

    def _reward_snow_clearance(self, swing_peak, implied_depth, confidence, first_contact):
        """Score the swing foot's peak height against the depth the ESTIMATE implies.

        Targets a value rather than rewarding 'higher is better' -- unbounded lifting is its
        own failure mode. Scored once per step, when the swing ends.
        """
        target = implied_depth + CLEARANCE_MARGIN_M
        score = jnp.exp(-((swing_peak - target) ** 2) / CLEARANCE_TOLERANCE)
        return jnp.sum(score * confidence * first_contact)

    def _cost_snow_stumble(self, snow_force, contact):
        """Ploughing drag on a foot that should be swinging freely.

        A physics consequence, so it may read the real force rather than the estimate.
        Graded rather than binary: a step function gives the optimiser no gradient about
        *how much* it is ploughing, only whether it crossed a line.
        """
        horizontal = jnp.linalg.norm(snow_force[:, :2], axis=-1)
        excess = jnp.maximum(horizontal - STUMBLE_PLOUGH_N, 0.0) / STUMBLE_PLOUGH_N
        return jnp.sum(excess * ~contact.astype(bool))

    def _cost_snow_flat_efficiency(self, swing_peak, implied_depth, confidence, first_contact):
        """Penalise lifting high when the estimate says the ground is firm.

        Without this the policy prances everywhere, which technically satisfies the clearance
        term and is obviously wrong.
        """
        on_flat = implied_depth < FLAT_DEPTH_THRESHOLD_M
        excess = jnp.maximum(swing_peak - FLAT_NOMINAL_CLEARANCE_M, 0.0)
        return jnp.sum(excess * on_flat * confidence * first_contact)


def make_snow_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick:
    return SnowJoystick(config_overrides=config_overrides)


def make_no_map_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick:
    """Spatial memory blanked, everything else intact.

    This is the run everest could never do. Its own benchmark conflated the map with the
    bilateral gate, and the decomposition showed the gate did all the work -- so the map's
    contribution has never actually been measured. Against the full env, this isolates it.
    """
    overrides = dict(config_overrides or {})
    overrides["snow.map_channel"] = 0.0
    return SnowJoystick(config_overrides=overrides)


def make_no_support_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick:
    """Bilateral reserve blanked, everything else intact."""
    overrides = dict(config_overrides or {})
    overrides["snow.support_channel"] = 0.0
    return SnowJoystick(config_overrides=overrides)


def make_ablation_env(config_overrides: dict[str, Any] | None = None) -> SnowJoystick:
    """Identical environment with the estimator block zeroed.

    This is the comparison that isolates the sensor channel. Baseline-vs-trained differs in
    three ways -- channel, rewards, AND snow training experience -- so only this run can
    attribute a gap to the sensor.
    """
    overrides = dict(config_overrides or {})
    overrides["snow.estimator_channel"] = 0.0
    overrides["snow.map_channel"] = 0.0
    overrides["snow.support_channel"] = 0.0
    return SnowJoystick(config_overrides=overrides)