mirror of
https://github.com/Z3Prover/z3
synced 2026-07-06 07:16:13 +00:00
## Summary
Fixes a **soundness regression** in the sequence/regex rewriter: a
symbolic character range such as `(re.range x x)` was unsoundly
collapsed to `re.empty`, causing a satisfiable membership constraint to
be reported `unsat`.
This was surfaced by the `snapshot-regression` corpus in
`Z3Prover/bench`.
- **Originating discussion:**
https://github.com/Z3Prover/bench/discussions/2761
- **Benchmark:** `iss-5873/bug-2.smt2` (in `Z3Prover/bench`, under
`inputs/issues/iss-5873/`)
- **z3 under test at capture:** `z3-4.17.0-x64-glibc-2.39` (Nightly)
## Divergence
The recorded oracle expects `sat`; current z3 returns `unsat`:
```diff
--- bug-2.expected.out (expected)
+++ produced (current z3)
@@ -1,3 +1,4 @@
-sat
-((tmp_str0 "\u{0}"))
+unsat
+(error "line 12 column 10: check annotation that says sat")
+(error "line 14 column 22: model is not available")
(:reason-unknown "")
```
The benchmark asserts (simplified):
```smt2
(assert (= (str.in_re (str.replace tmp_str0 tmp_str0 tmp_str0)
(re.range tmp_str0 tmp_str0))
(str.contains tmp_str0 tmp_str0)))
```
`str.contains x x` is always true and `str.replace x x x = x`, so this
requires `str.in_re x (re.range x x)` to hold, which is satisfiable
exactly when `x` is a single character (`len(x) = 1`).
## Root cause
`seq_rewriter::mk_re_range` treated any bound that is not a concrete
single-character literal as making the whole range **empty**:
```cpp
if (str().is_string(lo, slo) && slo.length() == 1) clo = slo[0];
else if (str().is_unit(lo, lo1) && m_util.is_const_char(lo1, clo)) ;
else is_empty = true; // unsound for a symbolic bound
```
For a symbolic bound this is unsound: `(re.range x x)` denotes `{x}`
whenever `x` is a single character, not `∅`. Collapsing it to `re.empty`
makes `str.in_re x (re.range x x)` false, contradicting the (true)
`str.contains x x`, so the solver derives an unsound `unsat`.
`git blame` attributes this unsound collapse to z3 commit `15f33f458d`
("Derive with ranges (#9965)"), which post-dates the oracle capture.
## Fix
Two surgical changes in `src/ast/rewriter/seq_rewriter.cpp`:
1. **`mk_re_range`** no longer assumes emptiness for symbolic bounds. It
concludes `re.empty` only when it can *prove* emptiness — a bound whose
length can never be 1, or two concrete bounds with `lo > hi`. When a
bound is symbolic it returns `BR_FAILED` and keeps the range. Concrete
single-character ranges keep their existing handling (`lo == hi →
str.to_re`, inverted → `re.empty`).
2. **`mk_str_in_regexp`** reduces membership in a range that has a
symbolic bound to the equivalent length/order constraints, which are
sound and complete under SMT-LIB `re.range` semantics:
`str.in_re e (re.range lo hi)` ⟶ `len(lo)=1 ∧ len(hi)=1 ∧ len(e)=1 ∧ lo
≤ e ∧ e ≤ hi`
(using `str.<=`). The derivative engine only unfolds ranges whose bounds
are concrete characters, so without this reduction a symbolic-bound
range would otherwise be left unsolved.
## Validation
Rebuilt z3 from this branch on the workflow runner (`./configure && make
-C build -j$(nproc)`) and re-ran the failing benchmark with the same
option the snapshot capture uses (`-T:20`):
```
$ z3 -T:20 inputs/issues/iss-5873/bug-2.smt2
sat
((tmp_str0 "A"))
(:reason-unknown "")
```
The verdict is now **`sat`** (was `unsat`) — the soundness regression is
resolved. A correctness battery over concrete and symbolic ranges all
returns the expected results, e.g.:
- `(str.in_re "b" (re.range "a" "c"))` → `sat`, `(str.in_re "d"
(re.range "a" "c"))` → `unsat`
- `(str.in_re x (re.range x x))` → `sat`; with `(= (str.len x) 2)` →
`unsat`
- `(str.in_re "b" (re.range x y))` → `sat`; with `(str.< y x)` → `unsat`
- `(str.in_re "" (re.range x y))` → `unsat`; `(str.in_re "ab" (re.range
"a" "c"))` → `unsat`
The pre-existing concrete-range derivative fast path is unchanged.
### Note on the model value (benign, unrelated to this fix)
The model value differs from the recorded oracle: current z3 prints
`((tmp_str0 "A"))` whereas the oracle recorded `((tmp_str0 "\u{0}"))`.
Both are valid single-character models (the formula has many). This
difference is **pre-existing and unrelated to this fix**: even a bare
`(assert (= (str.len x) 1))` yields `"A"` on current z3. It stems from
the seq/char theory's default character assignment for
otherwise-unconstrained characters (`theory_char.cpp` assigns fresh
characters starting from `'A'`), not from range handling. I deliberately
did **not** force the character to `\u{0}` — adding `x = "\u{0}"` would
be unsound over-constraining, and changing the global default character
is out of scope for this soundness fix and would perturb unrelated
models. The output is therefore semantically equivalent to the oracle
(same `sat` verdict and reason-unknown) but not byte-identical.
---
*Draft for human review. Diagnosed and fixed by the
`snapshot-regression-fixer` maintenance workflow.*
> Generated by [Fix a Z3 snapshot-regression
divergence](https://github.com/Z3Prover/bench/actions/runs/28502614658)
· 890.7 AIC · ⌖ 46.8 AIC · ⊞ 9K ·
[◷](https://github.com/search?q=repo%3AZ3Prover%2Fz3+%22gh-aw-workflow-id%3A+snapshot-regression-fixer%22&type=pullrequests)
<!-- gh-aw-agentic-workflow: Fix a Z3 snapshot-regression divergence,
engine: copilot, version: 1.0.63, model: claude-opus-4.8, id:
28502614658, workflow_id: snapshot-regression-fixer, run:
https://github.com/Z3Prover/bench/actions/runs/28502614658 -->
<!-- gh-aw-workflow-id: snapshot-regression-fixer -->
<!-- gh-aw-workflow-call-id: Z3Prover/bench/snapshot-regression-fixer
-->
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
259 lines
11 KiB
C++
259 lines
11 KiB
C++
/*++
|
||
Copyright (c) 2024 Microsoft Corporation
|
||
|
||
Regression tests for seq_rewriter smart constructors for regex ranges.
|
||
|
||
Tests:
|
||
1. Empty range (lo > hi) → re.none
|
||
2. Singleton range (lo == hi) → str.to_re lo
|
||
3. Range ∩ Range → reduced range or re.none
|
||
4. Range ∪ Range → merged range for overlapping/adjacent
|
||
5. Complement of range → one or two ranges
|
||
6. Downstream operators absorb empty ranges correctly
|
||
15. Symbolic-bound range membership rewrite (structural)
|
||
16. Symbolic-bound range membership: concrete element, symbolic bounds (structural)
|
||
17. Solver: (str.in_re x (re.range x x)) sat when len(x)=1
|
||
18. Solver: (str.in_re x (re.range x x)) unsat when len(x)=2
|
||
19. Solver: inverted symbolic bounds make membership unsatisfiable
|
||
--*/
|
||
|
||
#include "ast/arith_decl_plugin.h"
|
||
#include "ast/ast_pp.h"
|
||
#include "ast/reg_decl_plugins.h"
|
||
#include "ast/rewriter/th_rewriter.h"
|
||
#include "ast/seq_decl_plugin.h"
|
||
#include "smt/smt_context.h"
|
||
#include <iostream>
|
||
|
||
// Build a single-char string literal expression.
|
||
static expr_ref mk_str(ast_manager& m, seq_util& su, unsigned c) {
|
||
return expr_ref(su.str.mk_string(zstring(c)), m);
|
||
}
|
||
|
||
void tst_seq_rewriter() {
|
||
ast_manager m;
|
||
reg_decl_plugins(m);
|
||
th_rewriter rw(m);
|
||
seq_util su(m);
|
||
|
||
sort* str_sort = su.str.mk_string_sort();
|
||
sort* re_sort = su.re.mk_re(str_sort);
|
||
|
||
auto range = [&](unsigned lo, unsigned hi) -> expr_ref {
|
||
return expr_ref(su.re.mk_range(mk_str(m, su, lo), mk_str(m, su, hi)), m);
|
||
};
|
||
|
||
// Arbitrary regex variable for downstream tests.
|
||
app_ref R(m.mk_fresh_const("R", re_sort), m);
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 1. Empty range (lo > hi) → re.none
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e = range('z', 'a');
|
||
rw(e);
|
||
std::cout << "empty range lo>hi: " << mk_pp(e, m) << "\n";
|
||
ENSURE(su.re.is_empty(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 2. Singleton range (lo == hi) → str.to_re lo
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e = range('a', 'a');
|
||
rw(e);
|
||
std::cout << "singleton range: " << mk_pp(e, m) << "\n";
|
||
expr* inner = nullptr;
|
||
ENSURE(su.re.is_to_re(e, inner));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 3. Range intersection: overlapping → smaller range
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_inter(range('a', 'z'), range('f', 'k')), m);
|
||
rw(e);
|
||
std::cout << "range inter overlapping: " << mk_pp(e, m) << "\n";
|
||
unsigned lo = 0, hi = 0;
|
||
ENSURE(su.re.is_range(e, lo, hi) && lo == 'f' && hi == 'k');
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 4. Range intersection: disjoint → re.none
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_inter(range('a', 'f'), range('k', 'z')), m);
|
||
rw(e);
|
||
std::cout << "range inter disjoint: " << mk_pp(e, m) << "\n";
|
||
ENSURE(su.re.is_empty(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 5. Range intersection: touching at boundary → singleton (str.to_re "f")
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_inter(range('a', 'f'), range('f', 'z')), m);
|
||
rw(e);
|
||
std::cout << "range inter touching: " << mk_pp(e, m) << "\n";
|
||
expr* inner = nullptr;
|
||
ENSURE(su.re.is_to_re(e, inner));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 6. Range union: overlapping → merged range
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_union(range('a', 'f'), range('e', 'k')), m);
|
||
rw(e);
|
||
std::cout << "range union overlapping: " << mk_pp(e, m) << "\n";
|
||
unsigned lo = 0, hi = 0;
|
||
ENSURE(su.re.is_range(e, lo, hi) && lo == 'a' && hi == 'k');
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 7. Range union: adjacent → merged range
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_union(range('a', 'f'), range('g', 'k')), m);
|
||
rw(e);
|
||
std::cout << "range union adjacent: " << mk_pp(e, m) << "\n";
|
||
unsigned lo = 0, hi = 0;
|
||
ENSURE(su.re.is_range(e, lo, hi) && lo == 'a' && hi == 'k');
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 8. Range union: disjoint → stays as union
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_union(range('a', 'c'), range('m', 'z')), m);
|
||
rw(e);
|
||
std::cout << "range union disjoint (stays as union): " << mk_pp(e, m) << "\n";
|
||
ENSURE(!su.re.is_range(e));
|
||
}
|
||
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 11. Downstream: (re.* (re.range "z" "a")) → str.to_re ""
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_star(range('z', 'a')), m);
|
||
rw(e);
|
||
std::cout << "star of empty range: " << mk_pp(e, m) << "\n";
|
||
expr* inner = nullptr;
|
||
// star of empty → epsilon (str.to_re "")
|
||
ENSURE(su.re.is_to_re(e, inner) && su.str.is_empty(inner));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 12. Downstream: concat absorbs empty range → re.none
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_concat(R, su.re.mk_concat(range('z', 'a'), R)), m);
|
||
rw(e);
|
||
std::cout << "concat absorbs empty range: " << mk_pp(e, m) << "\n";
|
||
ENSURE(su.re.is_empty(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 13. Downstream: union absorbs empty range → R
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_union(R, range('z', 'a')), m);
|
||
rw(e);
|
||
std::cout << "union absorbs empty range: " << mk_pp(e, m) << "\n";
|
||
ENSURE(e.get() == R.get());
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 14. Downstream: inter absorbs empty range → re.none
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
expr_ref e(su.re.mk_inter(R, range('z', 'a')), m);
|
||
rw(e);
|
||
std::cout << "inter absorbs empty range: " << mk_pp(e, m) << "\n";
|
||
ENSURE(su.re.is_empty(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 15. Symbolic-bound range membership rewrite (structural).
|
||
// (str.in_re x (re.range x x)) with symbolic x should be unfolded
|
||
// by the rewriter into a conjunction of length and ordering
|
||
// constraints, not left stuck as an uninterpreted membership term.
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
app_ref x(m.mk_fresh_const("x", str_sort), m);
|
||
expr_ref rng(su.re.mk_range(x, x), m);
|
||
expr_ref e(su.re.mk_in_re(x, rng), m);
|
||
rw(e);
|
||
std::cout << "symbolic range (x in [x,x]): " << mk_pp(e, m) << "\n";
|
||
ENSURE(m.is_and(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 16. Symbolic-bound range membership: concrete element, symbolic bounds.
|
||
// (str.in_re "b" (re.range lo hi)) should also be unfolded to a
|
||
// conjunction when lo/hi are free variables.
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
app_ref lo(m.mk_fresh_const("lo", str_sort), m);
|
||
app_ref hi(m.mk_fresh_const("hi", str_sort), m);
|
||
expr_ref b_str(su.str.mk_string(zstring('b')), m);
|
||
expr_ref rng(su.re.mk_range(lo, hi), m);
|
||
expr_ref e(su.re.mk_in_re(b_str, rng), m);
|
||
rw(e);
|
||
std::cout << "symbolic range (\"b\" in [lo,hi]): " << mk_pp(e, m) << "\n";
|
||
ENSURE(m.is_and(e));
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Solver-level tests: the unfolded conjunction must be decidable.
|
||
// -----------------------------------------------------------------------
|
||
{
|
||
arith_util a_util(m);
|
||
|
||
// 17. sat: (str.in_re x (re.range x x)) ∧ len(x)=1
|
||
{
|
||
smt_params sp;
|
||
smt::context ctx(m, sp);
|
||
app_ref x(m.mk_fresh_const("x", str_sort), m);
|
||
ctx.assert_expr(su.re.mk_in_re(x, su.re.mk_range(x, x)));
|
||
ctx.assert_expr(m.mk_eq(su.str.mk_length(x), a_util.mk_int(1)));
|
||
lbool res = ctx.check();
|
||
std::cout << "symbolic range solver sat (len=1): " << res << "\n";
|
||
ENSURE(res == l_true);
|
||
}
|
||
|
||
// 18. unsat: (str.in_re x (re.range x x)) ∧ len(x)=2
|
||
// The unfolded membership requires len(x)=1, which contradicts len(x)=2.
|
||
{
|
||
smt_params sp;
|
||
smt::context ctx(m, sp);
|
||
app_ref x(m.mk_fresh_const("x", str_sort), m);
|
||
ctx.assert_expr(su.re.mk_in_re(x, su.re.mk_range(x, x)));
|
||
ctx.assert_expr(m.mk_eq(su.str.mk_length(x), a_util.mk_int(2)));
|
||
lbool res = ctx.check();
|
||
std::cout << "symbolic range solver unsat (len=2): " << res << "\n";
|
||
ENSURE(res == l_false);
|
||
}
|
||
|
||
// 19. unsat: inverted symbolic bounds make membership false.
|
||
// (str.in_re "b" (re.range lo hi)) ∧ lo="z" ∧ hi="a"
|
||
// The unfolded conjunction requires lo <=_lex "b" <=_lex hi, but
|
||
// "z" > "b" > "a" so the ordering constraints are unsatisfiable.
|
||
{
|
||
smt_params sp;
|
||
smt::context ctx(m, sp);
|
||
app_ref lo(m.mk_fresh_const("lo", str_sort), m);
|
||
app_ref hi(m.mk_fresh_const("hi", str_sort), m);
|
||
expr_ref b_str(su.str.mk_string(zstring('b')), m);
|
||
ctx.assert_expr(su.re.mk_in_re(b_str, su.re.mk_range(lo, hi)));
|
||
ctx.assert_expr(m.mk_eq(lo, su.str.mk_string(zstring('z'))));
|
||
ctx.assert_expr(m.mk_eq(hi, su.str.mk_string(zstring('a'))));
|
||
lbool res = ctx.check();
|
||
std::cout << "symbolic range solver inverted bounds unsat: " << res << "\n";
|
||
ENSURE(res == l_false);
|
||
}
|
||
}
|
||
|
||
std::cout << "tst_seq_rewriter: all tests passed\n";
|
||
}
|