Challenge 8 (partial): no-UB proofs and sorting-correctness harnesses for smallsort - #678
Challenge 8 (partial): no-UB proofs and sorting-correctness harnesses for smallsort#678samiam713 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>
feliperodri
left a comment
There was a problem hiding this comment.
Thanks @samiam713. Reviewed against Challenge 8. This is substantially stronger than the prior Ch8 solution (#576): all 7 no-UB fns exercised on the real trait impls/callees (no body swap), and — crucially — all 3 small_sort impls have genuine correctness oracles: assert_sorted + a real assert_permutation multiset check (per-value occurrence counts), across i32, Cell (non-Freeze), NonCopyI32 (Freeze non-Copy), u128, [u64;11], u8. Fully symbolic contents, no cfg/assume tricks, no decorative contracts. Great work.
Requesting changes — it's partial, not yet a complete Ch8 solution:
- Bounded, not arbitrary length. Harnesses cap at len ~8–9 (i32 stable/unstable 0–8; Cell {0,1,2,3,7}; u128 len 2; big len 2–3). Ch8 requires "all possible slices with arbitrary valid length" — up to the fallback threshold 16 and general/network threshold 32. The current bound stops well below the interesting small-sort branches.
sort13_optimalis not verified at all (neither composed nor direct);bidirectional_mergeonly at tiny composed lengths; thesort9_optimal/network band is verified only at u8 width (sound reduction via zero-one principle, but a reduction).- Correctness is expressed as in-harness oracles, not
#[ensures]/proof_for_contract. The challenge says "write contracts" — real correctness contracts on the 3 impls (with proof_for_contract) would match the criterion form and be reusable.
Between the open Ch8 solutions we're prioritizing this one (it strictly dominates #576). To close Ch8 it needs coverage up to the real length thresholds and, ideally, the correctness expressed as contracts.
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.