Skip to content

Commit 83d0418

Browse files
Kiguliclaude
andcommitted
Sparse interval-MDP abstraction (memory priority, ISSUE-0006)
Build the sparse solve::IMDPModel directly (only nonzero kernel-window successors), the same sparse representation as IntervalMDP.jl => synthesis + storage are O(nnz), not O(states^2). src/abstraction.{h,cpp}: interval transition-bound kernel transitionInterval1D (closed form: mass of N(mu,sigma^2) in [a,b] is unimodal in mu) + transitionIntervalBox (axis-decoupled product) + a 1-D sparse reach abstraction for affine diagonal-Gaussian systems (absorbing target/sink, feasible bounds). Verified (tests/unit/test_abstraction.cpp): kernel vs brute-force mean sampling; box == product; lossless pruning (sparse synthesis == dense-window synthesis); target cell = 1; O(N) sparsity (grow domain at fixed step/noise -> bounded nnz/cell). Scaling (benchmarks/sparse_scaling.cpp): 125k states ~255 MB + 0.42s vs dense ~250 GB. Honest caveat: O(N) holds with kernel-in-cells fixed (grow domain); refining eta at fixed sigma is a constant-factor saving (window ~ 6 sigma / eta). v1 dense path unchanged. Suite: 38/38 cases, 54375 assertions, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fee9da1 commit 83d0418

9 files changed

Lines changed: 387 additions & 11 deletions

File tree

benchmarks/RESULTS.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,28 @@ report before publication):
2222
> co-safe spec collapsed into separate reach-avoid targets. Phase 2 replaces this
2323
> with a real DFA product (no reduction).
2424
25-
## Baseline (v1, branch main) — TODO: fill via Docker/CI
25+
## v1 baseline note (ISSUE-0006)
26+
The v1 dense path OOMs even on the smallest shipped example: 2D-robot-RA builds a
27+
dense (state×input)×state matrix of 698103×1583 ≈ **8.84 GB** → killed under a 4 GB
28+
cap. Confirms the need for sparse storage before larger benchmarks.
29+
30+
## Sparse abstraction scaling (v2.0, `benchmarks/sparse_scaling.cpp`)
31+
1-D reach IMDP, domain grown with grid step (eta=0.1) and noise (sigma=0.3) FIXED, so
32+
the kernel spans a constant #cells ⇒ nnz is O(N) while a dense (min+max) matrix is
33+
O(N²). Synthesis = optimistic VI (eps=1e-6).
34+
35+
| domain | cells | nnz | nnz/cell | sparse (MB) | dense (MB) | synth (s) |
36+
|---|---|---|---|---|---|---|
37+
| 20 | 200 | 18,005 | 89 | 0.36 | 0.6 | 0.01 |
38+
| 100 | 1,000 | 99,980 | 100 | 2.0 | 16 | 0.00 |
39+
| 500 | 5,000 | 507,980 | 102 | 10.2 | 400 | 0.01 |
40+
| 2,500 | 25,000 | 2,547,980 | 102 | 51 | 10,000 | 0.08 |
41+
| 12,500 | 125,000 | 12,747,980 | 102 | **255** | **250,000** | 0.42 |
42+
43+
At 125k states the sparse model is ~255 MB and synthesizes in 0.42 s, where the dense
44+
matrix would be ~250 GB (infeasible). This is the IntervalMDP.jl-style memory win.
45+
46+
## Baseline (v1, branch main) — TODO: fill via Docker/CI on a tiny-enough model
2647
```
2748
# paste run_archcomp.py output here
2849
```

