3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-08-14 09:45:36 +00:00

Live-state traversal and interval-refinement product for seq_monadic, minus the postponed prunes (#10455)

Rebases the `seq-live-states` work onto current `master`, drops the two
additions that are being postponed, and fixes a live-state ordering
regression
that the rebase exposed.

### What is in

1. **`63ad8f944` Add lazy regex live-state traversal** — moves
live-state
   computation out of `seq_monadic`/`seq_regex` into a shared
`seq::live_states` that interns derivative states, keeps a predecessor
graph, and marks liveness by backward propagation from nullable states.
The
reachable set is exposed as a lazy iterator instead of being
materialised.
2. **`42e82f3e8` Consume live states lazily in `dfs_atoms` (#10381)** —
makes
the only consumer actually lazy. It previously drained the iterator into
a
`ptr_vector` before exploring anything, so a root with an exponential
live
   set was paid for in full even when a witness appeared immediately.
3. **`d67bbfab6` Build the `seq_monadic` product from interval
refinement
(#10384)** — replaces the cartesian product over cofactor guards with a
   cursor merge over per-state ordered-interval forms.
4. **`e1b3dc9af`** — reconciles the state display with upstream #10431,
which
   started reading `m_live_cache` after this work had replaced it. Adds
   `live_states::num_states()`.
5. **`1fea78d5d` Emit the root first when enumerating live states** —
new here,
   see below.

### What is out

* **The Bloom-filter / `atom_sig` prune** (`f96e37753` on
`seq-live-states`) is
  dropped. It keys on syntactic features and is a maintenance liability.
* **The approximate semilinear length abstraction** is dropped. It is
worth
revisiting, but with a real semilinear representation for `∃k. k·SL(R)`
rather than the gcd approximation, and after surveying what the Parikh
solver
  already provides.

Neither is on `master`; nothing is being reverted from upstream.

### The ordering fix

Evaluating commits 1–4 against `master` initially looked bad: +2/−5
decided and
**+23.3%** time on commonly decided benchmarks. Bisecting over
per-commit
builds put four of the five losses on commit 1, and the cause is an
enumeration
order change.

`m_live_frontier` is appended in the order liveness is *discovered*,
which is
bottom-up — `mark_live` records a nullable state and then propagates
backwards
through predecessors. A root is rarely nullable itself, so it becomes
live only
by back-propagation and lands **last**. The eager traversal that was
removed
emitted states in interning order, i.e. **root first**. `dfs_atoms` uses
that
sequence as a search order and stops at the first `l_true`, so the root
moving
to the back reordered every split search, and four `split_membership`
benchmarks lost their witness-first branch.

`1fea78d5d` restores the root to index 0 and leaves the rest of the
frontier
order alone. This is deliberately narrower than restoring full interning
order,
which was prototyped in #10381 and rejected — that fixed the same
regressions
but lost `medium_sat_0003/0026/0036/0052` and cost 31% on
light-antimirov,
because per-state forward liveness is quadratic where backward
propagation is
linear. Moving only the root keeps both.

The remap is well defined: every state in a search is reachable from the
root,
so liveness reaches the root within the same `expand()` step, and
whenever the
frontier is non-empty at an `ensure()` boundary the root is already in
it. The
element count is unchanged, so `ensure`'s bound still holds.

### Results

ClemensRegex, 1178 benchmarks, 10s timeout,
`smt.seq.regex_monadic=true`,
against `master`:

| | commits 1–4 | + ordering fix |
|---|---|---|
| decided | +2 / −5 | **+6 / −1** |
| time, 1072 commonly decided | +23.3% | **−22.5%** (197.3s → 152.9s) |

No sat/unsat contradictions. 23 benchmarks ≥2x faster, 7 ≥2x slower.

| family | n | both | base | new | Δ |
|---|---|---|---|---|---|
| `Automatic/generated` | 80 | 17 | 29.6s | 11.4s | −61.3% |
| `Automatic/generated_easy` | 20 | 11 | 14.0s | 3.9s | −72.4% |
| `Automatic/generated_tiny` | 30 | 30 | 6.6s | 5.2s | −21.4% |
| `Manual/with_lengths` | 12 | 5 | 7.0s | 7.1s | +0.7% |
| `Manual/without_lengths` | 120 | 113 | 29.9s | 20.0s | −33.2% |
| `QF_S_extracted_with_equations` | 52 | 32 | 10.2s | 9.5s | −6.9% |
| `QF_S_extracted_without_equations` | 864 | 864 | 100.0s | 95.9s |
−4.1% |

Newly decided: `medium_sat_0026/0036/0038`, `easy_unsat_0007/0010/0011`.
Largest single gains are `medium_unsat_0027` (7.4s → 1.3s),
`medium_unsat_0058`
(6.6s → 1.4s) and `noodler_killer_11_symmetric_nth` (3.8s → 0.15s).

MargusRegex (298) is unchanged — 297/298 decided by both sides, no
verdict
differences. That corpus is startup-dominated at ~25ms per benchmark and
serves
as a correctness net rather than a performance signal.

`test-z3 seq_monadic` and `test-z3 seq_rewriter` pass.

### What the interval-refinement product contributes on its own

Since #10384 is the largest diff here, it was re-measured in isolation:
two
builds differing only in `d67bbfab6`, both with the ordering fix, so
nothing is
confounded. +2 decided, 0 lost, 0 contradictions, and the benefit scales
with
how solver-bound the goal is:

| baseline cost | n | base | new | speedup |
|---|---|---|---|---|
| <100 ms | 821 | 61.5s | 63.2s | 1.00x |
| 100–250 ms | 209 | 26.6s | 24.8s | 1.07x |
| 250 ms–1 s | 33 | 12.2s | 10.3s | 1.19x |
| 1–5 s | 11 | 25.5s | 16.0s | 1.59x |
| >5 s | 3 | 19.5s | 3.1s | 6.35x |
| **solver-bound (≥250 ms)** | **47** | **57.3s** | **29.4s** |
**1.95x** |

The +2.8% on sub-100ms files is measurement noise: median-of-3 repeat
runs over
a 150-file sample give −0.1%, so the per-state interval cache costs
nothing on
cheap goals.

Split by verdict it is sharper still — the refinement is an **unsat
accelerator and exactly neutral on sat**, which follows from the
mechanism:
unsat has to exhaust the product state space so cell enumeration is the
entire
cost, while sat short-circuits at the first witness.

| | n | base | new | speedup |
|---|---|---|---|---|
| sat | 903 | 89.3s | 89.3s | 1.00x |
| unsat | 174 | 56.1s | 28.1s | 1.99x |
| unsat, ≥250 ms | 24 | 43.0s | 15.1s | **2.84x** |

This corroborates the 2.05x/2.36x reported in #10384, and locates it:
~1.0x on
three quarters of the corpus, 2.8x on the two dozen files that dominate
runtime.

### Known regression

`split_membership_medium_unsat_0047` (1.0s → timeout) is not an ordering
problem; it regressed with commit 2 and the ordering fix does not touch
it.
`master` hits the live-state cap before exploring anything and hands
over to
the legacy fallback in 0.7s, which then decides it in three further
monadic
checks. The lazy loop instead explores the truncated frontier first, and
those
explorations build very large derivative terms — 96k cofactor calls take
20s
against 165k in 0.7s on `master`, at 82MB versus 18MB.

The node budget does not catch it because the cost is in term size, not
node
count. Bounding the speculative work by budget units was tried and
rejected: it
did not recover 0047 and it lost `medium_sat_0026`. Charging the budget
by term
size would likely fix it, but that is a broader change than belongs
here.

### Follow-up: the budget is now miscalibrated

Worth flagging because it makes the numbers above an understatement. The
DFS
budget is a hard 200,000 pops, calibrated when each pop was ~8x more
expensive.
`split_membership_easy_sat_0019` is the only ≥2x slowdown attributable
to the
interval product, and it is entirely this effect: at 200k the merge
build bails
on budget 25 times and falls back to legacy (3.6s, against 1.8s for the
cartesian build), while at 1,000,000 it returns in **0.17s after 131
cofactor
calls** with no bails. #10384 measured +5/+6 decided at the raised
budget. That
is a policy change affecting all users, so it belongs in its own PR.

---------

Co-authored-by: Nikolaj Bjorner <nbjorner@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: veanes <veanes@microsoft.com>
Copilot-Session: 2bcccba7-ac7d-476e-8dfe-718a21d37554
Copilot-Session: a2ce3573-4e15-4a4a-afb5-21e3cb04e4a2
This commit is contained in:
Margus Veanes 2026-08-07 21:39:20 -07:00 committed by GitHub
parent 4e0ec5d01b
commit 9866fee194
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 640 additions and 248 deletions

View file

@ -48,6 +48,7 @@ z3_add_component(rewriter
seq_range_predicate.cpp
seq_rewriter.cpp
seq_regex_bisim.cpp
seq_regex_live.cpp
seq_skolem.cpp
term_enumeration.cpp
th_rewriter.cpp

View file

@ -39,7 +39,8 @@ namespace seq {
m_br(m),
m_re(re),
m_trail(m),
m_cofactor_cache(m),
m_brz_cofactor_cache(m),
m_ant_cofactor_cache(m),
m_ele(m),
m_path_expr(m) {
m_br.set_flat_and_or(false);
@ -1572,15 +1573,18 @@ namespace seq {
}
expr_ref_pair_vector const &derive::get_cached_cofactors(transition_mode mode, expr *r) {
cofactor_cache& cache =
mode == transition_mode::light_antimirov_tm ?
m_ant_cofactor_cache : m_brz_cofactor_cache;
expr_ref_pair_vector *v = nullptr;
if (m_cofactor_cache.find(r, v))
if (cache.find(r, v))
return *v;
v = alloc(expr_ref_pair_vector, m);
if (mode == transition_mode::light_antimirov_tm)
light_ant_derivative_cofactors(r, *v);
else
derivative_cofactors(r, *v);
m_cofactor_cache.insert(r, v); // takes ownership of v and pins the key r
cache.insert(r, v); // takes ownership of v and pins the key r
return *v;
}
@ -1659,4 +1663,3 @@ namespace seq {
}
}

View file

@ -95,7 +95,8 @@ namespace seq {
obj_pair_map<expr, expr, expr*> m_atop_cache, m_btop_cache; // post-simplify cache
expr_ref_vector m_trail; // pin cached results
cofactor_cache m_cofactor_cache;
cofactor_cache m_brz_cofactor_cache;
cofactor_cache m_ant_cofactor_cache;
// Op cache for ITE-hoisting operations (union, inter, concat, complement)
// Path-aware caches: key is (a, b, path_expr) for binary ops, (a, path_expr) for complement
@ -276,7 +277,8 @@ namespace seq {
expr_ref_pair_vector const &get_cached_cofactors(transition_mode mode, expr *r);
void maybe_reset_cached_cofactors(unsigned cap) {
m_cofactor_cache.maybe_reset(cap);
m_brz_cofactor_cache.maybe_reset(cap);
m_ant_cofactor_cache.maybe_reset(cap);
}
/**

View file

@ -51,6 +51,7 @@ Author:
#include "ast/rewriter/seq_monadic.h"
#include "ast/rewriter/guard_set.h"
#include "ast/rewriter/seq_range_collapse.h"
#include <set>
#include <vector>
#include <map>
@ -131,74 +132,91 @@ expr_ref_pair_vector const& seq_monadic::derivative_cofactors(expr* r) {
return m_rw.get_derive().get_cached_cofactors(m_config.m_mode, r);
}
bool seq_monadic::live_states(expr* R, expr_ref_vector& out) {
obj_map<expr, unsigned> id;
expr_ref_vector states(m);
vector<svector<unsigned>> succ;
bool_vector maybe_null;
auto intern = [&](expr* s) -> unsigned {
unsigned k;
if (id.find(s, k)) return k;
k = states.size();
id.insert(s, k);
states.push_back(s);
succ.push_back(svector<unsigned>());
maybe_null.push_back(nullable(s) != l_false); // unknown nullability => keep (conservative)
return k;
};
intern(R);
const unsigned STATE_CAP = 1u << 12;
for (unsigned i = 0; i < states.size(); ++i) {
if (states.size() > STATE_CAP) {
m_stats.inc_bail(bail_reason::state_cap);
return false;
}
if (!m.inc()) {
m_stats.inc_bail(bail_reason::resource);
return false;
}
expr_ref_pair_vector const& cof = derivative_cofactors(states.get(i));
for (auto const& [g, t] : cof) {
if (re().is_empty(t)) continue;
unsigned k = intern(t); // MUST precede succ[i] indexing: intern may
succ[i].push_back(k); // grow (realloc) succ, invalidating succ[i]&
}
}
unsigned n = states.size();
bool_vector live;
live.resize(n, false);
for (unsigned i = 0; i < n; ++i)
live[i] = maybe_null[i];
for (bool ch = true; ch; ) {
ch = false;
for (unsigned i = 0; i < n; ++i)
if (!live[i])
for (unsigned j : succ[i])
if (live[j]) { live[i] = true; ch = true; break; }
}
for (unsigned i = 0; i < n; ++i)
if (live[i]) { out.push_back(states.get(i)); m_pin.push_back(states.get(i)); }
return true;
void seq_monadic::reset_ivl_cache() {
for (auto& kv : m_ivl_cache)
dealloc(kv.m_value);
m_ivl_cache.reset();
m_ivl_pin.reset();
}
expr_ref_vector const* seq_monadic::live_states_cached(expr* R) {
expr_ref_vector* v = nullptr;
if (m_live_cache.find(R, v))
return v; // may be null: previously gave up on R
v = alloc(expr_ref_vector, m);
if (!live_states(R, *v)) {
dealloc(v);
v = nullptr;
}
m_pin.push_back(R); // keep the key alive for the cache's lifetime
m_live_cache.insert(R, v);
return v;
}
// Canonical interval form of r's derivative: the cofactor guards, translated into the
// range algebra, refined into a sorted list of disjoint ranges, each labelled with the
// targets reachable on it. Adjacent ranges with identical target sets are merged, so the
// result is the minimal ordered-ITE ("t-regex") representation of the transition relation.
// Returns null when some guard falls outside the range algebra.
seq_monadic::ivl_list const* seq_monadic::interval_cofactors(expr* r, expr* v0) {
ivl_list* res = nullptr;
if (m_ivl_cache.find(r, res))
return res && res->ok ? res : nullptr;
void seq_monadic::reset_live_cache() {
for (auto const& [k, v] : m_live_cache)
dealloc(v);
m_live_cache.reset();
unsigned max_char = u().max_char();
res = alloc(ivl_list);
m_ivl_cache.insert(r, res);
m_ivl_pin.push_back(r);
// (lo, hi, target) triples, plus the boundary set of this state's own partition.
struct tr_t { unsigned lo, hi; expr* t; };
svector<tr_t> tr;
svector<unsigned> bounds;
bounds.push_back(0);
for (auto const& [g, t] : derivative_cofactors(r)) {
if (re().is_empty(t))
continue;
seq::range_predicate* p = nullptr;
if (!m_rp_cache.find(g, p)) {
p = m_rp_cache.fresh(max_char);
if (!seq::guard_to_range_predicate(u(), v0, g, *p)) {
m_rp_cache.insert(g, nullptr);
res->ok = false;
return nullptr;
}
m_rp_cache.insert(g, p);
}
else if (!p) {
res->ok = false;
return nullptr;
}
m_ivl_pin.push_back(t);
for (auto const& rg : p->ranges()) {
tr.push_back({ rg.first, rg.second, t });
bounds.push_back(rg.first);
if (rg.second < max_char)
bounds.push_back(rg.second + 1);
}
}
if (tr.empty())
return res; // dead state: no outgoing transition
std::sort(bounds.begin(), bounds.end());
bounds.shrink((unsigned)(std::unique(bounds.begin(), bounds.end()) - bounds.begin()));
ptr_vector<expr> hits;
for (unsigned bi = 0; bi < bounds.size(); ++bi) {
unsigned lo = bounds[bi];
unsigned hi = bi + 1 < bounds.size() ? bounds[bi + 1] - 1 : max_char;
hits.reset();
for (auto const& e : tr)
if (e.lo <= lo && lo <= e.hi)
hits.push_back(e.t);
if (hits.empty())
continue; // gap: no transition on this range
// extend the previous range when it carries exactly the same target set
if (!res->ranges.empty()) {
ivl_range& prev = res->ranges.back();
if (prev.hi + 1 == lo && prev.count == hits.size()) {
bool same = true;
for (unsigned k = 0; k < hits.size() && same; ++k)
same = res->targets[prev.first + k] == hits[k];
if (same) {
prev.hi = hi;
continue;
}
}
}
res->ranges.push_back({ lo, hi, res->targets.size(), hits.size() });
res->targets.append(hits.size(), hits.data());
}
return res;
}
lbool seq_monadic::product_nonempty(svector<component> const& comps, expr_ref* witness_word) {
@ -290,6 +308,92 @@ lbool seq_monadic::product_nonempty(svector<component> const& comps, expr_ref* w
branches.resize(n);
key st_key;
bool bail = false;
// ---- interval-refinement ("t-regex merge") product --------------------------
// Over the character sort every cofactor guard denotes a union of ranges, so each
// component's derivative has a canonical ordered-interval ("t-regex") form, cached
// per state by interval_cofactors. The joint transitions are then exactly the cells
// of the common refinement of those n interval lists, obtained by a cursor merge in
// O(sum_i intervals_i) -- whereas the cartesian enumeration below tries
// prod_i(k_i) combinations, almost all of which are pruned as empty.
bool const sweep_ok = u().is_char(m_elem_sort);
unsigned const max_char = sweep_ok ? u().max_char() : 0;
svector<ivl_list const*> sw_lists;
svector<unsigned> sw_cur, sw_odo;
sw_lists.resize(n);
sw_cur.resize(n);
sw_odo.resize(n);
// Returns false when some guard falls outside the range algebra, in which case the
// caller falls back to the cartesian enumeration for this product state.
auto sweep = [&]() -> bool {
for (unsigned i = 0; i < n; ++i) {
sw_lists[i] = interval_cofactors(st[i], var0);
if (!sw_lists[i])
return false;
if (sw_lists[i]->ranges.empty())
return true; // component is stuck: no joint transition
sw_cur[i] = 0;
}
uint64_t b = 0;
while (b <= max_char) {
uint64_t next = (uint64_t)max_char + 1;
bool covered = true, done = false;
for (unsigned i = 0; i < n; ++i) {
auto const& rs = sw_lists[i]->ranges;
unsigned& c = sw_cur[i];
while (c < rs.size() && rs[c].hi < b)
++c;
if (c == rs.size()) { // this component has no transition left
done = true;
break;
}
if (rs[c].lo > b) { // gap in this component: skip ahead
covered = false;
next = std::min(next, (uint64_t)rs[c].lo);
}
else
next = std::min(next, (uint64_t)rs[c].hi + 1);
}
if (done)
break;
if (covered) {
// Emit every combination of the targets active on this cell. The modes
// whose cofactors partition the domain give exactly one target per
// component; the antimirov-style modes may give several.
for (unsigned i = 0; i < n; ++i)
sw_odo[i] = 0;
while (true) {
for (unsigned i = 0; i < n; ++i) {
auto const& r = sw_lists[i]->ranges[sw_cur[i]];
cur[i] = sw_lists[i]->targets[r.first + sw_odo[i]];
}
key const& ck = fill_key(cur);
if (visited.find(ck) == visited.end()) {
visited.insert(ck);
if (witness_word) {
expr* e = u().mk_char((unsigned)b);
m_pin.push_back(e);
parent[ck] = { st_key, e };
}
for (unsigned j = 0; j < n; ++j)
work.push_back(cur[j]);
}
unsigned i = n;
while (i-- > 0) {
if (++sw_odo[i] < sw_lists[i]->ranges[sw_cur[i]].count)
break;
sw_odo[i] = 0;
}
if (i == UINT_MAX)
break; // odometer wrapped: cell exhausted
}
}
b = next;
}
return true;
};
std::function<void(unsigned, guard_set const&)> rec =
[&](unsigned i, guard_set const& acc) {
if (bail) return;
@ -352,13 +456,17 @@ lbool seq_monadic::product_nonempty(svector<component> const& comps, expr_ref* w
return l_undef;
}
if (witness_word)
st_key = fill_key(st);
if (sweep_ok && sweep())
continue;
for (unsigned i = 0; i < n; ++i)
branches[i] = &derivative_cofactors(st[i]);
// joint transitions = cartesian product of the branches with the guards
// conjoined; prune as soon as the accumulated guard is empty, bail on unknown.
if (witness_word)
st_key = fill_key(st);
guard_set top(m, u(), m_elem_sort, var0, &m_rp_cache);
rec(0, top);
if (bail)
@ -417,7 +525,7 @@ void seq_monadic::reset_search() {
m_der_cache.reset();
m_nullable_cache.reset();
m_undef_vars = 0;
reset_live_cache();
m_live_states.reset();
}
bool seq_monadic::prepare(membership_vec const& memberships) {
@ -546,23 +654,13 @@ lbool seq_monadic::dfs_atoms(unsigned mi, unsigned i, expr* R) {
// A variable: the last atom is a plain membership in R, otherwise the variable drives
// the derivative automaton from R to some live state q, which splits the search.
bool last_atom = (i + 1 == atoms.size());
ptr_vector<expr> targets;
if (last_atom)
targets.push_back(nullptr);
else {
expr_ref_vector const* Q = live_states_cached(R);
if (!Q)
return l_undef;
for (expr* q : *Q)
targets.push_back(q);
}
unsigned vi = var_index(a.var.get());
uint64_t pos = (static_cast<uint64_t>(mi) << 32) | i;
uint64_t last = 0;
bool finalize = m_last_occ.find(a.var.get(), last) && last == pos;
bool any_undef = false;
for (expr* target : targets) {
// Explores one split target; the caller stops at the first l_true.
auto explore = [&](expr* target) -> lbool {
m_groups[vi].push_back(component{ a.var.get(), R, target });
// The group's emptiness test has to be run at some point anyway; running it as
// soon as the group is complete (or as soon as it holds several components, where
@ -583,6 +681,19 @@ lbool seq_monadic::dfs_atoms(unsigned mi, unsigned i, expr* R) {
--m_undef_vars;
}
m_groups[vi].pop_back();
return r;
};
if (last_atom)
return explore(nullptr);
// The live states are consumed as they are produced, so a satisfying branch under an
// early state means the rest of the reachable set is never expanded. That is what
// makes a root with an exponential live set tractable when a witness is found early.
bool any_undef = false;
auto live = m_live_states.reachable_live(R);
for (expr* q : live) {
lbool r = explore(q);
if (r == l_true)
return l_true;
if (r == l_undef) {
@ -591,6 +702,14 @@ lbool seq_monadic::dfs_atoms(unsigned mi, unsigned i, expr* R) {
any_undef = true;
}
}
// Short of the full reachable set the unexplored split states could still hold a
// solution, so the l_false the loop would otherwise report is not justified.
if (live.failed()) {
m_stats.inc_bail(
live.failure_reason() == seq::live_states::failure::state_cap ?
bail_reason::state_cap : bail_reason::resource);
return l_undef;
}
return any_undef ? l_undef : l_false;
}
@ -600,6 +719,7 @@ lbool seq_monadic::decide(membership_vec const& memberships) {
reset_search(); // clear the caches before dropping the
m_pin.reset(); // pins that keep their keys alive
m_rp_cache.maybe_reset(1u << 16);
reset_ivl_cache();
m_rw.get_derive().maybe_reset_cached_cofactors(1u << 16);
m_budget = 200000;
m_giveup = false;
@ -835,7 +955,7 @@ std::ostream& seq_monadic::display(std::ostream& out) const {
<< " :group-cache-size " << m_group_cache.size() << "\n"
<< " :derivative-cache-size " << m_der_cache.size() << "\n"
<< " :nullable-cache-size " << m_nullable_cache.size() << "\n"
<< " :live-cache-size " << m_live_cache.size() << "\n"
<< " :live-cache-size " << m_live_states.num_states() << "\n"
<< " :pinned-expressions " << m_pin.size() << ")\n";
out << " :statistics\n"

View file

@ -63,6 +63,7 @@ Author:
#include "ast/rewriter/seq_rewriter.h"
#include "ast/rewriter/seq_range_predicate.h"
#include "ast/rewriter/seq_regex_live.h"
#include "ast/rewriter/guard_set.h"
#include "ast/rewriter/th_rewriter.h"
#include "util/lbool.h"
@ -113,6 +114,20 @@ class seq_monadic {
statistics m_stats;
obj_map<expr, expr*> m_model; // last extracted model (var -> witness); see get_model()
guard_set::cache m_rp_cache; // cofactor guard -> range predicate
// Interval ("t-regex") form of a state's derivative cofactors over the character sort:
// a canonical list of disjoint ranges in increasing order, each carrying the targets
// reachable on that range. Built once per state and merged by the product, so the
// product enumerates only the cells of the common refinement.
struct ivl_range { unsigned lo, hi, first, count; };
struct ivl_list {
svector<ivl_range> ranges;
ptr_vector<expr> targets; // ranges[i] owns targets[first .. first+count)
bool ok = true; // false: some guard is outside the range algebra
};
obj_map<expr, ivl_list*> m_ivl_cache;
expr_ref_vector m_ivl_pin; // pins the states and targets the cache refers to
ivl_list const* interval_cofactors(expr* r, expr* v0);
void reset_ivl_cache();
obj_pair_map<expr, expr, expr*> m_der_cache; // memoizes der_elem per (regex, element)
obj_map<expr, char> m_nullable_cache; // memoizes nullability (0 false / 1 true / 2 unknown);
// seq_rewriter's own cache is capped and flushed whole
@ -166,7 +181,7 @@ class seq_monadic {
};
group_sig m_sig_buf; // reused by group_nonempty (avoids allocating per lookup)
std::unordered_map<group_sig, lbool, group_sig_hash> m_group_cache;
obj_map<expr, expr_ref_vector*> m_live_cache; // regex -> live split states (null = gave up)
seq::live_states m_live_states;
// Brzozowski derivative of regex `r` by the concrete element `elem`. Memoized on
// (r, elem): the search revisits the same constant step on many branches.
@ -175,20 +190,10 @@ class seq_monadic {
// Memoized nullability of a derivative state: l_true / l_false / l_undef (unknown).
lbool nullable(expr* r);
// Symbolic transition cofactors in the selected mode. Memoized per regex `r` in
// m_cofactors: the returned vector is owned by that cache (see the cofactor_cache
// class above for the persistence/reset policy).
// Symbolic transition cofactors in the selected mode. The returned vector is owned
// by seq_rewriter's mode-specific cofactor cache.
expr_ref_pair_vector const& derivative_cofactors(expr* r);
// Live reachable derivative states of R (BFS over cofactor targets + liveness
// least-fixpoint). These are the split states q. Returns false on a cap overrun.
bool live_states(expr* R, expr_ref_vector& out);
// Memoized live_states. Returns null if the computation gave up for this regex.
expr_ref_vector const* live_states_cached(expr* R);
void reset_live_cache();
// Product-reachability emptiness of a conjunction of components (all on one
// variable). l_false = empty (unsat), l_true = non-empty (sat), l_undef = gave up
// (cap overrun, non-range guard, or undecidable nullability).
@ -248,10 +253,10 @@ public:
seq_monadic(seq_rewriter& rw, trail_stack& undo_trail,
seq::transition_mode mode = seq::transition_mode::light_antimirov_tm) :
m(rw.m()), m_rw(rw), m_thrw(rw.m()), m_undo_trail(undo_trail),
m_pin(rw.m()), m_config(mode), m_rp_cache(rw.m()),
m_regexes(rw.m()) {}
m_pin(rw.m()), m_config(mode), m_rp_cache(rw.m()), m_ivl_pin(rw.m()),
m_regexes(rw.m()), m_live_states(rw, mode, 1u << 12) {}
~seq_monadic() { reset_live_cache(); }
~seq_monadic() { reset_ivl_cache(); }
void collect_statistics(::statistics &st) const;

View file

@ -0,0 +1,312 @@
/*++
Copyright (c) 2026 Microsoft Corporation
Module Name:
seq_regex_live.cpp
Abstract:
Shared lazy live-state traversal for regular-expression derivatives.
--*/
#include "ast/rewriter/seq_regex_live.h"
#include "ast/rewriter/seq_rewriter.h"
#include "util/obj_hashtable.h"
#include "util/uint_set.h"
namespace seq {
struct live_states::search {
svector<unsigned> m_to_explore;
unsigned m_explore_head = 0;
uint_set m_seen;
vector<svector<unsigned>> m_predecessors;
bool_vector m_live;
bool_vector m_closed;
svector<unsigned> m_live_frontier;
unsigned m_root_id = 0;
failure m_failure = failure::none;
bool m_complete = false;
};
struct live_states::imp {
seq_rewriter& m_rw;
ast_manager& m;
seq_util::rex& m_re;
transition_mode m_mode;
unsigned m_max_states;
obj_map<expr, unsigned> m_ids;
expr_ref_vector m_states;
vector<svector<unsigned>> m_successors;
svector<char> m_nullable;
bool_vector m_expanded;
obj_map<expr, search*> m_searches;
imp(seq_rewriter& rw, transition_mode mode, unsigned max_states) :
m_rw(rw),
m(rw.m()),
m_re(rw.u().re),
m_mode(mode),
m_max_states(max_states),
m_states(m) {
}
~imp() {
reset();
}
unsigned intern(expr* r) {
unsigned id = 0;
if (m_ids.find(r, id))
return id;
id = m_states.size();
m_ids.insert(r, id);
m_states.push_back(r);
m_successors.push_back(svector<unsigned>());
m_nullable.push_back(2);
m_expanded.push_back(false);
return id;
}
void resize(search& s) {
unsigned size = m_states.size();
if (s.m_predecessors.size() < size)
s.m_predecessors.resize(size);
if (s.m_live.size() < size)
s.m_live.resize(size, false);
if (s.m_closed.size() < size)
s.m_closed.resize(size, false);
}
char nullable(unsigned id) {
if (m_nullable[id] != 2)
return m_nullable[id];
expr* r = m_states.get(id);
lbool n = m_re.get_info(r).nullable;
if (n == l_undef) {
expr_ref f = m_rw.is_nullable(r);
n = m.is_true(f) ? l_true : m.is_false(f) ? l_false : l_undef;
}
m_nullable[id] = n == l_true ? 1 : n == l_false ? 0 : 3;
return m_nullable[id];
}
void mark_live(search& s, unsigned id) {
svector<unsigned> todo;
todo.push_back(id);
while (!todo.empty()) {
id = todo.back();
todo.pop_back();
if (!s.m_seen.contains(id) || s.m_live[id])
continue;
s.m_live[id] = true;
s.m_live_frontier.push_back(id);
for (unsigned predecessor : s.m_predecessors[id])
todo.push_back(predecessor);
}
}
bool add_state(search& s, unsigned id) {
if (s.m_seen.contains(id))
return true;
if (s.m_to_explore.size() >= m_max_states) {
s.m_failure = failure::state_cap;
return false;
}
resize(s);
s.m_seen.insert(id);
s.m_to_explore.push_back(id);
if (nullable(id) != 0)
mark_live(s, id);
return true;
}
bool expand_state(unsigned id, failure& f) {
if (m_expanded[id])
return true;
if (!m.inc()) {
f = failure::resource;
return false;
}
m_expanded[id] = true;
nullable(id);
auto const& cofactors = m_rw.get_derive().get_cached_cofactors(m_mode, m_states.get(id));
for (auto const& [guard, target] : cofactors) {
if (m_re.is_empty(target))
continue;
unsigned target_id = intern(target);
if (!m_successors[id].contains(target_id))
m_successors[id].push_back(target_id);
}
return true;
}
void close(search& s) {
for (unsigned id : s.m_to_explore)
if (!s.m_live[id])
s.m_closed[id] = true;
s.m_complete = true;
}
void expand(search& s) {
if (s.m_complete || s.m_failure != failure::none)
return;
if (s.m_explore_head == s.m_to_explore.size()) {
close(s);
return;
}
unsigned id = s.m_to_explore[s.m_explore_head++];
if (!expand_state(id, s.m_failure))
return;
resize(s);
for (unsigned target : m_successors[id]) {
if (!add_state(s, target))
return;
if (!s.m_predecessors[target].contains(id))
s.m_predecessors[target].push_back(id);
if (s.m_live[target])
mark_live(s, id);
}
}
bool ensure(search& s, unsigned index) {
while (index >= s.m_live_frontier.size() &&
!s.m_complete &&
s.m_failure == failure::none)
expand(s);
return index < s.m_live_frontier.size();
}
search* get_search(expr* root) {
search* s = nullptr;
if (m_searches.find(root, s))
return s;
s = alloc(search);
unsigned root_id = intern(root);
s->m_root_id = root_id;
add_state(*s, root_id);
m_searches.insert(root, s);
return s;
}
void reset() {
for (auto const& [root, s] : m_searches)
dealloc(s);
m_searches.reset();
m_ids.reset();
m_states.reset();
m_successors.reset();
m_nullable.reset();
m_expanded.reset();
}
};
live_states::live_states(seq_rewriter& rw, transition_mode mode, unsigned max_states) :
m_imp(alloc(imp, rw, mode, max_states)) {
}
live_states::~live_states() {
dealloc(m_imp);
}
expr* live_states::iterator::operator*() const {
return m_owner->get_live(m_search, m_index);
}
live_states::iterator& live_states::iterator::operator++() {
++m_index;
return *this;
}
bool live_states::iterator::operator!=(iterator const& other) const {
if (other.m_end)
return m_owner->ensure(m_search, m_index);
return m_owner != other.m_owner ||
m_search != other.m_search ||
m_index != other.m_index ||
m_end != other.m_end;
}
live_states::iterator live_states::reachable::begin() const {
return iterator(m_owner, m_search, 0, false);
}
live_states::iterator live_states::reachable::end() const {
return iterator(m_owner, m_search, 0, true);
}
bool live_states::reachable::failed() const {
return m_owner->get_failure(m_search) != failure::none;
}
live_states::failure live_states::reachable::failure_reason() const {
return m_owner->get_failure(m_search);
}
bool live_states::reachable::is_dead() {
return !m_owner->ensure(m_search, 0) && !failed();
}
live_states::reachable live_states::reachable_live(expr* r) {
return reachable(this, m_imp->get_search(r));
}
bool live_states::contains(expr* r) const {
return m_imp->m_ids.contains(r);
}
unsigned live_states::state_id(expr* r) {
return m_imp->intern(r) + 1;
}
unsigned live_states::num_states() const {
return m_imp->m_states.size();
}
void live_states::reset() {
m_imp->reset();
}
bool live_states::ensure(search* s, unsigned index) {
return m_imp->ensure(*s, index);
}
/*
Yield the root before the rest of the frontier.
States enter m_live_frontier in the order their liveness is *discovered*, which is
bottom-up: a nullable state is marked first and liveness then propagates backwards to
its predecessors, so the root -- reachable to every state, and rarely nullable itself
-- is typically marked last. Callers use this order as a search order, and the
eager traversal this replaced emitted states in interning order with the root at
index 0. Dropping the root to the back therefore reordered the consumer's search and
cost several benchmarks their witness-first branch.
The remap is well defined because every state in the search is reachable from the
root, so liveness of any state propagates to the root within the same expand() step:
whenever the frontier is non-empty at an ensure() boundary the root is already live
and present in it, and the element count is unchanged.
*/
expr* live_states::get_live(search* s, unsigned index) const {
unsigned root = s->m_root_id;
if (!s->m_live.get(root, false))
return m_imp->m_states.get(s->m_live_frontier[index]);
if (index == 0)
return m_imp->m_states.get(root);
for (unsigned i = 0, seen = 0; i < s->m_live_frontier.size(); ++i) {
if (s->m_live_frontier[i] == root)
continue;
if (++seen == index)
return m_imp->m_states.get(s->m_live_frontier[i]);
}
return m_imp->m_states.get(s->m_live_frontier[index]);
}
live_states::failure live_states::get_failure(search* s) const {
return s->m_failure;
}
}

View file

@ -0,0 +1,83 @@
/*++
Copyright (c) 2026 Microsoft Corporation
Module Name:
seq_regex_live.h
Abstract:
Shared lazy live-state traversal for regular-expression derivatives.
--*/
#pragma once
#include "ast/rewriter/seq_derive.h"
#include "ast/ast.h"
class seq_rewriter;
namespace seq {
class live_states {
struct search;
struct imp;
public:
enum class failure {
none,
state_cap,
resource
};
class iterator {
live_states* m_owner = nullptr;
search* m_search = nullptr;
unsigned m_index = 0;
bool m_end = false;
public:
iterator(live_states* owner, search* s, unsigned index, bool end) :
m_owner(owner), m_search(s), m_index(index), m_end(end) {}
expr* operator*() const;
iterator& operator++();
bool operator!=(iterator const& other) const;
};
class reachable {
live_states* m_owner = nullptr;
search* m_search = nullptr;
public:
reachable(live_states* owner, search* s) :
m_owner(owner), m_search(s) {}
iterator begin() const;
iterator end() const;
bool failed() const;
failure failure_reason() const;
bool is_dead();
};
private:
imp* m_imp;
bool ensure(search* s, unsigned index);
expr* get_live(search* s, unsigned index) const;
failure get_failure(search* s) const;
public:
live_states(seq_rewriter& rw,
transition_mode mode = transition_mode::brzozowski_tm,
unsigned max_states = UINT_MAX);
~live_states();
live_states(live_states const&) = delete;
live_states& operator=(live_states const&) = delete;
reachable reachable_live(expr* r);
bool contains(expr* r) const;
unsigned state_id(expr* r);
// Number of interned derivative states, i.e. the size of the shared state table.
unsigned num_states() const;
void reset();
};
}

View file

@ -31,8 +31,7 @@ namespace smt {
ctx(th.get_context()),
m(th.get_manager()),
m_monadic(seq_rw(), ctx.get_trail_stack()),
m_state_to_expr(m),
m_state_graph(state_graph::state_pp(this, pp_state)) {
m_live_states(seq_rw(), seq::transition_mode::brzozowski_tm, 10000) {
m_monadic.set_is_var([&th](expr *e) { return th.is_var(e); });
}
@ -640,8 +639,8 @@ namespace smt {
}
if (info.interpreted) {
update_state_graph(r);
if (m_state_graph.is_dead(get_state_id(r))) {
auto live = m_live_states.reachable_live(r);
if (live.is_dead()) {
STRACE(seq_regex_brief, tout << "(dead) ";);
th.add_axiom(~lit);
return true;
@ -665,8 +664,7 @@ namespace smt {
/**
* Propagate the atom (accept s i r)
*
* Propagation triggers updating the state graph for dead state detection:
* (accept s i r) => update_state_graph(r)
* Propagation triggers derivative reachability for dead state detection:
* (accept s i r) & dead(r) => false
*
* Propagation is also blocked under certain conditions to throttle
@ -1292,101 +1290,10 @@ namespace smt {
return sk().mk("re.first", n, a().mk_int(r->get_id()), elem_sort);
}
/**
* Dead state elimination using the state_graph class
*/
unsigned seq_regex::get_state_id(expr* e) {
// Assign increasing IDs starting from 1
if (!m_expr_to_state.contains(e)) {
m_state_to_expr.push_back(e);
unsigned new_id = m_state_to_expr.size();
m_expr_to_state.insert(e, new_id);
STRACE(seq_regex_brief, tout << "new(" << expr_id_str(e)
<< ")=" << state_str(e) << " ";);
STRACE(seq_regex, tout
<< "New state ID: " << new_id
<< " = " << mk_pp(e, m) << std::endl;);
SASSERT(get_expr_from_id(new_id) == e);
}
return m_expr_to_state.find(e);
}
expr* seq_regex::get_expr_from_id(unsigned id) {
SASSERT(id >= 1);
SASSERT(id <= m_state_to_expr.size());
return m_state_to_expr.get(id - 1);
}
bool seq_regex::can_be_in_cycle(expr *r1, expr *r2) {
// TBD: This can be used to optimize the state graph:
// return false here if it is known that r1 -> r2 can never be
// in a cycle. There are various easy syntactic checks on r1 and r2
// that can be used to infer this (e.g. star height, or length if
// both are star-free).
// This check need not be sound, but if it is not, some dead states
// will be missed.
return true;
}
/*
Update the state graph with expression r and all its derivatives.
*/
bool seq_regex::update_state_graph(expr* r) {
unsigned r_id = get_state_id(r);
if (m_state_graph.is_done(r_id)) return false;
if (m_state_graph.get_size() >= m_max_state_graph_size) {
STRACE(seq_regex, tout << "Warning: ignored state graph update -- max size of seen states reached!" << std::endl;);
STRACE(seq_regex_brief, tout << "(MAX SIZE REACHED) ";);
return false;
}
STRACE(seq_regex, tout << "Updating state graph for regex "
<< mk_pp(r, m) << ") ";);
STRACE(state_graph,
if (!m_state_graph.is_seen(r_id))
tout << std::endl << "state(" << r_id << ") = " << re().to_str(r) << std::endl << "info(" << r_id << ") = " << re().get_info(r) << std::endl;);
// Add state
m_state_graph.add_state(r_id);
STRACE(seq_regex, tout << "Updating state graph for regex "
<< mk_pp(r, m) << ") " << std::endl;);
STRACE(seq_regex_brief, tout << std::endl << "USG("
<< state_str(r) << ") ";);
expr_ref r_nullable = is_nullable_wrapper(r);
if (m.is_true(r_nullable)) {
m_state_graph.mark_live(r_id);
}
else {
// Add edges to all derivatives
expr_ref_vector derivatives(m);
STRACE(seq_regex_verbose, tout
<< "getting all derivs: " << r_id << " " << std::endl;);
get_derivative_targets(r, derivatives);
for (auto const& dr: derivatives) {
unsigned dr_id = get_state_id(dr);
STRACE(seq_regex_verbose, tout
<< std::endl << " traversing deriv: " << dr_id << " ";);
STRACE(state_graph,
if (!m_state_graph.is_seen(dr_id))
tout << "state(" << dr_id << ") = " << re().to_str(dr) << std::endl << "info(" << dr_id << ") = " << re().get_info(dr) << std::endl;);
// Add state
m_state_graph.add_state(dr_id);
bool maybecycle = can_be_in_cycle(r, dr);
m_state_graph.add_edge(r_id, dr_id, maybecycle);
}
m_state_graph.mark_done(r_id);
}
STRACE(seq_regex, m_state_graph.display(tout););
STRACE(seq_regex_brief, tout << std::endl;);
STRACE(seq_regex_brief, m_state_graph.display(tout););
return true;
}
std::string seq_regex::state_str(expr* e) {
if (m_expr_to_state.contains(e))
return std::to_string(get_state_id(e));
else
return expr_id_str(e);
if (m_live_states.contains(e))
return std::to_string(m_live_states.state_id(e));
return expr_id_str(e);
}
std::string seq_regex::expr_id_str(expr* e) {
return std::string("id") + std::to_string(e->get_id());

View file

@ -17,9 +17,9 @@ Author:
#pragma once
#include "util/scoped_vector.h"
#include "util/state_graph.h"
#include "ast/seq_decl_plugin.h"
#include "ast/rewriter/seq_monadic.h"
#include "ast/rewriter/seq_regex_live.h"
#include "ast/rewriter/seq_rewriter.h"
#include "ast/rewriter/seq_skolem.h"
#include "smt/smt_context.h"
@ -56,13 +56,6 @@ Author:
(next states)
d(r1)=r2: r2 is the derivative of r1
n(r1)=b: b = whether r1 is nullable or not
USG(r): updating state graph for regex r (add all derivatives)
-tr:state_graph
This is the tracing done by util/state_graph, the data structure
that seq_regex uses to track live and dead regexes, which can
altneratively be used to get a high-level picture of what states
are being explored and updated as the solver progresses.
-tr:seq_regex_verbose
Used for some more frequent tracing (in the style of seq_regex,
@ -72,21 +65,12 @@ Author:
These are the underlying sequence theory tracing, often used by
the rewriter.
DEBUGGING AND VIEWING STATE GRAPH GRAPHICAL OUTPUT
DEBUGGING
-dbg:seq_regex
Debugging that checks invariants. Currently, checks that derivative
normal form is correctly preserved in the rewriter.
-dbg:state_graph
Debugging for the state graph, which
1. Checks state graph invariants, and
2. Generates the files .z3-state-graph.dgml and .z3-state-graph.dot
which can be used to visually view the state graph being explored,
during or after executing Z3.
The output can be viewed:
- Using Visual Studio for .dgml
- Using a tool such as xdot (`xdot .z3-state-graph.dot`) for .dot
*/
namespace smt {
@ -163,23 +147,9 @@ namespace smt {
unsigned m_monadic_assumption_generation = UINT_MAX;
unsigned m_monadic_fallback_generation = UINT_MAX;
/*
state_graph for dead state detection, and associated methods
*/
ptr_addr_map<expr, unsigned> m_expr_to_state;
expr_ref_vector m_state_to_expr;
state_graph m_state_graph;
seq::live_states m_live_states;
/* map from uninterpreted regex constants to assigned regex expressions by EQ */
// expr_map m_const_to_expr;
unsigned m_max_state_graph_size { 10000 };
// Convert between expressions and states (IDs)
unsigned get_state_id(expr* e);
expr* get_expr_from_id(unsigned id);
// Cycle-detection heuristic
// Note: Doesn't need to be sound or complete (doesn't affect soundness)
bool can_be_in_cycle(expr* r1, expr* r2);
// Update the graph
bool update_state_graph(expr* r);
// Printing expressions for seq_regex_brief
std::string state_str(expr* e);
@ -239,17 +209,6 @@ namespace smt {
seq_regex_ptr must be a pointer to seq_regex and the
id must be a valid state id or else nothing is printed.
*/
static void pp_state(void* seq_regex_ptr, std::ostream& out, unsigned id, bool html_encode) {
seq_regex* sr = (seq_regex*)seq_regex_ptr;
if (sr) {
seq_util::rex re_util(sr->re());
if (1 <= id && id <= sr->m_state_to_expr.size()) {
expr* r = sr->get_expr_from_id(id);
seq_util::rex::pp(re_util, r, html_encode).display(out);
}
}
}
bool block_if_empty(expr* r, literal lit);
void add_monadic_membership(literal lit, expr* s, expr* r);
// Expand a term through theory_seq's solution map, but substitute a sub-term only