Challenge 8 (partial): no-UB proofs and sorting-correctness harnesses for smallsort - #678
Open
samiam713 wants to merge 4 commits into
Open
Challenge 8 (partial): no-UB proofs and sorting-correctness harnesses for smallsort#678samiam713 wants to merge 4 commits into
samiam713 wants to merge 4 commits into
Conversation
…sort Add a #[cfg(kani)] verify module to smallsort.rs: 48 harnesses proving absence of UB for the seven functions named by the challenge's success criteria and asserting sorting correctness (sortedness AND multiset-permutation, fully symbolic contents) for the three small_sort trait impls, one harness per concrete length up to the measured SAT-tractability frontier, with element types (i32, Cell<i32>, a non-Copy wrapper, u128, [u64; 11], u8) chosen to steer every specialization and dispatch branch. Callees of the beyond-frontier branches (sort8_stable, sort9_optimal) are verified directly at their call shapes. No production line changes. Towards model-checking#56. Co-authored-by: Claude (Anthropic AI) <noreply@anthropic.com>
…udget
The first CI run failed two ways, both in this PR's own additions:
- upstream_test: the verify module was not rustfmt-formatted; `./x fmt
--check` rejected it. Fixed with `./scripts/check_rustc.sh --bless`
(formatting only).
- "Verify std library using autoharness": four harnesses exceeded the
job's 10-minute per-harness cap on the standard runners ("CBMC timed
out"): check_ss_stable_i32_len_9 and check_ss_unstable_noncopy_len_9
(both OSes), check_ss_unstable_cell_len_8 (both OSes), and
check_ss_stable_cell_len_8 (ubuntu; macOS passed at 596.2 s of 600).
Stepped back one length: the two len-9 harnesses are dropped (their
families stay dense 0..=8) and the two Cell len-8 harnesses move to
len 7 (~120 s single-run locally, ~3x CI margin).
48 -> 46 harnesses, no production changes. Full suite re-verified from
the fixed tree: 46 successfully verified harnesses, 0 failures.
Co-authored-by: Claude (Anthropic AI) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lhpp1YCFeiqkHd7iKiMBv7
Switch the 13 `#[kani::solver(...)]` pins from kissat to cadical, which is Kani's own default SAT backend. Nothing else changes: same 46 harnesses, same lengths, same element types, same fully symbolic inputs, the same assertions (`assert_sorted` + `assert_permutation`), and the same generous `len + 2` unwind bounds with unwinding assertions enabled. Run 34366406171 timed out `check_ss_sort8_stable` and `check_ss_unstable_i32_len_8` on both runners under kissat. Measured single-run A/B, one harness per invocation, identical load: check_ss_sort8_stable kissat 578.2s cadical 297.7s minisat >1500s check_ss_unstable_i32_len_8 kissat 643.3s cadical 438.5s minisat >1500s Tightening the unwind bounds from `len + 2` to the exact bound measured as noise in both directions (-4.6% / +4.9%), so the generous bounds are kept. Full sequential suite with cadical on a quiet machine: 46 successfully verified harnesses, 0 failures, 46 total. Slowest harness 407s, next 285s -- inside the 600s per-harness cap with margin on both runners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit moved all 13 `#[kani::solver]` pins from kissat to cadical. On CI that fixed the macos-latest autoharness job outright and cut the composed/i32 len-8 harnesses by 1.5-1.9x on ubuntu-latest -- but it pushed two *different* harnesses past the 10-minute per-harness cap: harness kissat (CI ubuntu) cadical (CI ubuntu) check_ss_unstable_i32_len_8 583.2 s 339.1 s pass check_ss_unstable_noncopy_len_8 523.2 s 315.6 s pass check_ss_sort8_stable 534.6 s 313.1 s pass check_ss_stable_i32_len_8 437.9 s 301.0 s pass check_ss_sort9_optimal 276.8 s pass killed at 600 s check_ss_unstable_u8_len_9 380.5 s pass killed at 600 s So the solver advantage is per-harness, not global: cadical wins the composed and i32 proofs, kissat wins the two u8/len-9 ones. Confirmed back-to-back on one quiet box, one harness per invocation, no other load: check_ss_sort9_optimal kissat 280 s cadical 449 s check_ss_unstable_u8_len_9 kissat 283 s cadical 392 s This commit therefore restores kissat on exactly those two harnesses and leaves the other eleven pins on cadical. Two attribute lines change; the `check_ss_unstable_u8` macro has a single invocation, so its pin governs only check_ss_unstable_u8_len_9. Nothing about what is proven changes: same 46 harnesses, same lengths, same element types, same fully symbolic inputs, same assert_sorted + assert_permutation oracles, same two documented kani::assume preconditions, same `len + 2` unwind bounds with unwinding assertions enabled, no stubs. A SAT backend decides the same formula either way. Full suite re-verified sequentially under the split pins on one machine: 46 successfully verified harnesses, 0 failures, 46 total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
This is a partial contribution — it does not close Challenge 8. The challenge's
success criteria cover arbitrary valid lengths; the harnesses here verify the
smallsortmodule per concrete length with fully symbolic contents up to a measuredmodel-checking tractability frontier, and past that frontier coverage is per-callee
rather than end-to-end (the exact gap is itemized below). Whether and how a partial
result counts toward the challenge is entirely the maintainers' call; this PR only
reports what was proven — and, quantitatively, what resisted proof.
Two things are offered for Challenge 8
(tracking issue #56):
46 Kani harnesses, verified from a pristine clone: absence-of-UB proofs plus
sorting correctness asserted as sortedness AND multiset-permutation of the
input, per concrete length, with every specialization body steered via distinct
element types. The dual oracle is directly responsive to the Challenge 8: Verify safety and sorting correctness of SmallSort #576 review
(2026-08-16): the degenerate constant-overwrite "sort" that review described
passes a sortedness-only check and is caught only by the permutation oracle
(demonstrated by mutation, below). One file changed; everything added lives in a
#[cfg(kani)] mod verifyblock — zero production lines change, zero verificationescape hatches (no stubs; the only
kani::assumes are documented preconditions,itemized in the Verification section).
A measured tractability frontier — where, concretely, bounded model checking of
this module stops being feasible, per path, element width, and SAT solver. This is
the quantified form of the wall Add contracts for
SmallSort#234 hit ("out of memory or several tens ofminutes") before being abandoned: the wall is now measured rather than merely hit.
It is offered as information bearing on what a bounded model checker can reach on
this module as currently specified — not as an argument about the challenge.
Correctness oracle. Every correctness harness asserts both properties: the output is
non-decreasing under the comparator, and for every input value its multiplicity is
preserved (multiset equality — a degenerate "sort" that overwrote the slice with a
constant could not pass). Contents are fully symbolic (
kani::any()); no valueconstraints anywhere.
What is covered, and how far. The small-sort API is bounded by design
(
SMALL_SORT_FALLBACK_THRESHOLD= 16,SMALL_SORT_GENERAL_THRESHOLD=SMALL_SORT_NETWORK_THRESHOLD= 32). Verification is one harness per concrete lengthwith symbolic contents. Model-checking cost turned out to be the binding constraint (as
#234 found before us; measurements below), so coverage has three tiers:
Freeze)small_sortimplbodies (insertion-sort paths, threshold 16) are verified at lengths 0..=3 and 7
via
Cell<i32>instantiations, andinsertion_sort_shift_leftat lengths 1..=4with
offsetsymbolic over its full accepted range (longer lengths exceed thebudget once the insertion loops' data-dependent path conditions compound —
sharply: len 4 verifies in seconds, len 8 exceeds 25 minutes; measurements
below).
Freezebodies end-to-end:small_sort_general_with_scratch(stable, viai32at every length 0..=8 with thecaller-shaped 48-slot scratch, plus
u128and non-Copy/ oversized element typessteering every specialization branch), and
small_sort_network(viai32at everylength 0..=8 and
u8at 9). This exercises thelen < 2no-ops, copy-1,sort4_stable-pair, insertion, andsort9_optimal-region branches, and thethreshold/
has_efficient_in_place_swap/MAX_STACK_ARRAY_SIZEdispatch in allthree trait impls.
reasonable CI budget (measured: stable general ≥ ~10, network ≥ ~9 at 32-bit width;
no width or SAT-solver choice moves the wall materially), each callee the long
lengths execute is verified directly, at full
i32width, at its exact call-siteshape and under its documented precondition:
sort4_stable(criterion 6),sort8_stableat its only call shape (8),swap_if_lesswith symbolic in-bounds positions (criterion 4), and thesort9_optimalnetwork at its guard length (8-bit elements — 32-bit isintractable there). The gap this leaves is stated plainly: the end-to-end
composition at long lengths is not model-checked, and two branches resisted even
direct verification at any width or solver tried —
sort13_optimal(its 45-swapchain) and
bidirectional_mergeat lengths ≥ 16 (even with sorted halvesassumed). Those two, plus the composed long lengths, are the precise unverified
remainder.
Remaining criteria functions:
swap_if_less(pair ordered + preserved, restuntouched; distinctness of the two positions deliberately not assumed),
sort4_stable(sorted permutation, destination fully initialized),insertion_sort_shift_left(permutation unconditional; sortedness under the documentedsorted-prefix premise, asserted as an implication so the unsorted-prefix case is still
explored for UB),
has_efficient_in_place_swap.Measured tractability (why the frontier is where it is)
On a 20-thread / 31 GiB box with the repo-pinned toolchain, single harness per run:
Cell<i32>insertion paths len 15+; u128 general len 8sort8_stable513 s,sort9_optimal270 s, network u8 len 9 258 s; the rest are seconds to a few minutesThe cost driver is CBMC's symbolic-pointer case-splitting (
swap_if_less/sort4_stableselect pointers viahint::select_unpredictable; the merge loopsadvance pointers by data-dependent amounts), which compounds per step — element width
and solver choice barely move it. This is the quantified form of what #234 reported
("out of memory or several tens of minutes") before being abandoned.
Verification
From a pristine clone at current
main(2bd54c9) plus only this change, with therepo-pinned toolchain (Kani 0.67.0 built at
d4df833c,nightly-2025-11-25),re-verified 2026-09-09 after the CI-budget adjustment (addendum below):
Assertion liveness was checked by mutation: negating the sortedness oracle makes the
harnesses FAIL, and a production-side degenerate mutation (merge output overwritten with
copies of one element — exactly the "constant overwrite" scenario from the #576 review)
passes sortedness but fails the permutation oracle, which is the property that
review found missing.
Loops are covered with generous
#[kani::unwind]bounds (len + 2); unwindingassertions stay enabled, so a too-small bound fails the proof rather than masking
exploration. The only
kani::assumes are documented preconditions (index bounds,offsetrange). No stubs. Harnesses pin a SATbackend explicitly, following existing in-tree practice (
num/mod.rs,ptr/mod.rs):#[kani::solver(cadical)]— Kani's own default — on eleven,and
#[kani::solver(kissat)]on the two where CI measured kissat faster(
check_ss_sort9_optimal,check_ss_unstable_u8_len_9). The setting isper-harness because the measurements are; see the CI addenda below.
Scope and known limits
measured frontiers, end-to-end model checking exceeds any per-proof CI budget by an
order of magnitude (table above). Coverage there is per-callee, plus branch coverage
of the dispatch logic. If the committee prefers, the frontier harnesses can be
extended on bigger iron — the harness shapes accept any length by macro invocation.
a < b). UB-freedom underan adversarial comparator (arbitrary results / panics) is not modeled.
#[ensures]attributes on thetrait impls: the permutation property needs the pre-state of a generic
&mut [T]behind specialized trait methods with closure parameters, which function-contract
syntax cannot currently express there.
i32,Cell<i32>,u128, a non-Copywrapper,
[u64; 11],u8), chosen to steer every specialization/dispatch branch.CI addendum (2026-09-09). The first CI run failed two ways, both fixed in the
second commit: the verify module was not rustfmt-formatted
(
./scripts/check_rustc.sh --blessapplied — formatting only), and four harnessesexceeded the "Verify std library using autoharness" job's 10-minute per-harness
budget on the standard GitHub runners ("CBMC timed out"):
check_ss_stable_i32_len_9andcheck_ss_unstable_noncopy_len_9(both OSes),check_ss_unstable_cell_len_8(both OSes), andcheck_ss_stable_cell_len_8(ubuntu; macOS passed it at 596.2 s of the 600 s cap). Those four are stepped back
one length — the two len-9 harnesses dropped (their families stay dense 0..=8) and
the two
Celllen-8 harnesses moved to len 7 — taking the count from 48 to 46;no production changes. The practical frontier is therefore set by the standard
runners (2–3× slower than the 20-thread box the tables above were measured on),
not by local iron. The slowest kept harnesses still run close to the cap in CI
(ubuntu:
check_ss_unstable_i32_len_8583.2 s,check_ss_sort8_stable534.6 s,check_ss_unstable_noncopy_len_8523.2 s; macOS:check_ss_stable_i32_len_8493.4 s), which is a known flake risk on a slow runner day.
CI addendum, second iteration (2026-09-09). The following run still timed out on
both runners, on two harnesses that had passed the run before
(
check_ss_sort8_stable,check_ss_unstable_i32_len_8) — runner variance againstthin margins rather than a new wall. The fix is the SAT backend, not the scope: the
13 solver pins move from
kissattocadical— revised once more below, wherethe right setting turned out to be per-harness rather than global.
Measured single-run A/B on one machine, one harness per invocation, identical load:
check_ss_sort8_stablecheck_ss_unstable_i32_len_8Tightening the unwind bounds below
len + 2measured as noise in both directions(-4.6% / +4.9%), so the generous bounds stay. Re-running the whole suite
sequentially under cadical: 46 successfully verified harnesses, 0 failures, 46
total — slowest 407 s, next-slowest 285 s, against the 600 s per-harness cap.
Nothing was stepped back or dropped to achieve this: the harness count, the
lengths, the element types, the fully symbolic inputs, both oracles and the unwind
bounds are all unchanged from the run above; only the SAT engine differs.
CI addendum, third iteration (2026-09-09). The blanket move to
cadicaldid fixthe two harnesses that had been timing out — on the ubuntu runner
check_ss_unstable_i32_len_8went 583.2 s → 339.1 s andcheck_ss_sort8_stable534.6 s → 313.1 s, and the macOS autoharness job passed all 46 — but it pushed two
different harnesses over the cap:
check_ss_sort9_optimalandcheck_ss_unstable_u8_len_9, which kissat had been solving in 276.8 s and 380.5 s,both hit the 600 s timeout. So solver choice here is per-harness, not global: the two
u8/len-9 proofs are the ones kissat wins. The pins are split accordingly —cadicalon the eleven harnesses where it measured faster,
kissatretained on those two. Thatis the only change; the harness count, lengths, element types, fully symbolic inputs,
both oracles and the unwind bounds are untouched.
Being plain about what this does and does not buy: it moves every harness off the cap
by a measured margin rather than by scope reduction, but the margins on the slowest few
are still a few minutes on a shared runner, so a slow runner day remains a flake risk —
the same caveat as the first addendum, not a new one.
Relationship to #576: that PR covers the same challenge; its review (2026-08-16) found
two blockers — correctness asserted sortedness only (no permutation check), and every
harness pinned
[i32; 4]. This PR is an independent implementation built to thatreview's direction: sortedness ∧ permutation everywhere, and per-length sweeps plus
callee proofs in place of a single pinned shape, with the genuine cost wall measured and
disclosed rather than papered over. Also noting #234 (closed 2025 after hitting exactly
this wall) and #640 (withdrawn unreviewed, 2026-08-26).
AI disclosure: these harnesses were developed with substantial AI assistance (Claude,
Anthropic), with human direction and review; the commit carries a
Co-authored-bytrailer accordingly. All proofs were re-verified from a pristine checkout before
submission.
Toward #56.