Adaptive CBF Safety Layer for Neural Policies / adaptive_cbf_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math
2from pathlib import Path
3import numpy as np
4
5# Discrete simulation of xdot = u + d, with safe set h(x)=1-x >= 0.
6# The learned/reference model omits d: xhat_next=x+dt*u.
7# For scalar u, the CBF-QP has the closed-form projection used below.
8
9def filter_action(x, u_nom, ebar, *, dt=.1, alpha=1., kappa=1., umin=-2., umax=2.):
10 h = 1.0 - x
11 # hdot_model + alpha(h-kappa*ebar) - epsilon >= 0,
12 # epsilon is the projected disturbance margin kappa*ebar/dt.
13 upper = alpha * (h - kappa * ebar) - kappa * ebar / dt
14 u = float(np.clip(min(u_nom, upper), umin, umax))
15 return u, max(0., u_nom-u), upper
16
17def smoothing_crossing(rho, target, raw, max_steps=10000):
18 """First t (1-indexed) at which ebar >= target for constant raw error."""
19 e = 0.
20 for t in range(1, max_steps + 1):
21 e = rho*e + (1-rho)*raw
22 if e >= target - 1e-12:
23 return t
24 return None
25
26def run_episode(kappa, rho=.8, d=.35, filtered=True, seed=0, steps=100):
27 rng = np.random.default_rng(seed)
28 x, ebar = 0., 0.
29 violations, interventions, max_x = 0, 0., x
30 crossed = None
31 for t in range(steps):
32 # A fixed neural-policy-like head drives toward the upper boundary.
33 u_nom = .85 + .03*rng.normal()
34 if filtered:
35 u, intervention, _ = filter_action(x, u_nom, ebar, kappa=kappa)
36 else:
37 u, intervention = float(np.clip(u_nom, -2, 2)), 0.
38 xhat = x + .1*u
39 # Constant unknown velocity disturbance, observed after applying u.
40 x = x + .1*(u + d)
41 ebar = rho*ebar + (1-rho)*abs(x-xhat)
42 if crossed is None and kappa*ebar/.1 >= d:
43 crossed = t+1
44 violations += int(x > 1.0 + 1e-10)
45 interventions += intervention
46 max_x = max(max_x, x)
47 return dict(violations=violations, intervention=interventions, max_x=max_x,
48 final_ebar=ebar, crossing=crossed)
49
50def main():
51 out = {}
52 # Core math sanity: if epsilon bounds projected disturbance, robust residual is nonnegative.
53 rng = np.random.default_rng(11)
54 residuals = []
55 for _ in range(10000):
56 u = rng.uniform(-1, 1); d = rng.uniform(-.4, .4); h = rng.uniform(0, 1)
57 eps = abs(d) + 1e-9
58 # hdot=-u-d; model residual minus eps is a lower bound on true residual.
59 model_res = -u + h - eps
60 true_res = -u - d + h
61 residuals.append(true_res - model_res)
62 out['math_check'] = {'min_true_minus_model_lower_bound': float(min(residuals)),
63 'statement': 'true residual >= model residual when epsilon >= |d|'}
64
65 # Prediction 1: protection turns on when kappa*ebar/dt reaches d.
66 # Constant transition error is dt*d, so predicted crossing is
67 # ceil(log(1-d*dt/(kappa*dt))/log(rho)) = ceil(log(1-1/kappa)/log(rho)) for kappa>1.
68 rows = []
69 for k in [0.8, 1., 1.2, 1.5, 2., 3.]:
70 pred = None if k <= 1 else math.ceil(math.log(1-1/k)/math.log(.8))
71 r = run_episode(k, rho=.8, d=.35, seed=0)
72 rows.append({'kappa': k, 'predicted_margin_crossing_step': pred,
73 'observed_crossing_step': r['crossing'], 'violations': r['violations']})
74 out['margin_threshold_sweep'] = rows
75
76 # Prediction 2: ebar reaches a fixed fraction q of its asymptote with the
77 # exponential law t=ceil(log(1-q)/log(rho)); verify several rho values.
78 smooth = []
79 for rho in [.5, .8, .9, .95]:
80 q=.8; pred=math.ceil(math.log(1-q)/math.log(rho))
81 obs=smoothing_crossing(rho, q*.1*.35, .1*.35)
82 smooth.append({'rho':rho, 'target_fraction':q, 'predicted_step':pred, 'observed_step':obs})
83 out['smoothing_law_sweep'] = smooth
84
85 # Prediction 3: larger kappa monotonically increases intervention and decreases violations.
86 pareto=[]
87 for k in [0., .5, 1., 1.5, 2., 3., 4.]:
88 vals=[run_episode(k, rho=.8, d=.35, seed=s) for s in range(8)]
89 pareto.append({'kappa':k, 'mean_violations':float(np.mean([v['violations'] for v in vals])),
90 'mean_intervention':float(np.mean([v['intervention'] for v in vals])),
91 'mean_max_x':float(np.mean([v['max_x'] for v in vals]))})
92 base=[run_episode(0, filtered=False, d=.35, seed=s) for s in range(8)]
93 out['intervention_violation_sweep'] = pareto
94 out['baseline_static_clipping'] = {'mean_violations':float(np.mean([v['violations'] for v in base])),
95 'mean_max_x':float(np.mean([v['max_x'] for v in base]))}
96 # Explicit mechanism verdict checks (allow one step numerical tolerance).
97 out['checks'] = {
98 'smoothing_matches_exact_recursion': all(abs(x['predicted_step']-x['observed_step'])<=1 for x in smooth),
99 'intervention_monotone': all(pareto[i+1]['mean_intervention']+1e-9 >= pareto[i]['mean_intervention'] for i in range(len(pareto)-1)),
100 'violations_nonincreasing': all(pareto[i+1]['mean_violations'] <= pareto[i]['mean_violations']+1e-9 for i in range(len(pareto)-1)),
101 'adaptive_beats_clipping_at_kappa_2': pareto[4]['mean_violations'] < out['baseline_static_clipping']['mean_violations']
102 }
103 Path('results.json').write_text(json.dumps(out, indent=2))
104 print(json.dumps(out, indent=2))
105
106if __name__ == '__main__': main()