Skip to content

Float Point Support - #49

Merged
ChengyuSong merged 27 commits into
mainfrom
float
Jul 26, 2026
Merged

Float Point Support#49
ChengyuSong merged 27 commits into
mainfrom
float

Conversation

@ChengyuSong

Copy link
Copy Markdown
Collaborator

Adding support for collecting and solving fp constraints.

ChengyuSong and others added 27 commits July 23, 2026 14:26
Teach the instrumentation and the in-process z3 solver (z3-ts.cpp) to
collect and reconstruct floating-point expressions, casts, and common FP
intrinsics so branches guarded by FP arithmetic/comparisons can be flipped.
Scope is the in-process z3 flow only; the RGD/jigsaw path is unchanged.

- dfsan.h: self-defined FP ops (fp_neg/fabs/sqrt/round/min/max/copysign);
  mark FP arith commutative for dedup.
- dfsan.cpp: preserve FCmp operand bit patterns (like ICmp) for solver
  value validation.
- TaintPass.cpp: collect FAdd/FSub/FMul/FDiv/FRem, FNeg, FP<->int casts,
  FCmp, and FP intrinsics; decompose fma/fmuladd (from -ffp-contract) into
  FMul+FAdd; ClTraceFP defaults on.
- dfsan_custom.cpp/done_abilist.txt: model sqrt and other errno math
  libcalls (kept as libcalls at -O0) via custom __dfsw_ wrappers.
- ko_clang.c: KO_NO_TRACE_FP off-switch.
- z3-ts.cpp: lift BV->fpa, compute, lower fpa->BV for FP nodes; FCmp
  ordered/unordered predicates; FP cast handling.
