3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-07-15 03:25:43 +00:00
z3/doc/range_set_integration_plan.md
Margus Veanes bd3cf8514c Add range-set integration handoff plan
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>
2026-06-26 20:26:37 +03:00

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), ...] with hi_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| & - ^ ~, plus empty/top/singleton/range, contains, cardinality, equals, operator<, hash, display.
  • Pure value: no ast_manager allocation in construction or Boolean ops. Conversion to/from expr* 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, and inter with a complement operand → difference) and folds it into a canonical range_predicate. Returns false (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 single re.range nodes, sorted by expression id to match the canonical shape seq_rewriter::merge_regex_sets expects.

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 | pb
    • try_collapse_re_inter (~3703): pa & pb
    • try_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.cpp does not use range_predicate directly — 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 in seq_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 master on 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 (see regex_to_range_predicate guard) — never fabricate a char-class for a regex over (Seq Int) etc.
  • re.complement is sequence-level, not character-level. Its language includes epsilon, 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 in mk_re_complement. (See the long NOTE in seq_range_collapse.cpp.)
  • Canonical id-sorted union shape on materialization, or merge_regex_sets produces wrong unions.
  • Derivative-kind gating (from prior work, now in master): intersection- over-union and concat-over-union distribution are gated on derivative_kind::antimirov_t; brzozowski mode (used by regex_bisim and 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.range blowup 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/complement of 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 any is_union/is_range matcher 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 c is epsilon if c in set else empty).
  • 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_predicate results keyed on expr* id so the collapse cost is paid once per char-class subterm.
  • Use range_predicate for 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.complement caveat) 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).

  1. Land Approach B's caching/memoization first as a measurable baseline — it is cheap and de-risks the corpus.
  2. Prototype Approach A internal-only via OP_RE_OF_PRED on 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.
  3. 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 .smt2 files). 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 CSV file, 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, emits file, branch_res, branch_ms, master_res, master_ms, status.
  • Baseline master numbers 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

  1. Build shell + test-z3; test-z3 /a green.
  2. run_branch_only.ps1 over regex-equivalence -> CSV; compare to cached master baseline with the normalized-verdict comparison.
  3. Repeat over a QF_S subset (start with 20230329-automatark-lu).
  4. Gate: 0 mismatches; branch-only timeouts <= 14 on regex-equivalence.
  5. Spot-check the historically hard cases: flat_vs_loop_018_n20, det_blowup_024 (direct + equiv), a few mut_* sat cases, and the 5721/5728/5731 regressions.

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_RANGES op, or reuse OP_RE_OF_PRED with a range-set predicate? (Reuse minimizes new surface; a dedicated op is clearer and lets range_predicate ride 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)?