3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-08-09 23:42:21 +00:00

seq_monadic: add state-based search driver

Implement the state-based DFS redesign for the monadic regex solver
(the TODO in seq_monadic.cpp): keep a cursor per membership and expand
one shared variable across all memberships at once, intersecting the
per-variable component groups immediately to prune infeasible
shared-variable choices early. Gated behind config::m_state_search
(default true); the positional dfs_membership/dfs_atoms path is retained.

On the 1476-file benchmark set at 10s timeout this raises solved from
1364 to 1368 with no sat/unsat flips.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 57b9b87e-950a-49ea-bbb3-ed585646a5a9
This commit is contained in:
Nikolaj Bjorner 2026-08-03 10:20:05 -07:00
parent 8d95ca4dc1
commit 8ed4c75cd0
2 changed files with 292 additions and 1 deletions

View file

@ -396,6 +396,8 @@ void seq_monadic::reset_search() {
m_der_cache.reset();
m_nullable_cache.reset();
m_undef_vars = 0;
m_cursors.reset();
m_last_var = UINT_MAX;
reset_live_cache();
}
@ -578,6 +580,240 @@ lbool seq_monadic::dfs_atoms(unsigned mi, unsigned i, expr* R) {
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
// advance_cursor / initial_normalize). The state is complete when every cursor is
// complete, and accepting when additionally every variable group is non-empty.
lbool seq_monadic::advance_cursor(cursor& c, unsigned mi, expr* target) {
vector<atom> const& atoms = m_atoms[mi];
// 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 = atoms.size();
c.complete = true;
return l_true;
}
c.i += 1;
c.R = target;
// Eagerly consume the constant atoms following the variable (mirrors dfs_atoms walking
// a run of constants via der_elem), so that the cursor again exposes a variable head.
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
m_pin.push_back(d);
c.R = d;
c.i += 1;
}
if (c.i == atoms.size()) { // the remaining tail is epsilon
c.complete = true;
lbool nb = nullable(c.R);
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;
}
c.complete = false; // stopped on a variable head
return l_true;
}
lbool seq_monadic::initial_normalize() {
for (unsigned mi = 0; mi < m_cursors.size(); ++mi) {
cursor& c = m_cursors[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; // this membership is already empty
m_pin.push_back(d);
c.R = d;
c.i += 1;
}
// prepare() guarantees every membership has a variable, so c.i now points at a
// variable head (c.complete stays false). A membership of only constants would
// have been rejected by prepare().
c.complete = (c.i == atoms.size());
if (c.complete) {
// Defensive: no variable head (shouldn't happen); require the tail nullable.
lbool nb = nullable(c.R);
if (nb == l_false)
return l_false;
if (nb == l_undef)
++m_undef_vars;
}
}
return l_true;
}
lbool seq_monadic::accept_state() {
if (m_undef_vars > 0)
return l_undef; // some group / tail nullability gave up
if (!m_config.m_model)
return l_true; // groups already shown non-empty
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) {
m_model.reset();
return ne;
}
m_pin.push_back(w);
m_model.insert(m_vars[vi], w.get());
}
return l_true;
}
lbool seq_monadic::search() {
if (m_giveup)
return l_undef;
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;
// Gather the head variables of the active cursors and how often each occurs as a head.
unsigned best_vi = UINT_MAX, best_cnt = 0;
obj_map<expr, unsigned> head_cnt;
for (unsigned mi = 0; mi < m_cursors.size(); ++mi) {
cursor const& c = m_cursors[mi];
if (c.complete)
continue;
expr* v = m_atoms[mi][c.i].var.get();
unsigned cnt = 0;
head_cnt.find(v, cnt);
head_cnt.insert(v, ++cnt);
unsigned vi = m_var_idx[v];
// 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 && (best_vi == UINT_MAX || vi < best_vi))) {
best_cnt = cnt;
best_vi = vi;
}
}
if (best_vi == UINT_MAX)
return accept_state(); // every cursor complete
// 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 (m_last_var != UINT_MAX && m_last_var < m_vars.size()) {
unsigned lc = 0;
if (head_cnt.find(m_vars[m_last_var], lc) && lc > 0)
vi = m_last_var;
}
// All cursors whose current head is variable vi are expanded together at this step.
svector<unsigned> S;
expr* vv = m_vars[vi];
for (unsigned mi = 0; mi < m_cursors.size(); ++mi) {
cursor const& c = m_cursors[mi];
if (!c.complete && m_atoms[mi][c.i].var.get() == vv)
S.push_back(mi);
}
return choose_cont(vi, S, 0);
}
lbool seq_monadic::choose_cont(unsigned vi, svector<unsigned> const& S, unsigned k) {
if (m_giveup)
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 = S[k];
cursor& c = m_cursors[mi];
vector<atom> const& atoms = m_atoms[mi];
expr* R = c.R;
uint64_t pos = (static_cast<uint64_t>(mi) << 32) | c.i;
uint64_t last = 0;
bool finalize = m_last_occ.find(atoms[c.i].var.get(), last) && last == pos;
bool last_atom = (c.i + 1 == atoms.size());
// Enumerate this cursor's continuations for variable vi: a plain membership (null) when
// the variable is the last atom, otherwise every live reach target of R.
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; // gave up enumerating targets
for (expr* q : *Q)
targets.push_back(q);
}
bool any_undef = false;
for (expr* target : targets) {
m_groups[vi].push_back(component{ atoms[c.i].var.get(), R, target });
// Intersect immediately: prune as soon as vi's accumulated components are empty.
// The test is forced once the group is complete (past vi's last occurrence) 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 (finalize || m_groups[vi].size() > 1)
ne = group_nonempty(vi);
else
ne = l_true;
if (ne == l_false) {
m_groups[vi].pop_back();
continue; // 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();
continue;
}
unsigned undef_here = (ne == l_undef ? 1u : 0u) + (adv == l_undef ? 1u : 0u);
m_undef_vars += undef_here;
lbool r = choose_cont(vi, S, k + 1);
m_undef_vars -= undef_here;
c = saved;
m_groups[vi].pop_back();
if (r == l_true)
return l_true;
if (r == l_undef) {
if (m_giveup)
return l_undef;
any_undef = true;
}
}
return any_undef ? l_undef : l_false;
}
lbool seq_monadic::decide(membership_vec const& memberships) {
m_model.reset();
if (memberships.empty())
@ -590,7 +826,19 @@ lbool seq_monadic::decide(membership_vec const& memberships) {
m_giveup = false;
if (!prepare(memberships))
return l_undef;
lbool r = dfs_membership(0);
lbool r;
if (m_config.m_state_search) {
// Build one cursor per membership at its regex start; initial_normalize consumes
// leading constants so every active cursor exposes a variable head.
m_cursors.reset();
for (unsigned mi = 0; mi < m_atoms.size(); ++mi)
m_cursors.push_back(cursor{ 0, m_regexes.get(mi), false });
m_last_var = UINT_MAX;
lbool norm = initial_normalize();
r = (norm == l_false) ? l_false : search();
}
else
r = dfs_membership(0);
if (r != l_true)
m_model.reset();
return r;

View file

@ -112,6 +112,9 @@ private:
transition_mode m_mode;
bool m_model = true; // whether solve()/check() extract a feasible model
bool m_min_core = true; // whether check() minimizes the unsat core (else: all deps)
bool m_state_search = true; // use the state-based search driver (select next
// membership by the last-expanded / most-frequent
// head variable) instead of the positional DFS
config(transition_mode mode) : m_mode(mode) {}
};
@ -195,6 +198,18 @@ private:
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)
// ---- state-based search driver (see the "search state" note in the .cpp) ----
// A membership cursor: how far membership `mi` has been consumed on the current
// branch. `i` is the next unconsumed atom, `R` the derivative state of the regex
// after the consumed prefix, `complete` once every atom is consumed (and the tail is
// known nullable / covered by a membership component). The set of non-complete
// cursors is the "set of active membership constraints"; each non-complete cursor has
// a *variable* head (leading constants are eagerly consumed). The per-variable
// component groups (m_groups) are the "variable intersection membership constraints".
struct cursor { unsigned i; expr* R; bool complete; };
svector<cursor> m_cursors; // one cursor per membership (parallel to m_atoms)
unsigned m_last_var = UINT_MAX; // index (in m_vars) of the last expanded variable
// Brzozowski derivative of regex `r` by the concrete element `elem`. Memoized on
// (r, elem): the search revisits the same constant step on many branches.
expr_ref der_elem(expr* r, expr* elem);
@ -246,6 +261,34 @@ private:
lbool dfs_membership(unsigned mi);
lbool dfs_atoms(unsigned mi, unsigned i, expr* R);
// ---- state-based search driver ----------------------------------------------------
// Consume the leading constant atoms of every cursor so that each non-complete cursor
// has a variable head. l_false if some membership is already empty (unsat).
lbool initial_normalize();
// One search step: pick the next variable to expand (preferring the last-expanded
// variable, else the one occurring most often as a head atom of the active cursors),
// and expand it. Returns l_true (sat leaf found), l_false (this branch is empty), or
// l_undef (gave up on a sub-branch).
lbool search();
// Expand variable `vi`, which is the head of the cursors in `S`. Assign a continuation
// (a reach target q, or the epsilon/membership encoding for a last atom) to each cursor
// in turn (k indexes S), pushing the component on m_groups[vi] and pruning as soon as
// the accumulated intersection for vi is empty. When every cursor in S is assigned,
// recurse into search().
lbool choose_cont(unsigned vi, svector<unsigned> const& S, unsigned k);
// Advance cursor `mi` past its head variable to continuation `target` (null = the
// variable is a last atom, i.e. a plain membership component), then eagerly consume
// the following constant atoms. l_false = the continuation is empty (prune),
// l_undef = feasible but the tail nullability is unknown, l_true = feasible.
lbool advance_cursor(cursor& c, unsigned mi, expr* target);
// Every cursor is complete: the state is accepting iff every variable intersection is
// non-empty. Extracts witnesses into m_model when model generation is enabled.
lbool accept_state();
// Emptiness of the components accumulated for variable `vi` on the current branch,
// memoized on their signature. Duplicated components are collapsed before the
// product search (they constrain the variable identically).