Skip to content

feat(verify): #433 — take ordeal 0.33.0 and decide memory/UF goals via Solver::from_sliver - #435

Merged
avrabe merged 2 commits into
mainfrom
feat/433-ordeal-0.33-from-sliver
Oct 22, 2026
Merged

avrabe merged 2 commits into
mainfrom
feat/433-ordeal-0.33-from-sliver

Conversation

@avrabe

@avrabe avrabe commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

This does both asks in #433. Each has its own oracle, and I watched each one go red before the change.

1. Bump ordeal from 0.30.0 to 0.33.0 (soundness fix ordeal#247)

Oracle: a_query_variable_cannot_masquerade_as_a_sliver_read. It drives the issue's exact counterexample, select(memory, 5) != $sel:memory:5, through BoundedOrdealSolver, the production RuleSolver. The query is satisfiable because the two sides are independent.

ordeal verdict
0.30.0 Proven, with a certificate that rechecks. This is the bug, reproduced through loom's own path.
0.33.0 Disproven

Was loom's production path exposed? No, and I checked. I found 137 naming calls (BV/Bool/Array new_const/fresh_const, FuncDecl::new) in non-test loom-core code:

  • 132 are literals or format templates, and every one begins with a letter (param…, global…, pure_call_…, mem_after_call_…, f32_…).
  • The other 5 are pass-throughs: decl_name = "pure_call_{func_idx}", and three rule_solver sites that re-lower names the reflector read back from those same Z3 ASTs.

A collision needs a name that starts with $. The sliver's only production input is decide_bv_equivalence, which receives those ASTs.

2. lower_goal_ne uses Solver::from_sliver

From 0.33.0, only a solver built by from_sliver runs ordeal's trusted, Lean-proven check_sliver_lowering before it returns a verdict. The old path called sliver::lower and asserted the core by hand, which skipped that check.

Oracle: memory_obligations_are_decided_on_the_proven_sliver_path. It asserts Solver::is_sliver() for a memory goal, and not for a pure one. I extracted the construction into solver_for_goal_ne without changing behavior first. The test was then red on 0.33.0 with the old path, and it went green after the switch. No direct sliver::lower call remains in non-test code.

The reasons for avoiding the one-shot helpers (#347) still hold: a refusal is still an Err that the seam defers on, and the deadline still governs the check.

Wiring

Both tests run inside slice 3's verification step (the full suite under both, with the count pinned). To prove that by mutation, I reverted only the from_sliver call: the wired step went [FAIL] and named the new test (567 passed; 1 failed). The pin moves from 566 to 568.

Results

  • loom-core with verification: 568 passed under default, both and ordeal
  • clippy --all-targets -D warnings: clean
  • tools/run_verification.py passes slices 3 and 4
  • ordeal 0.33.0 in Cargo.lock, which is what CI builds

Kill-criterion

This change is wrong if any memory or UF verdict comes from a solver with is_sliver() == false, or if the $sel impostor is ever Proven.

One thing would still be false if every check here passed: that an is_sliver() solver gates every verdict, on the entry point loom uses (check_with_deadline), on check_sliver_lowering. That is ordeal's guarantee, documented on Solver::from_sliver, and loom can only observe its precondition. If that guarantee breaks, it should be filed against ordeal.

Merge note

#434 also moves the same slice 3 pin, from 566 to 569. Whichever of the two merges second needs the pin set to 571.

Fixes: #433
Refs: #313

🤖 Generated with Claude Code

https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn

…a Solver::from_sliver

Two asks from ordeal's side, each gated by an oracle observed RED first.

1. ordeal 0.30.0 -> 0.33.0. 0.32.1 fixes a sliver soundness bug
   (ordeal#247): a query variable named like the sliver's fresh read
   `$sel:<array>:<index>` was silently unified with it. New test
   `a_query_variable_cannot_masquerade_as_a_sliver_read` drives the exact
   counterexample, `select(memory, 5) != $sel:memory:5` (satisfiable),
   through the production solver. On 0.30.0 it came back `Proven`, with a
   certificate that rechecked. On 0.33.0 it is `Disproven`.

   loom's production path was not exposed: all 137 Z3 naming sites in
   loom-core were checked, and every name begins with a literal letter
   (`param…`, `pure_call_…`, `mem_after_call_…`); the five pass-throughs
   re-lower those same names. A collision needs a name starting with `$`.

2. `lower_goal_ne` builds memory/UF goals with `Solver::from_sliver`
   instead of `sliver::lower` plus a hand-asserted core. From 0.33.0 only
   such a solver runs the trusted, Lean-proven `check_sliver_lowering`
   before returning a verdict. New test
   `memory_obligations_are_decided_on_the_proven_sliver_path` asserts
   `is_sliver()` for a memory goal and not for a pure one. It was RED on
   0.33.0 with the old path and is green after the switch. The refusal
   stays an `Err` and the deadline still applies, which were the reasons
   for avoiding the one-shot helpers (#347).

Wiring: both tests run in slice 3's verification step (full suite under
`both`, count-pinned). Reverting just the `from_sliver` call turned that
step [FAIL] by the new test's name (567 passed, 1 failed). Pin 566 -> 568.

loom-core with `verification`: 568 passed under default, `both` and
`ordeal`; clippy --all-targets -D warnings clean; gate passes slices 3, 4.

Fixes: #433
Refs: #313
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn
@github-actions

Copy link
Copy Markdown

Rivet verification gate

✅ 30/38 passed

count
Passed 30
Failed 0
Skipped (no steps) 8

Filter: (and (= type "feature") (matches id "^TEST-"))

Failed artifacts

(none)

Updated automatically by tools/post_verification_comment.py. Source of truth: artifacts/verification.yaml.

@avrabe
avrabe merged commit 598a4f9 into main Oct 22, 2026
25 of 26 checks passed
@avrabe
avrabe deleted the feat/433-ordeal-0.33-from-sliver branch October 22, 2026 15:10
avrabe added a commit that referenced this pull request Oct 22, 2026
…nd trap_gate

Slice 2 left two hand-written copies of the div/rem trap condition: its own
`trap_condition` over the neutral RuleTerm, and ordeal's `trap::trap_div`,
which trap_gate's constant-fold backstop called. Both had to agree on the
div_s/rem_s asymmetry and nothing forced them to.

The rule is now written once, `trap_gate::div_trap_condition`, generic over
a `TrapTerms` trait that supplies only constructors (constant, eq, and, or).
The seam instantiates it over RuleTerm/RuleBool, which it lowers to BOTH
engines; trap_gate instantiates it over ordeal's BvTerm/BoolTerm. Neither
instantiation decides when an op traps, so they cannot drift.

Why loom's formula and not ordeal's: under `both`, the seam's clause must
lower to the incumbent too, and an ordeal BoolTerm cannot. This reverses the
documented "ordeal supplies the trap clause" boundary for div/rem only; the
memory clause (`trap_mem_oob`) stays ordeal's. Module docs updated.

Why the backstop stays: the default backend is Z3, and the seam defers to
the incumbent before building the trap relation, so in default builds the
backstop is the only trap check on const div/rem folds.

Evidence:
- the new rule is PROVEN equivalent to ordeal's trap_div (the clause it
  replaced) for all four kinds at widths 1, 8, 32, 64, 128, over symbolic
  operands;
- slice 2's instantiation, sent through the production lowering, is PROVEN
  equivalent to trap_gate's at the same widths;
- both comparisons are shown to discriminate (div_s vs rem_s must differ);
- planting ordeal#84 (rem_s given the overflow disjunct) fails 5 tests
  across both consumers, including the wasm spec table by row;
- loom-core with `verification`, rebased on main after #435 (ordeal 0.33):
  571 passed under `both`. Slice 3's pinned count moves 568 -> 571 for the
  3 new tests; the old pin is checked to no longer match. Slice 4's
  filtered pin (18 passed) still holds.
- Rebase also moves the `a_division_under_a_conditional...` doc comment
  back onto its own test; #435 had inserted its two tests between them.

Refs: #313
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn
avrabe added a commit that referenced this pull request Oct 23, 2026
…nd trap_gate (#434)

Slice 2 left two hand-written copies of the div/rem trap condition: its own
`trap_condition` over the neutral RuleTerm, and ordeal's `trap::trap_div`,
which trap_gate's constant-fold backstop called. Both had to agree on the
div_s/rem_s asymmetry and nothing forced them to.

The rule is now written once, `trap_gate::div_trap_condition`, generic over
a `TrapTerms` trait that supplies only constructors (constant, eq, and, or).
The seam instantiates it over RuleTerm/RuleBool, which it lowers to BOTH
engines; trap_gate instantiates it over ordeal's BvTerm/BoolTerm. Neither
instantiation decides when an op traps, so they cannot drift.

Why loom's formula and not ordeal's: under `both`, the seam's clause must
lower to the incumbent too, and an ordeal BoolTerm cannot. This reverses the
documented "ordeal supplies the trap clause" boundary for div/rem only; the
memory clause (`trap_mem_oob`) stays ordeal's. Module docs updated.

Why the backstop stays: the default backend is Z3, and the seam defers to
the incumbent before building the trap relation, so in default builds the
backstop is the only trap check on const div/rem folds.

Evidence:
- the new rule is PROVEN equivalent to ordeal's trap_div (the clause it
  replaced) for all four kinds at widths 1, 8, 32, 64, 128, over symbolic
  operands;
- slice 2's instantiation, sent through the production lowering, is PROVEN
  equivalent to trap_gate's at the same widths;
- both comparisons are shown to discriminate (div_s vs rem_s must differ);
- planting ordeal#84 (rem_s given the overflow disjunct) fails 5 tests
  across both consumers, including the wasm spec table by row;
- loom-core with `verification`, rebased on main after #435 (ordeal 0.33):
  571 passed under `both`. Slice 3's pinned count moves 568 -> 571 for the
  3 new tests; the old pin is checked to no longer match. Slice 4's
  filtered pin (18 passed) still holds.
- Rebase also moves the `a_division_under_a_conditional...` doc comment
  back onto its own test; #435 had inserted its two tests between them.

Refs: #313

Claude-Session: https://claude.ai/code/session_01RZof1M5HMBSYVZPeoT4MEn

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ordeal: bump to 0.32.1 (sliver soundness fix ordeal#247) and lower memory goals via Solver::from_sliver

1 participant