Skip to content

[wip]: dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge - #3631

Open
aleksisch wants to merge 4 commits into
masterfrom
aleksisch/smt-lint
Open

[wip]: dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge#3631
aleksisch wants to merge 4 commits into
masterfrom
aleksisch/smt-lint

Conversation

@aleksisch

Copy link
Copy Markdown
Collaborator

Four commits: three grow the opt-in dasSMT module into a solver-backed lint, one adds a solver-free style rule that came out of the same work.

SFloat / SDouble over the Z3 FPA theory

smt_expr gains symbolic IEEE floats — the exact model of das float / double, unlike SReal (exact rational, no NaN). Rounded ops use round-nearest-ties-to-even, == is Z3_mk_fpa_eq rather than structural equality (so NaN == NaN is false and +0 == -0 is true, matching runtime), and model readback goes through the IEEE bit pattern so NaN, the infinities and negative zero survive it.

Int↔float conversions are provided but documented as a trap: unbounded Int → Real → FP is not decidable in practice — it times out on trivial goals.

SMT001 / SMT002 — reachability lint

modules/dasSMT/daslib/smt_lint.das is a [lint_macro] that symbolically executes each function body, accumulating a path condition, and asks Z3 whether each branch condition can hold on the paths that reach it. Nothing else in the tree tracks values across branches.

  • SMT001 — a branch unsatisfiable on every path that reaches it.
  • SMT002 — a condition always true with no else: a redundant guard. Default-off (noisy by nature), seeded via seed_default_disabled.

Only an UNSAT verdict is ever reported. Timeouts, unmodeled types, unproven integer overflow and anything the translation cannot represent all yield silence — the pass under-reports by construction rather than guessing.

SMT003SMT008, plus three soundness fixes

Sweeping all 2748 .das files showed the pass was analyzing almost nothing, and trusting stale values where it did.

Coverage: a class method's body is with (self) { ... }, and ExprWith was not a statement the walker descended into — so every method body in the tree was skipped. daslib/perf_lint.das went from 0 if-nodes analyzed to 344; daslib/aot_cpp.das from 79 to 552.

Soundness (all three found only by sweeping real code):

  • Method calls are ExprInvoke, not ExprCall, so out-params of methods were never havocked — one stale fact then poisoned every later guard in the function (23 false positives across three files).
  • Arrays, tables and structs pass by reference without flags.ref set, so testing that flag missed arr |> push(x). Constness is the reliable signal.
  • length(v) is memoized per container so two reads agree — but a mutated container's length changes under us, so volatile containers now get a fresh symbol per read.

New codes: division/modulo by a definitely-zero divisor (SMT003), an assert/verify that cannot hold when reached (SMT004), a while whose body never runs (SMT005), a shift count outside 0..31 (SMT006), a definitely-negative subscript (SMT007), and a &&/|| condition that is constant whatever its inputs are (SMT008, judged on a second solver carrying no path condition — always-true-on-its-own is a defect, so it is default-on, unlike the context-relative SMT002).

Each asks "is this ALWAYS broken here", never "could it be" — a merely-possible zero divisor would fire on every unconstrained value.

STYLE039 — condition collapses to a single comparison

x != 1 && x > 0 is x >= 2, and nothing detected that. Solver-free, so unlike the SMT rules it needs no opt-in module and runs in every build and under the CI zero-warning gate.

The &&/||/! tree is evaluated on an interval lattice — the domain LLVM's ConstantRange and Clang's RangeConstraintManager use — and reported only when the result is one one-sided interval, a single point, or the complement of a point. Not subsumption: in x != 1 && x > 0 neither operand implies the other, so dropping an implied literal cannot find it; Z3's own ctx-solver-simplify leaves the pair untouched. Endpoints are int64 so c ± 1 cannot wrap at an int boundary.

Measured over daslib, utils, modules, tests, tutorials, examples and dastest: zero findings — quiet rather than noisy, which is why it is default-on.