- tests: fp_solving.c plus fp_challenge_{float,double}.c adapted from the
  AFL test-float/test-double challenges (multi-branch, one input per check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- fp_arith.c: add z3-only RUN lines; seed x=1.0 misses all four branches so
  one concolic run flips FP arith, FP->int cast, sqrt, and fabs (one input
  per branch).
- fcmp.c: drop the fastgen RUN lines (RGD path does not model FP) and fix the
  check-prefix mismatch (source defined CHECK-GEN1/2 but RUN used CHECK-GEN);
  map the two generated inputs to the double/float equality branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ko-clang defaults its next-stage compiler to plain clang/clang++, and
lit.cfg prepends /usr/lib/llvm-18/bin to PATH, so the default clang is
already clang-18 -- exactly what every other test relies on.  Setting
KO_CC explicitly was redundant; remove it for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default clang/clang++ from llvm_bin_dir (prepended to PATH) is what the
suite uses; these clang-14 overrides were dead relics from the old build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The =functional ABI is a no-op in SymSan (WK_Functional drops the return
shadow), so math functions marked functional silently lose taint and the
solver can't flip branches that depend on them. Convert the FP predicate
and round-to-int libcalls to custom wrappers that record the real op:

  isnan/isinf/__isinf/finite/__signbit  -> fp_is_nan/fp_is_inf/
                                           fp_is_finite/fp_signbit
  lrint/llrint (+f variants)            -> fp_lrint (RNE round -> sbv)

The long double (*l) variants and transcendentals stay functional:
our FP lifting only supports IEEE 16/32/64-bit sorts, and z3's FP
theory has no operation to invert exp/log/pow.

New self-defined ops in dfsan.h, __dfsw_ wrappers in dfsan_custom.cpp,
serialize handlers + OP_MAP names in z3-ts.cpp, and abilist entries.

Also fix a partial-function boundary bug shared by the FPToSI/FPToUI
cast handlers (and the new fp_lrint): INT64_MAX/UINT64_MAX are not
representable as doubles and round up to 2^63/2^64, outside the target
range where to_sbv/to_ubv is undefined -- the solver could pick that
point and assign the result freely, yielding an input that doesn't
satisfy the branch. Use the largest doubles strictly below the overflow
point (2^63-1024 signed, 2^64-2048 unsigned) as the closed upper bound.

Add tests/fp_libcall.c (lrint/llrint/__signbit) and tests/fp_cast64.c
(64-bit signed/unsigned FP->int cast regression). Full FP suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add KO_USE_FASTGEN=1 %fgtest RUN blocks to the FP lit tests (fcmp,
fp_arith, fp_cast64, fp_challenge_{float,double}, fp_libcall, fp_solving)
so the fastgen instrumentation + out-of-process protocol (through the
in-process z3-ts.cpp) is exercised alongside the existing KO_USE_Z3
blocks, with matching branch/output orderings.

Add driver/afltest.cpp, a standalone harness that drives the RGD
(out-of-process) solver chain -- rgd-parser.cpp + I2S/JIT/z3-solver.cpp --
via the launcher API, mirroring fgtest.cpp but for the RGD AstNode path
that previously had no lit/fgtest coverage.  Register the %afltest lit
substitution and the AFLTest CMake target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Teach the RGD (rgd::AstNode) parser and solvers to reason about
floating-point constraints, so branches guarded by FP arithmetic, casts,
FP libcalls, and FCmp are solvable via the AFL++ custom mutator path (not
just the in-process z3-ts.cpp flow).

- z3-solver.cpp: build the solver with logic QF_BVFP instead of QF_BV.
  Under QF_BV z3 treats the FloatingPoint sort as uninterpreted and
  returns spurious all-zero SAT.  Add FP helpers mirroring z3-ts.cpp
  (bv<->fpa lifting, rounding modes, FCmp, FP casts with the to_sbv/to_ubv
  range constraints) and serialize the FP AstNode kinds.
- rgd-parser.cpp: parse the FP op labels into AstNode kinds.
- ast.h: add the FP op kinds and isFloatingPointKind([FAdd, FUne]); FP
  comparison kinds are kept outside isRelationalKind() so integer-only
  solvers reject them.
- i2s-solver.cpp / solver.h: reject any constraint whose ops bitset
  intersects the FP range via a new fp_ops_mask.  Input-to-state cannot
  handle bytes that reach a comparison through an FP op (e.g. (long)x==42,
  lrint(x)==42): the constant no longer appears literally in the input, so
  copying it yields a bogus solution that pre-empts z3.  Reject and fall
  through to the FP-aware z3 solver.
- jit-solver.cpp: return TIMEOUT (not ERROR) when jigsaw rejects an
  unsupported (FP) root so the chain advances to z3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AFL test-float.c/test-double.c challenges are designed for the
input-to-state (i2s) solver: each guard is a DIRECT comparison of an
input-derived float/double against a constant, so i2s can flip it by
copying the constant's IEEE-754 bytes into the input -- no FP arithmetic
reasoning / z3 required.

Replace the earlier `fabs(x-mid)<half` rewrite (which forced z3 and
defeated the i2s demonstration) with faithful two-sided range checks
(`lo <= x && x <= hi`) and, for double, the exact-equality case
(`x3 == pi`). Add %afltest RUN blocks exercising the out-of-process RGD
path with i2s only (no SYMSAN_USE_Z3), demonstrating that i2s alone
solves the challenge; the existing %fgtest / KO_USE_Z3 lines still
confirm the in-process z3 solver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the input-to-state solver to flip a "direct" FCmp -- an
input-derived float/double compared against a constant, with no FP
arithmetic in between -- by writing the constant's IEEE-754 bytes (or a
nextafter-adjusted value for strict inequalities) into the input, the
same RedQueen trick i2s already uses for integer comparisons.

- fp_ops_mask now covers [FAdd, FpLrint] (all FP arithmetic, casts, and
  intrinsics/libcalls) but NOT the top-level FP comparisons, so a direct
  FCmp falls through to the new solve_fcmp path while any FP arithmetic
  underneath still forces z3.
- solve_fcmp decodes the i2s candidate value, identifies the symbolic
  operand side, computes a target via fp_i2s_target (handling all 14
  LLVM FCmp predicates incl. ordered/unordered and NaN), re-decodes, and
  VERIFIES with i2s_eval_fcmp before committing -- guarding against bogus
  SAT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the input-to-state solver to flip an FCmp whose symbolic operand
passes through one FP arithmetic op against a constant -- (x op C) <cmp> K
-- by inverting the op to recover x (write K-C, K/C, ... into the input),
mirroring the integer x op C == K path (_get_binop_value_r / solve_icmp).

