File size: 6,622 Bytes
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
"""Local terrain belief map (ported from hackathon-everest's `mapping.py`).

This is the half of the problem the RL repo was missing. Without it the policy sees only
what is under its feet *right now*; with it, each foot's observation accumulates into a
spatial memory that the OTHER foot can read before it lands. That cross-foot transfer is
everest's central claim.

Notably, everest's own benchmark could not measure whether the map helped: decomposing its
7 unsafe transfers showed all 7 were stance-capacity failures, which the bilateral gate
catches and the map has nothing to do with. Here the map is a separate observation channel
with its own ablation flag, so its contribution is directly measurable for the first time.

Differences from everest's version, all forced by running inside a 50 Hz JAX training loop:

* **Two channels, not six.** everest maps six quantities; only support and depth reach the
  policy, so mapping the rest would cost memory for nothing.
* **Coarser.** 40x10 cells at 0.2 m over the corridor, versus everest's 40x40 at 0.05 m.
  The map lives per-environment in `state.info`, so at 8192 envs this is ~52 MB; everest's
  resolution would be ~26x that.
* **Reflect-tiled**, matching terrain.sample, so map and terrain stay in the same frame.
* Update is the same Gaussian-kernel Kalman step: a measurement at one point updates
  neighbours with weight falling off over a correlation length.
"""
from __future__ import annotations

from typing import NamedTuple

import jax
import jax.numpy as jnp

from snow_rl.terrain import DEFAULT_CONFIG, TerrainConfig, _reflect

MAP_ROWS = 40          # along the corridor
MAP_COLS = 10          # across it
CORRELATION_LENGTH_M = 0.35
MIN_WEIGHT = 0.01      # below this a cell is left untouched

# Priors, matching everest's mapping.py defaults where they overlap.
PRIOR_SUPPORT_N = 330.0
PRIOR_SUPPORT_STD_N = 190.0
PRIOR_DEPTH_M = 0.08
PRIOR_DEPTH_STD_M = 0.07

SUPPORT_NORM_N = 900.0
DEPTH_NORM_M = 0.25

# Where the policy reads the map: offsets ahead of each foot, along the corridor.
LOOKAHEAD_M = (0.0, 0.25, 0.50)
READOUT_PER_FOOT = len(LOOKAHEAD_M) * 4          # mean+std for support and depth
READOUT_SIZE = 2 * READOUT_PER_FOOT              # both feet


class BeliefMap(NamedTuple):
    """Per-environment spatial memory. Leading axis is the env batch under vmap."""
    support_mean: jax.Array   # (MAP_ROWS, MAP_COLS) N
    support_var: jax.Array
    depth_mean: jax.Array     # m
    depth_var: jax.Array


def init() -> BeliefMap:
    shape = (MAP_ROWS, MAP_COLS)
    return BeliefMap(
        support_mean=jnp.full(shape, PRIOR_SUPPORT_N),
        support_var=jnp.full(shape, PRIOR_SUPPORT_STD_N**2),
        depth_mean=jnp.full(shape, PRIOR_DEPTH_M),
        depth_var=jnp.full(shape, PRIOR_DEPTH_STD_M**2),
    )


def _fractional_cell(xy_m: jax.Array, cfg: TerrainConfig):
    """World position -> fractional cell index, reflect-tiled.

    Everything in this module works in CELL space and goes through this one function, so
    `update` and `readout` cannot disagree. They previously did: update measured distances
    with a periodic wrap while readout reflected, and the two used different half-cell
    offsets. Beyond the corridor edge the map was written to one cell and read from another,
    so a readout returned the untouched prior.
    """
    fx = _reflect((xy_m[..., 0] + cfg.length_m / 2) / (cfg.length_m / MAP_ROWS) - 0.5, MAP_ROWS)
    fy = _reflect((xy_m[..., 1] + cfg.width_m / 2) / (cfg.width_m / MAP_COLS) - 0.5, MAP_COLS)
    return fx, fy


def _kalman(mean, var, weight, observed, observed_std):
    """Gaussian-kernel Kalman update: distant cells see a larger effective observation
    variance, so a measurement informs its neighbourhood and not the whole map."""
    effective_var = observed_std**2 / jnp.maximum(weight, 1e-6)
    gain = jnp.where(weight >= MIN_WEIGHT, var / (var + effective_var), 0.0)
    return mean + gain * (observed - mean), (1.0 - gain) * var


def update(
    belief: BeliefMap,
    xy_m: jax.Array,          # (2,) where the measurement was taken
    support_n: jax.Array,     # ()
    support_std_n: jax.Array,
    depth_m: jax.Array,
    depth_std_m: jax.Array,
    cfg: TerrainConfig = DEFAULT_CONFIG,
) -> BeliefMap:
    """Fuse one foot's estimate into the map."""
    fx, fy = _fractional_cell(xy_m, cfg)
    rows = jnp.arange(MAP_ROWS)[:, None]
    cols = jnp.arange(MAP_COLS)[None, :]
    dx = (rows - fx) * (cfg.length_m / MAP_ROWS)
    dy = (cols - fy) * (cfg.width_m / MAP_COLS)
    weight = jnp.exp(-(dx**2 + dy**2) / (2.0 * CORRELATION_LENGTH_M**2))

    sm, sv = _kalman(belief.support_mean, belief.support_var, weight,
                     support_n, jnp.maximum(support_std_n, 1.0))
    dm, dv = _kalman(belief.depth_mean, belief.depth_var, weight,
                     depth_m, jnp.maximum(depth_std_m, 1e-3))
    return BeliefMap(sm, sv, jnp.clip(dm, 0.0, DEPTH_NORM_M), dv)


def _index(xy_m: jax.Array, cfg: TerrainConfig):
    """Nearest cell to a world position. Shares _fractional_cell with `update`."""
    fx, fy = _fractional_cell(xy_m, cfg)
    return (jnp.clip(jnp.round(fx), 0, MAP_ROWS - 1).astype(int),
            jnp.clip(jnp.round(fy), 0, MAP_COLS - 1).astype(int))


def readout(belief: BeliefMap, feet_xy_m: jax.Array,
            cfg: TerrainConfig = DEFAULT_CONFIG) -> jax.Array:
    """What the policy sees: belief ahead of each foot, normalised.

    Returns (READOUT_SIZE,) laid out per foot, per lookahead offset, as
    [support_mean, support_std, depth_mean, depth_std].
    """
    out = []
    for foot in range(feet_xy_m.shape[0]):
        for ahead in LOOKAHEAD_M:
            probe = feet_xy_m[foot] + jnp.array([ahead, 0.0])
            i, j = _index(probe, cfg)
            out.extend([
                belief.support_mean[i, j] / SUPPORT_NORM_N,
                jnp.sqrt(belief.support_var[i, j]) / SUPPORT_NORM_N,
                belief.depth_mean[i, j] / DEPTH_NORM_M,
                jnp.sqrt(belief.depth_var[i, j]) / DEPTH_NORM_M,
            ])
    return jnp.stack(out)


def lower_confidence_support_n(belief: BeliefMap, xy_m: jax.Array,
                               cfg: TerrainConfig = DEFAULT_CONFIG,
                               sigmas: float = 2.0) -> jax.Array:
    """Conservative support at a point: mean minus `sigmas` standard deviations.

    everest ranks candidate footholds on this rather than the mean, so uncertainty costs a
    candidate rather than being averaged away.
    """
    i, j = _index(xy_m, cfg)
    return belief.support_mean[i, j] - sigmas * jnp.sqrt(belief.support_var[i, j])