benchmarks/sparse_scaling.cpp

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Sparse-abstraction scaling demo (ISSUE-0006): grow the DOMAIN with the grid step
2+
// (eta) and noise (sigma) FIXED, so the Gaussian kernel spans a constant number of
3+
// cells. Then stored nonzeros are O(N) while a dense (min+max) matrix is O(N^2):
4+
// large state counts that would OOM densely run in megabytes.
5+
// (Refining eta with fixed sigma instead would widen the kernel-in-cells and is NOT
6+
// asymptotically sparse — the win is fixed kernel width, growing N.)
7+
//
8+
// Build: c++ -std=c++17 -O2 benchmarks/sparse_scaling.cpp \
9+
// src/abstraction.cpp src/solve.cpp src/omaximization.cpp src/graph_utils.cpp -o /tmp/scaling
10+
// Run: /tmp/scaling
11+
#include "../src/abstraction.h"
12+
#include "../src/solve.h"
13+
#include <cstdio>
14+
#include <chrono>
15+
16+
using namespace impact;
17+
18+
int main() {
19+
printf("%10s %10s %12s %10s %12s %12s %9s\n",
20+
"domain", "cells", "nnz", "nnz/cell", "sparse(MB)", "dense(MB)", "synth(s)");
21+
for (double half : {10.0, 50.0, 250.0, 1250.0, 6250.0}) { // grow domain, fixed eta+sigma
22+
abstraction::System1D s;
23+
s.a = 0.9; s.b = 1.0; s.sigma = 0.3;
24+
s.xlb = -half; s.xub = half; s.eta = 0.1; // fixed step => kernel ~ constant #cells
25+
s.ulb = -1; s.uub = 1; s.ueta = 1.0; // 3 inputs
26+
s.tlo = half - 2; s.thi = half;
27+
28+
auto ab = abstraction::buildSparseReach1D(s, 1e-7);
29+
auto t0 = std::chrono::steady_clock::now();
30+
auto r = solve::maxReachOptimistic(ab.model, ab.targets, 1e-6);
31+
double secs = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
32+
33+
double sparseMB = ab.nnz * (double)(sizeof(int) + 2 * sizeof(double)) / 1e6;
34+
double denseMB = (double)ab.nCells * ab.nCells * 2 * sizeof(double) / 1e6; // min+max N x N
35+
printf("%10.0f %10d %12lld %10.1f %12.2f %12.1f %9.2f\n",
36+
2 * half, ab.nCells, ab.nnz, (double)ab.nnz / (ab.nCells + 2),
37+
sparseMB, denseMB, secs);
38+
(void)r;
39+
}
40+
return 0;
41+
}

issues/0006-dense-transition-matrix-oom.md

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
id: ISSUE-0006
33
title: Dense transition matrix causes memory overflow even on "small" cases
4-
status: open
4+
status: in-progress
55
severity: high
66
labels: scalability, performance, tool-v1, memory
77
created: 2026-06-25
@@ -30,10 +30,19 @@ abstraction vectors computed (is/ss/ts/min*/max* h5 produced), then:
3030
- Confirms the plan's dense→sparse concern; products (IMDP×automaton, Phase 2/3)
3131
multiply state count and will hit this much sooner.
3232

33-
## Resolution / plan
34-
- Short term: generate goldens from a *genuinely tiny* model (few states/inputs)
35-
or raise the memory cap only where the host allows.
36-
- Core: **sparse transition representation** (CSR-style) for transition matrices
37-
and products — scheduled as the Phase 2/3 cross-cutting "sparse" task in the plan.
38-
This is the right place to also reduce the (state×input)×state blow-up.
39-
- Keep "very small case studies only" for local/CI runs until sparse lands.
33+
## Resolution / progress
34+
- **DONE — sparse synthesis path.** The v2.0 solver works on a sparse model
35+
(`solve::IMDPModel` = per-state lists of only nonzero successor intervals), so
36+
synthesis memory is O(nnz), not O(states²) — same model as IntervalMDP.jl.
37+
- **DONE — sparse abstraction (`src/abstraction.{h,cpp}`).** Builds the sparse IMDP
38+
directly for affine, axis-decoupled, diagonal-Gaussian systems (1-D end-to-end),
39+
storing only kernel-window successors. Verified: kernel vs brute-force; lossless
40+
pruning (sparse synthesis == dense-window synthesis); O(N) scaling
41+
(`benchmarks/sparse_scaling.cpp`): 125k states ≈ 255 MB + 0.42 s vs dense ≈ 250 GB.
42+
**Caveat:** the O(N) win holds with grid step + noise FIXED and the domain growing
43+
(constant kernel-in-cells). Refining eta at fixed sigma widens the kernel-in-cells
44+
(window ≈ 6σ/eta), giving a constant-factor (not asymptotic) saving — documented.
45+
- **TODO:** n-D (diagonal) and coupled dynamics (mixed-monotone / NLopt bounds, as v1);
46+
sparse for IMDP×automaton products; and either refactor v1 `IMDP.cpp` to sparse or
47+
drive ARCH benchmarks through the new sparse pipeline. v1's dense path is unchanged
48+
for now (still OOMs); use the sparse pipeline for large runs.