- Narrow fp_ops_mask to [FRem, FpLrint] and add fp_arith_mask =
  {FAdd,FSub,FMul,FDiv}, so a single invertible FP binop reaches
  solve_fcmp instead of being pre-rejected; FRem/FNeg/casts/intrinsics/
  libcalls still fall to z3.
- solve_fcmp classifies the comparison by AST structure (is a compare
  operand an arith node?) rather than by value matching -- this avoids a
  false "direct" match when the arith result coincidentally equals the
  raw input bytes (e.g. 0.0 * C == 0.0 on an all-zero seed).
- New precision-aware helpers fp_binop_eval / fp_binop_invert /
  fp_binop_const. Only a single arith op that is a direct child of the
  comparison and has a constant operand is handled; every guess is
  verified end-to-end with i2s_eval_fcmp before committing, so rounding
  that breaks an inversion just falls back to z3.

Add tests/fp_arith_i2s.c (FAdd/FMul float, FSub/FDiv double incl. a
const-lhs case), solved by i2s alone (%afltest, no SYMSAN_USE_Z3) plus
the in-process z3 paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several libm FP functions were marked =functional in done_abilist.txt on
the rationale that z3's fpa theory cannot invert them. That only applies
to z3: the i2s solver computes the closed-form libm inverse for a guard
f(x) <cmp> K and verifies it end-to-end, so monotonic transcendentals are
exactly solvable there.

Make exp/expf/exp2, log/log2/log2f/log10/log1p/log1pf, and pow/powf
=custom and invert them in I2SSolver::solve_fcmp:
- new op kinds FpExp/FpExp2/FpLog/FpLog2/FpLog10/FpLog1p/FpPow (ast.h,
  inside [FAdd,FUne] so isFloatingPointKind covers them) + runtime codes
  in dfsan.h + wrappers in dfsan_custom.cpp + parser mapping in
  rgd-parser.cpp
- unary transcendentals inverted via fp_trans_invert (exp<->log,
  exp2<->log2, log10->pow(10,.), log1p->expm1)
- FpPow is binary, reuses fp_binop_* both directions: exponent-const
  pow(x,C)==K -> pow(K,1/C), base-const pow(C,x)==K -> log(K)/log(C)
- masks: fp_ops_mask=[FRem,FpExp), fp_arith_mask gains FpPow, new
  fp_trans_mask=[FpExp,FpPow)

The RGD chain is i2s->jigsaw->z3 (optimistic), so z3 (z3-solver.cpp,
z3-ts.cpp) and jigsaw (jit.cc) explicitly reject the new kinds. fmod,
frexp*, modf, nextafter*, nexttoward*, and *l variants stay =functional
(not i2s-invertible). Real FP support in jigsaw is left for a future task.

New test tests/i2s_transcendental.c (%afltest only -- only i2s inverts
transcendentals) covers all 8 forms. Note LLVM's optimizePow rewrites
pow(x,2.0)->x*x and pow(2.0,x)->exp2(x) even at -O0, so the test uses a
cube exponent and base 3.0 to keep the pow libcall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The out-of-process RGD chain runs i2s -> jigsaw -> z3, but jigsaw was
integer-only: codegen threw on every FP node and addFunction's root gate
only accepted integer relational kinds, so FP-rooted constraints fell
straight through to z3.  FP guards that i2s cannot invert (two symbolic
operands, e.g. x*x > 25.0, or an intrinsic like sqrt) thus skipped jigsaw
entirely, even though their distance is perfectly measurable for gradient
descent.

