Skip to content

Commit d191219

Browse files
Kiguliclaude
andcommitted
Verify sparse pipeline end-to-end (Monte-Carlo); fix ISSUE-0007 (loose sink)
End-to-end verification against the real continuous system (benchmarks/validate_montecarlo.cpp): synthesize a robust controller on the sparse IMDP, simulate the continuous closed loop under it, and check the empirical reach vs the synthesized bounds. This caught ISSUE-0007: the SINK (outside-grid, value 0) interval was 1 - sum of loose per-cell lower bounds ~= 1, so pessimistic nature drained all mass to the sink => synthesized reach ~= 0 while true reach ~= 1. Internal differential tests missed it because dense and sparse builds shared the same wrong sink. Fix: bound the outside-grid probability tightly as the whole-grid-box complement [1 - gridHi, 1 - gridLo] (buildSparseReach1D and buildSparseReachND). Also expose SparseReach.actions (per-action input vectors) so a synthesized policy is simulable. After the fix the robust controller's empirical reach >= synthesized lower bound for all start states, in a saturated case (~1) and a discriminating high-noise case (value ~0.32, empirical ~0.36). (A robust controller guarantees empirical >= lower; benign noise may exceed the worst-case upper. Infinite-horizon reach needs a long sim horizon to validate -- truncation gave 0.73 vs 1.0.) Suite: 42/42 cases, 54971 assertions, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f955448 commit d191219

6 files changed

Lines changed: 185 additions & 10 deletions

File tree

