File size: 10,779 Bytes
2eeee5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
08d8fb9
 
4e49627
 
 
 
 
2eeee5a
4e49627
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
"""JAX support-estimate corruption model (Step 3).

Reproduces the *error behaviour* of hackathon-everest's sklearn ExtraTrees estimator so an
MJX/Brax training loop can consume a realistic support signal without ever calling sklearn.

Error structure is measured, not assumed (see scripts/calibrate_from_everest.py):

1. Regression-to-the-mean shrinkage. The tree ensemble compresses its output range, so
   `pred ~= intercept + slope * true`. This is the dominant error term and it is
   *systematic* -- a policy cannot average it away the way it can average away noise.
   Modelling it as zero-mean Gaussian would make the task substantially easier than reality.
2. Conditional Gaussian noise around that shrunk value (smaller than the marginal residual
   spread, because most of the marginal spread IS the shrinkage).
3. False-safe events, latched per stance rather than per timestep -- see FALSE_SAFE_NOTE.
4. Dropout that holds the last valid value and drops confidence.
5. Per-environment noise scaling so no single calibration is memorised.

All functions are pure and vmap/jit-safe: no data-dependent Python control flow.
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import NamedTuple

import jax
import jax.numpy as jnp

CALIBRATION_PATH = Path(__file__).with_name("calibration.json")

DEPTH_NORM_M = 0.25          # RL terrain range 0..0.25 m
SUPPORT_NORM_N = 900.0       # material_priors bearing_capacity_n upper bound
N_FEET = 2
N_CHANNELS = 4               # depth_est, support_est, confidence, void_prob
OBS_SIZE = N_FEET * N_CHANNELS

# Fitted over the terrain generator's own distribution (see scripts/fit_support_to_depth.py).
# Valid only while bearing is derived from the sinkage law; regenerate if that changes.
SUPPORT_TO_DEPTH_SLOPE = -3.244e-4
SUPPORT_TO_DEPTH_INTERCEPT = 0.1833
# Chosen by measurement, and RE-measured whenever the calibration contract changes.
# Under the real 10-channel rig: corr(implied, true depth) peaks at 0.691 with w=0.50
# (0.683 at 0.75, 0.664 depth-only, 0.327 support-only). Under the optimistic 19-channel
# contract the optimum was 0.75 at corr 0.776 -- the real rig costs ~0.09 of correlation.
# Support contributes depth information mainly in shallow terrain: past the failure sinkage
# the substrate never carries load, so bearing stops distinguishing 12 cm from 25 cm.
DEPTH_CHANNEL_WEIGHT = 0.50

FALSE_SAFE_NOTE = """\
everest measures two very different false-safe rates on the same held-out split:
  0.005 when the consumer sees the CONSERVATIVE lower bound (mean - 2*sigma)
  0.303 when the consumer sees the POINT estimate