jit.cc: add as_fp/as_bits helpers (FP node values are integers holding IEEE
bits) and codegen for every measurable FP kind -- arith, casts, intrinsics,
FpRound (rounding mode in index()), transcendentals (llvm.* / libm calls
resolved from the solver process; log1p via a direct libm call), FpPow, and
FP comparisons (operands FPExt'd to double, stored at arg[0]/arg[1]).
Extend the addFunction root gate to accept isFPRelationalKind.  The four
boolean predicates (FpIsNan/FpIsInf/FpIsFinite/FpSignbit) stay rejected --
a bit, not a magnitude -- and fall back to z3.

gd.cc: get_distance dispatches isFPRelationalKind to fp_get_distance, which
reinterprets the operands as doubles, computes a non-negative distance d
(0 iff satisfied), and returns its IEEE bit-pattern (monotonic in d for
d>=0, exactly 0 iff d==0).  Strict predicates nudge by DBL_TRUE_MIN at the
a==b boundary, mirroring the integer sat_inc(a-b,1).

tests/jigsaw_fp.c: 6 inequality branches (FMul/FAdd/FpSqrt/float/FNeg/FpMin)
that i2s cannot solve; %afltest + SYMSAN_USE_JIGSAW=1 without SYMSAN_USE_Z3
to isolate jigsaw.  GD solves inequalities well but not exact FP equality
(that remains z3's job).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
try_i2s now snaps raw input chunks onto FP comparison operands for exact FP
equalities gradient descent cannot hit (two symbolic operands, e.g. x==y+1.0).
Because an i2s candidate is a maximal run of consecutive symbolic bytes, two
adjacent FP operands merge into one oversized run, so the FP branch slides an
FP-sized window (8 then 4) across each run rather than requiring the whole run
to be exactly 4/8 bytes. Each window promotes float->double, matches against the
stored (double-bit) operands, snaps via get_i2s_fp_value (ULP-nudge for strict
inequalities), and verifies with try_new_i2s_fp_value (fp_get_distance == 0)
before committing. try_new_i2s_fp_value seeds all args from the current input and
overrides only the window bytes, so the other symbolic operand is not clobbered
(unlike the integer try_new_i2s_value, which broadcasts the value across the
whole local_map).

Test tests/jigsaw_fp_i2s.c: three adjacent-double equalities (add/sub/reversed
operand), isolated with SYMSAN_USE_JIGSAW=1 and no z3; standalone i2s solves none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The FP arith/trans/direct structural checks in solve_fcmp matched candidates by
VALUE only, which two operands holding coincidentally-equal values could fool.
For `X == Y op C` on a seed where X == Y, feeding X through `op C` produces the
same value as `Y op C`, so the X candidate (which sorts first) passed the check
and the inverted result was written to X -- corrupting an unrelated input and
leaving the guard unsatisfied.

A Read node's index() is its input byte offset, so anchor each candidate to the
offset the intended operand actually reads: arith/trans require the candidate
offset to equal the symbolic operand's Read offset; direct compares require the
matched side's Read offset. When the operand is not a plain Read (transformed
input, offset not determinable) fall back to the value-only check to preserve
behavior. This also rejects the adjacent-float misdecode (an 8-byte run holding
two floats no longer matches a double operand at the run start).

Regression test tests/fp_i2s_offset.c: `X == Y op C` guards with X and Y
non-adjacent; pre-fix the inverted value was written to X (output Bad), now it
is written to Y (guards flip). Solved by standalone i2s, %afltest only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Item 1: rescale jigsaw's FP distance so a mixed FP+integer task converges.
fp_get_distance previously returned the raw IEEE bit-pattern of |d|, which is
~2^62 for an O(1) distance -- far larger than typical integer distances, so the
FP term dominated (and sat_inc could saturate) the summed per-constraint
distance in gd.cc. Shift the bit-pattern down by 32 bits: an O(1) distance now
maps to ~2^30 and the whole finite range stays below 2^31, comparable to
integer distances, while preserving both GD requirements (still monotonic in d,
still 0 iff d==0) and the log-like wide dynamic range (exponent -> octave
resolution, retained high mantissa bits -> within-octave resolution).

Item 3: fix the same value-only coincidental-offset bug in solve_icmp that
solve_fcmp already had. The direct-match branch matched candidates by value
alone, so on a seed where two symbolic operands share a value (e.g. `b + 1 == a`
on an all-zero seed) it wrote the inverted result to the wrong input. Anchor
each direct match to the compared side's Read offset (mirrors solve_fcmp); a
non-plain-Read side falls back to the value check and is handled by the binop
branch. New regression test tests/i2s_offset.c (integer analogue of
fp_i2s_offset.c) fails pre-fix, passes post-fix.

All 96 git-tracked lit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Differential testing jigsaw (JIT+GD) against z3 on the SMT-COMP QF_BV
sat-labeled set (14,685 files, every SAT model validated by z3) surfaced two
pre-existing soundness bugs where jigsaw returned a model z3 rejects.  Both
affect real fuzzing, not just the smttest driver.

1. Over-width shift / divide-by-zero.  codegen() emitted raw LLVM shl/lshr/ashr
   (poison for amount >= width; the JITed x86 shift also masks the count) and a
   divisor=1 div-by-zero hack.  SMT-LIB2 defines all of these (shift >= width ->
   0 / sign-fill; bvudiv x 0 -> ~0; bvurem/bvsrem x 0 -> x; bvsdiv x 0 ->
   x>=0?~0:1), and SymSan's z3 backends already model SMT semantics -- so jigsaw
   disagreed with its own oracle.  Route these through new helpers
   (build_shl/lshr/ashr/udiv/sdiv/urem/srem, nonzero_divisor) that implement the
   SMT-LIB result by default (JIGSAW_SMTLIB_SEMANTICS); define JIGSAW_HW_SEMANTICS
   to keep the legacy C/C++/hardware behavior instead.

2. Signed comparison sign-extension.  The ICmp codegen zero-extended both
   operands to i64 before storing them for gd.cc get_distance, which does
   (int64_t)a </<= (int64_t)b.  A negative sub-64-bit value (e.g. i32 0xFE000000)
   zero-extends to a large positive i64, giving the wrong signed distance and an
   unsound SAT.  Sign-extend the operands for the signed kinds (Slt/Sle/Sgt/Sge);
   unsigned/Equal/Distinct stay zero-extended.

After both fixes the QF_BV sat run reports 0 soundness violations (4,958 models
z3-validated, 4 trivially-sat with no free vars), down from 3 unsound before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…olver

smttest reads an SMT-LIB2 file, translates each assertion to NNF then DNF (one
rgd::SearchTask per disjunct), and drives the RGD chain (i2s excluded as unsound
for conjunctions; jigsaw JIT+GD; optional z3) directly -- no target execution,
launcher, or trace parser needed.  This is the differential-testing harness used
to validate jigsaw against z3 on the SMT-COMP QF_BV set.

  * driver/smttest.cpp   -- the front-end (SMT-LIB reader, DNF translator,
    SearchTask builder, solver loop) with per-phase timing (parse/codegen/jit/
    gd/solve/total, microseconds) behind --time / SMT_TIME.
  * driver/CMakeLists.txt -- SMTTest target (installed as `smttest`).
  * include/solver.h      -- public JITSolver timing accessors
    (get_codegen_time / get_jit_time / get_solving_time) that --time reports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The input-to-state pass only touched unsatisfied constraints and committed
only on strict global improvement.  For the SAGE byte-assembly pattern
(X == (b3<<24)|(b2<<16)|(b1<<8)|b0 coupled with X == const), the all-zero
seed leaves X==assemble satisfied (skipped) and X==const unsatisfied.
Snapping X=const breaks X==assemble by exactly the amount it fixes X==const,
so the global distance is unchanged, the strict-improvement gate reverts the
snap, and the assembly bytes are never eligible -> deadlock.

Iterate try_i2s to a bounded fixpoint (I2S_MAX_ROUNDS=8), additionally
accepting lateral (equal-distance) snaps that shift which constraints are
satisfied.  A later round then sees X==assemble newly-unsatisfied and snaps
b0..b3 to a strict improvement.  distance() already refreshes the compare
operands live each call, so re-targeting across rounds works.  The round cap
guarantees termination if lateral snaps cycle.

Validated by z3-checked A/B (3 runs each, JIGSAW_I2S toggle, over 6,636
files: 4,962 previously-solved + 1,674 GD-declined): 0 unsound models in
either config; mean single-run solved 4,966 -> 4,997, stable (all-3-runs)
4,769 -> 4,841, gains concentrated in the sage family.  Also adds a
JIGSAW_DEBUG trace of which phase (i2s vs gradient descent) solved a task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two latent bugs in the gradient helper:

* Grad::clear() iterated `for (auto gradu : grads)` by value, so it zeroed
  copies and left the real vector untouched.  Benign today only because
  cal_gradient fully overwrites every entry after the restart-loop clear(),
  but a trap for any future reader.  Iterate by reference.

* Grad::val_sum() used a plain `ret += gradu.val` (its own //FIXME noted the
  missing saturating add).  On overflow, descend's initial guess_step
  (f0 / val_sum) becomes garbage.  Saturate to UINT64_MAX on overflow,
  matching sat_inc's convention.

Also switch max_val()'s read-only loop to by-reference to avoid copying each
GradUnit in the hot `while (grad.max_val() == 0)` restart loop.

No search-outcome change (clear() was already masked; overflow is an edge
case); pure correctness + throughput.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
partial_derivative does not restore the input between delta probes, so
`update(index, dir, delta)` added delta *cumulatively* -- the probe walked
orig+1, orig+5, orig+21, orig+85... instead of the intended orig+delta the
"if f(x+delta) == f(x)" comment describes.  The plus/minus finite differences
were therefore measured at drifted, asymmetric offsets, giving noisy gradient
signs/magnitudes.

Track the accumulated offset and add only the increment (delta - added) so each
probe lands exactly at orig+delta -- a clean finite difference.  Same number of
evaluations (no restore/reprobe), so throughput is unchanged; only the probe
point is corrected.

z3-validated A/B (JIGSAW_CLEAN_DELTA toggle, since removed; 3 runs each over
6,636 files): 0 unsound in both configs; per-run mean solved 4,987 -> 5,022,
union-of-3 ceiling 5,137 -> 5,169 (genuinely new files, not just reliability),
stable(all-3) 4,834 -> 4,857.  Gains (83) outweigh regressions (51), both
sage-dominated -- real signal, not restart noise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two throughput-oriented GD search improvements, validated together by a
z3-checked A/B (2x2, skip x backtrack, 3 runs each over 6,636 files):

#4 cal_gradient: skip bytes touching no currently-unsatisfied constraint.
Their partial derivative is definitionally 0 (a byte feeding only satisfied
distance-0 constraints cannot lower f0, only keep it 0 or raise it), so
partial_derivative would burn ~16 probes just to conclude val=0. Skipping
yields a bit-identical gradient while reclaiming that budget for the
MAX_EXEC_TIMES-capped search. Standalone: mean 5007->5024, union 5161->5169,
stable 4850->4869, 0 unsound, faster wall-clock.

#5 descend: on line-search overshoot (f grew between step/2 and step), bisect
up to BACKTRACK_MAX times back toward the last-good point along the same
gradient instead of abandoning the jumped-over minimum. Negative in isolation
(net -13: pure added probes), but the budget #4 frees funds it: combined
"both" config beats baseline on every metric -- mean 5027, union ceiling
5182 (+21), stable 4872 (+22), 0 unsound -- and adds +13 ceiling files over
skip-alone. Default-on rebuild reconfirmed: 5044 z3-valid, 0 unsound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The signed-compare SExt fix (commit ab770de) made jigsaw's JIT'd comparison
function sign-dependent: signed kinds (Slt/Sle/Sgt/Sge) sign-extend operands
to 64-bit, unsigned/equality kinds zero-extend. But the function-reuse cache
never accounted for this -- isEqualAstRecursive treats every relational kind
as interchangeable, and jit-solver's fCache keys on it (make_constraint also
hashes all relational roots to Bool). So a signed comparison could reuse an
already-compiled unsigned (zero-extending) function over identical operands.

