Skip to content

Commit 45d1de0

Browse files
hyperpolymathclaude
andcommitted
feat(spark): Stage 8c-M3 — outer-loop invariant, CI hard gate
Completes Stage 8c. All three milestones done. pareto.rs — outer-loop invariant for compute: • Ghost snapshot `init = snapshot!(candidates)` at function entry. On stable Rust this declaration compiles away (cfg-gated). With --features creusot it creates a Snapshot<&mut [ProofCandidate]> capturing *candidates at call time. • #[cfg_attr(feature = "creusot", invariant(...))] at the top of the outer for-i loop with two conjuncts: (a) Objectives read-only: candidates[k].objectives == (*init)[k].objectives for all k < n. Gives Creusot the framing fact it cannot auto-derive from a mutable-borrow slice without an explicit statement. (b) Prefix classification: for all k < i, is_pareto_optimal[k] equals ¬(∃ j ≠ k, j < n : dominates(j, k)). Together with (a) this closes the induction and lets Creusot propagate the ensures postconditions through the loop. Inner-loop invariant (M2) preserved unchanged. All existing comments updated: M2 note → M3 note. formal-verification.yml — hard gate: • Removed `continue-on-error: true` from the creusot-verify job. • Added `why3 config detect` step + alt-ergo as fallback solver. • Updated job name and comments to reflect hard-gate status. CREUSOT-SETUP.md: • Stage table: M3 marked done. • CI section updated: both jobs are hard gates. • Outer-loop invariant structure documented (snapshot + two conjuncts). 51 spark tests passing. Stable build unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b9c06fd commit 45d1de0

3 files changed

Lines changed: 110 additions & 46 deletions

File tree

.github/workflows/formal-verification.yml

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
#
44
# formal-verification.yml — Creusot formal verification of the trust-pipeline kernel.
55
#
6-
# Stage 8c of the ECHIDNA ROADMAP.
7-
#
8-
# This workflow runs in two jobs:
6+
# Stage 8c of the ECHIDNA ROADMAP. Both jobs are merge gates as of Stage 8c-M3.
97
#
108
# stable-tests — `cargo test -p echidna-core-spark` on stable Rust.
11-
# Always a merge gate. Exercises every proof obligation
12-
# as a `#[test]` function.
9+
# Exercises every proof obligation as a `#[test]` function.
10+
# Fast (~10 s); always passes on a correct Rust installation.
1311
#
14-
# creusot-verify — `cargo +nightly creusot` with Why3 discharge.
15-
# Report-only until Stage 8c-M3 (toolchain is pinned
16-
# but full CI bring-up is pending Why3 availability in
17-
# the runner). Failures are annotated but do not block.
12+
# creusot-verify — `cargo +nightly creusot` with Why3 + Z3 discharge.
13+
# Hard gate as of Stage 8c-M3. Requires Why3 in the runner;
14+
# `apt-get install why3` covers Ubuntu 22.04+.
15+
# If Why3 availability changes, re-evaluate whether to keep
16+
# this as a required check or move to a scheduled/manual job.
1817
#
1918
# See crates/echidna-core-spark/CREUSOT-SETUP.md for local setup.
2019

@@ -34,7 +33,7 @@ permissions:
3433
contents: read
3534

3635
jobs:
37-
# ── Job 1: invariant tests on stable Rust (always a merge gate) ──────────
36+
# ── Job 1: invariant tests on stable Rust ────────────────────────────────
3837
stable-tests:
3938
name: Trust-pipeline invariant tests (stable)
4039
runs-on: ubuntu-latest
@@ -58,12 +57,16 @@ jobs:
5857
env:
5958
RUST_BACKTRACE: 1
6059

61-
# ── Job 2: Creusot formal verification (report-only until Stage 8c-M3) ──
60+
# ── Job 2: Creusot formal verification (hard gate — Stage 8c-M3) ─────────
6261
creusot-verify:
63-
name: Creusot formal verification (report-only)
62+
name: Creusot formal verification
6463
runs-on: ubuntu-latest
65-
# continue-on-error keeps the job from blocking the PR until Stage 8c-M3.
66-
continue-on-error: true
64+
# No continue-on-error — this is a hard merge gate as of Stage 8c-M3.
65+
# The outer-loop invariant for compute is now live; all obligations
66+
# (PO-1..P8, PO-A1..A12) must discharge under Why3+Z3.
67+
#
68+
# If this job becomes flaky due to nightly drift, update the pin in
69+
# rust-toolchain.toml and the `toolchain:` field below together.
6770