The brief's observation block hands the policy a point estimate plus a separate confidence,
which puts the honest rate near 0.303, not 0.005. Applying 0.005 would make the environment
~60x more forgiving than the estimator it claims to model. Default here is the point-estimate
rate; set `false_safe_rate` explicitly to override."""


class Channel(NamedTuple):
    slope: float
    intercept: float
    sigma: float


class EstimatorParams(NamedTuple):
    depth: Channel
    support: Channel
    false_safe_rate: float
    false_safe_support_n: float   # what a false-safe estimate reports
    dropout_prob: float
    noise_scale_range: tuple[float, float]
    confidence_resid_corr: float
    void_false_positive_rate: float
    void_false_negative_rate: float


class EstimatorState(NamedTuple):
    """Per-environment carry. Leading axis is the env batch under vmap."""
    last_obs: jax.Array        # (N_FEET, N_CHANNELS) held values for dropout
    false_safe: jax.Array      # (N_FEET,) bool, latched for the current stance
    noise_scale: jax.Array     # () per-env multiplier on all sigmas


def load_params(
    path: Path | str = CALIBRATION_PATH,
    *,
    false_safe_rate: float | None = None,
    dropout_prob: float = 0.02,
    void_false_positive_rate: float = 0.05,
    void_false_negative_rate: float = 0.10,
) -> EstimatorParams:
    """Build params from measured calibration.

    void_* rates default to non-zero on purpose. everest's void classifier scores a perfect
    1.0/1.0/0.0 on held-out data, which would hand the policy an oracle for voids and quietly
    violate the "rewards never key off ground truth" constraint. Injecting realistic error
    keeps void_prob an estimate rather than a truth channel.
    """
    cal = json.loads(Path(path).read_text())
    ch = cal["channels"]
    fs = cal["false_safe"]
    rate = fs["rate_point_estimate"] if false_safe_rate is None else false_safe_rate
    return EstimatorParams(
        depth=Channel(ch["depth"]["shrink_slope"], ch["depth"]["shrink_intercept"],
                      ch["depth"]["conditional_sigma"]),
        support=Channel(ch["support"]["shrink_slope"], ch["support"]["shrink_intercept"],
                        ch["support"]["conditional_sigma"]),
        false_safe_rate=float(rate),
        false_safe_support_n=float(fs["unsafe_threshold_n"] * 1.25),
        dropout_prob=float(dropout_prob),
        noise_scale_range=(0.7, 1.4),
        confidence_resid_corr=float(ch["support"]["uncertainty_resid_corr"]),
        void_false_positive_rate=float(void_false_positive_rate),
        void_false_negative_rate=float(void_false_negative_rate),
    )


def init_state(key: jax.Array, params: EstimatorParams) -> EstimatorState:
    """Initialise one environment's carry. vmap over keys for a batch."""
    lo, hi = params.noise_scale_range
    return EstimatorState(
        last_obs=jnp.zeros((N_FEET, N_CHANNELS)),
        false_safe=jnp.zeros((N_FEET,), dtype=bool),
        noise_scale=jax.random.uniform(key, (), minval=lo, maxval=hi),
    )


def _shrink(true_value: jax.Array, ch: Channel) -> jax.Array:
    return ch.intercept + ch.slope * true_value


