Skip to content

Commit b52f28d

Browse files
Kiguliclaude
andcommitted
Safety synthesis (max P(never reach avoid) = 1 - min-reach)
Add a controllerMax flag to the solver so the controller can MINIMIZE reach (for safety) in addition to maximizing (reachability), reusing OVI. solve::maxSafety {Pessimistic,Optimistic} return sound [lower,upper] on the safety probability. Broadens coverage to the ARCH safety category (building automation, rooms). Verified (tests/unit/test_safety.cpp): controller-can-avoid -> 1; unavoidable -> 0; coin -> 0.5; point MDP pess==opt; differential vs reachability on single-action interval MDPs (safety == 1 - reach). Suite: 56/56 cases, 606499 assertions, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e92443a commit b52f28d

5 files changed

Lines changed: 135 additions & 7 deletions

File tree

paper/IMPaCT-v2.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ Format per entry: `date — feature | decision + rationale | algorithm | refs |
235235
- ISSUE-0010 (perf, low): OVI's VI-from-below is slow on large strongly-recurrent
236236
IMDPs; MECCollapse (optimistic) / robust-EC interval iteration is the fast path.
237237

238+
- **Safety synthesis** (broadens to the ARCH safety category: BAS, rooms).
239+
`solve::maxSafety{Pessimistic,Optimistic}` = max P(never reach `avoid`) =
240+
1 - min-reach-to-avoid. Added a `controllerMax` flag to the solver so the
241+
controller can MINIMIZE reach (for safety) as well as maximize (reachability),
242+
reusing OVI. Verified (test_safety.cpp): controller-can-avoid → 1; unavoidable →
243+
0; coin → 0.5; point pess==opt; and a differential vs reachability on
244+
single-action interval MDPs (safety == 1 - reach). Suite: 56/56, 606499 assertions.
245+
- **Consolidated CAV paper draft** at `paper/draft.md` (algorithms + verified
246+
citations + verification methodology incl. the MC-found bugs + experimental results).
247+
238248
<!-- add new dated entries above this line -->
239249

240250
---

src/solve.cpp

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ namespace {
1414
// Robust Bellman backup at one state: max over actions of the omax optimum.
1515
// sense = Min for pessimistic (adversarial nature), Max for optimistic.
1616
// A state with no actions has value 0 (cannot make progress to a target).
17-
double backup(const StateActions& actions, const std::vector<double>& V, omax::Sense sense) {
17+
// controllerMax: the controller MAXIMIZES over actions (reachability) or MINIMIZES
18+
// (used for safety = 1 - min-reach-to-avoid). A state with no actions has value 0.
19+
double backup(const StateActions& actions, const std::vector<double>& V, omax::Sense sense,
20+
bool controllerMax = true) {
1821
double best = 0.0;
1922
bool any = false;
2023
std::vector<double> lo, hi, vv;
@@ -23,9 +26,10 @@ double backup(const StateActions& actions, const std::vector<double>& V, omax::S
2326
lo.reserve(act.size()); hi.reserve(act.size()); vv.reserve(act.size());
2427
for (const Interval& iv : act) { lo.push_back(iv.lo); hi.push_back(iv.hi); vv.push_back(V[iv.to]); }
2528
const double val = omax::optimize(lo, hi, vv, sense).value;
26-
if (!any || val > best) { best = val; any = true; }
29+
if (!any) { best = val; any = true; }
30+
else best = controllerMax ? std::max(best, val) : std::min(best, val);
2731
}
28-
return best;
32+
return any ? best : 0.0;
2933
}
3034

3135
struct Collapsed {
@@ -142,7 +146,7 @@ IntervalResult solveReach(const IMDPModel& m, const std::set<int>& targets,
142146
// needed, so it converges on nature-confinable ECs (ISSUE-0003). If the guess is
143147
// not yet inductive, refine L (smaller delta) and retry.
144148
IntervalResult solveOVI(const IMDPModel& m, const std::set<int>& targets,
145-
double eps, omax::Sense sense) {
149+
double eps, omax::Sense sense, bool controllerMax = true) {
146150
const int n = (int)m.size();
147151
std::vector<double> L(n, 0.0), tmp(n), U(n), FU(n);
148152
for (int t : targets) L[t] = 1.0;
@@ -153,7 +157,7 @@ IntervalResult solveOVI(const IMDPModel& m, const std::set<int>& targets,
153157
for (int it = 0; it < MAXINNER; ++it) { // refine L from below
154158
++iters;
155159
for (int s = 0; s < n; ++s)
156-
tmp[s] = targets.count(s) ? 1.0 : backup(m[s], L, sense);
160+
tmp[s] = targets.count(s) ? 1.0 : backup(m[s], L, sense, controllerMax);
157161
double ch = 0.0;
158162
for (int s = 0; s < n; ++s) ch = std::max(ch, std::fabs(tmp[s] - L[s]));
159163
L.swap(tmp);
@@ -163,7 +167,7 @@ IntervalResult solveOVI(const IMDPModel& m, const std::set<int>& targets,
163167
U[s] = targets.count(s) ? 1.0 : std::min(1.0, L[s] + eps);
164168
bool inductive = true; // verify F(U) <= U
165169
for (int s = 0; s < n; ++s) {
166-
FU[s] = targets.count(s) ? 1.0 : backup(m[s], U, sense);
170+
FU[s] = targets.count(s) ? 1.0 : backup(m[s], U, sense, controllerMax);
167171
if (FU[s] > U[s] + 1e-12) inductive = false;
168172
}
169173
if (inductive) return IntervalResult{L, U, iters}; // L <= V* <= U, gap <= eps
@@ -173,6 +177,26 @@ IntervalResult solveOVI(const IMDPModel& m, const std::set<int>& targets,
173177
return IntervalResult{L, U, iters}; // best-effort fallback
174178
}
175179

180+
// Min-reach: controller MINIMIZES reach to `targets`. Used for safety.
181+
IntervalResult minReach(const IMDPModel& m, const std::set<int>& targets,
182+
double eps, omax::Sense sense) {
183+
return solveOVI(m, targets, eps, sense, /*controllerMax=*/false);
184+
}
185+
186+
// Safety = 1 - min-reach-to-avoid (controller maximizes staying out of `avoid`).
187+
IntervalResult safetyFromMinReach(const IMDPModel& m, const std::set<int>& avoid,
188+
double eps, omax::Sense sense) {
189+
IntervalResult mr = minReach(m, avoid, eps, sense);
190+
IntervalResult r;
191+
r.iterations = mr.iterations;
192+
r.lower.resize(mr.lower.size()); r.upper.resize(mr.upper.size());
193+
for (size_t s = 0; s < mr.lower.size(); ++s) {
194+
r.lower[s] = 1.0 - mr.upper[s]; // safety lower = 1 - max possible reach
195+
r.upper[s] = 1.0 - mr.lower[s];
196+
}
197+
return r;
198+
}
199+
176200
IntervalResult dispatch(const IMDPModel& m, const std::set<int>& targets,
177201
double eps, omax::Sense sense, Method method) {
178202
return (method == Method::MECCollapse) ? solveReach(m, targets, eps, sense)
@@ -194,5 +218,14 @@ IntervalResult maxReachOptimistic(const IMDPModel& m, const std::set<int>& targe
194218
return dispatch(m, targets, eps, omax::Sense::Max, method);
195219
}
196220

221+
// Robust safety: max over controller of P(never reach `avoid`); pessimistic =
222+
// nature adversarial (maximizes reach to avoid, omax Sense::Max).
223+
IntervalResult maxSafetyPessimistic(const IMDPModel& m, const std::set<int>& avoid, double eps) {
224+
return safetyFromMinReach(m, avoid, eps, omax::Sense::Max);
225+
}
226+
IntervalResult maxSafetyOptimistic(const IMDPModel& m, const std::set<int>& avoid, double eps) {
227+
return safetyFromMinReach(m, avoid, eps, omax::Sense::Min);
228+
}
229+
197230
} // namespace solve
198231
} // namespace impact

src/solve.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ namespace solve {
5252
IntervalResult maxReachPessimistic(const IMDPModel& m, const std::set<int>& targets, double eps, Method method);
5353
IntervalResult maxReachOptimistic (const IMDPModel& m, const std::set<int>& targets, double eps, Method method);
5454

55+
// Robust safety: max over controller of P(never reach `avoid`) = 1 - min-reach to
56+
// avoid. Pessimistic = nature adversarial; optimistic = nature cooperative.
57+
// Returns sound [lower,upper] on the safety probability (gap <= 2*eps).
58+
IntervalResult maxSafetyPessimistic(const IMDPModel& m, const std::set<int>& avoid, double eps);
59+
IntervalResult maxSafetyOptimistic (const IMDPModel& m, const std::set<int>& avoid, double eps);
60+
5561
} // namespace solve
5662
} // namespace impact
5763

tests/Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ TEST_SRCS := unit/test_main.cpp \
3333
unit/test_ltl_dfa.cpp \
3434
unit/test_product.cpp \
3535
unit/test_abstraction.cpp \
36-
unit/test_omega.cpp
36+
unit/test_omega.cpp \
37+
unit/test_safety.cpp
3738

3839
BIN := run_tests
3940

tests/unit/test_safety.cpp

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// ============================================================================
2+
// CONTRACT TESTS — safety synthesis: max P(never reach `avoid`) = 1 - min-reach.
3+
// Verified by hand cases + a differential vs reachability on single-action models
4+
// (where the controller has no choice, so safety == 1 - reach).
5+
// ============================================================================
6+
#include "../doctest.h"
7+
#include "../contracts/contracts.h"
8+
9+
#include <set>
10+
#include <vector>
11+
#include <random>
12+
#include <cmath>
13+
14+
using namespace impact::solve;
15+
16+
static const double EPS = 1e-7;
17+
static double mid(const IntervalResult& r, int s) { return 0.5*(r.lower[s]+r.upper[s]); }
18+
19+
TEST_CASE("safety: controller can avoid -> safety 1") {
20+
// 0: a->1(avoid) | b->2(safe sink) ; 1 avoid sink ; 2 safe sink
21+
IMDPModel m = { {{ {1,1,1} }, { {2,1,1} }}, {{ {1,1,1} }}, {{ {2,1,1} }} };
22+
auto sf = maxSafetyPessimistic(m, {1}, EPS);
23+
CHECK(mid(sf, 0) == doctest::Approx(1.0).epsilon(1e-6)); // pick b
24+
CHECK(mid(sf, 2) == doctest::Approx(1.0).epsilon(1e-6));
25+
CHECK(mid(sf, 1) == doctest::Approx(0.0).epsilon(1e-6)); // already in avoid
26+
}
27+
28+
TEST_CASE("safety: avoid unavoidable -> safety 0") {
29+
IMDPModel m = { {{ {1,1,1} }}, {{ {1,1,1} }} }; // 0 -> 1(avoid) forced
30+
auto sf = maxSafetyPessimistic(m, {1}, EPS);
31+
CHECK(mid(sf, 0) == doctest::Approx(0.0).epsilon(1e-6));
32+
}
33+
34+
TEST_CASE("safety: coin to avoid -> safety 0.5") {
35+
IMDPModel m = { {{ {1,0.5,0.5}, {2,0.5,0.5} }}, {{ {1,1,1} }}, {{ {2,1,1} }} };
36+
auto sf = maxSafetyPessimistic(m, {1}, EPS);
37+
CHECK(mid(sf, 0) == doctest::Approx(0.5).epsilon(1e-3));
38+
}
39+
40+
TEST_CASE("safety: point MDP pessimistic == optimistic") {
41+
IMDPModel m = { {{ {1,0.3,0.3}, {2,0.7,0.7} }}, {{ {1,1,1} }}, {{ {2,1,1} }} };
42+
auto sp = maxSafetyPessimistic(m, {1}, EPS);
43+
auto so = maxSafetyOptimistic(m, {1}, EPS);
44+
for (int s = 0; s < 3; ++s) CHECK(mid(sp,s) == doctest::Approx(mid(so,s)).epsilon(1e-4));
45+
}
46+
47+
TEST_CASE("safety: differential vs reachability on single-action interval MDPs") {
48+
// With one action per state the controller has no choice, so
49+
// safety == 1 - reach(avoid) (nature adversarial both ways).
50+
std::mt19937 rng(909);
51+
std::uniform_real_distribution<double> u01(0.0, 1.0);
52+
int checked = 0;
53+
for (int trial = 0; trial < 4000 && checked < 400; ++trial) {
54+
int n = 2 + (int)(rng() % 5);
55+
std::set<int> avoid; avoid.insert((int)(rng() % n));
56+
IMDPModel m(n);
57+
for (int s = 0; s < n; ++s) {
58+
std::vector<int> succ;
59+
for (int t = 0; t < n; ++t) if (rng() & 1u) succ.push_back(t);
60+
if (succ.empty()) succ.push_back((int)(rng() % n));
61+
std::vector<double> w(succ.size()); double sum = 0;
62+
for (double& x : w) { x = u01(rng) + 1e-3; sum += x; }
63+
ActionDist act;
64+
for (size_t k = 0; k < succ.size(); ++k) { double p = w[k]/sum;
65+
double r = 0.1 * u01(rng);
66+
act.push_back({succ[k], std::max(0.0,p-r), std::min(1.0,p+r)}); }
67+
m[s].push_back(act); // exactly one action
68+
}
69+
++checked;
70+
auto sf = maxSafetyPessimistic(m, avoid, EPS);
71+
auto rc = maxReachOptimistic(m, avoid, EPS); // single action: nature-max reach
72+
for (int s = 0; s < n; ++s) {
73+
CHECK(sf.lower[s] <= sf.upper[s] + 1e-9);
74+
CHECK(std::fabs(mid(sf, s) - (1.0 - mid(rc, s))) < 3e-3); // safety == 1 - reach
75+
}
76+
}
77+
CHECK(checked > 150);
78+
}

0 commit comments

Comments
 (0)