3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-07-12 10:06:23 +00:00

Lazy regex factorization via iterator

This commit is contained in:
CEisenhofer 2026-06-30 22:22:33 +02:00
parent ccfc355edb
commit f5baba1068
3 changed files with 277 additions and 73 deletions

View file

@ -408,6 +408,17 @@ public:
void simplify_split(split_set& s) { m_split.simplify(s); }
// Build the *suspended* sigma(r) split-set term (no expansion); drive it with
// iterate_split. Returns null on a non-regex argument. See seq_split.h.
expr_ref make_split(expr* r) { return m_split.make(r); }
// Create a lazy enumerator over a suspended split-set `node` (typically the
// result of make_split()). See seq_split::iterator for the arguments.
seq_split::iterator iterate_split(expr* node, unsigned threshold,
const split_mode mode = split_mode::strong, split_oracle const& oracle = {}) {
return m_split.iterate(node, mode, threshold, oracle);
}
// decompose a membership constraint into a set of pairs of regex splits
std::pair<expr_ref, expr_ref> split_membership(expr* str, expr* regex, unsigned threshold, split_set& result) const {
return m_split.split_membership(str, regex, threshold, result);

View file

@ -72,6 +72,21 @@ namespace seq {
return result;
}
// Suspended state of a lazy regex factorization (apply_regex_factorization).
// One rf_state drives the whole binary "remaining splits" chain for a single
// membership: it owns the lazy split iterator and remembers the chosen
// head/tail boundary plus the leading constant run consumed from the tail.
struct rf_state {
str_mem m_mem; // the membership being factorized (kept on child B)
euf::snode const* m_head; // prefix boundary (head ∈ Δ)
euf::snode const* m_tail; // suffix boundary, const run already consumed (tail ∈ ∇)
zstring m_c; // leading constant run consumed from the tail
seq_split::iterator m_iter; // lazy split enumerator, shared down the child-B chain
rf_state(str_mem const& mem, euf::snode const* head, euf::snode const* tail,
zstring const& c, seq_split::iterator&& it) :
m_mem(mem), m_head(head), m_tail(tail), m_c(c), m_iter(std::move(it)) {}
};
std::pair<euf::snode const*, euf::snode const*> split_membership(euf::snode const *str, euf::snode const *regex, euf::sgraph& sg, unsigned threshold, split_set& result) {
seq_util& seq = sg.get_seq_util();
ast_manager& m = sg.get_manager();
@ -636,7 +651,7 @@ namespace seq {
nielsen_graph::nielsen_graph(euf::sgraph &sg, sub_solver_i &solver, context_solver_i &ctx_solver) :
m(sg.get_manager()), a(sg.get_manager()), m_seq(sg.get_seq_util()), m_sg(sg), m_rw(m), m_a_rw(m),
m_sk(m, m_rw), m_length_solver(solver), m_context_solver(ctx_solver), m_parikh(alloc(seq_parikh, sg)),
m_seq_regex(alloc(seq::seq_regex, sg)), m_partial_dfa_pin(sg.get_manager()) {
m_seq_regex(alloc(seq::seq_regex, sg)), m_split_rw(sg.get_manager()), m_partial_dfa_pin(sg.get_manager()) {
}
nielsen_graph::~nielsen_graph() {
@ -736,6 +751,12 @@ namespace seq {
for (nielsen_edge *e : m_edges) {
dealloc(e);
}
// suspended factorization iterators (release their pinned expressions
// while m_split_rw / the ast_manager are still alive)
for (rf_state* st : m_rf_states) {
dealloc(st);
}
m_rf_states.reset();
m_nodes.reset();
m_edges.reset();
m_root = nullptr;
@ -2282,8 +2303,13 @@ namespace seq {
// reasons, this node is unsat too — independently of its (integer) side
// constraints — so we prune without re-exploring its subtree. We derive
// the conflict from this node's own constraint deps (a sound over-approx).
//
// A lazy-factorization continuation node (rf_cont set) is EXEMPT: it shares
// its parent's string constraints (only the suspended split iterator
// differs), so it would alias the parent's signature, yet it still has
// pending splits to explore — it is not a true recurrence.
{
if (m_unsat_node_cache.contains(node)) {
if (!node->rf_cont() && m_unsat_node_cache.contains(node)) {
node->set_conflict(backtrack_reason::sibling, nullptr /*we use the one of the sibling*/);
node->set_general_conflict();
node->m_unsat_cacheable = true;
@ -2377,7 +2403,12 @@ namespace seq {
// with string-only conflicts and self-contained cuts (see the epilogue).
// -------------------------------------------------------------------
node->canonize_and_compute_final_node_hash();
{
// A lazy-factorization continuation node (rf_cont set) is EXEMPT from the
// loop-cut: it aliases its parent's string signature (only the suspended
// split iterator differs) but is not a true recurrence — it still has
// pending splits. The iterator is finite, so the continuation chain
// terminates on its own (exhaustion → regex conflict).
if (!node->rf_cont()) {
auto it = m_siblings.find(node);
if (it != m_siblings.end() && !it->second.empty()) {
nielsen_node* anc = it->second.back(); // deepest sibling still on the path
@ -4012,16 +4043,185 @@ namespace seq {
// Modifier: apply_regex_factorization (Boolean Closure)
// -----------------------------------------------------------------------
// Safety cap handed to the lazy split iterator. Large by design: the whole
// point of the lazy factorization is that the binary child-B chain walks the
// splits one at a time, so the count must not bound how many splits we may
// explore. It still guards internal materialisation of intersection /
// complement bodies against runaway space blow-up.
static const unsigned RF_LAZY_CAP = 1u << 20;
// The cycle machinery (apply_cycle_decomposition) owns variables it has put
// under a noloop guard: factorizing such a variable's membership would split
// it in a way that violates the guard's premise (that the variable is handled
// by the stabilizer/guard decomposition), yielding a spurious conflict. So
// factorization defers whenever the leading token is a guarded variable.
static bool leading_var_guarded(nielsen_node const* node, euf::snode const* lead) {
for (str_mem const& g : node->str_mems())
if (g.is_guard() && g.m_str && g.m_str->first() == lead)
return true;
return false;
}
rf_state* nielsen_graph::mk_rf_state(nielsen_node* /*node*/, str_mem const& mem) {
euf::snode const* const first = mem.m_str->first();
SASSERT(first);
SASSERT(!first->is_char()); // constants are consumed earlier
// Choose the factorization boundary so the tail starts with the LONGEST
// run of concrete characters c — this gives the split-engine lookahead
// oracle the most pruning information. head = u' (tokens before the run),
// tail = c · u''' (tokens from the run onward).
euf::snode_vector toks;
mem.m_str->collect_tokens(toks);
const unsigned total = toks.size();
unsigned run_start = 0, run_len = 0;
for (unsigned i = 0; i < total; ) {
if (!toks[i]->is_char()) { ++i; continue; }
unsigned j = i;
while (j < total && toks[j]->is_char()) ++j;
if (j - i > run_len) { run_len = j - i; run_start = i; }
i = j;
}
// No constant run → fall back to splitting off the first token.
const unsigned p = run_len == 0 ? 1 : run_start;
SASSERT(p >= 1);
euf::snode const* head = p == 1 ? first : m_sg.drop_right(mem.m_str, total - p);
SASSERT(head);
// Build the constant lookahead c and (if non-empty) an oracle that prunes
// splits whose ∇ cannot match c. The constant run is consumed from the
// tail per split (the δ_c derivative in rf_step), so the stored tail is
// u''' (c already removed).
zstring c;
for (unsigned i = 0; i < run_len; ++i) {
expr* ch = nullptr;
unsigned cv = 0;
VERIFY(m_seq.str.is_unit(toks[run_start + i]->get_expr(), ch));
VERIFY(m_seq.is_const_char(ch, cv));
c = c + zstring(cv);
}
euf::snode const* tail = c.empty() ? m_sg.drop_left(mem.m_str, p)
: m_sg.drop_left(mem.m_str, run_start + run_len);
SASSERT(tail);
// Suspended sigma(regex): the iterator expands it one split at a time.
const expr_ref suspended = m_split_rw.make_split(mem.m_regex->get_expr());
if (!suspended)
return nullptr; // non-regex argument (should not happen for a well-formed mem)
split_oracle oracle;
if (!c.empty()) {
euf::sgraph& sg = m_sg;
oracle = [&sg, c](expr*, expr* n) { return split_lookahead_viable(n, sg, c); };
}
seq_split::iterator it =
m_split_rw.iterate_split(suspended, RF_LAZY_CAP, split_mode::strong, oracle);
rf_state* st = alloc(rf_state, mem, head, tail, c, std::move(it));
m_rf_states.push_back(st);
return st;
}
nielsen_graph::rf_step_result
nielsen_graph::rf_step(nielsen_node* node, rf_state* st, dep_tracker& conflict_dep) {
euf::snode const* const first = st->m_mem.m_str->first();
dep_tracker eliminated_dep = st->m_mem.m_dep;
expr_ref d(m), n(m);
while (st->m_iter.next(d, n)) {
// Consume the constant run c from the tail: tail = c·u''' ∈ ∇ ⟺
// u''' ∈ δ_c(∇) (Brzozowski). Drops any split whose ∇ cannot start
// with c (there δ_c(∇) = ∅). Identity when c is empty.
euf::snode const* sn_q = m_sg.mk(n);
for (unsigned k = 0; sn_q && !sn_q->is_fail() && k < st->m_c.length(); ++k)
sn_q = m_sg.brzozowski_deriv(sn_q, m_sg.mk_char(st->m_c[k]));
SASSERT(sn_q);
if (sn_q->is_fail())
continue; // ∇ can't start with c → infeasible split, skip
euf::snode const* sn_p = m_sg.mk(d);
// Feasibility: Δ must be non-empty. When head is the single token
// `first`, also intersect with other primitive constraints on `first`;
// for a multi-token head Δ constrains the whole prefix, so we only
// check Δ ≠ ∅.
euf::snode_vector regexes_p;
regexes_p.push_back(sn_p);
dep_tracker first_filter_dep = nullptr;
if (st->m_head == first) {
for (auto const& prev_mem : node->str_mems()) {
if (prev_mem.m_str == first) {
regexes_p.push_back(prev_mem.m_regex);
first_filter_dep = m_dep_mgr.mk_join(first_filter_dep, prev_mem.m_dep);
}
}
}
if (m_seq_regex->check_intersection_emptiness(regexes_p, 100) == l_true) {
eliminated_dep = m_dep_mgr.mk_join(eliminated_dep, first_filter_dep);
continue; // infeasible split → skip without branching
}
const dep_tracker split_dep = m_dep_mgr.mk_join(st->m_mem.m_dep, first_filter_dep);
// child A — the "first case": apply this split and drop the original
// membership.
nielsen_node* child_a = mk_child(node);
mk_edge(node, child_a, "regex fact", true);
auto& child_mems = child_a->str_mems();
for (unsigned k = 0; k < child_mems.size(); ++k) {
if (child_mems[k] == st->m_mem) {
child_mems[k] = child_mems.back();
child_mems.pop_back();
break;
}
}
child_a->add_str_mem(str_mem(m, st->m_head, sn_p, split_dep));
child_a->add_str_mem(str_mem(m, st->m_tail, sn_q, split_dep));
// child B — the "did not use the first case" branch: keep the
// membership and hand down the SAME iterator so factorization resumes
// from the next split. No substitution: child B is an exact clone, so
// st->m_mem stays valid down the whole chain.
nielsen_node* child_b = mk_child(node);
mk_edge(node, child_b, "regex fact rest", true);
child_b->set_rf_cont(st);
return rf_step_result::branched;
}
// No feasible split remained.
conflict_dep = eliminated_dep;
return st->m_iter.gave_up() ? rf_step_result::gaveup : rf_step_result::conflict;
}
bool nielsen_graph::apply_regex_factorization(nielsen_node* node) {
if (m_regex_factorization_threshold == 0)
return false;
struct rf_split {
euf::snode const* m_p;
euf::snode const* m_q;
dep_tracker m_dep;
};
// Continuation: resume the iterator handed down to this node by its
// parent's "remaining splits" branch.
if (rf_state* st = node->rf_cont()) {
node->set_rf_cont(nullptr); // the iterator migrates to child B (or is dropped)
// If the cycle machinery has, in the meantime, put the leading variable
// under a guard, stop factorizing and defer (the iterator is dropped).
if (leading_var_guarded(node, st->m_mem.m_str->first()))
return false;
dep_tracker conflict_dep = nullptr;
switch (rf_step(node, st, conflict_dep)) {
case rf_step_result::branched:
return true;
case rf_step_result::conflict:
// Every split has been tried: the membership's split disjunction
// is refuted on this branch.
node->set_general_conflict();
node->set_conflict(backtrack_reason::regex, conflict_dep);
return true;
case rf_step_result::gaveup:
return false; // engine give-up → let other modifiers handle the membership
}
}
// Fresh: find the first factorizable membership and start an iterator.
for (str_mem const& mem : node->str_mems()) {
SASSERT(mem.well_formed());
@ -4035,74 +4235,25 @@ namespace seq {
if (!mem.is_plain())
continue;
split_set pairs;
auto [head, tail] = split_membership(mem.m_str, mem.m_regex, sg(), m_regex_factorization_threshold, pairs);
if (!head) {
SASSERT(!tail);
// Defer to the cycle machinery when the leading variable is guarded.
if (leading_var_guarded(node, mem.m_str->first()))
continue;
}
SASSERT(tail);
euf::snode const* const first = mem.m_str->first();
rf_state* st = mk_rf_state(node, mem);
if (!st)
continue; // unsupported regex shape → try the next membership
vector<rf_split> feasible;
dep_tracker eliminated_dep = mem.m_dep;
for (auto const &[tp, tq] : pairs) {
euf::snode const* sn_p = m_sg.mk(tp);
euf::snode const* sn_q = m_sg.mk(tq);
// Also check intersection with other primitive constraints on `head`.
// Only valid when head is the single token `first`; for a multi-token
// head Δ constrains the whole prefix, so we only check Δ ≠ ∅.
euf::snode_vector regexes_p;
regexes_p.push_back(sn_p);
dep_tracker first_filter_dep = nullptr;
if (head == first) {
for (auto const& prev_mem : node->str_mems()) {
if (prev_mem.m_str == first) {
regexes_p.push_back(prev_mem.m_regex);
first_filter_dep = m_dep_mgr.mk_join(first_filter_dep, prev_mem.m_dep);
}
}
}
if (m_seq_regex->check_intersection_emptiness(regexes_p, 100) == l_true) {
eliminated_dep = m_dep_mgr.mk_join(eliminated_dep, first_filter_dep);
continue;
}
feasible.push_back({ sn_p, sn_q, m_dep_mgr.mk_join(mem.m_dep, first_filter_dep) });
if (feasible.size() > m_regex_factorization_threshold)
break;
}
if (feasible.empty()) {
node->set_general_conflict();
node->set_conflict(backtrack_reason::regex, eliminated_dep);
dep_tracker conflict_dep = nullptr;
switch (rf_step(node, st, conflict_dep)) {
case rf_step_result::branched:
return true;
case rf_step_result::conflict:
node->set_general_conflict();
node->set_conflict(backtrack_reason::regex, conflict_dep);
return true;
case rf_step_result::gaveup:
continue; // engine gave up on this membership → try the next one
}
if (feasible.size() > m_regex_factorization_threshold)
continue;
for (auto& [m_p, m_q, m_dep] : feasible) {
nielsen_node* child = mk_child(node);
mk_edge(node, child, "regex fact", true);
// remove the original mem from child
auto& child_mems = child->str_mems();
for (unsigned k = 0; k < child_mems.size(); ++k) {
if (child_mems[k] == mem) {
child_mems[k] = child_mems.back();
child_mems.pop_back();
break;
}
}
child->add_str_mem(str_mem(m, head, m_p, m_dep));
child->add_str_mem(str_mem(m, tail, m_q, m_dep));
}
return true;
}
return false;
}

View file

@ -163,7 +163,12 @@ namespace seq {
// and arithmetic <= dependencies.
void deps_to_lits(dep_manager &dep_mgr, dep_tracker deps, svector<enode_pair> &eqs, svector<sat::literal> &lits);
// decompose a membership constraint into a set of pairs of regex splits
// suspended state of a lazy regex factorization (see apply_regex_factorization).
struct rf_state;
// decompose a membership constraint into a set of pairs of regex splits.
// Eagerly materialises the full split-set (used by the eager propagation path
// in theory_nseq::propagate_pos_mem); the lazy Nielsen path uses rf_state.
std::pair<euf::snode const*, euf::snode const*> split_membership(euf::snode const* str, euf::snode const* regex, euf::sgraph& sg, unsigned threshold, split_set& result);
// Lookahead oracle for the split engine: is the split's right component
@ -589,6 +594,12 @@ namespace seq {
// Parikh filter: set to true once apply_parikh_to_node has been applied
// to this node. Prevents duplicate constraint generation across DFS runs.
bool m_parikh_applied = false;
// Lazy regex factorization continuation. Set only on a "remaining splits"
// child created by apply_regex_factorization: it carries the suspended
// split iterator so factorization resumes from the next split when this
// node is extended. Owned by nielsen_graph::m_rf_states (raw pointer here).
rf_state* m_rf_cont = nullptr;
// number of constraints inherited from the parent node at clone time.
// constraints[0..m_parent_ic_count) are already asserted at the
// parent's solver scope; only [m_parent_ic_count..end) need to be
@ -644,6 +655,10 @@ namespace seq {
nielsen_edge* parent_edge() const { return m_parent_edge; }
void set_parent_edge(nielsen_edge* e) { m_parent_edge = e; }
// lazy regex factorization continuation (see m_rf_cont).
rf_state* rf_cont() const { return m_rf_cont; }
void set_rf_cont(rf_state* s) { m_rf_cont = s; }
// returns 0 if hash is unknown
unsigned hash() const {
return m_hash;
@ -897,6 +912,15 @@ namespace seq {
// inclusion, derivatives. Allocated in the constructor; owned by this graph.
seq_regex* m_seq_regex = nullptr;
// Persistent split engine driving the lazy regex factorization
// (apply_regex_factorization). A single instance kept here so the
// suspended split iterators stored in m_rf_states stay valid across
// search_dfs recursion and iterative deepening.
seq_rewriter m_split_rw;
// Owns the suspended factorization continuations (rf_state); nodes hold
// raw pointers into this pool. Freed in reset().
ptr_vector<rf_state> m_rf_states;
// Maps each variable to its current length term
// ptr_vector<euf::snode> m_length_trail;
@ -1344,9 +1368,27 @@ namespace seq {
// mirrors ZIPT's GPowerIntrModifier
bool apply_gpower_intr(nielsen_node* node);
// generalized regex factorization (Boolean closure derivation rule)
// generalized regex factorization (Boolean closure derivation rule).
// Lazy: instead of materialising every split ⟨Δ,∇⟩ at once and branching
// N-way, it pulls the splits one at a time from a suspended iterator and
// branches binary — child A applies the next feasible split (head∈Δ,
// tail∈∇, original membership dropped); child B keeps the membership and
// carries the SAME iterator (rf_state) so factorization resumes from the
// next split. When the iterator is exhausted the membership's split
// disjunction is refuted → the continuation node is a regex conflict.
bool apply_regex_factorization(nielsen_node* node);
// Build a suspended factorization (boundary head/tail + split iterator)
// for `mem`. Returns null if the regex shape is unsupported (the engine
// cannot even start a split). Allocated into m_rf_states.
rf_state* mk_rf_state(nielsen_node* node, str_mem const& mem);
enum class rf_step_result { branched, conflict, gaveup };
// Pull the next feasible split from `st` and, on success, create the two
// children of `node` (see apply_regex_factorization). On exhaustion sets
// `conflict_dep` and returns conflict; on engine give-up returns gaveup.
rf_step_result rf_step(nielsen_node* node, rf_state* st, dep_tracker& conflict_dep);
// helper for apply_gpower_intr: fires the substitution.
// `fwd=true` uses left-to-right decomposition; `fwd=false` mirrors ZIPT's
// backward (right-to-left) direction.