Testing

  • modules/dasSMT/tests/smt_lint_check.das — fixture driver; each fixture marks expected findings with a trailing // smt_expect <CODE> and the driver fails on both a missed detection and an unexpected one. 7/7, 3/3, 10/10 across the three fixtures. Wired into CMake as an interpreted example run.
  • utils/lint/tests/style039_condition_interval_merge.dasexpect 31209:5; the full lint fixture suite is 63/63.
  • Lint on every changed .das: 0 issues. Full build, tests/ suite (12144 passed / 0 failed), das2rst regeneration and a Sphinx -W build all clean locally.

Notes for review

  • The SMT pass runs after optimization, and the optimizer rewrites if (c) { return x } / return y into a ternary — leaving no if-node. Guard-style findings therefore need no_optimizations, the way utils/lint/main.das and the fixture driver compile their inputs. utils/lint/main.das does not run this pass at all: it cannot require an opt-in module.
  • Soundness limits and the query budget (250 ms/query, 200 queries/function, depth 64, all overridable per module) are documented in modules/dasSMT/README.md.
  • The style rule is numbered 039 because 036–038 landed on master while this branch was open.

🤖 Generated with Claude Code

aleksisch and others added 4 commits August 5, 2026 14:58
SReal is an exact rational, so it cannot express NaN, the infinities or
rounding. Model das float/double on the FPA theory instead: `x != x` is now
satisfiable, `+0 == -0` holds, and `0.1 + 0.2 == 0.3` is false, all matching
runtime exactly.

`==` uses Z3_mk_fpa_eq, NOT Z3_mk_eq. The latter is structural equality, which
would make NaN == NaN true and +0 == -0 false -- the exact inverse of IEEE.

Model readback goes through Z3_mk_fpa_to_ieee_bv rather than the rational
value, so NaN, the infinities and negative zero all round-trip.

Int -> FP conversions are provided but documented as impractical: unbounded
Int -> Real -> FP times out even on `i < 3 && to_float(i) > 5.0f`. Every other
conversion answers instantly.

s_lift deliberately gains no float overloads: `double` already lifts to SReal
for the constrain macro, and adding a second mapping would silently mix sorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Symbolically executes each function body, accumulating a path condition on the
solver stack, and asks Z3 whether each branch condition can hold on the paths
that reach it. Nothing else in the tree tracks values across branches: LINT001
is syntactic, STYLE010 needs a literal, LINT010 abandons any variable touched
under a branch.

SMT001 reports an unreachable branch; SMT002 an always-true condition with no
else, seeded default-off since it is the noisier half. Both report under the
existing 31209 style-warning code, so nolint / .lint_config / LintIssue work
with no C++ change.

Only UNSAT is ever reported. Everything else is silence:

- int is unbounded SMT Int, which does not wrap, so every integer add/sub/mul
  feeding a decision must first be proven inside the 32-bit range under the
  path condition. Found by walking the Z3 term -- an earlier das-side version
  missed arithmetic that arrived through a tracked local and false-positived
  on `let b = a + 1; if (b < a)`.
- Generic instantiations are skipped entirely: a specialized body's `at` points
  at the shared template, so a verdict cannot be attributed there. A tree sweep
  caught this via `if (a != a)` in `def f(a : auto(TT))` -- dead for TT=int,
  live for TT=float.
- Literal conditions are left to STYLE010; a $v(...) qmacro splice collapsing
  to a constant is the macro author's intent.
- Aliased locals, loop-carried values, calls, fields and indices are all opaque.
- Every query carries a 250ms Z3 timeout, 200 queries per function.

The pass runs after optimization, which rewrites `if (c) { return x }` +
`return y` into a ternary, leaving no if-node. Guard-style findings therefore
need optimizations off, the way utils/lint/main.das compiles its inputs -- so
the fixtures go through their own driver rather than the lint_macro path.
utils/lint/main.das is untouched: it cannot require an opt-in module.

Fixtures mark expectations inline with `// smt_expect <CODE>`; the driver fails
on a missed detection and on an unexpected one, and runs under run_examples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ep found