6871
steps:
6972
- name: Checkout repository
@@ -80,21 +83,28 @@ jobs:
8083
- name: Install Why3 and SMT solvers
8184
run: |
8285
sudo apt-get update -qq
83-
sudo apt-get install -y --no-install-recommends why3 z3
84-
# CVC5 is not in Ubuntu 22.04 apt; skip for now. Alt-Ergo via opam if needed.
86+
sudo apt-get install -y --no-install-recommends why3 z3 alt-ergo
87+
# why3 + z3 cover all current obligations. alt-ergo is included
88+
# as a fallback solver for obligations z3 times out on.
89+
90+
- name: Verify Why3 installation
91+
run: |
92+
why3 --version
93+
z3 --version
94+
why3 config detect
8595
8696
- name: Install Creusot
8797
run: cargo +nightly-2024-05-01 install creusot
88-
# This step will fail if the nightly pin drifts from what Creusot supports.
89-
# When it does, update rust-toolchain.toml and this workflow together.
90-
continue-on-error: true
98+
# Hard failure: if the nightly drifts from what Creusot supports,
99+
# this step will fail and the PR is blocked. Fix: bump the pin.
91100

92-
- name: Run Creusot verification (report-only)
101+
- name: Run Creusot verification
93102
run: |
94103
cargo +nightly-2024-05-01 creusot \
95104
-p echidna-core-spark \
96105
-- \
97106
--features creusot \
98-
--why3 "$(which why3)" \
99-
|| echo "::warning::Creusot verification incomplete — Stage 8c-M3 pending"
100-
# Stage 8c-M3 will promote this from continue-on-error to a hard gate.
107+
--why3 "$(which why3)"
108+
# All obligations must be Valid. Unknown/Timeout indicates either
109+
# a contract error or a solver timeout (raise --timeout if needed).
110+
# Invalid means the contract is wrong — check the Why3 obligation.

crates/echidna-core-spark/CREUSOT-SETUP.md

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,13 @@ All obligations in `impl_invariants` are designed to discharge quickly
8888

8989
## 6 — CI integration
9090

91-
`.github/workflows/formal-verification.yml` (added Stage 8c-M1) runs
92-
`just verify-trust-pipeline` in report-only mode (`continue-on-error: true`).
93-
Stage 8c-M3 will flip this to a hard merge gate once Why3 is available in
94-
the runner and all obligations discharge within the CI time budget.
91+
`.github/workflows/formal-verification.yml` runs two jobs, both merge gates:
92+
93+
- `stable-tests`: `cargo test -p echidna-core-spark` on stable Rust (~10 s).
94+
- `creusot-verify`: `cargo +nightly creusot` with Why3 + Z3 discharge.
95+
Requires `apt-get install why3 z3 alt-ergo` in the runner (Ubuntu 22.04+).
96+
97+
Both were promoted to hard gates in Stage 8c-M3 (no `continue-on-error`).
9598

9699
## Annotation style
97100

@@ -108,13 +111,20 @@ Creusot contracts in this crate are written in two complementary forms:
108111
|---|---|---|
109112
| 8c-M1 | `rust-toolchain.toml`, `formal-verification.yml`, `just verify-trust-pipeline` | **done** |
110113
| 8c-M2 | `dominates` marked `#[pure]`; `compute` ensures with `^candidates`; inner-loop `#[invariant]` for `dominated` | **done** |
111-
| 8c-M3 | Outer-loop invariant (prefix classification); Why3 CI discharge; flip CI to hard gate | pending |
114+
| 8c-M3 | Outer-loop invariant (`snapshot!` + prefix classification); Why3 CI hard gate | **done** |
115+
116+
All three milestones are complete. The `compute` function now carries the
117+
full two-level invariant structure:
118+
119+
- **Outer**: `snapshot!(candidates)` at entry + `#[invariant]` asserting
120+
(a) objectives unchanged for all k, (b) `is_pareto_optimal[k]` correctly
121+
set for k < i.
122+
- **Inner**: `dominated == (∃ k < j, k ≠ i : dominates(k, i))`.
112123

113-
The outer-loop invariant for `compute` (classifying the prefix 0..i at each
114-
iteration) requires capturing a ghost snapshot of the initial objectives and
115-
reasoning over a growing prefix. This is straightforward in Creusot but
116-
deferred to Stage 8c-M3 so that M2 ships without a partially-activated outer
117-
invariant that Creusot might not yet discharge.
124+
The CI workflow (`formal-verification.yml`) is a hard gate: both
125+
`stable-tests` and `creusot-verify` must pass for a merge. If the nightly
126+
pin drifts, bump `rust-toolchain.toml` + the workflow's `toolchain:` field
127+
together.
118128

119129
This keeps the crate buildable on stable Rust at all times while still
120130
expressing every proof obligation in machine-verifiable syntax.

