Seed the range-set-integration branch with a handoff plan summarizing the current range_predicate / seq_range_collapse infrastructure in master, the two candidate integration approaches (first-class re.ranges AST node vs. deeper internal-only use of range-sets), and a concrete evaluation plan against master on the regex-equivalence and QF_S benchmarks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
12 KiB
Range-Set Integration — Handoff Plan
Status: planning / clean slate
Branch: veanes/range-set-integration (based on master)
Author of range-set infrastructure: Margus Veanes (veanes), 2026
This document is the starting point for follow-up work on tighter integration of
range-sets into the regex/sequence solver. It summarizes the current state in
master (after the "Derive with ranges" merge, PR #9965), lays out the two
candidate integration approaches we discussed (the invasive re.ranges AST node
vs. an internal-only deepening), and gives a concrete evaluation plan so the
branch can be measured against master on the same benchmarks used during the
derivative work.
1. Current state in master (baseline)
The "Derive with ranges" work already landed a self-contained range-set value type and a translator, but range-sets are not first-class in the regex AST. They live only as a transient internal representation at the smart-constructor boundary.
1.1 range_predicate — the range-set value type
- Files:
src/ast/rewriter/seq_range_predicate.h,seq_range_predicate.cpp - A canonical subset of the unsigned character domain
[0, max_char], stored as a sorted vector of disjoint, non-adjacent ranges[(lo_0,hi_0), ...]withhi_i + 1 < lo_{i+1}. - Canonical: two predicates over the same domain are extensionally equal iff their internal vectors are elementwise equal.
- Full Boolean algebra, all linear in the number of ranges:
operator| & - ^ ~, plusempty/top/singleton/range,contains,cardinality,equals,operator<,hash,display. - Pure value: no
ast_managerallocation in construction or Boolean ops. Conversion to/fromexpr*is delegated to a separate translator.
1.2 seq_range_collapse — the regex <-> range_predicate translator
- Files:
src/ast/rewriter/seq_range_collapse.h,seq_range_collapse.cpp regex_to_range_predicate(u, r, out): recognizes the boolean-combination-of-ranges fragment (re.empty,re.allchar,re.range,re.union,re.inter,re.diff, andinterwith acomplementoperand → difference) and folds it into a canonicalrange_predicate. Returnsfalse(caller falls back to the generic path) for anything outside the fragment.range_predicate_to_regex(u, p, seq_sort): materializes a predicate back into AST as a right-associated union of singlere.rangenodes, sorted by expression id to match the canonical shapeseq_rewriter::merge_regex_setsexpects.
1.3 Where it is used today (the only integration point)
src/ast/rewriter/seq_rewriter.cpp, smart constructors:try_collapse_re_union(~3690):pa | pbtry_collapse_re_inter(~3703):pa & pbtry_collapse_re_diff(~3866):pa - pb
- Pattern in each: collapse both operands to
range_predicate, run the Boolean op on the value type, materialize back to AST. If either operand is outside the fragment, bail out and use the generic regex constructor. seq_derive.cppdoes not userange_predicatedirectly — the derivative engine still works over regex AST + ITE path conditions; range-sets only touch the union/inter/diff smart constructors.
1.4 Latent AST vehicle already present
OP_RE_RANGE(re.range): the single-range character class.OP_RE_OF_PRED(re.of.pred,is_of_pred): a regex that accepts a single character satisfying a predicate — already declared and signed inseq_decl_plugin.cpp(sig at ~240, build at ~1206, nullable/printer cases at ~410/1111/1694). This is the natural existing hook for a first-class character-class / range-set node.
1.5 Key invariants that any integration MUST preserve
- Soundness: 0 sat/unsat mismatches vs
masteron the corpus (this held through all derivative work; it is the gate). - Domain guard: the range algebra only models length-1 character classes
over
[0, max_char]. Reject non-string regex element sorts up front (seeregex_to_range_predicateguard) — never fabricate a char-class for a regex over(Seq Int)etc. re.complementis sequence-level, not character-level. Its language includesepsilon, length >= 2 strings, and length-1 strings outside the operand. It must not be collapsed to char-level~. De-Morgan / empty / full special cases are handled inmk_re_complement. (See the long NOTE inseq_range_collapse.cpp.)- Canonical id-sorted union shape on materialization, or
merge_regex_setsproduces wrong unions. - Derivative-kind gating (from prior work, now in
master): intersection- over-union and concat-over-union distribution are gated onderivative_kind::antimirov_t; brzozowski mode (used byregex_bisimand the emptiness enumeration) keeps intersections above unions. Do not regress this.
2. Candidate approaches
Approach A — first-class re.ranges node (most invasive)
Make a range-set a single regex AST node, either by introducing a new internal
op (e.g. _OP_RE_RANGES) or by reusing OP_RE_OF_PRED with a range-set
encoded predicate so a length-1 character class is one node carrying a
range_predicate (or a canonical predicate term).
Potential benefits:
- Eliminates the repeated collapse <-> materialize round-trips; a char class is created, hashed, and shared once.
- Removes the union-of-
re.rangeblowup in the stored AST: one node instead of N union leaves; structural sharing + hash-consing give O(1) equality on char classes. - Derivative head conditions / OneStep predicates become single nodes; bisim and emptiness compare char classes by pointer/hash instead of walking unions.
- Cleaner normal form:
union/inter/diff/complementof char classes fold into the node directly.
Costs / risks (this is the invasive part):
- Touches every site that pattern-matches the regex AST:
seq_rewriter,seq_derive,seq_regex(theory),seq_decl_plugin(nullable, derivative, pretty-printer, well-formedness),sls_seq_plugin, NNF/translation, model construction, and anyis_union/is_rangematcher that currently assumes the union-of-ranges shape. - Needs nullable + derivative rules for the new node (a length-1 class is never
nullable; its derivative wrt
cisepsilonifc in setelseempty). - Sub-decision: internal-only (never exposed to SMT-LIB / API / models; rewritten away at the boundary) vs fully exposed (parser, printer, proofs, model values, API, pretty-printing all updated). Internal-only is strongly preferred as the first step — much smaller blast radius.
Approach B — deepen internal range-set use, keep it out of the AST (least invasive)
Keep range_predicate as a transient value type (no AST node), but use it in
more places and cache it harder.
Possible moves:
- Memoize
regex_to_range_predicateresults keyed onexpr*id so the collapse cost is paid once per char-class subterm. - Use
range_predicatefor the char-class arithmetic inside derivative path conditions / OneStep instead of building ITE/union AST and re-collapsing. - Extend the recognized fragment (carefully — respecting the
re.complementcaveat) and short-circuit more constructor cases through the value type.
Benefits: low risk, incremental, no AST/API/printer/model changes, all existing matchers keep working; easy to land and measure piecewise. Costs: the collapse/materialize cost at the AST boundary remains; the union-of-ranges blowup still exists in any AST that is stored or compared structurally (bisim/emptiness still walk unions).
Recommended phasing
- Land Approach B's caching/memoization first as a measurable baseline — it is cheap and de-risks the corpus.
- Prototype Approach A internal-only via
OP_RE_OF_PREDon the single hottest path (derivative char-class conditions or the union/inter/diff constructor output), keeping it rewritten-away at the SMT-LIB/API boundary. - Only consider full exposure (parser/printer/model/API) if the internal-only prototype shows a clear, sustained win on the corpus.
3. Evaluation plan
Goal: measure this branch against master on the same benchmarks used during
the derivative work, with soundness as the gate (0 mismatches).
3.1 Benchmarks
- Regex-equivalence corpus:
C:\git\bench\inputs\regex-equivalence(1523.smt2files). Key sub-dirs:cade29[_equiv]\parametric,cade29[_equiv]\boolean_and_loops,mutations[_equiv],realworld\{email,url,snort}. - QF_S:
C:\git\bench\inputs\QF_S(e.g.20230329-automatark-lu,20230329-woorpje-lu,20240318-omark,2019-Jiang, ...).
3.2 Harness (already present, untracked)
C:\git\z3\.regtests\run_branch_only.ps1— runs one solver over a directory, emits CSVfile, branch_result, branch_ms. Param:-Out <csv>(NOT-OutCsv),-Timeout <sec>(default 10),-Dir <path>.C:\git\z3\.regtests\run_regex_equiv.ps1— runs both solvers, emitsfile, branch_res, branch_ms, master_res, master_ms, status.- Baseline
masternumbers do not change — reuse the cached master columns rather than re-running master.
3.3 Metric / comparison method (proven during derivative work)
- Normalize verdicts to
sat/unsat/to(timeout/unknown/empty/error annotations stripped) before comparing — CSV-quoting of(error ...)self- check noise from mutation benchmarks otherwise causes false MISMATCH. - Report:
- branch-only timeouts vs master (branch times out, master solves) — the primary regression signal,
- branch wins (master times out, branch solves),
- soundness mismatches — must be 0.
- Reference numbers from the derivative branch at hand-off (regex-equivalence, 10s timeout): the shipped config had 14 branch-only timeouts and 13 branch wins vs master, 0 mismatches. Use that as the bar to beat; a range-set change should not increase branch-only timeouts.
3.4 Build (Windows / CMake + Ninja)
& $env:ComSpec /c 'call "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Auxiliary\Build\vcvars64.bat" >nul && cd /d C:\git\z3\build\release && ninja shell test-z3'
Unit tests: C:\git\z3\build\release\test-z3.exe /a (expect all pass).
(The 'vswhere.exe' is not recognized warning is harmless.)
3.5 Procedure
- Build
shell+test-z3;test-z3 /agreen. run_branch_only.ps1overregex-equivalence-> CSV; compare to cached master baseline with the normalized-verdict comparison.- Repeat over a QF_S subset (start with
20230329-automatark-lu). - Gate: 0 mismatches; branch-only timeouts <= 14 on regex-equivalence.
- Spot-check the historically hard cases:
flat_vs_loop_018_n20,det_blowup_024(direct + equiv), a fewmut_*sat cases, and the5721/5728/5731regressions.
4. Pointers / file index
| Area | File | Notes |
|---|---|---|
| Range-set value type | src/ast/rewriter/seq_range_predicate.{h,cpp} |
canonical Boolean algebra, no AST alloc |
| Translator | src/ast/rewriter/seq_range_collapse.{h,cpp} |
regex <-> range_predicate, fragment + caveats |
| Constructor integration | src/ast/rewriter/seq_rewriter.cpp ~3690/3703/3866 |
try_collapse_re_{union,inter,diff} |
| Derivative engine | src/ast/rewriter/seq_derive.{cpp,h} |
derivative-kind gating; no range_predicate yet |
| Bisim | src/ast/rewriter/seq_regex_bisim.cpp |
brzozowski cofactors |
| Theory | src/smt/seq_regex.cpp |
emptiness/membership, get_derivative_targets |
| AST ops | src/ast/seq_decl_plugin.{h,cpp} |
OP_RE_RANGE, OP_RE_OF_PRED (is_of_pred) |
5. Open questions for the author
- For Approach A: new internal
_OP_RE_RANGESop, or reuseOP_RE_OF_PREDwith a range-set predicate? (Reuse minimizes new surface; a dedicated op is clearer and letsrange_predicateride directly.) - Internal-only first (rewritten away at the boundary), or commit to full SMT-LIB/API/model exposure?
- How invasive is acceptable for the bisim/emptiness comparison path — is a pointer/hash equality on char classes worth the AST churn?
- Which is the single hottest path to prototype on (derivative char-class conditions vs. constructor output)?