Skip to content

Reject a specification function whose obligation says nothing (#356) - #409

Merged
0xGeorgii merged 2 commits into
mainfrom
356-bug-fix-vacuous-hassert-obligations
Aug 14, 2026
Merged

Reject a specification function whose obligation says nothing (#356)#409
0xGeorgii merged 2 commits into
mainfrom
356-bug-fix-vacuous-hassert-obligations

Conversation

@0xGeorgii

@0xGeorgii 0xGeorgii commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #356.

The problem

A spec function whose body computes instead of asserting contributed a vacuous obligation with no diagnostic:

spec Caller {
  fn caller() -> i32 {
    return helper();
  }
}

emitted Definition ..._hspec1 : hassert := HA_true. The semantic content ("caller equals helper") was dropped, the theorem was trivially provable, and the user got a green Qed with zero verification content and no warning.

The fix

New fatal proof-mode diagnostic P010.

The issue named three paths (Stmt::Return, the empty contribution list, plain spec methods). There are at least twelve. Because HAssert::and/imp/or/ex all absorb the identity (core/hassert/src/ir.rs:143-179), every one of them collapses to exactly HAssert::True, so the predicate is a single equality on the value translate_fn returns — no shape enumeration to keep in sync.

That distinction is load-bearing. Two shapes look like they contribute and do not:

  • a trailing assume block, where the fold is Imp(p, ⊤) = ⊤
  • an if whose branches are all vacuous

A body-shape check would pass both. Checking the translated result catches the whole family.

P010 is raised after the existing P001–P008 early-continue, so it never stacks on a function that already reported. Its wording is keyed on what the body claimed, via a new claim.rs scan that picks the message only — it never decides whether an obligation is emitted, so a mistake there can mis-word a diagnostic but cannot drop a claim.

P009 widened

A plain (Regular) spec method that states a real property was dropped with neither an entry nor a diagnostic — worse than the HA_true this fixes, and the escape hatch that made a function-only rule falsifiable. It now raises P009. A method that only computes stays a silent helper, since a method produces no obligation either way.

Consequences

A computing helper can no longer live inside a spec block; it belongs at file scope, where a spec function still applies it as a T_app. Compile mode is unaffected (it has no obligations), and any spec function that already stated a property emits byte-identical output.

Known asymmetry, stated in the CHANGELOG rather than quietly widened: spec S { fn h() -> i32 { return 1; } } is rejected, but the same helper wrapped in a spec-inner struct still compiles, because a spec method keeps its helper exemption.

Evidence in committed artifacts

Artifact Before After
tests/test_data/rocq/spec_literal_ctx.v hspec1 := HA_true. a real T_app 2 obligation against Vi64 4294967296
proof_specs.wasm hspecs section 106 B, two 0x00 (⊤) trees 165 B, two real trees

A fixture that was not testing what it claimed

spec_narrow_uzumaki.inf exists to cover narrow-width HA_has_type slot guards. All three of its functions bound slots and never asserted, so the translator dropped every guard and all 3/3 obligations were HA_true — that coverage had never reached the printer or coqc. It now asserts through file-scope identity helpers, and the guards emit for real: T_i32 for u8/i8/u16/i16/bool/enum, T_i64 for i64/u64.

HA_true also becomes unreachable from any source program, so every_stub_declaration_has_a_producer grows a hand-built producer for it rather than an exemption — an exemption claims a producer is impossible, and here one is possible.

Verification

  • cargo test --workspace --no-fail-fast: 5821 passed, 0 failed, 68 binaries
  • coqc (Rocq 9.2) genuinely ran; no gate reported a skip
  • The issue's exact example exits 1 with the P010 diagnostic and writes no artifacts; the same source still exits 0 in compile mode
  • Every tests/test_data/inf/*.inf verified P010-clean end to end
  • cargo clippy -p inference-wasm-codegen --all-targets -- -D warnings clean

New coverage

core/wasm-codegen/src/hassert/tests.rs gains a section with 13 tests: every verified vacuity path, message-wording pins per claim variant, and negative controls — a real obligation is kept, a bare call statement is a genuine HA_app_ok and not vacuous, P010 does not stack on P002/P004, and a vacuous sibling does not take a real obligation down with it.

Note for reviewers

T_app on a spec-local helper still cannot round-trip the full infc -v pipeline (#390 — spec bodies are omitted from the .v module record). That is pre-existing and unrelated, but it is why the sibling-call test drives the translation pass directly rather than compiling.

Confidence Score: 4/5

The PR is not yet safe to merge because an existential-mode conditional with vacuous branches still bypasses P010 and emits a verification-free tautology.

The structural equality check catches literal HA_true, but existential conditional translation can produce the logically true, non-canonical form Or(nz(cond), eqz(cond)), so the previously reported vacuous-obligation path remains reachable.

Files Needing Attention: core/wasm-codegen/src/hassert/mod.rs and core/wasm-codegen/src/hassert/translate.rs

Important Files Changed

Filename Overview
core/wasm-codegen/src/hassert/mod.rs Adds P010 rejection and revised P009 dispatch while retaining the structural HAssert::True predicate.
core/wasm-codegen/src/hassert/claim.rs Separates diagnostic wording from recursive assertion detection, fixing false P009 reports for property-less nondeterministic blocks.
core/wasm-codegen/src/hassert/tests.rs Adds broad vacuity and method-classification regression coverage.
core/wasm-codegen/src/hassert/diag.rs Registers P010 and updates the documented P009 scope.
core/wasm-to-v/ROCQ_CONTRACT.md Documents the revised proof-obligation and diagnostic contract.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Translate spec function] --> B{Earlier P001-P008 diagnostics?}
  B -- Yes --> C[Record diagnostics; emit no obligation]
  B -- No --> D{Translated result equals HA_true?}
  D -- Yes --> E[Record P010; emit no obligation]
  D -- No --> F[Emit hassert obligation]
  G[Inspect spec method] --> H{Quantified or contains assert?}
  H -- Yes --> I[Record P009]
  H -- No --> J[Keep silent helper exemption]
Loading

Reviews (2): Last reviewed commit: "Report a spec method only when an assert..." | Re-trigger Greptile

Context used (3)

A spec function whose body computes instead of asserting contributed a
vacuous `HA_true` obligation with no diagnostic. `spec Caller { fn
caller() -> i32 { return helper(); } }` emitted `Definition
..._hspec1 : hassert := HA_true.` — the meaning the author wrote was
dropped, the theorem was trivially provable, and nothing said so.

New fatal proof-mode diagnostic P010 rejects it. The predicate is the
translated result rather than the body shape: `HAssert::and`/`imp`/`or`/
`ex` absorb the identity, so every vacuity path collapses to exactly
`HAssert::True` and one equality catches them all. That matters for the
shapes that look like they contribute and do not — a trailing `assume`
folds to `Imp(p, ⊤) = ⊤`, and an `if` whose branches are both vacuous
folds the same way. The check runs after the existing P001-P008
early-continue, so P010 never stacks on a function that already reported.

The message names what the body claimed, keyed on a new syntactic scan
(`claim.rs`) that picks wording only and never decides whether an
obligation is emitted, so a mistake there can mis-word a message but not
drop a claim.

P009 is widened alongside it: a plain spec method that states a property
was dropped with neither an entry nor a diagnostic, which is worse than
the `HA_true` this fixes. A method that only computes stays a silent
helper, since a method produces no obligation either way.

Consequence: a computing helper can no longer live inside a `spec` block.
It belongs at file scope, where a spec function still applies it as a
`T_app`. Compile mode is unaffected — it has no obligations — and a spec
function that already stated a property emits byte-identical output.

Two goldens now carry the difference rather than describing it:
`spec_literal_ctx.v`'s first obligation moves from `HA_true` to a real
`T_app` claim, and `proof_specs.wasm`'s hspecs section from two
single-byte `HA_true` trees to two real ones.

`spec_narrow_uzumaki.inf` gains assertions that read its slots. All three
of its functions bound slots and never asserted, so the translator
dropped every guard and all three obligations were `HA_true`: the
narrow-width `HA_has_type` coverage the fixture exists for had never
reached the printer or coqc.

`HA_true` becomes unreachable from any source program, so the stub
declaration audit grows a hand-built producer for it.
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.93671% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
core/wasm-codegen/src/hassert/claim.rs 92.50% 3 Missing ⚠️
core/wasm-codegen/src/hassert/mod.rs 97.36% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread core/wasm-codegen/src/hassert/mod.rs
Comment thread core/wasm-codegen/src/hassert/claim.rs Outdated
@0xGeorgii 0xGeorgii self-assigned this Aug 14, 2026
P009's widening classified a nested non-deterministic block as a stated
property on sight, so a plain spec method whose only content was an empty
`forall` block was rejected as one — `fn m(self) { forall { } }` reported
"states a property" when it states nothing, and that method compiled
before this branch.

The scan was answering one question where there are two. `first_claim`
asks what the author wrote that signals intent, and classifying a block
on sight is right there: it is what lets a spec function's report name
the block, where falling through would advise moving the function out of
the `spec` block — impossible, since A042 rejects a non-deterministic
block outside one. `states_an_assertion` asks whether an assertion is
actually lost, and descends through such a block to find one.

P009 for a plain method now gates on the second: a method's obligation is
never emitted, so what the diagnostic exists to catch is a dropped
`assert`, and a block that asserts nothing drops nothing. A quantified
body is unchanged and still reported either way — the quantifier is an
obligation on its own, whatever the body does with what it binds.

For the three method shapes this makes silent again, the emitted `.wasm`
and `.v` are byte-identical to the commit before this branch.
@0xGeorgii
0xGeorgii merged commit b6a03a5 into main Aug 14, 2026
9 checks passed
@0xGeorgii
0xGeorgii deleted the 356-bug-fix-vacuous-hassert-obligations branch August 14, 2026 10:08
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.

Spec bodies that compute degrade to HA_true silently (vacuous obligation, no diagnostic)

1 participant