def estimate(
    key: jax.Array,
    state: EstimatorState,
    true_depth_m: jax.Array,      # (N_FEET,)
    true_support_n: jax.Array,    # (N_FEET,)
    true_void: jax.Array,         # (N_FEET,) bool
    new_stance: jax.Array,        # (N_FEET,) bool -- foot just made contact
    params: EstimatorParams,
) -> tuple[jax.Array, EstimatorState]:
    """One 50 Hz estimator tick for one environment.

    Returns (obs, state) where obs is (OBS_SIZE,) laid out per foot as
    [depth_est_norm, support_est_norm, confidence, void_prob].
    """
    k_d, k_s, k_fs, k_drop, k_conf, k_void = jax.random.split(key, 6)
    scale = state.noise_scale

    depth = _shrink(true_depth_m, params.depth) + params.depth.sigma * scale * jax.random.normal(
        k_d, true_depth_m.shape
    )
    support = _shrink(true_support_n, params.support) + params.support.sigma * scale * jax.random.normal(
        k_s, true_support_n.shape
    )

    # False-safe: resampled only on a NEW STANCE, then held for that stance's duration.
    # Sampling per 50 Hz timestep instead would fire ~20x per 0.4 s stance and inflate the
    # effective per-footstep rate by more than an order of magnitude.
    unsafe = true_support_n < params.false_safe_support_n / 1.25
    draw = jax.random.uniform(k_fs, true_support_n.shape) < params.false_safe_rate
    fresh = jnp.where(new_stance, unsafe & draw, state.false_safe)
    support = jnp.where(fresh, params.false_safe_support_n, support)

    # Confidence: weakly informative, matching the measured |resid| vs uncertainty correlation.
    # A false-safe estimate looks CONFIDENT -- that is what makes it dangerous.
    resid_proxy = jnp.abs(support - _shrink(true_support_n, params.support)) / (
        params.support.sigma * 3.0
    )
    # A false-safe is exactly the case where the tree ensemble AGREES and is wrong -- tight
    # spread is why it cleared everest's 2-sigma gate. So it reports near-zero apparent
    # residual, i.e. high confidence. That is what makes it dangerous rather than merely noisy.
    resid_proxy = jnp.where(fresh, 0.0, resid_proxy)
    noise_c = jax.random.uniform(k_conf, true_support_n.shape, minval=-0.3, maxval=0.3)
    rho = params.confidence_resid_corr
    confidence = jnp.clip(1.0 - rho * resid_proxy + noise_c, 0.0, 1.0)

    flip = jax.random.uniform(k_void, true_void.shape)
    void_prob = jnp.where(
        true_void,
        jnp.where(flip < params.void_false_negative_rate, 0.15, 0.85),
        jnp.where(flip < params.void_false_positive_rate, 0.75, 0.10),
    )

    obs = jnp.stack(
        [
            jnp.clip(depth / DEPTH_NORM_M, 0.0, 1.5),
            jnp.clip(support / SUPPORT_NORM_N, 0.0, 1.5),
            confidence,
            void_prob,
        ],
        axis=-1,
    )  # (N_FEET, N_CHANNELS)

    # Dropout: hold the last valid sample and collapse confidence.
    dropped = jax.random.uniform(k_drop, (N_FEET,)) < params.dropout_prob
    held = state.last_obs.at[:, 2].multiply(0.3)
    obs = jnp.where(dropped[:, None], held, obs)

    return obs.reshape(OBS_SIZE), EstimatorState(
        last_obs=obs, false_safe=fresh, noise_scale=scale
    )


def deshrink(value: jax.Array, ch: Channel) -> jax.Array:
    """Invert the measured shrinkage to recover a correctly-SCALED estimate.

    The raw estimate is compressed toward the training mean (depth slope 0.115), so as an
    absolute number it is worse than a constant (R^2 -0.06) even though it still TRACKS truth
    (corr +0.45). A learned policy could rescale such an input itself, but a fixed reward
    formula cannot -- `depth_est + 0.05` would be a near-constant target.

    De-shrinking restores the scale at the cost of amplifying noise by 1/slope. The result is
    noisy but unbiased and correctly scaled, which is what a reward target needs.

    Note this is only legitimate because the shrinkage was MEASURED on held-out data. It uses
    no per-timestep ground truth and is applied to the corrupted estimate, so it does not leak.
    """
    return (value - ch.intercept) / ch.slope


def implied_depth_m(obs: jax.Array, params: EstimatorParams) -> jax.Array:
    """Clearance-relevant snow depth implied by the estimator block, per foot.

    Blends the de-shrunk depth channel with the depth implied by support. In terrain where
    bearing is derived from the sinkage law the two are coupled (corr -0.50), so support
    carries independent information about how deep the snow is.
    """
    depth_raw = obs[..., 0] * DEPTH_NORM_M
    support_raw = obs[..., 1] * SUPPORT_NORM_N
    from_depth = deshrink(depth_raw, params.depth)
    from_support = SUPPORT_TO_DEPTH_SLOPE * deshrink(support_raw, params.support) + SUPPORT_TO_DEPTH_INTERCEPT
    blended = DEPTH_CHANNEL_WEIGHT * from_depth + (1.0 - DEPTH_CHANNEL_WEIGHT) * from_support
    return jnp.clip(blended, 0.0, DEPTH_NORM_M)


batched_estimate = jax.vmap(estimate, in_axes=(0, 0, 0, 0, 0, 0, None))
batched_init = jax.vmap(init_state, in_axes=(0, None))