benchmarks/validate_montecarlo.cpp

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// End-to-end verification of the sparse pipeline against the REAL continuous system
2+
// (ISSUE-0006 / "actually verify the approaches"). We build a sparse IMDP for an
3+
// affine diagonal-Gaussian system, synthesize a robust controller, then simulate the
4+
// CONTINUOUS closed loop under that controller and check the empirical reach
5+
// probability lies within the synthesized [lower, upper] bounds (the abstraction's
6+
// soundness guarantee, validated against ground truth).
7+
//
8+
// Build: c++ -std=c++17 -O2 benchmarks/validate_montecarlo.cpp \
9+
// src/abstraction.cpp src/solve.cpp src/omaximization.cpp src/graph_utils.cpp -o /tmp/mc
10+
#include "../src/abstraction.h"
11+
#include "../src/solve.h"
12+
#include "../src/omaximization.h"
13+
#include <cstdio>
14+
#include <vector>
15+
#include <random>
16+
#include <cmath>
17+
18+
using namespace impact;
19+
20+
int main() {
21+
// 2-D robot-style affine system: x0'=0.9 x0 + 1.4 u0 ; x1'=0.8 x1 + 1.4 u1 + noise
22+
abstraction::SystemND s;
23+
s.dim_x = 2; s.dim_u = 2;
24+
s.xlb = {-3, -3}; s.xub = {3, 3}; s.eta = {0.3, 0.3};
25+
s.ulb = {-1, -1}; s.uub = {1, 1}; s.ueta = {0.5, 0.5}; // 5 pts/dim
26+
s.A = {{0.9, 0.0}, {0.0, 0.8}};
27+
s.B = {{1.4, 0.0}, {0.0, 1.4}};
28+
s.c = {0.0, 0.0};
29+
const double sd = 0.8; // high noise -> reach values in (0,1): discriminating check
30+
s.sigma = {sd, sd};
31+
s.tlo = {2.0, 2.0}; s.thi = {3.0, 3.0};
32+
33+
auto ab = abstraction::buildSparseReachND(s, 1e-9);
34+
auto val = solve::maxReachPessimistic(ab.model, ab.targets, 1e-6); // robust controller
35+
36+
// grid geometry
37+
std::vector<int> Nd(2); std::vector<long long> stride(2); long long N = 1;
38+
for (int i = 0; i < 2; ++i) { Nd[i] = (int)std::llround((s.xub[i]-s.xlb[i])/s.eta[i]); stride[i]=N; N*=Nd[i]; }
39+
auto locate = [&](double x0, double x1, int& lin)->bool {
40+
double xs[2] = {x0, x1}; lin = 0;
41+
for (int i = 0; i < 2; ++i) { int j = (int)std::floor((xs[i]-s.xlb[i])/s.eta[i]);
42+
if (j < 0 || j >= Nd[i]) return false; lin += (int)(j*stride[i]); }
43+
return true;
44+
};
45+
// success == entering a TARGET CELL (a cell whose box is fully inside the target
46+
// region), matching the abstraction's absorbing TARGET exactly. This alignment is
47+
// required for the robust lower-bound performance guarantee to apply.
48+
auto inTargetCell = [&](double x0, double x1) {
49+
double xs[2] = {x0, x1};
50+
for (int i = 0; i < 2; ++i) {
51+
int j = (int)std::floor((xs[i]-s.xlb[i])/s.eta[i]);
52+
if (j < 0 || j >= Nd[i]) return false;
53+
double lo = s.xlb[i] + j*s.eta[i], hi = lo + s.eta[i];
54+
if (!(lo >= s.tlo[i]-1e-12 && hi <= s.thi[i]+1e-12)) return false;
55+
}
56+
return true;
57+
};
58+
59+
// extract robust policy: argmax_a min_nature E[V_lower] (omax Sense::Min)
60+
std::vector<int> policy(N, 0);
61+
for (long long c = 0; c < N; ++c) {
62+
const auto& acts = ab.model[c];
63+
double best = -1; int bi = 0;
64+
for (size_t a = 0; a < acts.size(); ++a) {
65+
std::vector<double> lo, hi, V;
66+
for (const auto& iv : acts[a]) { lo.push_back(iv.lo); hi.push_back(iv.hi); V.push_back(val.lower[iv.to]); }
67+
double q = omax::optimize(lo, hi, V, omax::Sense::Min).value;
68+
if (q > best) { best = q; bi = (int)a; }
69+
}
70+
policy[c] = bi;
71+
}
72+
73+
std::mt19937 rng(2026);
74+
std::normal_distribution<double> g0(0.0, sd), g1(0.0, sd);
75+
const int TRIALS = 4000, HORIZON = 2000;
76+
77+
printf("%-14s %8s %10s %8s %7s\n", "start(x0,x1)", "lower", "empirical", "upper", "in?");
78+
int startsChecked = 0, startsOK = 0;
79+
double tests[][2] = { {-2.5,-2.5}, {-1.0,-1.0}, {0.0,0.0}, {1.0,1.0}, {1.5,1.5}, {-2.5,2.5} };
80+
for (auto& st : tests) {
81+
int lin; if (!locate(st[0], st[1], lin)) continue;
82+
int succ = 0;
83+
for (int t = 0; t < TRIALS; ++t) {
84+
double x0 = st[0], x1 = st[1]; bool done = false;
85+
for (int k = 0; k < HORIZON && !done; ++k) {
86+
if (inTargetCell(x0, x1)) { ++succ; done = true; break; }
87+
int cl; if (!locate(x0, x1, cl)) { done = true; break; } // left grid -> fail
88+
const auto& u = ab.actions[policy[cl]];
89+
double nx0 = 0.9*x0 + 1.4*u[0] + g0(rng);
90+
double nx1 = 0.8*x1 + 1.4*u[1] + g1(rng);
91+
x0 = nx0; x1 = nx1;
92+
}
93+
}
94+
double emp = (double)succ / TRIALS;
95+
double ci = 1.96 * std::sqrt(std::max(emp*(1-emp), 1e-9) / TRIALS);
96+
double lo = val.lower[lin], hi = val.upper[lin];
97+
bool ok = (emp >= lo - ci - 5e-3); // robust guarantee: real (benign) noise >= worst-case lower
98+
++startsChecked; startsOK += ok;
99+
printf("(%4.1f,%4.1f) %8.3f %8.3f+-%.3f %8.3f %7s\n",
100+
st[0], st[1], lo, emp, ci, hi, ok ? "yes" : "NO");
101+
}
102+
printf("\n%d/%d start states: empirical reach >= robust lower bound (performance guarantee).\n", startsOK, startsChecked);
103+
printf("states=%lld actions=%zu nnz=%lld\n", N, ab.actions.size(), ab.nnz);
104+
return startsOK == startsChecked ? 0 : 1;
105+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
id: ISSUE-0007
3+
title: Abstraction SINK (outside-grid) bound too loose -> severe reach under-estimation
4+
status: resolved
5+
severity: high
6+
labels: correctness, soundness, bug, abstraction
7+
created: 2026-06-25
8+
updated: 2026-06-25
9+
related:
10+
- src/abstraction.cpp
11+
- benchmarks/validate_montecarlo.cpp
12+
---
13+
14+
## Summary
15+
The first sparse abstraction set the SINK (outside-grid, value 0) interval to
16+
`[1 - sumHi, 1 - sumLo]`, where sumLo/sumHi are the sums of the per-successor
17+
lower/upper bounds. Because each successor lower bound is a loose per-region
18+
minimum, `sumLo` is tiny, so `sinkHi = 1 - sumLo` ≈ 1. For pessimistic (robust)
19+
reachability nature then routes almost all mass to the value-0 sink, making the
20+
synthesized reach ≈ 0 even where the true reach ≈ 1.
21+
22+
## How it was found
23+
Monte-Carlo closed-loop validation (`benchmarks/validate_montecarlo.cpp`):
24+
synthesize the robust controller, simulate the continuous system under it, compare
25+
the empirical reach to the synthesized bounds. The empirical reach was ~0.99 from
26+
every start while the synthesized upper bound was ~0.01–0.76 — a gross
27+
under-estimate that exposed the loose sink. (Internal tests passed because dense
28+
and sparse builds shared the same wrong sink.)
29+
30+
## Fix
31+
Bound the outside-grid probability TIGHTLY as the complement of the whole-grid-box
32+
probability: `P(outside) ∈ [1 - gridHi, 1 - gridLo]`, where `[gridLo,gridHi]` is
33+
the transition bound to the entire state-space box (product of per-dimension
34+
in-grid probabilities). This is a valid and tight bound (P(outside)=1-P(in grid)).
35+
Applied in both `buildSparseReach1D` and `buildSparseReachND`.
36+
37+
## Verification of the fix
38+
- Unit suite still green (lossless pruning / sparse==dense / target=1 / O(N)).
39+
- Monte-Carlo: empirical reach >= synthesized robust lower bound for all tested
40+
start states, in BOTH a saturated case (value≈1) and a discriminating
41+
high-noise case (value≈0.32, empirical≈0.36). For a robust controller the real
42+
(benign) noise meets-or-exceeds the worst-case lower bound; empirical may exceed
43+
the robust upper (which bounds the worst case, not the actual).
44+
45+
## Classification
46+
`our-bug`, resolved. Notable as a soundness bug caught by end-to-end Monte-Carlo
47+
validation that internal differential tests missed (both builds shared the error) —
48+
motivates keeping the closed-loop validation in the verification toolbox.

issues/INDEX.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ See [`README.md`](README.md) for conventions and the literature-counterexample p
77
| [0001](0001-sorted-synthesis-tolerance-stopping.md) | v1 sorted synthesis uses tolerance stopping, not sound interval iteration | in-progress | medium | soundness, tool-v1 |
88
| [0002](0002-mec-naive-acceptance-rule.md) | MEC naive "staying-action-exists" acceptance rule is unsound | resolved | low | methodology, naive-strawman |
99
| [0003](0003-pessimistic-interval-nature-trap.md) | Pessimistic interval iteration doesn't converge on nature-confinable ECs | resolved | medium | soundness, robust-ec |
10-
| [0006](0006-dense-transition-matrix-oom.md) | Dense transition matrix causes memory overflow even on "small" cases | open | high | scalability, memory |
10+
| [0006](0006-dense-transition-matrix-oom.md) | Dense transition matrix causes memory overflow even on "small" cases | in-progress | high | scalability, memory |
11+
| [0007](0007-abstraction-sink-too-loose.md) | Abstraction sink bound too loose -> reach under-estimation (found by Monte-Carlo) | resolved | high | correctness, abstraction |
1112
| [0004](0004-ltlf-dfa-and-product.md) | Phase 2 — LTLf→DFA (done) + IMDP×DFA product + co-safe reachability | open | medium | enhancement, phase-2 |
1213
| [0005](0005-ltlf-dfa-state-explosion.md) | LTLf→DFA state explosion from syntactic normalization | resolved | high | correctness, bug |
1314

paper/IMPaCT-v2.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,21 @@ Format per entry: `date — feature | decision + rationale | algorithm | refs |
186186
Next: nonlinear dynamics (mixed-monotone/NLopt mean bounds) + drive an ARCH-COMP
187187
case through the sparse pipeline and verify the value end-to-end.
188188

189+
- **End-to-end verification vs the real continuous system (Monte-Carlo).**
190+
`benchmarks/validate_montecarlo.cpp`: build sparse IMDP → synthesize robust
191+
controller → extract policy (argmax of `omax` over `val.lower`) → simulate the
192+
continuous closed loop → check empirical reach vs synthesized bounds. This caught
193+
**ISSUE-0007** (sink interval too loose: `1 - sumLo` ≈ 1 let nature drain all mass
194+
to the value-0 sink → reach ≈ 0 vs true ≈ 1). Fixed: bound outside-grid prob
195+
tightly as the grid-box complement `[1-gridHi, 1-gridLo]` (both builders). Notable:
196+
internal differential tests missed it (dense & sparse shared the error) — the
197+
closed-loop check found it. After the fix: empirical reach ≥ robust lower bound for
198+
all start states, in a saturated case (≈1) AND a discriminating high-noise case
199+
(value≈0.32, empirical≈0.36). Interpretation recorded: a robust controller's
200+
guarantee is empirical ≥ lower; benign noise may exceed the (worst-case) upper.
201+
Also found a Monte-Carlo gotcha: validating an infinite-horizon reach needs a long
202+
sim horizon (truncation gave 0.73 vs true 1.0). Suite still 42/42, 54971 assertions.
203+
189204
<!-- add new dated entries above this line -->
190205

191206
---

src/abstraction.cpp

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ SparseReach buildSparseReach1D(const System1D& sys, double prune) {
5050
out.nnz = 0;
5151
out.model.assign(N + 2, {});
5252
out.targets.insert(TARGET);
53+
for (int k = 0; k <= M; ++k) out.actions.push_back({sys.ulb + k * sys.ueta});
5354

5455
auto cellLo = [&](int j) { return sys.xlb + j * sys.eta; };
5556
auto isTargetCell = [&](int j) {
@@ -88,12 +89,11 @@ SparseReach buildSparseReach1D(const System1D& sys, double prune) {
8889
if (b.hi > prune) row.push_back({j, b.lo, b.hi});
8990
}
9091

91-
// remaining mass (outside grid / pruned) -> SINK, with feasible bounds
92-
double sumLo = 0, sumHi = 0;
93-
for (const auto& iv : row) { sumLo += iv.lo; sumHi += iv.hi; }
94-
double sinkLo = std::max(0.0, 1.0 - sumHi);
95-
double sinkHi = std::min(1.0, 1.0 - sumLo);
96-
row.push_back({SINK, sinkLo, sinkHi});
92+
// mass leaving the grid -> SINK (value 0), bounded TIGHTLY as the
93+
// complement of the whole-grid-box probability (not 1 - sum of loose
94+
// per-cell lower bounds, which would let nature drain all mass to sink).
95+
Bound g = transitionInterval1D(muLo, muHi, sys.sigma, sys.xlb, sys.xub);
96+
row.push_back({SINK, std::max(0.0, 1.0 - g.hi), std::min(1.0, 1.0 - g.lo)});
9797

9898
out.nnz += (long long)row.size();
9999
out.model[i].push_back(std::move(row));
@@ -136,6 +136,7 @@ SparseReach buildSparseReachND(const SystemND& sys, double prune) {
136136

137137
SparseReach out; out.nCells = (int)N; out.nnz = 0;
138138
out.model.assign((size_t)N + 2, {}); out.targets.insert(TARGET);
139+
out.actions = actions;
139140

140141
auto cellLoDim = [&](int i, int j) { return sys.xlb[i] + j * sys.eta[i]; };
141142
auto isTargetMi = [&](const std::vector<int>& mi) {
@@ -188,9 +189,11 @@ SparseReach buildSparseReachND(const SystemND& sys, double prune) {
188189
if (i == dx) break;
189190
}
190191

191-
double sumLo = 0, sumHi = 0;
192-
for (const auto& iv : row) { sumLo += iv.lo; sumHi += iv.hi; }
193-
row.push_back({SINK, std::max(0.0, 1.0 - sumHi), std::min(1.0, 1.0 - sumLo)});
192+
// mass leaving the grid -> SINK, bounded tightly via the whole-grid-box
193+
// complement (product of per-dim in-grid probabilities).
194+
double gl = 1.0, gh = 1.0;
195+
for (int i = 0; i < dx; ++i) { Bound g = transitionInterval1D(muLo[i], muHi[i], sys.sigma[i], sys.xlb[i], sys.xub[i]); gl *= g.lo; gh *= g.hi; }
196+
row.push_back({SINK, std::max(0.0, 1.0 - gh), std::min(1.0, 1.0 - gl)});
194197
out.nnz += (long long)row.size();
195198
out.model[lin].push_back(std::move(row));
196199
}

src/abstraction.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ namespace abstraction {
6363
std::set<int> targets; // { N }
6464
int nCells;
6565
long long nnz; // total stored successor intervals (sparsity metric)
66+
// Input vector for each action index (same action set for every non-target
67+
// cell); lets a synthesized policy be simulated on the continuous system.
68+
std::vector<std::vector<double>> actions;
6669
};
6770

6871
// Build the sparse IMDP. `prune` drops successor cells whose upper bound <= prune

0 commit comments

Comments
 (0)