Sweeping all 2748 .das files showed the pass was analyzing almost nothing and,
where it did, trusting stale values. Both are fixed, and six defect classes are
added on top of the two reachability rules.

Coverage: a class method's body is `with (self) { ... }`, and ExprWith was not a
statement the walker descended into, so every method body in the tree was
skipped. daslib/perf_lint.das went from 0 if-nodes analyzed to 344,
daslib/aot_cpp.das from 79 to 552. ExprTryCatch and ExprLabel/ExprGoto are now
handled too (conservatively).

Soundness, all three found only by sweeping real code:

- Method calls are ExprInvoke, not ExprCall, so out-params of methods were never
  havocked. `parse_range_leg(..., var bound_out : int&)` left bound_out at its
  initial value and one stale fact then poisoned every later guard in the
  function -- 23 false positives in three files.
- Arrays, tables and structs pass by reference WITHOUT flags.ref set, so testing
  that flag missed `arr |> push(x)`. Constness is the reliable signal.
- `length(v)` is memoized per container so two reads agree, but a mutated
  container's length changes under us; volatile containers now get a fresh
  symbol each read.

A report is also refused when the path condition is itself unsatisfiable: that
means dead code or a broken model, and it stops one bad fact cascading.

New rules. SMT003 divisor provably zero, SMT004 assert that cannot hold, SMT005
loop that never runs, SMT006 shift count outside 0..31, SMT007 always-negative
subscript -- each asks "is this ALWAYS broken here", never "could it be", since a
merely-possible zero divisor would fire on every unconstrained value.

SMT008 judges a &&/|| chain on its own, via a second solver carrying no path
condition: `x != 1 || x != 2` always holds (the author meant &&), `x > 5 && x < 3`
never does. Always-true *given the enclosing guards* is a redundancy (SMT002,
default-off); always-true *on its own* is a defect in the expression, so SMT008 is
default-on. `a > b && b > a` moves from SMT001 to SMT008 accordingly.

A bounds off-by-one rule was written and dropped: "the path condition mentions
this container's length" is too weak a proxy for "the author bounds-checked this
index", giving 137 false positives against one true case. Relating an index to a
length needs real range analysis.

`options _smt_lint_stats = true` prints why each if was or was not queried; that
is what exposed the coverage gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`x != 1 && x > 0` is `x >= 2`, and nothing in the tree detected that. This is the
solver-free replacement for an SMT-backed attempt that did not survive review.

Evaluates the &&/||/! tree on a value lattice over one int variable, the domain
LLVM's ConstantRange and Clang's RangeConstraintManager use: `x > 0` is [1,MAX],
`x != 1` is [MIN,0]+[2,MAX], && intersects, || unites, ! complements. If the
result is one one-sided interval, a single point, or the complement of a point,
that comparison is reported.

Not subsumption: in `x != 1 && x > 0` neither operand implies the other, so
dropping an implied literal cannot find this -- Z3's own ctx-solver-simplify
leaves the pair untouched. The merge is arithmetic, not boolean.

Solver-free, so unlike the SMT rules it needs no opt-in module and runs in every
build, in utils/lint/main.das, and under the CI zero-warning gate.

Endpoints are int64 so `c - 1` and `c + 1` cannot wrap at an int boundary.

Silent when no single comparison is equivalent (two-sided range, disjoint
union), when more than one variable appears, when the condition is already a
single comparison, or on non-int operands. Always-true stays with STYLE010.

Measured over daslib, utils, modules, tests, tutorials, examples and dastest:
zero findings, so it is quiet rather than noisy -- which is why it is default-on.

Lives in STYLE, not LINT: LINT codes report through macro_error, and a
simplification suggestion must not fail a build.

Numbered 039: master took 036 (inert cast contract), 037 and 038 while this
branch was open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aleksisch aleksisch changed the title dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge [wip]: dasSMT: solver-backed lint (SMT001-SMT008) + SFloat/SDouble; STYLE039 interval merge Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant