Skip to content

Model number-to-string conversions with a radix, fix toBinaryString divergence - #76

Closed
nils-loose wants to merge 6 commits into
feat/bitop-modelsfrom
feat/number-to-string-models
Closed

nils-loose wants to merge 6 commits into
feat/bitop-modelsfrom
feat/number-to-string-models

Conversation

@nils-loose

Copy link
Copy Markdown
Contributor

Stacked on #70 (feat/bitop-models); base is that branch.

What changes

  • Integer/Long toString(x, radix) and toUnsignedString(x, radix) are modelled exactly (new NumberStrings).
    • A radix outside 2..36 falls back to 10, as in the JDK.
    • Constant radix up to 10: the digit string is str.from_int of the integer that has the same decimal digits. Power-of-two digits are bit fields; the other radixes use a chain of divisions by the constant radix.
    • Radix above 10: per-digit characters, with the leading zeros cut. 16 and 32 keep the encoding from Exact Integer/Long/Double bit-op models and radix-2^k string conversions #70.
    • Symbolic radix: it is pinned to its concrete value by choice branches. This is a binary search over 2..36 on the normalised radix, 5 to 6 branches. On each path the conversion therefore has a constant radix, and the explorer reaches the other radixes by flipping the branches.
    • I also tried encoding the radix choice inside the formula (an ite over the radixes). It is exact too, but no Z3 query on it finished within 90 s, not even for radix 10.
  • toBinaryString / toOctalString divergence (Exact Integer/Long/Double bit-op models and radix-2^k string conversions #70): these now use the same str.from_int encoding.
    • The cause was the old encoding: a concatenation of per-digit str.from_code strings, cut to a symbolic length. On it Z3 4.14 returns models that violate the constraint, or needs 13 to 64 s.
    • With the from_int encoding, the same query (toBinaryString(x) == "11000111010111") takes 0.03 s. Long.toBinaryString no longer times out.
  • Number concatenation:
    • invokedynamic makeConcatWithConstants (DynamicInvocation) and StringBuilder.append(I/J/...) both go through Value.asStringValue().
    • IntValue was already exact. LongValue and ShortValue used str.from_int of the signed value, which is "" for a negative number; they now use NumberStrings.decimal.
    • No change in StringBuilderValue, StringValue or StringInvocation.
  • Float/Double: asStringValue returns the concrete String.valueOf string. Before, it threw NotImplementedException, which ended the run even for concrete values (e.g. the "pi2_hi = " + pi2_hi in the MathSin constructor). A symbolic value records context loss. I found no exact encoding of the shortest decimal representation that would cover a useful range, so Float/Double.toString stay unmodelled (context loss as before).
  • Choice-branch infrastructure: the files are identical to Model symbolic-index accesses to object arrays instead of recording precision loss #69 (BranchElement/BranchDTO/SymbolicTraceHandler.addChoiceBranch, the explorer's choice flag and the SA-walk masking), so the two PRs merge cleanly.
  • Explorer branch order: SVCompDriver.solve_candidates now solves choice branches after the program's own branches (a stable sort, so DFS order otherwise). Without this, the explorer enumerated all radixes before it tried the equals branch, and the radix tasks timed out.

Measured

Config: --sv-comp, 300 s, 15 GB, no SA. 333 runs:

  • 38 affected runs (KB causes context-loss-number-to-string, fp-to-string-not-implemented);
  • 235 baseline-correct runs in the touched code paths (autostub Integer/Long/Short/Byte/Double/Float/String/StringBuilder, and every task whose sources call toString/valueOf/append or concatenate strings);
  • 60 random baseline-correct runs.
comparison gained lost net new wrong verdicts
vs integration baseline +17 0 +17 0
vs feat/bitop-models (#70) +11 0 +11 0

Flips over #70 (all UNKNOWN or timeout to correct):

task prop #70 this PR
Integer.toBinaryString(int) valid-assert unknown (divergence) violation (+1)
Long.toBinaryString(long) valid-assert timeout violation (+1)
Integer.toString(int, int) valid-assert unknown violation (+1)
Long.toString(long, int) valid-assert unknown violation (+1)
Integer.toUnsignedString(int, int) valid-assert unknown violation (+1)
Long.toUnsignedString(long, int) valid-assert unknown violation (+1)
float-nonlinear-calculation/MathSin valid-assert unknown violation (+1)
jbmc-regression/StringValueOf01 valid-assert unknown safe (+2)
jbmc-regression/StringValueOf01 no-runtime-exception unknown safe (+2)

The other 6 runs of the +17 come from #70 itself (toHexString, toOctalString, toUnsignedString(x)).

Tests

  • NumberStringsSpec:
    • Integer/Long toString/toUnsignedString against the JDK on edge values (MIN, MAX, 0, -1, ...), for every radix 2..36 and for -1, 0, 1, 37, MIN and MAX;
    • the one-argument variants, plus Short/Byte.toString;
    • the concatenation string of int/long/short;
    • concrete float/double.
  • RadixChoiceAgentSpec (forked agent) checks the choice branches for radixes 36, 0 and 2.
  • test_solver_rlimit has a new case for the branch order.
  • Executor test: 8819 tests, 0 failures.

Known limits

  • Slow radixes: constant radixes other than 2^k and 10 (3, 5, 6, 7, 9, 11..36 except 16 and 32) are exact but slow in Z3: 2 to 30 s for a single equality.
  • Float/Double.toString, valueOf and toHexString of a symbolic value are still context loss; the 8 autostub runs stay UNKNOWN.
  • Many paths: a SAFE proof over a symbolic radix explores up to 35 radix paths per call.

🤖 Generated with Claude Code

nils-loose and others added 6 commits October 18, 2026 22:54
…d's values

Same infrastructure as the object-array index search: a branch the
executor records itself, flipped by the explorer like any other, with no
counterpart in the static pre-analysis graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The digits of a constant radix up to 10 are encoded as str.from_int of
the integer with the same decimal digits, which Z3 decides directly; the
per-digit concatenation cut to a symbolic length timed out or gave
models that violate the constraint (Integer.toBinaryString diverged).
toBinaryString and toOctalString now use the same encoding.

A symbolic radix is pinned to its concrete value by choice branches
(binary search over 2..36 on the radix after the JDK's fallback to 10),
so the conversion is exact on each path with a constant radix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LongValue/ShortValue.asStringValue used str.from_int of the signed value,
which is "" for a negative number; StringBuilder.append(long) and
string concatenation of a long or short now get the '-' and the digits
of the magnitude.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
String.valueOf(float/double), StringBuilder.append(F/D) and string
concatenation threw NotImplementedException, which ended the run even
for a concrete value. The string is now the concrete one; a symbolic
value records context loss, as no exact encoding of the shortest decimal
representation is practical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A choice branch enumerates another value of an operand (e.g. another
radix) and restarts from there; the program's own branches on the path
come first, in DFS order otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every radix 2..36 and radixes outside that range on edge values for
Integer/Long toString and toUnsignedString, the one-argument variants,
the string of numbers in concatenation, and a forked-agent test of the
radix choice branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
…fixes

Merges without conflicts on top of #70/#76's Integer/Long/Short models.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
DoubleValue/FloatValue.java: asStringValue is already #76's (concrete
String.valueOf, context loss when symbolic), the same as #86's.
FloatValueTest.groovy: keep #74's FCMP cases and #86's asStringValue cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
Combining #72, #74, #78, #82 and #88: the driver tests create #88's
budget and verdict lock, and #88's tests get the stack-overflow (#78) and
copy-free (#82) Database reads and the choice flag (#76). A run reports
one verdict (#88), so the incomplete-search subtests reset the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
Tree.py: #84's loop-state positions are registered at the start of #72's
iterative Tree.add; the recursive add_recursive is not brought back.
Node/Branch/TraceParser/DataTransferObjects.py, BranchDTO/DTOBuilder.java:
both the choice flag (#69/#76) and the loop states (#84).
SymbolicTraceHandler.java: addChoiceBranch, checkAndSetLibraryBranch and
the loop-state collection side by side.
StrategyService.py: the walk on the live tree (#82) reports #84's count
of pruned subtrees.
SVCompDriver.py: #84's --no-loop-state-matching next to the other state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
…tates to every branch

Combining #72, #82 and #84:
- #84 pruned a subtree below a repeated loop-head state by returning from
  the recursive dfs. In #72's iterative walk that return ended the whole
  walk, so the open branches of every later subtree were never offered,
  and an exhausted search could report SAFE. It now skips that subtree
  only (regression test added).
- The pruned-subtree count is reset for each walk, as #82 walks the
  persistent tree instead of a copy.
- The choice branches (#69/#76) and library-call branches (#75/#90) take
  the pending loop-head states too, so a state always goes with the
  branch right after it, which its position assumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nils-loose

Copy link
Copy Markdown
Contributor Author

Included in dev through #92 (merge commit 534c182), together with the fixes for interactions between PRs. This PR stays as the record of the change and its measured flips.

@nils-loose nils-loose closed this Oct 20, 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