Repository navigation
verify: Check that a contract is defined where C++ evaluates it - #57
Merged
Merged
Conversation
SwayamInSync
force-pushed
the
fix/contract-wellformedness
branch
2 times, most recently
from
October 22, 2026 19:30
b713c0e to
4737e54
Compare
…ditions in order.
…th a check's own time.
…c970 for the Lean contract test.
…80 for the Lean contract test.
…0 for the Lean contract test.
… for the Lean contract test.
… for the Lean contract test.
…ero_d81a153d for the Lean contract test.
…and an unproved one does not.
…the race, and stop it when the obligations settle the module.
…gations that choose its failure.
…er work instead of 500 ms.
SwayamInSync
force-pushed
the
fix/contract-wellformedness
branch
from
October 23, 2026 03:26
20481ed to
6dbd097
Compare
This was referenced Oct 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A contract is C++, evaluated where the function is called and where it returns, as C++26 evaluates contracts. On
mainnothing checks that it is defined there:mainpre(a / b == 0)pre(*p > 0)pre(valid(p, 8) && p[i] == 3)i = 8is Verified toopost(p[i] == old(p[i])),i <= 8cppverify::checkthat reads past an extentpost(1000 / result > 0)The last one is a wrong result. The logic defines what C++ leaves undefined (
x / 0is0), so the trusted contract read as excluding a result of 0, and a caller that divided by that result verified:Now:
Design
How the references handle this:
CheckWellFormed$$f, BoogieGenerator.cs). It checks eachrequiresand then assumes it, left to right, havocs the heap withinmodifiesand the outputs, and checks eachensuresin that state.&&,==>,||, andifguard what follows them. It is not rechecked at call sites.recommends, checked after a failure).This PR takes Dafny's procedure and C++26's meaning, and keeps Verus's totality for specs:
Passivizer::contract): a module of its own per function,contract of f. The generated preconditions (pointer validity, separation, type invariants) are entry facts. Each written precondition, in order, gets the definedness checks its operations get in a body, and is then assumed. A clause is therefore checked where the clauses before it hold, and within a clause, where the operands of&&,||,?:before it allow.run()): checked in the body's own final state, as C++26 and Verus do, each guarded by the postconditions before it, now with the memory checks too.emitCallStmtis factored intohavocFootprintsandfootprintBytes, unchanged.cppverify::check: the memory checks of the body.collectSafety) and, with memory checking, the memory checks of the clause as written.instrumentUBCheckscomputes them before spec inlining, as it does for the body's accesses, and records them beside each written precondition and postcondition, each loop invariant, and eachcppverify::check(VMemoryCheck). Cloning and loop unrolling copy them. A read underoldis checked in the entry state, a quantifier's body at every value of its range, and in a pre- or postcondition every parameter is its entry value. In the object model an extent is its pointer's object, so the object checks cover it; a proof function, outside the object model, is checked against its extents. Passivization joins a clause's memory checks to its core checks, one obligation per kind.post(result == cell(p, 7))with a speccellthat readsp[i]failed at the read. And an inlined lifted helper repeats its arguments' reads under each of its guards: one postcondition ofscan_uleb128_1(llvm_uleb128_decoder_exhaustive.cpp) grew from 183 thousand to 4.1 million lines of passive program.constexprhelper's checks are then reported at its call (collectSafetyanchors an unfolded helper's checks there).Unresolvedwithcallee.contract, and the check is a producer of the contract fact in the cycle check. The function's own verdict is about its body and does not change. An unresolved check goes through--lean-fallbacklike any module; under BMC the check is solved by Z3, as spec checks are.recommendsstays a warning.h_pre_<n>orh_post_<n>, and what holds of the state a function without a verified body returns in, its frame and its result, ish_ret_<n>. Without this every hypothesis of these goals wash_fact_<n>.A trusted contract whose
validmarker cannot be read was accepted silently; it is now reported, as it is for a definition.A flaw in the Z3 race, which this exposed
CI's release job failed twice on
certified_failure_attribution.cpp(--jobs=3), past its 20-second limit. Pinned to one core it took 60.33 s, against 0.67 s onmain. The cause is in the race between a module's whole query and its obligations (verifyModuleDirect), not in this PR's checks:certifyModel), and that check was capped by what was left of the slice. On a loaded core the search took about 460 ms, the check had 41 ms, and the model was left unchecked: no certified failure, so the race went on.The race's own comment says a model check has a budget of its own. Now it does: a whole query given a slice of the budget checks a model it finds with the time a check of the module's queries gets, half the module's timeout or
--certify-timeout(Z3Encoder::setSearchSlice,solveQuery'sSlice). The function's deadline still bounds it.That was not all: the release job failed again (
within_seconds: the command took longer than 20 swith--jobs=3). Reproduced on two loaded cores (taskset -c 2,3with two busy loops on them): 61 s in each of five runs, against 0.9 s onmain. Instrumented, the whole query's 500 ms slice ended unresolved at 581 ms; the 18 easy obligations were proved, the postcondition's ran to its 60 s timeout, and only then did the retry of the whole query, with the full budget, refute the function in 585 ms. Onmainthe smaller query fits its slice even under load, so the order of the fallback never mattered there.The slice exists to keep the obligations' time when they and the whole query share one worker. Racing, they do not, and the retry waited for them anyway. Now:
A first version ran the retry on the function's own thread before helping its group: the verdicts were the same, but
loop_frames.cpptook 61 s against 1.3 s, since every worker held a retry while the obligations that would settle the functions waited in their queues; and without the second rule, a retry beside obligations that had already failed ran to its timeout. Measured with the final version, on two loaded cores:certified_failure_attribution.cpptakes 1.2 s with--jobs=3and with--jobs=1.The whole query's probe is bounded by work, not time
CI's assertions job then failed
certified_failure_attribution.cppwith--jobs=1, past its 20 s limit (the macOS log was not available). Reproduced on an assertions build, on one core shared with two busy loops (taskset -c 0):Unresolvedwithsolver.timeoutafter 121 s. A module without logic functions gave its whole query 500 ms. This one needs about 700 thousand Z3 resource units, which an assertions build does not do in 500 ms under load. With one job its obligations then ran, the postcondition among them, whose search does not end. More jobs hid it, since the retry then runs beside them. Where the probe stopped depended on the load and on the build.Now the probe is a fixed amount of work,
WholeProbeUnits: 6 million Z3 resource units (rlimit), with the module's timeout as its bound in time.UnitsCap), as it had the probe's time before.Measured:
certified_failure_attribution.cppon that loaded core: 7 s with--jobs=1and with--jobs=3(before: 121 s and 1.5 s). Idle: 2.3 s.--jobs=1), and the choice without the work cap (60 s with both).Tests
suite/contract_wellformedness.cpp, three runs (Z3,--no-check-ub, BMC):orderedholds,reversedfails),||,?:, a quantifier's range (all_positiveholds,one_morefails), behaviors.oldis checked in the entry state (take); a parameter the body reassigns is its entry value (advance).p[n]fails in each.*q(holds) and one that writes it (fails), a contract without a definition.validmarker cannot be read is an error, and its caller is demoted (main: Verified).p[2](which isq[0]) are checked againstp's own object, though the body reassignsp.cppverify::checkin a ghost block gets the memory checks too.proof.cycle(main: Verified); with measures that every use lowers (a cluster), both verify.--no-check-ub: core checks only, sosecond(one object) passes andatfails its dereference.suite/lean_contract_certify.test(#60's certification): a trusted contract whose second precondition divides, and a caller. Its check is exported ascontract of sensor; with proofs of its goal and the caller's, both areCertified(reading [trusts=sensor]); with the check's proof left a stub, the check isUnresolvedand so isreading, withcallee.contract.suite/lean_readable.cpp: the trustedpickmay write*p, so its postcondition100 / *p > 0may divide by zero. The test checks every hypothesis of that goal in order:h_entry_1,h_null_1,h_null_2,h_object_1,h_pre_1,h_ret_1(the frame),h_ret_2(the result's validity),h_post_1.Existing tests whose contracts were undefined:
dynamic_storage_fail.cpp:unsafe_forwarded_read's postcondition readsold(source[1])through a one-object pointer. It now fails there instead of being demoted for its callee.certified_quantifier_ranges.cpp: its preconditions readp[i]up to two million through a one-object pointer. They now declare the extent,valid(p, n); every verdict the test checks is unchanged.offset_preconditionformssource + 1withsourcepossibly null;slen_bad,sound_quant_needs_nonalias_fail'sf, andsound_store_changed_fail'sfreads[n - 1],s[i], andp[2]through a one-object pointer;unsafe_difference_preconditionformsp + (count + 1), past the end;missing_extent_pointer_forwardformsp + 2from one object; the trustedbeforeformsresult + 1with a result that may be null.Docs
contract of flabel and its obligation IDs.Checked
Rebased onto
mainat f861d01 (with #46 and #49). The one conflict, inrelease-notes.rst, is #49's entry and this PR's "Results that depended on load" added at the same place; both are kept. Before that, on 62af455 (with #54, #55, #56, #58, #59, and #60). On this tree: the 39 Lean tests,contract_wellformedness.cpp,certified_failure_attribution.cpp, the docs tests, and the newlean_contract_certify.testpass. Earlier, on 85f0b90: The one conflict, inPassivize.cpp, is #59's label of each entry assumption: A32 skips the written preconditions there in the contract's check, so the label comes from the precondition's index.Verify lit suite: 438 passed on a release build, including the docs tests, with
llvm_uleb128_decoder_exhaustive.cppexcluded (lit never runs it). Before this rebase, at 14a7383 with the race fix: 433 passed and 1 unsupported on an assertions build.VerifyTests: 62 passed, 1 skipped.
certified_failure_attribution.cpppinned to one core (taskset -c 0): passed five of five runs, 2.2 s each under lit.The race retry, on assertions builds of this branch before and after it: a verdict differential over 413 test sources with
--jobs=4(the exhaustive ULEB128 file left out, as lit does): no verdict differs; total 742 s → 568 s,llvm_uleb128.cpp129 s → 56 s, no file slower. The 21 tests that pass--jobsabove 1 pass. On two loaded cores,certified_failure_attribution.cpptakes 1.2 s with--jobs=3and with--jobs=1(61 s and 1.3 s before).The work-bounded probe, on assertions builds of this branch before the race fix and with the probe: a verdict differential over the 413 test sources with
--jobs=4and with--jobs=1. No verdict differs. With--jobs=4, the total is 729 s → 561 s, andllvm_uleb128.cpp127 s → 52 s (the race fix). With--jobs=1, 1321 s → 1331 s;loop_frames.cpp3.6 s → 8.8 s, since its five failing functions each spend 1.4 s on a probe that never decides. On the same build, lit runs the 25 tests that pass--jobsabove 1 or that this PR adds or changes: all pass.On
main: each of the three RUN lines ofcontract_wellformedness.cppfails.Verdict differential against
main(573c171 plus verify: Bound pointers into a record by the array, field, or record they point into #53, before the rebase): 440 test sources. Verdicts differ in the new test, in verify: Bound pointers into a record by the array, field, or record they point into #53'ssubobject_pointers.cpp, in the six tests above whose contracts are undefined (dynamic_storage_fail.cppand the five with the seven functions), and in the exhaustive file below. Total time 3536 s → 3732 s. Nearly all of it isllvm_uleb128_decoder_exhaustive.cpp(UNSUPPORTED, which lit never runs): 1601 s → 1875 s run alone, and itsdecode_uleb128_length_10and_11reach the timeout. Each has about 12 new bounds checks, one per postcondition, of reads at constant indexes (buffer[3]invalid(buffer, 11)). Deciding such accesses without the solver is a separate item for the body and contracts alike. The case studyllvm_uleb128.cpptakes 311 s against 330 s.inductive_rules.cppshowed one 33 s run in the differential, a load stall thatmainshows too (one 34 s run in 16 parallel runs).Mutation: 29 mutants; 28 are killed by the tests:
oldchecked in the final state; quantifier bodies unchecked; proof functions unchecked against their extents; a clause's parameters taken as reassigned;The surviving mutant drops the guard that checks a body's postcondition where the ones before it hold. It is equivalent for verdicts: only the first failure in source order is reported, and a later clause's failure under a failing earlier one is never that.
Readable-goal labels: four mutants, all killed by
lean_readable.cpp: nopre,ret, orpostlabel in the contract's check, and an entry assumption labeled by its position among the entry assumptions rather than among the preconditions (which names the extent's facth_pre_1).Docs:
check-doc-examples.pypasses on every page this changes (74 examples, 25 against the verdicts their pages show).Merges: clean with verify: Send crash reports to cpp-verify's issue tracker #46, docs: Publish the documentation of each release, and of main at /dev/ #49, verify: Print Lean goals that mean what their obligations mean #54, verify: Settle what a Lean proof rests on, as a solver's, after certifying it first #55, and verify: Pin Lean 4.35.0, move projects 0.1 pinned, and certify with the pinned toolchain #56. It conflicts with verify: Bound what a run builds and what its solvers allocate #48 in
Passivize.cpp: this moves the end ofPassivizer::run()intofinish(), shared with the contract check, so verify: Bound what a run builds and what its solvers allocate #48'sNodeBudgetcheck of the spec facts belongs infinish()(with noreturn P;), where the contract check gets it too. Whichever lands second takes that resolution.