paper/IMPaCT-v2.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,20 @@ Format per entry: `date — feature | decision + rationale | algorithm | refs |
160160
addition to LTL. Phase 3 pipelines chosen from verified literature (Dutreix-Coogan
161161
Rabin+permanent-WC primary; Weininger game-reduction; Asadi qualitative).
162162

163+
- **Sparse abstraction (priority: memory / larger benchmarks, ISSUE-0006).**
164+
`src/abstraction.{h,cpp}`: builds the sparse `solve::IMDPModel` directly for affine,
165+
axis-decoupled, diagonal-Gaussian systems (storing only kernel-window successors) —
166+
same sparse model as IntervalMDP.jl, so memory is O(nnz). Verified: the interval
167+
kernel `transitionInterval1D` vs brute-force mean sampling; box == product of 1-D;
168+
lossless pruning (sparse synthesis == dense-window synthesis); target-cell = 1.
169+
Scaling (`benchmarks/sparse_scaling.cpp`, domain grown at fixed step/noise):
170+
**125,000 states ≈ 255 MB + 0.42 s** vs a dense (min+max) matrix ≈ **250 GB** — the
171+
large runs that v1's dense path OOMs on (8.84 GB on the *smallest* 2D-robot case).
172+
Honest caveat recorded: O(N) holds for fixed kernel-in-cells (grow domain); refining
173+
eta at fixed sigma is only a constant-factor saving. Suite: 38/38, 54375 assertions.
174+
Next: n-D/coupled abstraction (mixed-monotone/NLopt bounds), sparse products, and
175+
driving ARCH benchmarks through the sparse pipeline. Branch pushed to origin.
176+
163177
<!-- add new dated entries above this line -->
164178

165179
---

src/abstraction.cpp

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#include "abstraction.h"
2+
3+
#include <cmath>
4+
#include <algorithm>
5+
6+
namespace impact {
7+
namespace abstraction {
8+
9+
namespace { constexpr double INV_SQRT2 = 0.70710678118654752440; }
10+
11+
double normalCdf(double z) { return 0.5 * std::erfc(-z * INV_SQRT2); }
12+
13+
double massInInterval(double mu, double sigma, double a, double b) {
14+
return normalCdf((b - mu) / sigma) - normalCdf((a - mu) / sigma);
15+
}
16+
17+
Bound transitionInterval1D(double muLo, double muHi, double sigma, double a, double b) {
18+
if (muHi < muLo) std::swap(muLo, muHi);
19+
const double center = 0.5 * (a + b);
20+
const double muMax = std::min(std::max(center, muLo), muHi); // closest to centre
21+
double hi = massInInterval(muMax, sigma, a, b);
22+
double lo = std::min(massInInterval(muLo, sigma, a, b),
23+
massInInterval(muHi, sigma, a, b)); // farther endpoint
24+
lo = std::max(0.0, std::min(1.0, lo));
25+
hi = std::max(0.0, std::min(1.0, hi));
26+
if (lo > hi) lo = hi;
27+
return {lo, hi};
28+
}
29+
30+
Bound transitionIntervalBox(const std::vector<double>& muLo,
31+
const std::vector<double>& muHi,
32+
const std::vector<double>& sigma,
33+
const std::vector<double>& aLo,
34+
const std::vector<double>& aHi) {
35+
double lo = 1.0, hi = 1.0;
36+
for (size_t d = 0; d < sigma.size(); ++d) {
37+
Bound b = transitionInterval1D(muLo[d], muHi[d], sigma[d], aLo[d], aHi[d]);
38+
lo *= b.lo; hi *= b.hi;
39+
}
40+
return {lo, hi};
41+
}
42+
43+
SparseReach buildSparseReach1D(const System1D& sys, double prune) {
44+
const int N = std::max(1, (int)std::llround((sys.xub - sys.xlb) / sys.eta));
45+
const int TARGET = N, SINK = N + 1;
46+
const int M = std::max(0, (int)std::llround((sys.uub - sys.ulb) / sys.ueta)); // input grid points = M+1
47+
48+
SparseReach out;
49+
out.nCells = N;
50+
out.nnz = 0;
51+
out.model.assign(N + 2, {});
52+
out.targets.insert(TARGET);
53+
54+
auto cellLo = [&](int j) { return sys.xlb + j * sys.eta; };
55+
auto isTargetCell = [&](int j) {
56+
double lo = cellLo(j), hi = lo + sys.eta;
57+
return lo >= sys.tlo - 1e-12 && hi <= sys.thi + 1e-12;
58+
};
59+
60+
const double W = 6.0 * sys.sigma; // truncation window (~all mass within 6 sigma)
61+
62+
for (int i = 0; i < N; ++i) {
63+
if (isTargetCell(i)) { // absorbing target cell
64+
out.model[i].push_back({ {TARGET, 1.0, 1.0} });
65+
out.nnz += 1;
66+
continue;
67+
}
68+
const double xl = cellLo(i), xr = xl + sys.eta;
69+
for (int k = 0; k <= M; ++k) {
70+
const double u = sys.ulb + k * sys.ueta;
71+
// affine mean range over the source cell (handle sign of a)
72+
double m1 = sys.a * xl + sys.b * u, m2 = sys.a * xr + sys.b * u;
73+
double muLo = std::min(m1, m2), muHi = std::max(m1, m2);
74+
75+
solve::ActionDist row;
76+
// mass into the target region [tlo,thi]
77+
Bound bT = transitionInterval1D(muLo, muHi, sys.sigma, sys.tlo, sys.thi);
78+
if (bT.hi > prune) row.push_back({TARGET, bT.lo, bT.hi});
79+
80+
// mass into non-target grid cells within the support window
81+
int jmin = (int)std::floor((muLo - W - sys.xlb) / sys.eta);
82+
int jmax = (int)std::floor((muHi + W - sys.xlb) / sys.eta);
83+
jmin = std::max(0, jmin); jmax = std::min(N - 1, jmax);
84+
for (int j = jmin; j <= jmax; ++j) {
85+
if (isTargetCell(j)) continue; // counted in the TARGET aggregate
86+
double cl = cellLo(j), cr = cl + sys.eta;
87+
Bound b = transitionInterval1D(muLo, muHi, sys.sigma, cl, cr);
88+
if (b.hi > prune) row.push_back({j, b.lo, b.hi});
89+
}
90+
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});
97+
98+
out.nnz += (long long)row.size();
99+
out.model[i].push_back(std::move(row));
100+
}
101+
}
102+
out.model[TARGET].push_back({ {TARGET, 1.0, 1.0} }); // absorbing
103+
out.model[SINK].push_back({ {SINK, 1.0, 1.0} });
104+
return out;
105+
}
106+
107+
} // namespace abstraction
108+
} // namespace impact

src/abstraction.h

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#ifndef IMPACT_ABSTRACTION_H
2+
#define IMPACT_ABSTRACTION_H
3+
4+
// ============================================================================
5+
// Sparse interval-MDP abstraction of continuous-state stochastic systems
6+
// (Phase: sparse / scalability — ISSUE-0006). Produces a SPARSE solve::IMDPModel
7+
// (per-state lists of only the nonzero successor probability intervals), the same
8+
// memory model as IntervalMDP.jl, so memory is O(nnz) not O(states^2).
9+
//
10+
// This header provides the correctness-critical kernel: the transition-probability
11+
// INTERVAL [lo,hi] from a SOURCE CELL (a box of states) to a TARGET box, under a
12+
// Gaussian noise model. For affine, axis-decoupled dynamics the per-dimension mean
13+
// ranges are independent, so the box probability is the product of 1-D factors and
14+
// its bounds are the product of the per-dimension bounds.
15+
//
16+
// Refs: interval-MDP abstraction of stochastic systems — Lahijanian-Andersson-Belta
17+
// (IEEE TAC 2015); FAUST^2 (TACAS 2015); AMYTISS (CAV 2020); IntervalMDP.jl (arXiv
18+
// 2401.04068) for the sparse representation. Verified against brute-force numerics
19+
// in tests/unit/test_abstraction.cpp.
20+
// ============================================================================
21+
22+
#include <vector>
23+
#include "solve.h"
24+
25+
namespace impact {
26+
namespace abstraction {
27+
28+
// Standard normal CDF.
29+
double normalCdf(double z);
30+
31+
// Probability mass of N(mu, sigma^2) in the interval [a, b].
32+
double massInInterval(double mu, double sigma, double a, double b);
33+
34+
struct Bound { double lo; double hi; };
35+
36+
// The 1-D transition probability mass of N(mu, sigma^2) in [a,b], minimized and
37+
// maximized over the mean mu ranging in [muLo, muHi]. Closed form: the mass is
38+
// unimodal in mu with peak at the box centre (a+b)/2 — so the max is at mu
39+
// clamped to the centre and the min at the farther endpoint. Requires muLo<=muHi,
40+
// a<=b, sigma>0.
41+
Bound transitionInterval1D(double muLo, double muHi, double sigma, double a, double b);
42+
43+
// Axis-decoupled n-D box bound = product of per-dimension 1-D bounds.
44+
Bound transitionIntervalBox(const std::vector<double>& muLo,
45+
const std::vector<double>& muHi,
46+
const std::vector<double>& sigma,
47+
const std::vector<double>& aLo,
48+
const std::vector<double>& aHi);
49+
50+
// --- 1-D sparse reach abstraction (the first end-to-end, verifiable case) -----
51+
// System: x' = a*x + b*u + N(0, sigma^2), x on grid [xlb,xub] step eta, inputs
52+
// on grid [ulb,uub] step ueta. Target region [tlo,thi] (absorbing). Mass leaving
53+
// the grid (or into the target) is handled by absorbing TARGET / SINK states.
54+
struct System1D {
55+
double a, b, sigma;
56+
double xlb, xub, eta;
57+
double ulb, uub, ueta;
58+
double tlo, thi;
59+
};
60+
61+
struct SparseReach {
62+
solve::IMDPModel model; // states: 0..N-1 cells, N = TARGET, N+1 = SINK
63+
std::set<int> targets; // { N }
64+
int nCells;
65+
long long nnz; // total stored successor intervals (sparsity metric)
66+
};
67+
68+
// Build the sparse IMDP. `prune` drops successor cells whose upper bound <= prune
69+
// (mass folded into SINK); prune=0 keeps every cell (a dense-equivalent build).
70+
SparseReach buildSparseReach1D(const System1D& sys, double prune);
71+
72+
} // namespace abstraction
73+
} // namespace impact
74+
75+
#endif // IMPACT_ABSTRACTION_H

tests/Makefile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@ IMPL_SRCS := ../src/omaximization.cpp \
2222
../src/graph_utils.cpp \
2323
../src/solve.cpp \
2424
../src/ltl.cpp \
25-
../src/product.cpp
25+
../src/product.cpp \
26+
../src/abstraction.cpp
2627

2728
TEST_SRCS := unit/test_main.cpp \
2829
unit/test_omaximization.cpp \
2930
unit/test_graph.cpp \
3031
unit/test_interval_iteration.cpp \
3132
unit/test_ltl_dfa.cpp \
32-
unit/test_product.cpp
33+
unit/test_product.cpp \
34+
unit/test_abstraction.cpp
3335

3436
BIN := run_tests
3537

tests/contracts/contracts.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,7 @@
3939
// Phase 2 part 3 — IMDP x DFA product + co-safe reachability.
4040
#include "../../src/product.h"
4141

42+
// Sparse interval-MDP abstraction of continuous systems (scalability / ISSUE-0006).
43+
#include "../../src/abstraction.h"
44+
4245
#endif // IMPACT_TEST_CONTRACTS_H

0 commit comments

Comments
 (0)