get_distance then applied its signed (int64_t) cast to a zero-extended
operand, so a sub-64-bit negative value looked positive -> unsound SAT.
Reproduced on sage/app9/bench_824.smt2: (bvsle 0 (bvshl T4_482 3)) with the
shift result 0x9b545ff0 (signed-negative) was reported satisfied.

Fix: add isSignedRelationalKind (Slt..Sge) and require signed==signed in
isEqualAstRecursive, keeping signed and unsigned comparisons in separate
JIT-reuse classes. Equal/Distinct stay with the unsigned group (sound, since
the JIT zero-extends them). Also harden the compare-operand extend to
SExtOrTrunc/ZExtOrTrunc -- bare SExt/ZExt would assert on 64-bit operands.

Validated on the full 6636-file QF_BV set (seed 1, config all): 0 sat,invalid
at budget 1000 AND 10000 (the 1000 cap had been masking the bug); budget
10000 now safely yields +260 solves. All FP/core lit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump MAX_EXEC_TIMES 1000 -> 10000. A full-set QF_BV sweep (seed 1, 6636
files) showed the attempts-to-solve distribution has a long tail: the
higher cap recovers ~97% of the budget-limited solves (+260 on the set)
then plateaus, and is nearly free for the common case since most tasks
solve well under 1000 attempts and stop early. Overridable at runtime via
JIGSAW_MAX_EXEC (smttest --budget) through the new jigsaw_max_exec()
helper. This is now safe to raise only because the signed/unsigned
JIT-reuse soundness bug it exposed (bench_824) was fixed in 2c0fbd7.