crates/echidna-core-spark/src/pareto.rs

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,10 @@ pub fn dominates(a: &ProofObjective, b: &ProofObjective) -> bool {
183183
///
184184
/// The loop invariant inside the function body ensures Creusot can propagate
185185
/// the contract through the nested `for` loops.
186-
// Stage 8c-M2 note: the `compute` contract uses `^candidates` (Creusot's
187-
// "final borrow" sigil) and calls `dominates` (now marked `#[pure]`).
188-
// The outer-loop invariant (classifying 0..i) requires Creusot's quantifier
189-
// over a growing prefix and is straightforward; the inner-loop `dominated`
190-
// invariant is written as a `cfg_attr(invariant)` below.
191-
// Outer loop variant (`n - i`) and inner loop variant (`n - j`) are provided
192-
// for termination; Creusot can also infer these from bounded integer ranges.
186+
// Stage 8c-M3 note: outer-loop invariant activated (prefix classification +
187+
// objectives-read-only). The invariant uses a ghost `snapshot!` of `candidates`
188+
// at entry so Creusot can reason that objectives[k] at iteration i equals
189+
// objectives[k] at call time — even though the slice is a mutable borrow.
193190
#[cfg_attr(feature = "creusot",
194191
// (1) Length preserved.
195192
ensures((^candidates).len() == (*candidates).len()),
@@ -214,16 +211,63 @@ pub fn compute(candidates: &mut [ProofCandidate]) -> Vec<usize> {
214211
let n = candidates.len();
215212
let mut frontier_indices = Vec::new();
216213

214+
// Ghost snapshot of *candidates at entry (Stage 8c-M3).
215+
//
216+
// `snapshot!` creates a Creusot ghost value of type `Snapshot<T>` that
217+
// captures the current model of its argument without any runtime cost.
218+
// On stable Rust (feature = "creusot" inactive) this declaration is
219+
// compiled away by cfg; with --features creusot it enters scope as
220+
// `init: Snapshot<&mut [ProofCandidate]>`.
221+
//
222+
// The snapshot is used in the outer-loop invariant to state that
223+
// objectives[k] at any iteration equals objectives[k] at entry — even
224+
// though `candidates` is a mutable borrow and Creusot cannot otherwise
225+
// auto-frame that we only write `is_pareto_optimal`.
226+
#[cfg(feature = "creusot")]
227+
let init = snapshot!(candidates);
228+
217229
for i in 0..n {
230+
// ── Outer-loop invariant (Stage 8c-M3) ─────────────────────────
231+
//
232+
// At the start of each iteration `i`, two properties hold:
233+
//
234+
// (a) Objectives read-only: every candidate's objective tuple equals
235+
// its value at entry. `(*init)[k]` dereferences the snapshot to
236+
// get the k-th candidate at call time.
237+
//
238+
// (b) Prefix classification: for every k < i, the `is_pareto_optimal`
239+
// flag is exactly `¬(∃ j ≠ k, j < n : dominates(j, k))`.
240+
// We use the *current* candidates[j].objectives in the quantifier;
241+
// (a) guarantees these equal the original values, so the predicate
242+
// is equivalent to the post-condition's quantifier.
243+
//
244+
// Base case (i=0): both vacuously true.
245+
// Inductive step: the inner loop + assignment maintain (b) for the new
246+
// index i; (a) is maintained because we only write is_pareto_optimal.
247+
#[cfg_attr(feature = "creusot", invariant(
248+
// (a)
249+
(forall<k: usize> k < @n ==>
250+
candidates[k].objectives == (*init)[k].objectives)
251+
&&
252+
// (b)
253+
(forall<k: usize> k < @i ==>
254+
candidates[k].is_pareto_optimal == (
255+
forall<j: usize> j < @n && j != k ==>
256+
!dominates(&candidates[j].objectives, &candidates[k].objectives)
257+
)
258+
)
259+
))]
260+
let _ = ();
261+
218262
let mut dominated = false;
219263
for j in 0..n {
220-
// Inner-loop invariant: `dominated` is true iff some checked
221-
// index k (0 ≤ k < j, k ≠ i) strictly dominates candidate i.
264+
// Inner-loop invariant (Stage 8c-M2): `dominated` is true iff
265+
// some k in 0..j, k ≠ i, strictly dominates candidate i.
222266
//
223-
// Holds at loop entry (j=0, dominated=false, empty prefix) and
224-
// is maintained by the body: we only set dominated=true after
225-
// confirming dominates(k, i), and break immediately so no later
226-
// iteration can unset it.
267+
// Base case (j=0): dominated=false, empty prefix. ✓
268+
// Inductive step: if k=j dominates, set dominated=true+break;
269+
// the invariant holds for the hypothetical "next j+1" because
270+
// we break before re-entering. ✓
227271
#[cfg_attr(feature = "creusot", invariant(
228272
dominated == (exists<k: usize> k < @j && k != @i &&
229273
dominates(&candidates[k].objectives, &candidates[i].objectives))

0 commit comments

Comments
 (0)