Development Update: Late September 2026
Following the mid-September update,
145 commits landed on master between 20 September and 1 October. Most of the
user-visible ones fix a wrong verdict, so this update starts there; the Python,
C/C++ and Ladder Diagram front ends follow.
Wrong verdicts in the verification engine
False proofs in the default configuration. Three fixes this fortnight
concern runs with no options at all.
Interval-based guard pruning, which symbolic execution performs by default
(--no-interval-symex-guard turns it off), tracked only assignments, assumes,
declarations and DEAD; the writes symex performs itself — binding a call’s
result or a callee’s parameters, returning into the caller’s left-hand side, a
body-less call storing through a pointer, a recursive callee overwriting its
caller’s locals — left stale intervals behind, and a loop guard was pruned as
false. The issue’s program verified:
int f(void) { int r = nondet_int(); return r; }
int main() {
int n = 0, i = 0;
n = f(); /* the domain still believed n == 0 */
while (i < n) { i++; if (i > 3) break; }
__ESBMC_assert(i == 0, "loop not entered");
}Each of those writes now havocs what it overwrites
(#8061). A multi-dimensional VLA’s
flattened size was multiplied in the type of its second level, so in
int a[2][3][m] with 3 * m == 2^32 + 2 the 32-bit stride wrapped,
a[1][0][0] aliased a[0][0][2], and an assertion that the two are distinct
objects was proved; mismatched level widths (int a[2][m][3] indexed
symbolically) aborted the solver instead. The product is now taken in size_t
(#8018). And the pointer round trip
added in #7895 tied every flattened
pointer to every pointer rebuilt at the same address; when the stored pointer was
NULL-based, the tie left the formula with no model, so a reachable assert(0)
after a pointer was stored in malloc’d memory and read back through void *
verified, and an SV-COMP counterexample was lost. Only flattened pointers are
tied now; a rebuilt one is defined by the flattens that reach it by data flow
(#7926). This one affected master
between 19 and 27 September, not a release.
False proofs under k-induction. Under --add-symex-value-sets, which every
--k-induction run enables, the inductive step bound each pointer havoc to the
pointer’s loop-entry value and its loop-entry points-to set, so it checked only
the loop-entry pointer state and proved loops whose bug lies beyond k:
int a[2] = {1, 2};
int *p = a, s = 0;
for (int i = 0; i < 3; i++) { s += *p; p++; } /* reads a[2] */Both restrictions are gone (#7972).
This costs proofs: a safe list walk that was proved only through the restriction
now ends UNKNOWN, and SV-COMP list proofs may be lost the same way. Under
--k-induction --interval-analysis, a while (1) loop headed by an assert
aborted, and where the instruction before the loop head was a jump past the
loop, the havoc never reached the loop and the inductive step proved a missed
bug; the havoc now always goes at the head
(#8067).
False proofs under non-default options. --interval-symex-assert joined a
parked path’s interval snapshot back only for variables a phi function
assigned, so an __ESBMC_assume on one branch survived the merge and pruned
assertions the other branch violates
(#8051). Under --no-slice, a VLA
declared on an untaken path with a negative bound sign-extended to a size the
address space cannot hold, the formula was UNSAT on every path, and
assert(n != -1) passed; the exact layout is now required only up to the
largest object size symex admits
(#8087). Under --big-endian, the
SMT layer laid arrays and structs out with element 0 in the low bits while the
byte operations read address 0 from the high bits, so
union { short a[4]; short b[4]; } stored a[1] at b[2] and
assert(u.b[1] != 5) verified; short union members, symbolic-offset struct byte
reads and union counterexample values were wrong for the same reason
(#8084).
--gcse, three ways. The common-subexpression pass (off by default) had
three independent soundness defects, each a false proof. An assignment target
equal to an available expression — such as the (signed int)b that b ^= 1
lowers to — was replaced by the cached symbol, dropping the store; stores
through arrays, aliases and shared heap objects left stale expressions
available; and a cached symbol was reused after a kill when a guard or call
made its expression available again
(#7999). The points-to analysis
behind it read the pointer in *e, &e[i] and e->f as a fresh node with no
constraints, which it took to mean “points to nothing”
(#8074). And the analysis is
sequential, so a value another thread wrote in between was reused; --gcse is
now skipped with a warning on a program that may create threads
(#8072).
A false alarm under --no-propagation. assume(x == c) was lifted into an
assignment that bumped x’s SSA version without emitting a step, so later reads
of x were unconstrained and produced spurious counterexamples
(#7975).
Multi-property results under the k-step strategies
--multi-property under the k-step strategies. This finishes the reporting
work tracked in discussion #7900.
--k-induction and --incremental-bmc ran a separate property check at every
k, renumbered the claims each time, and reported a base case that came back
UNSAT — a bounded result — as PASSED under an interim
VERIFICATION SUCCESSFUL. One table now covers the whole run, printed once
where it concludes, with ids that stay fixed across k; a base case records only
violations, and the forward condition or inductive step settles the rest
(#7941). Clearing claims that a
round had settled was keyed on position and guard text, which dropped
--loop-invariant body copies and the assertions a NULL-pointer check was
raised in without solving them, and the forward condition then reported them
PASSED; completeness is now judged per round
(#7963). Two checks sharing a
description and position, such as the bound checks of a[i] + a[j], shared one
row, so once one failed the other was skipped as already verified; each now has
its own row, ending with its condition where the rows would otherwise read the
same (#7923). Interval analysis gave
a literally false ASSERT no successor, so under
--k-induction --interval-analysis --multi-property the code after a violated
assertion was treated as unreachable and the inductive step proved an assertion
there; the
same pruning made --all-witnesses delete code after a failed assertion
(#7916). The loop-free program of
#1902, which kept incrementing k,
now stops at k = 2 and is pinned by a test
(#7970), and a new
multi-property-matrix suite pins every row, summary and verdict under each
strategy; 14 of its 22 cells fail on v8.5
(#7973). The
usage page lists what each
strategy reports per property.
Python
is None on an Optional value. r: Optional[str] = f(nondet_int())
followed by assert r is not None verified SUCCESSFUL even when f could
return None: symex folded is None to false for a string pointer, and an
unannotated caller of an Optional[T] function got a placeholder type that
could not hold None. A NULL string pointer is now None, and T if c else None
yields T* rather than an Optional struct
(#8014); Optional[List] and
Optional[Set] use the container’s pointer type in the same way
(#8020). Optional[int] was an
int* holding the value, so a stored 0 could not be told from None;
Optional[int|float|bool] now uses the Optional<T> struct that int | None
already had, across variables, returns, parameters, fields and dict values, and
a None returned from a function declared -> int is reported rather than read
as 0 (#8050).
Optional[Dict] and Optional[Tuple] remain open
(#8017).
String rendering that proved what CPython refutes. The model behind
str(), f-strings and print of a runtime float truncated to six fractional
digits, had no scientific notation and printed -0.0 as 0.0, so
assert str(v) != "1e+16" with v = 1e16 verified. It now renders only strings
that are provably CPython’s shortest repr — integral values below 253, and
values in [1e-4, 232) whose 6-digit decimal reads back exactly — and leaves
every other value an unconstrained string
(#7953). The constant fold had the
same class of bug (10000000000000002.0 where CPython prints
1.0000000000000002e+16) and now takes the shortest round-trip digits with
CPython’s layout (#7959). repr(),
ascii() and f-string !r/!a/= are modelled for runtime ints, bools,
floats and ASCII strings, where they previously gave a nondeterministic string or
an unsupported call; a format spec on a !r field, which was folded to the
unpadded value (f"{42!r:>6}" as '42', a false proof), now yields an
unconstrained string (#7952,
#7959). repr('a') no longer fails
to terminate, and f'{None!r}' folds to 'None'
(#8022). Containers and non-ASCII
strings under !r stay unconstrained.
Code points and byte order. Runtime ord() read the first UTF-8 byte
as a signed char, so assert ord(chr(200)) < 0 verified; a new model decodes the
first code point (#7950), and
len(chr(200)) counts one code point rather than two bytes
(#8023). Strings built at run time
and indexing still work on bytes. int.from_bytes turned every byteorder other
than the literal "big" into little-endian, so a value folded on a loop’s first
iteration was flipped on the second — a false proof
(#7944). The byteorder is now folded
where the callee is resolved, so an alias (f = int.from_bytes) honours it and
int.from_bytes(b) defaults to big-endian as in CPython rather than raising
TypeError; a byteorder that is not a literal is refused with a located error
instead of guessed (#7948).
Classes. A method called through a derived class’s name bound the
first base, depth-first, whose method symbol already existed, which proved false
results for forward references, async def overrides and class attributes, and
picked the wrong method in a diamond. Where every class in the hierarchy is bound
once in the file, the call now follows Python’s C3 method resolution order, and
is refused rather than bound to another class’s method when the deciding class
has no converted symbol yet (#8058).
len(xs[i]) over a list of class instances never called __len__; it does now
when the program defines a single class, and stops with an error otherwise
(#7977). isinstance("int", type)
was folded to true because a type object is modelled as a char array
(#7989). A module-level class and a
function- or method-scope class of the same name shared one symbol and the later
constructor was silently dropped; that is now refused, listing the conflicting
lines (#7942). In the other
direction, a program class that shares its name with a class in an imported
module is no longer refused: the imported class is renamed and mod.Name,
pkg.mod.Name and from mod import Name are rewritten, where the scoping is
plain enough to do so (#8026).
cls(...) inside a @classmethod constructs the class the method runs on,
including through a subclass, where that class is known statically
(#7958).
Conversions and unary operators. bool() and the numeric
constructors relabelled most arguments instead of converting them, so
assert bool(g(k)) + 1 == 6 verified where CPython fails it, and
bool(~k) aborted in the solver
(#7961). bool() of a list or dict
relabelled the container pointer: bool(xs) of an empty list parameter was
proved true, and bool([1, 2]) aborted; it now tests emptiness as not x does
(#7969). An if or while test of
the form ~h(0) or -h(1) was evaluated as not h(...), which missed bugs, and
assert -f() == -2.5 crashed in SMT encoding
(#7957). x = 1.5; x = True aborted
with a binary2integer assertion, and b = True; x = 1.5; x = b proved
not isinstance(b, bool); a bool assigned to a numeric variable is now converted
rather than retyped (#7951).
Lambdas, sorting and lists. An unannotated lambda’s parameters and
result were double, so an int above 2**53 was rounded and
g = lambda n: n + 0; x: int = 2**53 + 1; assert g(x) == 2**53 verified; parameters now take
the scalar type every call site agrees on, and the return type follows an
integral body (#7949). A lambda over
a class instance (lambda c: c.speed applied to cars[0]) aborted the solver
with a sort mismatch (#7934), as did
sorted/min/max(cars, key=lambda c: c.speed); the sorted scan also applied
the key O(n²) times, which gave a false proof for an impure key, and now applies
it once per element as CPython does
(#7955). a = stack.pop() was
typed as the list rather than an element, which produced a false
VERIFICATION FAILED when popped values were passed to a helper
(#8053). An element of a list mixing
strings and numbers, such as [3.0, "+", 2.0], is read as a tagged value, so
isinstance(token, float) answers per element
(#8052). d, n = heappop(h) on a
heap of tuples now unpacks, and a class object used as a value (A = int) no
longer crashes symex under --smt-symex-guard
(#8059). Two crashes on dynamically
typed values are fixed: reading a branch-divergent value back out of a dict
(#7943), and calling
bit_length(), bit_count(), conjugate() or is_integer() on one inside an
isinstance guard (#8085).
Bytes. A function returning -> bytes now keeps its length and can be indexed inline, bytes + bytes is concatenation rather than unmodelled NumPy broadcasting, and a bare
bytes parameter takes its size from the call site. nondet_bytes(n) gives n
independent nondeterministic bytes for a constant n
(#7902).
Located preprocessor errors. A rejection raised while preprocessing
— ERROR: Decimal() with non-constant arguments is not supported — reached the
user with no position, because only one of the raise sites attached one. Every
such rejection now carries the file:line:col of the narrowest node that could
not be handled (#7940).
NumPy. A method chained directly on a constructor, such as
np.eye(3).transpose(), produced a nondeterministic result instead of the value;
those methods are now evaluated
(#7976), and the ones that were
refused in the interim (reshape, prod, std, var, argmin, argmax,
diagonal) followed (#8011). A
function that builds an array in a local and returns it keeps the array’s type,
a pinned KNOWNBUG until now; searchsorted takes a vector of values and a
sorter= argument; sort/argsort accept kind='stable'/'mergesort'; and
a parameter with a symbolic shape gets a named diagnostic rather than an
AttributeError (#7925). A dtype=
keyword on a constructor no longer makes later transpose, sort or reducer
calls decline, and *_like constructors accept a dtype= override
(#7976). .size works on a
constructor call (np.full((2, 4), 7).size) and searchsorted on symbolic
search values (#8011).
C and C++
C++ objects built in the wrong place. Several ways of creating an object ran the right constructor on the wrong storage, or none at all, and most of them gave a false proof:
new C[2]{C(1), C(2)}ranC(1)on every element, sop[1].v == 1verified; elements past the end of a braced list were zeroed rather than built by clang’s array filler, soC c[3]{C(1)}never calledC()andS s[2]{S{1, 2}}ignoredS’s default member initialisers (#8069). For scalars the list was dropped altogether:new int[2]{1, 2}thenassert(p[1] == 2)was a false alarm, and with a user-replacedoperator new[]a false proof (#8038).- Placement
newat an address with a side effect — any call, such asnew (std::addressof(buf)) int(42)— was treated as an allocatingnew: the object went to fresh memory and the buffer kept its old bytes. The address is now evaluated once, before the initialiser, as [expr.new]/19 requires (#8028). - A class member initialised from a prvalue of its own type,
C impl_ = C::make();, is that prvalue’s result object ([dcl.init]/17.6.1); ESBMC copied a temporary into it and destroyed the temporary, or forC impl_ = C{&x};ran the constructor on no object at all. The member is now built in place (#8025). - Before C++17, clang elides the copy in
C c = C::make();andreturn C(x);, but ESBMC ran the copy constructor and destroyed a second object. Under--std c++14, Apple clang’s default, a destructor count that aborts natively verified, and the true count was a false alarm (#8041). - A function-local static with a dynamic initialiser was initialised before
main, sovoid n() { static int c = bump(); }ranbump()even whennwas never called. It is now initialised on the first pass through its declaration, guarded, as [stmt.dcl]/3 requires (#7984). - In C and C++ alike,
char a[4] = {"ab"}stored the literal’s address cast tocharintoa[0]and zeroed the rest, soa[1] == 0verified. A braced string literal now initialises the array as the bare literal does (C11 6.7.9p14) (#8070).
Two declarations that shared one symbol. ESBMC names a declaration by
clang’s USR, and in four places the USR did not tell two different entities
apart, so the second was verified through the first. When several C files are
verified together, each file’s copy of a static function a header defines —
and its static locals — was one symbol, so a counter called once from each of
two files appeared to count to 2
(#7998). Two same-named locals
declared by one macro expansion shared storage, so a value stored through an
int s was read back through an unsigned char s
(#7986). struct S defined inside
two different functions was one type, read through whichever layout came first
(#7982). And f<int A::*> and
f<long B::*>, or overloads differing only in a member-pointer parameter, shared
one body (#7979). All four gave false
proofs. The fix in #7986 briefly gave a block-scope extern declared in a macro
its own symbol instead of the global it names; every MatIEC-generated PLC
program, which binds located variables that way, then failed a dereference check
before reaching its properties. Declarations with external storage keep the
global’s identity again (#8037).
Two related aborts are gone: a C++20 class-type template argument, g<S{1}>(),
ended the run with “Unable to generate the USR”
(#7987), and
std::hash<std::thread::id>{} was a CONVERSION ERROR because the friend
declaration in <thread> left the record registered as incomplete
(#7938).
GNU vector extensions. Following the four fixes in the previous update, a
vector can now be read and written through a pointer to it, at its own
alignment, so the vectorised loop of
#1030 —
*(v4si *)(result + i) = a * b; — verifies instead of aborting with
“Unrecognized dest type during dereference”. A vector struct member now sits at
its alignment rather than straight after the preceding field
(#7919). Three solver aborts are
fixed: a store into an array of vectors, v4i a[1]; a[0][0] = c;
(#8015); arithmetic between a
constant vector and one built lane by lane, which paired each lane with the whole
other vector (#8077); and reading a
union containing vectors through its bytes, or writing an array of structs with
a vector member at a symbolic index
(#8078).
_Float16 has the right format. _Float16 and __fp16 were built with 4
exponent bits and 11 fraction bits; IEEE 754 binary16 has 5 and 10. The largest
finite value was therefore about 256, so (_Float16)1000.0f folded to +inf
and a correct program reported FAILED, and since Bitwuzla does not support
that format, any _Float16 reaching the solver aborted the run. ESBMC now agrees
with clang on the maximum finite value, rounding, overflow, the smallest normal
and subnormal values, and sizeof
(#7932).
Library models. is >> std::ws, std::u16string / std::u32string, and
shared_ptr<void> / unique_ptr<void, D> compile with libc++ but were
PARSING ERRORs; all three now parse
(#7980). POSIX getline is declared
in <stdio.h> — and so in <cstdio>, where its absence had been a
PARSING ERROR — and modelled: it returns a fresh buffer holding
nondeterministic bytes from the stream, so a program cannot prove the line it
read back is the one it had before
(#7933).
Ladder Diagram
Variables written outside the program. A PLC program’s
VAR_IN_OUT and %M marker variables can be written between scans by a
caller, an HMI or a fieldbus, but the LD front end re-sampled only inputs, so a
violation caused by an outside write was missed, and VAR_EXTERNAL was not read
at all (“undeclared variable”). These variables now take any value at the start
of each scan cycle, and the run lists them
(LD: sampling each scan, as writable outside the program: ...).
--ld-closed-world asserts that only the program writes them. One scan loop
cannot model a faster task preempting the program mid-scan, or several program
instances, so a configuration with more than one task or more than one program
instance is now refused with UnsupportedConstruct
(#8065).
False proofs from scoping and unmodelled wiring. Every POU’s
interface variables went into one document-wide list, so a function block’s
variable and a program variable of the same name became one symbol: a program
variable declared TRUE could start FALSE and hide a violation. Program
variables are now declared only by the program POU; a function block with an
LD body, whose rungs ran in the program’s scope, and a document with more than
one program POU, whose bodies were merged into one scan, are refused
(#8063). In graphical rungs, an
operator block or a timer/counter pin wired to an outVariable left the
variable unassigned, a block wired to a PT or PV pin was read as 0, and an
unparsable literal or initial value was read as 0 or as its numeric prefix
("1.5" as 1) behind a warning; each gave VERIFICATION SUCCESSFUL on programs
that violate the property, and each is now refused, while an integral decimal
such as "2.0" is still read as an integer
(#8064). And a user function block
whose Structured Text body failed to translate was skipped, so its outputs kept
stale values and a property over them could be proved although the real body
violates it; each output and in/out pin of such a block now takes a
nondeterministic value every scan. --ld-sound-mode therefore no longer turns
an untranslatable body into a no-op
(#8079).
Function-block inputs and Structured Text. Every input of a
user function-block instance was re-sampled nondeterministically each scan,
ignoring its wiring, which produced false alarms on correct programs. An input
now takes its wire’s value: a program variable, another block’s output pin, or a
BOOL or integer literal. An unwired input, or one fed by something not
modelled, stays nondeterministic with a warning
(#8034). The Structured Text
translator for function-block bodies had no Boolean level: Q := A AND B; was
modelled as Q := A, and a parenthesised Boolean expression dropped the whole
body. AND/&, OR, XOR and NOT now parse at IEC 61131-3 precedence on
BOOL operands, and VAR_TEMP, VAR_IN_OUT and outputs after the first keep
their declared type instead of becoming 32-bit integers
(#8035).
Rung order and evaluation follow Beremiz. When the right power rail listed no coils, the order of graphical rungs came from hash-map iteration, and a contact reading a variable that a coil wrote saw a scan-start snapshot, so a rung saw an earlier rung’s write one scan late. Coils now run in Beremiz’s layout order (coils less than 10 units apart vertically share a row and are ordered left to right; rows top to bottom), whether or not the rail lists them, and evaluation is sequential: each coil re-reads its contacts and a block steps once per scan (#8036). Because the last powered coil wins, ESBMC now warns when one variable has both set and reset coils, naming the winning coil where it can (#8080).
Counters. CTU kept counting past PV and CTD went
negative, saturating only at the 32-bit bounds, where MATIEC counts up only
while CV < PV and down only while CV > 0. Q was unaffected, but logic that
reads CV saw values the runtime never produces, a source of false alarms; the
bounds now match MATIEC (#8066). A
graphical CTD never read its LD pin, so the counter could not be reloaded;
CTD now loads CV := PV before counting down, accepts CODESYS’s LOAD as
well as LD, and CTU’s R and CTD’s LD take the power flow of the
contact or block Q wired to them, with other sources refused
(#8033).
Crashes, counterexamples, contracts and a new option
Crashes and hangs. Release builds for Linux x86_64 are built with the
goto-contractor, whose interval library left the FPU rounding toward +inf after
its static initialisation; CaDiCaL’s local-search setup then never terminated,
and Bitwuzla and cvc5 reported Out of memory on bit-vector formulas of a few
dozen gates. main now restores round-to-nearest before any thread starts
(#8045). The simplifier folded
(a + k) - a to k in the offset’s width rather than ptrdiff_t’s, and both
Bitwuzla and Z3 aborted on code as ordinary as
ptrdiff_t n = k; if (c) n = (a + 3) - a;
(#8040). A byte-wise walk bounded by
another member’s address, for (char *p = (char *)&s.a[0]; p != (char *)&s.a[1]; ++p),
never left the loop under default flags because the guard did not fold through
the casts (#8009); the normalisation
that fixed it then rewrote the anchor of an array of byte arrays
(unsigned char pool[8][32]) without end and crashed with SIGBUS
(#8029). new D[2]() for a
polymorphic class D aborted in the pointer analysis, which matched the zero
fill to the base subobject rather than the whole element
(#8075).
Counterexamples. Symbolic execution lowers s.f = v into a whole-object update, so the
trace builder read and printed all of s for a step that shows s.f. It now
reads the model for the component the step prints: on the PLC benchmark from
InduByte/esbmc-evaluation#6 the run goes from 32.7 s to 22.4 s and the
counterexample from 28.1 MB to 0.98 MB, with 1668 values now concrete; violation
witnesses carry the assumptions that component writes contribute
(#7936). The SMT-LIB backend read a
signed bit-vector model value as a natural number, so a = -4 printed as
4294967292 and f = (a < 0) as f = 0; the verdict was unaffected
(#7931). The NeuroSym backend now
serves counterexamples from its own model output instead of a second solve, and
no longer hangs on a wide shift amount or aborts when --neurosym-model-prog is
missing (#7928).
Function contracts. Under --enforce-contract, struct pointer parameters
are no longer backed by one implicit element: like any other pointer they get a
nondet extent, so s->field needs
__ESBMC_requires(__ESBMC_is_fresh(s, sizeof(*s))). Only a C++ this keeps one
object, which the language guarantees
(#8042). This closes the
contract-soundness umbrella #6485.
Transition-system extraction (experimental). --ts-check extracts the main
loop as a transition system — initial state, one-iteration step relation,
inputs and bad states — prints whether extraction succeeded, and stops with
VERIFICATION UNKNOWN; --ts-dump prints the system. No engine consumes it
yet (#8081;
usage).
The website documentation has been updated to match. As always, the full list is in the commit history — and if you hit a bug or a missing feature, we would love an issue report.