3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-08-14 17:55:36 +00:00
z3/src/ast/rewriter/seq_monadic.cpp
2026-08-09 19:57:09 +00:00

1189 lines
43 KiB
C++

/*++
Copyright (c) 2026 Microsoft Corporation
Module Name:
seq_monadic.cpp
Abstract:
Whole-language monadic decomposition for regex membership. See seq_monadic.h.
Automaton-based (product-reachability); reach(q) is never materialized as a regex, and
the disjunction produced by the decomposition is never materialized as a DNF: it is
explored as a depth-first search tree with per-variable emptiness pruning.
Generic in the element sort. The decomposition, liveness and product-reachability
are element-agnostic; only the *guard algebra* over the derivative cofactor guards
depends on the element sort. For the character sort it is the exact, compact
seq::range_predicate; for any other element sort it is a candidate-basis over the
element values mentioned by the guards (sound and complete for the
{true,false,=,<=,and,or,not} grammar the derivatives emit). The same guard algebra
yields the concrete element used to build a witness sequence.
TODOs:
- create a validation harness: expose certificates for correctness that can be checked.
- take into account shape of terms to prune the search space (e.g., if the term is xax, then retain the effect of
intersecting with .*a.*).
- connect to semi-linear pruning, such as xx in (ab)*a is unsat due to parity
- support units of non-values (element variables).
Model construction would assign values to the elements.
- make unsat core tracking less naive by tracking dependencies at a finer grain.
- add selective tracing TRACE(seq, ..).
Author:
Nikolaj Bjorner / Margus Veanes 2026
--*/
#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>
#include <tuple>
#include <functional>
#include <algorithm>
#include <unordered_set>
namespace {
char const* mode_name(seq::transition_mode mode) {
switch (mode) {
case seq::transition_mode::brzozowski_tm:
return "brzozowski";
case seq::transition_mode::light_antimirov_tm:
return "light-antimirov";
}
return "unknown";
}
char const* result_name(lbool r) {
switch (r) {
case l_true: return "sat";
case l_false: return "unsat";
default: return "unknown";
}
}
}
#define SEQ_MONADIC_BAIL_PREFIX "seq monadic bail "
char const* seq_monadic::bail_stat_name(bail_reason reason) {
switch (reason) {
case bail_reason::unsupported: return SEQ_MONADIC_BAIL_PREFIX "unsupported";
case bail_reason::state_cap: return SEQ_MONADIC_BAIL_PREFIX "state cap";
case bail_reason::budget: return SEQ_MONADIC_BAIL_PREFIX "budget";
case bail_reason::resource: return SEQ_MONADIC_BAIL_PREFIX "resource";
case bail_reason::nullability: return SEQ_MONADIC_BAIL_PREFIX "nullability";
case bail_reason::guard: return SEQ_MONADIC_BAIL_PREFIX "guard";
default: return SEQ_MONADIC_BAIL_PREFIX "unknown";
}
}
char const* seq_monadic::bail_name(bail_reason reason) {
return bail_stat_name(reason) + (sizeof(SEQ_MONADIC_BAIL_PREFIX) - 1);
}
expr_ref seq_monadic::der_elem(expr* r, expr* elem) {
expr* cached = nullptr;
if (m_der_cache.find(r, elem, cached))
return expr_ref(cached, m);
expr_ref d = m_rw.mk_derivative(elem, r); // mk_derivative(element, regex)
// Normalize: for a general element sort the derivative by a non-matching constant can
// leave a ground guard (e.g. (= 1 2)) unfolded; simplifying collapses such dead
// branches to re.empty so nullability/emptiness stay decidable.
expr_ref d2(m);
m_thrw(d, d2);
m_pin.push_back(r); // keep the cache keys and value alive
m_pin.push_back(elem);
m_pin.push_back(d2);
m_der_cache.insert(r, elem, d2);
return d2;
}
lbool seq_monadic::nullable(expr* r) {
// Nullability is a structural property, and the seq plugin already computes it as
// part of the regex info -- cached by expr id and, unlike seq_rewriter's op_cache
// (capped at 10000 and flushed whole), never evicted. Use it whenever it is
// determined; only fall back to building the symbolic nullability formula for the
// regexes whose info leaves it undetermined.
lbool i = re().get_info(r).nullable;
if (i != l_undef)
return i;
char v = 0;
if (m_nullable_cache.find(r, v)) {
if (v == 1) return l_true;
if (v == 0) return l_false;
return l_undef;
}
expr_ref nb = m_rw.is_nullable(r);
lbool res = m.is_true(nb) ? l_true : m.is_false(nb) ? l_false : l_undef;
m_pin.push_back(r);
m_nullable_cache.insert(r, res == l_true ? 1 : res == l_false ? 0 : 2);
return res;
}
expr_ref_pair_vector const& seq_monadic::derivative_cofactors(expr* r) {
++m_stats.m_cofactor_calls;
return m_rw.get_derive().get_cached_cofactors(m_config.m_mode, r);
}
void seq_monadic::reset_ivl_cache() {
for (auto& kv : m_ivl_cache)
dealloc(kv.m_value);
m_ivl_cache.reset();
m_ivl_pin.reset();
}
// 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;
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) {
unsigned n = comps.size();
if (n == 0) {
if (witness_word)
*witness_word = expr_ref(u().str.mk_empty(m_seq_sort), m);
return l_true;
}
expr_ref var0(m.mk_var(0, m_elem_sort), m); // the element variable the guards range over
typedef std::vector<unsigned> key;
struct key_hash {
size_t operator()(key const& k) const {
size_t h = 1469598103934665603ull;
for (unsigned x : k)
h = (h ^ x) * 1099511628211ull;
return h;
}
};
// Product states are held in a flat stack of stride n; `visited` owns one copy of the
// id-tuple of every discovered state. Both avoid a per-state heap allocation, which
// dominated the search when this ran per derivative step of the outer decomposition.
ptr_vector<expr> work;
std::unordered_set<key, key_hash> visited;
key kbuf;
kbuf.resize(n);
ptr_vector<expr> st;
st.resize(n);
ptr_vector<expr> cur;
cur.resize(n);
auto fill_key = [&](ptr_vector<expr> const& s) -> key const& {
for (unsigned i = 0; i < n; ++i)
kbuf[i] = s[i]->get_id();
return kbuf;
};
bool undecided = false;
auto is_accept = [&]() -> bool {
for (unsigned i = 0; i < n; ++i) {
if (comps[i].target) {
if (st[i] != comps[i].target) return false;
}
else {
lbool nb = nullable(st[i]);
if (nb == l_true) continue;
if (nb == l_false) return false;
undecided = true; return false;
}
}
return true;
};
// tree of first-discovery edges for witness reconstruction (only built when a
// witness is requested): child-key -> (parent-key, element read on the edge).
std::map<key, std::pair<key, expr*>> parent;
key start_key;
start_key.resize(n);
for (unsigned i = 0; i < n; ++i) {
work.push_back(comps[i].state);
start_key[i] = comps[i].state->get_id();
}
visited.insert(start_key);
auto reconstruct = [&](key end_key) -> expr_ref {
ptr_vector<expr> elems; // collected in accept..start order
key k = end_key;
while (k != start_key) {
auto it = parent.find(k);
if (it == parent.end()) break; // safety (should not happen)
elems.push_back(it->second.second);
k = it->second.first;
}
expr_ref_vector es(m); // start..accept order
for (unsigned idx = elems.size(); idx-- > 0; )
es.push_back(u().str.mk_unit(elems[idx]));
if (es.empty())
return expr_ref(u().str.mk_empty(m_seq_sort), m);
return expr_ref(u().str.mk_concat(es.size(), es.data(), m_seq_sort), m);
};
// Hoisted out of the search loop: the per-component cofactor vectors are owned by the
// cofactor cache and stay valid for the whole search, so they are referenced rather
// than copied (copying re-materialized every branch as expr_ref pairs on every pop).
svector<expr_ref_pair_vector const*> branches;
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;
if (i == n) {
key const& ck = fill_key(cur);
if (visited.find(ck) == visited.end()) {
visited.insert(ck);
if (witness_word) {
expr_ref e(m);
if (acc.eval(&e) == l_true) {
m_pin.push_back(e);
parent[ck] = { st_key, e.get() };
}
}
for (unsigned j = 0; j < n; ++j)
work.push_back(cur[j]);
}
return;
}
for (auto const& [g, t] : *branches[i]) {
if (re().is_empty(t)) continue;
guard_set nacc = acc;
nacc.conjoin(g);
lbool ne = nacc.eval(nullptr);
if (ne == l_undef) {
m_stats.inc_bail(bail_reason::guard);
bail = true;
return;
}
if (ne == l_false) continue; // empty joint guard: prune
cur[i] = t;
rec(i + 1, nacc);
if (bail) return;
}
};
while (!work.empty()) {
if (m_budget == 0) {
m_stats.inc_bail(bail_reason::budget);
m_giveup = true;
return l_undef;
}
if (!m.inc()) {
m_stats.inc_bail(bail_reason::resource);
m_giveup = true;
return l_undef;
}
--m_budget;
for (unsigned i = n; i-- > 0; ) {
st[i] = work.back();
work.pop_back();
}
if (is_accept()) {
if (witness_word)
*witness_word = reconstruct(fill_key(st));
return l_true;
}
if (undecided) {
m_stats.inc_bail(bail_reason::nullability);
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.
guard_set top(m, u(), m_elem_sort, var0, &m_rp_cache);
rec(0, top);
if (bail)
return l_undef;
}
return l_false;
}
bool seq_monadic::parse_term(expr* t, vector<atom>& atoms) {
if (u().str.is_concat(t))
return all_of(*to_app(t), [&](expr* arg) { return parse_term(arg, atoms); });
if (u().str.is_empty(t))
return true; // epsilon: contributes nothing
zstring s;
if (u().str.is_string(t, s)) {
for (unsigned i = 0; i < s.length(); ++i) {
expr* elem = u().str.mk_char(s, i);
atoms.push_back(atom(m, false, nullptr, elem));
}
return true;
}
expr *elem = nullptr;
if (u().str.is_unit(t, elem) && m.is_value(elem)) { // seq.unit of a constant element
atoms.push_back(atom(m, false, nullptr, elem));
return true;
}
// uninterpreted constant of sequence sort => a sequence variable
if (is_var(t)) {
atoms.push_back(atom(m, true, t, nullptr));
return true;
}
return false;
}
unsigned seq_monadic::var_index(expr* v) {
unsigned vi;
if (m_var_idx.find(v, vi))
return vi;
vi = m_vars.size();
m_var_idx.insert(v, vi);
m_vars.push_back(v);
m_groups.push_back(svector<component>());
m_num_occ.push_back(0);
m_head_cnt.push_back(0);
return vi;
}
void seq_monadic::reset_search() {
m_seq_sort = nullptr;
m_elem_sort = nullptr;
m_atoms.reset();
m_regexes.reset();
m_vars.reset();
m_var_idx.reset();
m_groups.reset();
m_num_occ.reset();
m_group_cache.clear();
m_der_cache.reset();
m_nullable_cache.reset();
m_undef_vars = 0;
m_cursors.reset();
m_last_var = UINT_MAX;
m_head_cnt.reset();
m_head_stack.reset();
m_live_states.reset();
}
bool seq_monadic::prepare(membership_vec const& memberships) {
reset_search();
for (auto const& [term, regex, d] : memberships) {
if (!u().is_re(regex, m_seq_sort)) {
m_stats.inc_bail(bail_reason::unsupported);
return false;
}
if (!u().is_seq(m_seq_sort, m_elem_sort)) {
m_stats.inc_bail(bail_reason::unsupported);
return false;
}
vector<atom> atoms;
if (!parse_term(term, atoms)) {
m_stats.inc_bail(bail_reason::unsupported);
return false;
}
m_regexes.push_back(regex);
m_atoms.push_back(atoms);
m_pin.push_back(regex);
}
for (unsigned mi = 0; mi < m_atoms.size(); ++mi) {
vector<atom> const& atoms = m_atoms[mi];
for (atom const& a : atoms) {
if (!a.is_var)
continue;
++m_num_occ[var_index(a.var.get())];
}
}
return true;
}
lbool seq_monadic::group_nonempty(unsigned vi) {
svector<component> const& g = m_groups[vi];
group_sig& sig = m_sig_buf;
sig.clear();
for (auto const& c : g)
sig.push_back({ c.state->get_id(), c.target ? c.target->get_id() : UINT_MAX });
std::sort(sig.begin(), sig.end());
sig.erase(std::unique(sig.begin(), sig.end()), sig.end());
auto it = m_group_cache.find(sig);
if (it != m_group_cache.end())
return it->second;
// Collapse duplicated components: they constrain the variable identically, and the
// product search is exponential in the number of components.
svector<component> comps;
if (sig.size() == g.size())
comps = g;
else {
std::set<std::pair<unsigned, unsigned>> seen;
for (auto const& c : g)
if (seen.insert({ c.state->get_id(), c.target ? c.target->get_id() : UINT_MAX }).second)
comps.push_back(c);
}
lbool r = product_nonempty(comps, nullptr);
m_group_cache.emplace(sig, r); // sig is m_sig_buf; emplace copies it
return r;
}
lbool seq_monadic::leaf() {
if (m_undef_vars > 0)
return l_undef; // some variable's emptiness test gave up
if (!m_config.m_model)
return l_true;
m_model.reset();
for (unsigned vi = 0; vi < m_groups.size(); ++vi) {
if (m_groups[vi].empty())
continue;
expr_ref w(m);
lbool ne = product_nonempty(m_groups[vi], &w);
if (ne != l_true) { // groups were already shown non-empty;
m_model.reset(); // only reachable if the search was cut short
return ne;
}
m_pin.push_back(w);
m_model.insert(m_vars[vi], w.get());
}
return l_true;
}
bool seq_monadic::inc_budget() {
if (m_giveup)
return false; // unwind the whole search, don't keep branching
if (m_budget == 0) {
m_stats.inc_bail(bail_reason::budget);
m_giveup = true;
return false;
}
if (!m.inc()) {
m_stats.inc_bail(bail_reason::resource);
m_giveup = true;
return false;
}
--m_budget;
return true;
}
lbool seq_monadic::dfs_membership(unsigned mi) {
if (mi == m_atoms.size())
return leaf();
return dfs_atoms(mi, 0, m_regexes.get(mi));
}
lbool seq_monadic::dfs_atoms(unsigned mi, unsigned i, expr* R) {
if (!inc_budget())
return l_undef;
vector<atom> const& atoms = m_atoms[mi];
if (i == atoms.size()) { // the rest of this membership is epsilon
lbool nb = nullable(R);
if (nb == l_true)
return dfs_membership(mi + 1);
if (nb == l_false)
return l_false;
m_stats.inc_bail(bail_reason::nullability);
return l_undef; // undecidable nullability
}
atom const& a = atoms[i];
if (!a.is_var) { // a constant element is consumed by a derivative
expr_ref d = der_elem(R, a.elem.get());
if (re().is_empty(d))
return l_false;
return dfs_atoms(mi, i + 1, d);
}
// 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());
unsigned vi = var_index(a.var.get());
// 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
// an inconsistency can first arise) prunes the entire subtree below.
lbool ne = l_true;
if (re().is_empty(R))
ne = l_false;
else if (group_complete(vi) || m_groups[vi].size() > 1)
ne = group_nonempty(vi);
lbool r;
if (ne == l_false)
r = l_false;
else {
if (ne == l_undef)
++m_undef_vars;
r = last_atom ? dfs_membership(mi + 1) : dfs_atoms(mi, i + 1, target);
if (ne == l_undef)
--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) {
if (m_giveup)
return l_undef;
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;
}
// ---- state-based search driver ------------------------------------------------------
//
// This is an alternative to the strictly positional dfs_membership/dfs_atoms above. The
// positional search finishes membership 0 entirely, then membership 1, and so on, so two
// memberships that share a variable only intersect that variable's components deep in the
// tree -- after the first membership's alignment was chosen blindly. The state-based
// search keeps a *cursor* per membership and, at each step, expands ONE variable across
// ALL memberships whose current head is that variable, intersecting the per-variable
// components (m_groups) immediately. An infeasible choice for a shared variable is thus
// pruned as soon as it is made, rather than after committing to a full membership.
//
// A search state is:
// - the set of active (non-complete) cursors == active membership constraints,
// - the per-variable component groups (m_groups) == variable intersection constraints,
// - the last expanded variable (m_last_var) == locality hint for the next choice.
// Every non-complete cursor has a variable head (leading constants are eagerly consumed by
// consume_constants). The state is complete when every cursor is complete, and accepting
// when additionally every variable group is non-empty.
lbool seq_monadic::consume_constants(cursor& c, unsigned mi) {
vector<atom> const& atoms = m_atoms[mi];
while (c.i < atoms.size() && !atoms[c.i].is_var) {
expr_ref d = der_elem(c.R, atoms[c.i].elem.get());
if (re().is_empty(d))
return l_false; // dead: this continuation is empty
c.R = d;
c.i += 1;
}
c.complete = (c.i == atoms.size());
if (!c.complete)
return l_true; // stopped on a variable head
lbool nb = nullable(c.R); // the remaining tail is epsilon
if (nb == l_false)
return l_false;
if (nb == l_undef) {
m_stats.inc_bail(bail_reason::nullability);
return l_undef; // tail nullability undecidable
}
return l_true;
}
lbool seq_monadic::advance_cursor(cursor& c, unsigned mi, expr* target) {
// Step past the head variable. target == null encodes "the variable is the last atom",
// i.e. a plain membership component: nothing follows, the cursor is complete.
if (!target) {
c.i = m_atoms[mi].size();
c.complete = true;
return l_true;
}
c.i += 1;
c.R = target;
return consume_constants(c, mi);
}
lbool seq_monadic::search() {
if (!inc_budget())
return l_undef;
unsigned best_vi = UINT_MAX, best_cnt = 0;
for (unsigned mi = 0; mi < m_cursors.size(); ++mi) {
cursor const& c = m_cursors[mi];
if (c.complete)
continue;
unsigned vi = m_var_idx[m_atoms[mi][c.i].var.get()];
unsigned cnt = ++m_head_cnt[vi];
// Prefer the most frequent head variable; break ties toward the smallest index so
// the choice is deterministic. m_last_var (locality) is applied afterwards.
if (cnt > best_cnt || (cnt == best_cnt && vi < best_vi)) {
best_cnt = cnt;
best_vi = vi;
}
}
// Locality: if the last expanded variable is still an active head, expand it next --
// its freshly chosen continuation can be checked against the intersection immediately.
unsigned vi = best_vi;
if (best_vi != UINT_MAX && m_last_var != UINT_MAX && m_head_cnt[m_last_var] > 0)
vi = m_last_var;
// All cursors whose current head is variable vi are expanded together at this step.
unsigned s_offset = m_head_stack.size(), s_size = 0;
for (unsigned mi = 0; mi < m_cursors.size(); ++mi) {
cursor const& c = m_cursors[mi];
if (c.complete)
continue;
unsigned hv = m_var_idx[m_atoms[mi][c.i].var.get()];
m_head_cnt[hv] = 0;
if (hv == vi) {
m_head_stack.push_back(mi);
++s_size;
}
}
if (best_vi == UINT_MAX)
return leaf(); // every cursor complete
lbool r = choose_cont(vi, s_offset, s_size, 0);
m_head_stack.shrink(s_offset);
return r;
}
lbool seq_monadic::choose_cont(unsigned vi, unsigned s_offset, unsigned s_size, unsigned k) {
if (!inc_budget())
return l_undef;
if (k == s_size) {
unsigned saved = m_last_var;
m_last_var = vi;
lbool r = search();
m_last_var = saved;
return r;
}
unsigned mi = m_head_stack[s_offset + k];
cursor& c = m_cursors[mi];
vector<atom> const& atoms = m_atoms[mi];
expr* R = c.R;
expr* v = atoms[c.i].var.get();
bool last_atom = (c.i + 1 == atoms.size());
auto explore = [&](expr* target) -> lbool {
m_groups[vi].push_back(component{ v, R, target });
// Intersect immediately: prune as soon as vi's accumulated components are empty.
// The test is forced once the group is complete so the accepting state does not
// need to re-verify; running it earlier (size > 1) prunes.
lbool ne;
if (re().is_empty(R))
ne = l_false;
else if (group_complete(vi) || m_groups[vi].size() > 1)
ne = group_nonempty(vi);
else
ne = l_true;
if (ne == l_false) {
m_groups[vi].pop_back();
return l_false; // infeasible continuation for vi: prune
}
cursor saved = c; // save/restore cursor across the branch
lbool adv = advance_cursor(c, mi, target);
if (adv == l_false) {
c = saved;
m_groups[vi].pop_back();
return l_false;
}
unsigned undef_here = (ne == l_undef ? 1u : 0u) + (adv == l_undef ? 1u : 0u);
m_undef_vars += undef_here;
lbool r = choose_cont(vi, s_offset, s_size, k + 1);
m_undef_vars -= undef_here;
c = saved;
m_groups[vi].pop_back();
return r;
};
// A last variable contributes a plain membership component. Otherwise consume live
// split states lazily so an early satisfying continuation avoids expanding the rest.
if (last_atom)
return explore(nullptr);
bool any_undef = false;
auto live = m_live_states.reachable_live(R);
for (expr* target : live) {
lbool r = explore(target);
if (r == l_true)
return l_true;
if (r == l_undef) {
if (m_giveup)
return l_undef;
any_undef = true;
}
}
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;
}
lbool seq_monadic::decide(membership_vec const& memberships, bool core_trial) {
m_last_search_memberships = memberships;
m_model.reset();
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);
constexpr unsigned state_search_budget = 200000;
constexpr unsigned positional_search_budget = 200000;
constexpr unsigned extended_positional_search_budget = 1000000;
m_budget = m_config.m_state_search ? state_search_budget : positional_search_budget;
m_giveup = false;
lbool r = l_true; // empty conjunction is vacuously true
if (!memberships.empty() && !prepare(memberships))
r = l_undef;
else if (!memberships.empty()) {
if (m_config.m_state_search) {
// Build one cursor per membership at its regex start; consuming the leading
// constants leaves every active cursor on a variable head.
m_cursors.reset();
m_last_var = UINT_MAX;
for (unsigned mi = 0; mi < m_atoms.size() && r != l_false; ++mi) {
m_cursors.push_back(cursor{ 0, m_regexes.get(mi), false });
r = consume_constants(m_cursors.back(), mi);
if (r == l_undef)
++m_undef_vars;
}
if (r != l_false)
r = search();
// State search and positional DFS have complementary good orderings.
// If state search exhausts its private work budget, retry from a clean
// search state. The extended retry is reserved for conjunctions with enough
// independent intersections to justify delaying the legacy solver.
bool use_portfolio = memberships.size() >= m_vars.size() + 2;
if (!core_trial && r == l_undef && m_giveup && m_budget == 0) {
m_giveup = false;
m_budget = positional_search_budget;
if (prepare(memberships))
r = dfs_membership(0);
}
if (!core_trial && use_portfolio && r == l_undef && m_giveup && m_budget == 0) {
m_giveup = false;
m_budget = extended_positional_search_budget;
if (prepare(memberships))
r = dfs_membership(0);
}
}
else
r = dfs_membership(0);
}
if (r != l_true)
m_model.reset();
m_last_search_result = r;
return r;
}
lbool seq_monadic::solve(expr* term, expr* R) {
m_core.reset();
membership_vec mv;
mv.push_back({ expr_ref(term, m), expr_ref(R, m), nullptr });
m_last_result = decide(mv);
return m_last_result;
}
void seq_monadic::add(expr* term, expr* regex, void* d) {
m_memberships.push_back({ expr_ref(term, m), expr_ref(regex, m), d });
m_undo_trail.push(push_back_vector(m_memberships));
}
namespace {
// Restores a membership term on backtrack. The previous term is pinned by this trail
// object's own expr_ref and released in undo() (the trail region does not run
// destructors, mirroring obj_ref_trail).
class set_term_trail : public trail {
vector<std::tuple<expr_ref, expr_ref, void*>>& m_v;
unsigned m_idx;
expr_ref m_old;
public:
set_term_trail(vector<std::tuple<expr_ref, expr_ref, void*>>& v, unsigned idx, expr* old, ast_manager& m):
m_v(v), m_idx(idx), m_old(old, m) {}
void undo() override {
std::get<0>(m_v[m_idx]) = m_old;
m_old.reset();
}
};
}
void seq_monadic::set_term(void* d, expr* term) {
for (unsigned i = 0; i < m_memberships.size(); ++i) {
if (std::get<2>(m_memberships[i]) != d)
continue;
expr_ref& t = std::get<0>(m_memberships[i]);
if (t.get() == term)
return;
m_undo_trail.push(set_term_trail(m_memberships, i, t, m));
t = term;
return;
}
}
bool seq_monadic::can_decide_term(expr* term) {
vector<atom> atoms;
return parse_term(term, atoms);
}
void seq_monadic::add_lo(expr* term, unsigned lo, void* d) {
if (lo == 0)
return;
sort* re_sort = re().mk_re(term->get_sort());
expr_ref all_char(re().mk_full_char(re_sort), m);
expr_ref prefix(re().mk_loop_proper(all_char, lo, lo), m);
expr_ref all(re().mk_full_seq(re_sort), m);
expr_ref regex(re().mk_concat(prefix, all), m);
add(term, regex, d);
}
void seq_monadic::add_hi(expr* term, unsigned hi, void* d) {
sort* re_sort = re().mk_re(term->get_sort());
expr_ref all_char(re().mk_full_char(re_sort), m);
expr_ref regex(re().mk_loop_proper(all_char, 0, hi), m);
add(term, regex, d);
}
void seq_monadic::add_len(expr* term, unsigned len, void* d) {
sort* re_sort = re().mk_re(term->get_sort());
expr_ref all_char(re().mk_full_char(re_sort), m);
expr_ref regex(re().mk_loop_proper(all_char, len, len), m);
add(term, regex, d);
}
void seq_monadic::minimize_core(membership_vec const& memberships) {
m_core.reset();
if (!m_config.m_min_core) {
// No minimization: the core is simply every asserted membership's dependency.
for (auto const& [term, regex, d] : memberships)
if (d)
m_core.push_back(d);
return;
}
// Deletion-based minimization: start from the full unsat set and try to drop each
// membership; a membership is kept only if removing it makes the set no longer
// provably unsat. The result is a minimal unsat subset (relevant constraints only).
membership_vec keep(memberships);
for (unsigned i = 0; i < keep.size(); ) {
membership_vec trial(keep);
trial.erase(trial.begin() + i);
if (decide(trial, true) == l_false)
keep.swap(trial); // membership i is not needed for unsat
else
++i; // membership i is needed; keep it
}
for (auto const& [term, regex, d] : keep)
if (d)
m_core.push_back(d);
}
lbool seq_monadic::check() {
m_core.reset();
lbool r = decide(m_memberships);
if (r == l_false) {
minimize_core(m_memberships);
m_model.reset();
}
m_last_result = r;
return m_last_result;
}
std::ostream& seq_monadic::display(std::ostream& out) const {
auto display_expr = [&](expr* e) {
if (e)
out << mk_pp(e, m);
else
out << "null";
};
out << "(seq-monadic\n"
<< " :mode " << mode_name(m_config.m_mode) << "\n"
<< " :generate-model " << (m_config.m_model ? "true" : "false") << "\n"
<< " :minimize-core " << (m_config.m_min_core ? "true" : "false") << "\n"
<< " :state-search " << (m_config.m_state_search ? "true" : "false") << "\n"
<< " :last-result " << result_name(m_last_result) << "\n"
<< " :budget " << m_budget << "\n"
<< " :giveup " << (m_giveup ? "true" : "false") << "\n"
<< " :sequence-sort ";
if (m_seq_sort)
out << mk_pp(m_seq_sort, m);
else
out << "null";
out << "\n :element-sort ";
if (m_elem_sort)
out << mk_pp(m_elem_sort, m);
else
out << "null";
out << "\n :memberships (";
for (unsigned i = 0; i < m_memberships.size(); ++i) {
auto const& [term, regex, dep] = m_memberships[i];
out << "\n [" << i << "] ";
display_expr(term);
out << " in ";
display_expr(regex);
out << " :dependency " << dep;
}
if (!m_memberships.empty())
out << "\n ";
out << ")\n :model (";
for (auto const& [var, value] : m_model) {
out << "\n ";
display_expr(var);
out << " -> ";
display_expr(value);
}
if (!m_model.empty())
out << "\n ";
out << ")\n :core (";
for (void* dep : m_core)
out << " " << dep;
out << " )";
out << "\n :last-internal-search\n"
<< " (:result " << result_name(m_last_search_result)
<< "\n :memberships (";
for (unsigned i = 0; i < m_last_search_memberships.size(); ++i) {
auto const& [term, regex, dep] = m_last_search_memberships[i];
out << "\n [" << i << "] ";
display_expr(term);
out << " in ";
display_expr(regex);
out << " :dependency " << dep;
}
if (!m_last_search_memberships.empty())
out << "\n ";
out << ")\n :variables (";
for (expr* var : m_vars) {
out << " ";
display_expr(var);
}
out << " )\n :variable-occurrences (";
for (unsigned n : m_num_occ)
out << " " << n;
out << " )\n :parsed-memberships (";
for (unsigned mi = 0; mi < m_atoms.size(); ++mi) {
out << "\n [" << mi << "] :regex ";
display_expr(m_regexes.get(mi));
out << " :atoms (";
for (atom const& a : m_atoms[mi]) {
out << " " << (a.is_var ? "var:" : "elem:");
display_expr(a.is_var ? a.var.get() : a.elem.get());
}
out << " )";
}
if (!m_atoms.empty())
out << "\n ";
out << ")\n :groups (";
for (unsigned vi = 0; vi < m_groups.size(); ++vi) {
out << "\n ";
display_expr(m_vars[vi]);
out << " (";
for (component const& c : m_groups[vi]) {
out << "\n ";
display_expr(c.state);
if (c.target) {
out << " -> ";
display_expr(c.target);
}
else {
out << " nullable";
}
}
if (!m_groups[vi].empty())
out << "\n ";
out << ")";
}
if (!m_groups.empty())
out << "\n ";
out << ")\n"
<< " :undefined-variables " << m_undef_vars << "\n"
<< " :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_states.num_states() << "\n"
<< " :pinned-expressions " << m_pin.size() << ")\n";
out << " :statistics\n"
<< " (:cofactor-calls " << m_stats.m_cofactor_calls;
for (unsigned i = 0; i < static_cast<unsigned>(bail_reason::num_reasons); ++i)
out << "\n :bail-" << bail_name(static_cast<bail_reason>(i)) << " " << m_stats.m_bails[i];
return out << "))\n";
}
void seq_monadic::collect_statistics(::statistics& st) const {
st.update("seq monadic cofactor calls", m_stats.m_cofactor_calls);
for (unsigned i = 0; i < static_cast<unsigned>(bail_reason::num_reasons); ++i)
st.update(bail_stat_name(static_cast<bail_reason>(i)), m_stats.m_bails[i]);
}