Consolidate the search-strategy work (near-miss jitter + descend-
stagnation escape via do_escape) and gate ALL of jigsaw's diagnostic
scaffolding behind a single compile-time switch, JIGSAW_SEARCH_DEBUG
(config.h, default 0): the phase/solve tracing (JIGSAW_DEBUG,
JIGSAW_REPORT_ITERS), the step tracer (JIGSAW_TRACE / JIGSAW_TARGET), and
the A/B strategy toggles (JIGSAW_NO_JITTER / JIGSAW_NO_STAGNATION /
JIGSAW_STAG_RESTART). Production builds carry none of these runtime getenv
branches in the hot search loop -- g_trace/no_jitter/no_stag/stag_restart
become constexpr false and fold away -- and always use the winning
strategy (flat-loop + stagnation escape with jitter on). Set the macro to
1 to reproduce the strategy experiments.

Validated: full 6636-file set, seed 1, 0 sat,invalid; bench_824 solves
soundly at the default budget; all FP/core lit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MutInput seeded its restart PRNG from time(NULL) (1s granularity) on the
non-JIGSAW_SEED path, so parallel fuzzing workers -- and the two MutInputs
per solve -- constructed in the same second shared an identical restart
stream, killing random-restart exploration diversity.

Production path now mixes CLOCK_MONOTONIC nanoseconds, a monotonic counter,
and the instance address so no two constructions collide. The JIGSAW_SEED
branch stays deterministic (base + counter) so smttest --seed A/B remains
reproducible. Sound + throughput-neutral (1500-file mixed QF_BV/FP/BVFP
z3-differential sweep: 0 unsound).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
smttest's parser silently dropped the rounding-mode operand of (fp.add rm
a b) etc., so directed-rounding benchmark arithmetic was computed as
round-to-nearest -- leaving the wintersteiger directed-rounding cluster
unsolved and a latent benchmark-soundness gap. Carry the SMT-LIB rounding
mode through the RGD AST and JIT rounding-mode-correct arithmetic.

- smttest: fp.add/sub/mul/div + fp.sqrt carry the rm selector in index(),
  folded into hash() so fCache never reuses wrong-mode compiled code
  (isEqualAstRecursive ignores index()). FpRound hash retrofit too. rna is
  rejected on arithmetic: x86 MXCSR can't represent it, and the runtime
  leaves index()==0 on arith to mean "RNE default" (0/1 both == RNE).
- jit.cc: detect_fp_mode() finds the formula's single FP mode. A directed
  mode (rtp/rtn/rtz) sets StrictFP + constrained-FP builder + llvm.set.rounding
  (MXCSR) at entry, restores RNE before the ret, and emits
  CreateConstrainedFPBinOp / constrained.sqrt (so the opt passes constant-fold
  in the chosen mode, and runtime-symbolic ops round via MXCSR). Mixed modes
  return -1 -> jigsaw bails, driver falls to z3. RNE/no-FP path unchanged.
  A Phase-0 feasibility spike is kept behind smttest --spike-fp-rounding.
- z3-solver.cpp (RGD path): get_arith_rm() (0/1->RNE, else raw) for
  FAdd/FSub/FMul/FDiv/FpSqrt.
- z3-ts.cpp (runtime union-table path): no functional change -- its op1/op2
  are operand values, not an rm, and instrumented code always emits RNE
  fadd; documented why RNE is correct there.

Validation (0 unsound mandatory, every model z3-checked against the true rm):
2379/2890 recorded-failure directed files now solve+validate (82.3% net-new);
360 directed + 120 RNE-regression + 550 broad QF_FP/QF_BVFP sweeps all 0
unsound; FP lit 11/11; mixed-mode bail proven correct. Mixed-mode rate on
wintersteiger is 0%, so single-mode-first loses nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Directed FP rounding now flows through the real symex path, not just the
SMT-LIB benchmark path.  LLVM carries rounding in llvm.experimental.constrained.*
intrinsics (emitted under strict FP / FENV_ACCESS, e.g. code calling fesetround,
including the common round.dynamic case), but TaintPass blanket-filtered all
llvm.experimental.* and dropped the taint before it reached the union table.

- TaintPass.cpp: capture constrained fadd/fsub/fmul/fdiv/sqrt/fmuladd and
  constrained fcmp/fcmps before the experimental filter.  The rounding selector
  is packed into the high byte of `op` (the slot cmp uses for its predicate; FP
  arith carries no predicate).  Static modes pack a constant; round.dynamic reads
  the live MXCSR via @llvm.get.rounding and maps FLT_ROUNDS -> selector at
  runtime.  fcmp/fcmps are modeled as a regular FCmp (predicate in high byte) so
  strict-FP branches keep their taint.

- rgd-parser.cpp / z3-ts.cpp: read the selector from op's high byte for FP
  arith and sqrt (get_arith_rm: 0/1 -> RNE, 2/3/4 directed).  Fold the selector
  into the node hash so fCache never reuses wrong-mode compiled code
  (isEqualAstRecursive ignores index()); also retrofits the pre-existing
  fp_round floor-vs-ceil hash-collision hazard.

- dfsan.cpp / dfsan.h: mask op to the base opcode (op & 0xff) in the op1/op2
  symbolic-operand zeroing and in is_commutative, so packed FP-arith ops behave
  like their unpacked counterparts (dedup parity, commutativity).

Validated end-to-end (0 unsound) with a rounding-discriminating exact-equality
test (target = RTN(x*3) bits, 1 ULP below RNE): the solver recovers the exact
input that HITs a strict-FE_DOWNWARD oracle while an RNE model would MISS, via
all three consumers (fgtest/z3-ts, afltest RGD+z3, afltest RGD+jigsaw-JIT).
FP lit suite 11/11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds tests/fp_rounding.c, exercising the real-symex instrumentation path
for constrained FP intrinsics: -frounding-math + FENV_ACCESS ON lowers a
symbolic square to @llvm.experimental.constrained.fmul with round.dynamic,
which TaintPass now captures (rm selector packed in the op high byte) and
the solvers honor.

The guard is x*x == 0x40488000000000ab (== RTN(x*x) for the solution, one
ULP below the round-to-nearest square).  This has teeth by construction:
  * two symbolic operands (x*x) => i2s cannot invert it, so its
    re-execution-based (rounding-agnostic) solve can't mask a regression;
  * an exact FP equality => jigsaw gradient descent cannot land it;
  * x*x == target is SAT under round-toward-negative but UNSAT under
    round-to-nearest (verified with z3) -- so a solver that drops the
    captured rounding mode finds it UNSAT, emits no input, and CHECK-GEN
    fails.  Confirmed by forcing RNE in z3-ts (branch became not solvable).

Covers all consumers: fgtest/z3-ts, afltest RGD+z3, afltest RGD+jigsaw-JIT,
and KO_USE_Z3 in-runtime z3.  Generated inputs re-validated against the
-frounding-math oracle (must print HIT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ChengyuSong
ChengyuSong merged commit f225218 into main Jul 26, 2026
1 check passed
@ChengyuSong
ChengyuSong deleted the float branch July 26, 2026 14:55
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