Skip to content

Change pHL to prevent negative probabilities - #1105

Open
oskgo wants to merge 2 commits into
mainfrom
negative-phoare-false
Open

Change pHL to prevent negative probabilities#1105
oskgo wants to merge 2 commits into
mainfrom
negative-phoare-false

Conversation

@oskgo

@oskgo oskgo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This essentially changes the semantics of phoare[M.f: pre ==> post] R x to be
forall m arg, 0 <= x{m} /\ pre{m} => Pr[M.f(arg)@&m: post] R x{m}
instead of
forall m arg, pre{m} => Pr[M.f(arg)@&m: post] R x{m}.

This is one possible resolution to most of the issues we're having with negative probabilities.

The advantage of this approach is that we do not require proving bounds when using pHL seq and call. The disadvantage is that we need to prove bounds when changing them using conseq and when proving phoare statements from statements not involving phoare, such as with exfalso and bypr.

Edit: Closes #1100 and #1101, and supercedes #1096 by fixing its underlying issue.

@fdupress

Copy link
Copy Markdown
Member

Does this handle the few situations that allow the code to change the value of the bound? (Those are usually framed, and my assumption is that there is then a conseq that handles the bound change, but I though it good to ask explicitly.)

@fdupress
fdupress requested a review from strub August 27, 2026 14:52
@oskgo

oskgo commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

I believe so. There are some tactics where we should be able to get rid of the bound unconditionally (I'm aware of phoare split), but for now those require proving bounds.

The while variant in #1102 also proves the bad result directly, so that will have to be changed no matter which solution we go for and thus isn't addressed here.

For the record, I'm not certain that this is the right approach, but I thought it useful to at least see what the fallout would be. There's going to be a significant amount of breakage regardless AFAICT.

@fdupress

Copy link
Copy Markdown
Member

Failures in external projects are where I would expect—developments that involve a lot of cross-logic reasoning (and therefore a lot of bypr). All of them have bounds that should be obviously positive, so I'm not too worried about our ability to fix it in a timely manner.

The main blocker is that I, personally, will likely not have the capacity to deal with any of them for about 2 weeks.

Comment thread theories/crypto/prp_prf/Strong_RP_RF.eca Outdated
@Yiping106283

Copy link
Copy Markdown

Some data that may help decide between this PR and per-tactic side-conditions (#1095 style). I built this PR's head (bb75dba, merges cleanly on 311ba5d) and ran (a) the reproducers of the open negative-bound issues and (b) the four CI external projects that fail here.

Reproducers. With this PR, #1100 (seq) and #1101 (call) are rejected, since the negative bound eventually reaches exfalso/skip/bypr. #1102 (while, diverging loop) and #1119 (rnd E after a diverging prefix) are still accepted and still prove false: the variant-less while (<=) yields only the hoare body goal, rnd (<=) converts to a hoare judgment whose mu d E <= bd post-condition is vacuous when the prefix diverges, and byphoare then trusts the bound. Both files are in repro/ at https://github.com/Yiping106283/easycrypt/tree/pr1105-downstream-fixes. Adding the leaf check to those two rules closes them, but that re-breaks the rnd sites in downstream projects (see below), so the two issues seem to need their own fix either way; #1130 does that for while, and I have the analogous change for rnd ready if wanted.

External projects. Against this PR, sha3 (next), cryptobox (next), xmss-security and sphincsplus need 29 script changes in 10 files; with them, each project's CI scenario passes here except for the SMT failures that already fail on unpatched main (sha3 SHA3Security.ec:835, Gconcl_list.ec:192; cryptobox Cryptobox.ec:1248; xmss WOTS_TW.eca:2320). One diff per project is in downstream_fixes/ on the branch above. Most sites are a split/smt(ge0_mu1 mu_bounded) on the new 0%r <= bd conjunct at bypr/conseq/exfalso/skip; in four places the bound expression itself has to change because it is negative outside the reachable range (sha3 fel step function i%r * mu1 ... -> (max 0 i)%r * mu1 ..., xmss EUFRMA_Interactive_Equiv (n_eufrma - q)%r * d -> n%r * d), and rewrite Pr[mu_ge0] /= => [a ->] needs => -[a ->] to parse. For comparison, per-tactic non-negativity goals at seq/call/rnd would touch 39 sites in the same three projects (sphincsplus unaffected) and only 7 of them coincide with this PR's sites, so a project adapted to one approach is not adapted to the other.

Happy to open merge requests on the four downstream repositories with these diffs if this is the direction you take.

@oskgo

oskgo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

@Yiping106283 We decided to go with this PR over the per tactic fixes. There are only a few meaningful differences (mainly whether we want to add side conditions to conseq, bypr and exfalso or to seq), and we decided that it was preferable to have checks on entry points into pHL.

It would help a lot if you could contribute fixes to the regressions in external libraries we check in CI.

@Yiping106283

Yiping106283 commented Sep 11, 2026

Copy link
Copy Markdown

Thanks, that makes sense. On the two points:

External projects. I will open merge requests with the script changes against sha3, formosa-xmss and formosa-slhdsa (the diffs are the ones in downstream_fixes/ on my branch, made against bb75dba). For cryptobox, merge-negative-phoare-false already exists with @fdupress's changes to PKEY.ec; I will check whether anything from my diff is still needed on top of it and only send that. Edit: checked, nothing needed. Since CI picks up a merge-negative-phoare-false branch on each external repository, someone with push rights there will need to create those branches from the merge requests once they are reviewed.

One remaining entry point. On bb75dba the #1102 and #1119 reproducers still prove false, and so does the loop-free variant from #1119 (comment). The reason is that the 0%r <= bd conjunct is added in bypr (ecPhlPr.ml) and conseq (ecPhlConseq.ml) but not in byphoare (ecPhlDeno.ml): under the new semantics phoare[M.f : true ==> true] <= (-1)%r is vacuously true, so proving it is fine, but byphoare h still turns it into Pr[M.f() @ &m : true] <= -1%r without asking for 0%r <= -1%r. Adding the same conjunct there would close both issues in the entry-point style of this PR; I am happy to send that as a small patch on top of this branch if you want (it will land in the same file as the ehoare check from #1095, so probably after the rebase on main). With that in place #1130 and #1135 are not needed, and I will close them.

Edit: as @oskgo points out below, under the new semantics that judgement is false, so the fault is in the rnd/while rules that prove it, not at byphoare; I am not pursuing the byphoare conjunct. #1130 and #1135 stay, rebased on this branch with the unconditional forall &hr, 0%r <= bd; #1131 is unrelated to the bound semantics and stays on main.

@oskgo

oskgo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Under the new semantics phoare[M.f : true ==> true] <= (-1)%r is false. Any phoare statement with a bound that is negative in some memory, including one not satisfying the precondition, is going to be false moving forward.

I've intentionally kept this PR as small as possible. It should only contain changes that are necessary only under the new semantics. For fixes that are necessary with either semantics they should be kept separate. It might affect some of the details of how these fixes are implemented. If that is the case feel free to stack the PRs on top of this one or mark them as blocked, but still keep them separate.

@Yiping106283

Copy link
Copy Markdown

@fdupress The script changes for the external projects, against bb75dba:

The two GitHub PRs come from branches named merge-negative-phoare-false, so the projects' own CI should pick up this EasyCrypt branch once the workflows are approved; someone with push rights will need to create the merge-negative-phoare-false branches from them for the EasyCrypt CI to see them.

@fdupress

Copy link
Copy Markdown
Member

Thank you so much @Yiping106283. We were looking at another two weeks of calendar time for me to free up enough time to do that maintenance work.

I'll be away from reasonable keyboards until Monday, but will try to find some time to pull and merge then. How would you like attribution on the gitlab commit?

@Yiping106283

Copy link
Copy Markdown

Thanks, @fdupress — no rush, Monday is fine.

Attribution: Yiping Ma <yipingma@berkeley.edu> as author, with a Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> trailer. An author-ready patch is at https://github.com/Yiping106283/easycrypt/blob/pr1105-downstream-fixes/downstream_fixes/sha3_negative-phoare-false.patch (git am on next); its commit message records the 17 sites, the max 0 step-weight change and the EasyCrypt head it was checked against.

If it needs adjusting after #1105 is rebased, let me know.

Yiping106283 added a commit to Yiping106283/easycrypt that referenced this pull request Sep 13, 2026
…ries

Summary: the pHL `while` tactic (invariant only, `<=`) accepted
`phoare[M.p : true ==> false] <= (-1%r)` for `while (true) {}` (upstream
EasyCrypt#1102), and likewise with the invariant `false` and a diverging prefix.

Root cause (src/phl/ecPhlWhile.ml, `t_bdhoare_while_rev_r`): the rule is a
fixpoint induction whose base case (the loop diverges: probability 0) and
exit case need `0%r <= bd`, and whose exit case needs
`inv /\ !e /\ post => bd = 1%r`. The exit condition was emitted inside the
post-condition of the hoare judgment on the prefix, which only has to hold
on its terminating runs, and `0%r <= bd` was not required at all, so a
diverging loop -- or a diverging prefix with the invariant `false` -- proved
any bound.

Fix: the prefix goal becomes `hoare[s : pre ==> inv]`; the exit condition
`forall &hr, inv /\ !e /\ post => bd = 1%r` and the non-negativity
`forall &hr, 0%r <= bd` are emitted by the rule as separate goals quantified
over all memories (always, in this order, after the body and prefix goals).
Based on EasyCrypt#1105: under its semantics the bound must be non-negative in every
memory, so the goal is unconditional (restricting it to `pre \/ inv` would
still let the rule prove a judgment whose bound is negative outside them).
The `while` tactic (`process_while`) tries `t_trivial` on the two of them
so trivial cases stay effort-free. examples/PRG.ec closes the two relocated
goals.

Test: tests/phoare-while-neg-bound.ec (invariants `true` and `false`). The
remaining goal `forall &hr, 0%r <= -1%r` is introduced with `move=> &hr`
(which fails when the goal is not emitted) and asserted unprovable with
`fail (by smt())`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Yiping106283 added a commit to Yiping106283/easycrypt that referenced this pull request Sep 13, 2026
…`rnd`

Summary: the pHL `rnd` tactic accepted `phoare[M.f : true ==> true] <= (-1)%r`
for a procedure that diverges before its sampling (upstream EasyCrypt#1119), from which
`false` follows with `Pr[mu_ge0]`.

Root cause (src/phl/ecPhlRnd.ml, `Core.t_bdhoare_rnd_r`): for an upper bound,
the rule lifts the bound check `mu d E <= bd` into the post-condition of a
hoare judgment on the statements preceding the sampling. A hoare
post-condition only has to hold on terminating runs, so a diverging prefix
discharges it vacuously. The check is sound for the mass of the terminating
runs, but the non-terminating runs contribute probability 0 to the
conclusion, which is bounded by `bd` only if `0%r <= bd`: that premise was
missing from the rule.

Fix: the two `<=` branches that emit the hoare goal (explicit event, and
event inferred from a post-condition depending on the sampled variable) now
also emit `forall &hr, 0%r <= bd` as a last goal. Based on EasyCrypt#1105: under its
semantics the bound must be non-negative in every memory, so the goal is
unconditional (restricting it to memories satisfying `pre` would still let
the rule prove a judgment whose bound is negative outside `pre`). The rule
always emits it; the `rnd` tactic (`process_rnd`) tries `t_trivial` on it,
so trivially non-negative bounds stay effort-free and a genuinely negative
bound is left as an unprovable goal. `auto` now only recurses into the
program-logic sub-goals produced by `rnd` (src/phl/ecPhlAuto.ml), so it
keeps applying `rnd` when the extra goal is present, and its own final
`t_trivial` closes the goal when it is trivial. The documented rule in
doc/tactics/rnd.rst is updated accordingly.

Scripts that discharged the old post-condition with `rnd; skip`, `rnd; auto`
or `rnd=> //` now reach the extra goal through `;`: theories/crypto/
{Birthday.eca, PROM.ec, RndExcept.eca, prp_prf/Strong_RP_RF.eca} and
examples/{PRG.ec, ChaChaPoly/chacha_poly.ec, cramer-shoup/cramer_shoup.ec,
global-hybrid/GlobalHybridExamp1.ec, prg-tutorial/PRGc.ec} close it with
`smt` and the relevant non-negativity lemmas (since the goal no longer
carries the pre-condition, facts such as `0 <= fsize m` come from the
library lemmas instead). In examples/prg-tutorial/PRGc.ec the `fel` bound
`(i + 1)%r * pr_dstate` is negative for `i < -1`, so under EasyCrypt#1105 the
per-query judgment is false for such counter values; the bound becomes
`(max 0 i + 1)%r * pr_dstate` (as EasyCrypt#1105 does in Strong_RP_RF.eca) and the
sum is rewritten back with `eq_big_seq`.

Test: tests/phoare-rnd-neg-bound.ec (explicit event, inferred event, and a
loop-free variant). The remaining goal `forall &hr, 0%r <= -1%r` is
introduced with `move=> &hr` (which fails when the goal is not emitted) and
asserted unprovable with `fail (by smt())`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Yiping106283 added a commit to Yiping106283/easycrypt that referenced this pull request Sep 13, 2026
…ries

Summary: the pHL `while` tactic (invariant only, `<=`) accepted
`phoare[M.p : true ==> false] <= (-1%r)` for `while (true) {}` (upstream
EasyCrypt#1102), and likewise with the invariant `false` and a diverging prefix.

Root cause (src/phl/ecPhlWhile.ml, `t_bdhoare_while_rev_r`): the rule is a
fixpoint induction whose base case (the loop diverges: probability 0) and
exit case need `0%r <= bd`, and whose exit case needs
`inv /\ !e /\ post => bd = 1%r`. The exit condition was emitted inside the
post-condition of the hoare judgment on the prefix, which only has to hold
on its terminating runs, and `0%r <= bd` was not required at all, so a
diverging loop -- or a diverging prefix with the invariant `false` -- proved
any bound.

Fix: the prefix goal becomes `hoare[s : pre ==> inv]`; the exit condition
`forall &hr, inv /\ !e /\ post => bd = 1%r` and the non-negativity
`forall &hr, 0%r <= bd` are emitted by the rule as separate goals quantified
over all memories (always, in this order, after the body and prefix goals).
Based on EasyCrypt#1105: under its semantics the bound must be non-negative in every
memory, so the goal is unconditional (restricting it to `pre \/ inv` would
still let the rule prove a judgment whose bound is negative outside them).
The `while` tactic (`process_while`) tries `t_trivial` on the two of them
so trivial cases stay effort-free. examples/PRG.ec closes the two relocated
goals.

Test: tests/phoare-while-neg-bound.ec (invariants `true` and `false`). The
remaining goal `forall &hr, 0%r <= -1%r` is introduced with `move=> &hr`
(which fails when the goal is not emitted) and asserted unprovable with
`fail (by smt())`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Yiping106283 added a commit to Yiping106283/easycrypt that referenced this pull request Sep 13, 2026
…`rnd`

Summary: the pHL `rnd` tactic accepted `phoare[M.f : true ==> true] <= (-1)%r`
for a procedure that diverges before its sampling (upstream EasyCrypt#1119), from which
`false` follows with `Pr[mu_ge0]`.

Root cause (src/phl/ecPhlRnd.ml, `Core.t_bdhoare_rnd_r`): for an upper bound,
the rule lifts the bound check `mu d E <= bd` into the post-condition of a
hoare judgment on the statements preceding the sampling. A hoare
post-condition only has to hold on terminating runs, so a diverging prefix
discharges it vacuously. The check is sound for the mass of the terminating
runs, but the non-terminating runs contribute probability 0 to the
conclusion, which is bounded by `bd` only if `0%r <= bd`: that premise was
missing from the rule.

Fix: the two `<=` branches that emit the hoare goal (explicit event, and
event inferred from a post-condition depending on the sampled variable) now
also emit `forall &hr, 0%r <= bd` as a last goal. Based on EasyCrypt#1105: under its
semantics the bound must be non-negative in every memory, so the goal is
unconditional (restricting it to memories satisfying `pre` would still let
the rule prove a judgment whose bound is negative outside `pre`). The rule
always emits it; the `rnd` tactic (`process_rnd`) tries `t_trivial` on it,
so trivially non-negative bounds stay effort-free and a genuinely negative
bound is left as an unprovable goal. `auto` now only recurses into the
program-logic sub-goals produced by `rnd` (src/phl/ecPhlAuto.ml), so it
keeps applying `rnd` when the extra goal is present, and its own final
`t_trivial` closes the goal when it is trivial. The documented rule in
doc/tactics/rnd.rst is updated accordingly.

Scripts that discharged the old post-condition with `rnd; skip`, `rnd; auto`
or `rnd=> //` now reach the extra goal through `;`: theories/crypto/
{Birthday.eca, PROM.ec, RndExcept.eca, prp_prf/Strong_RP_RF.eca} and
examples/{PRG.ec, ChaChaPoly/chacha_poly.ec, cramer-shoup/cramer_shoup.ec,
global-hybrid/GlobalHybridExamp1.ec, prg-tutorial/PRGc.ec} close it with
`smt` and the relevant non-negativity lemmas (since the goal no longer
carries the pre-condition, facts such as `0 <= fsize m` come from the
library lemmas instead). In examples/prg-tutorial/PRGc.ec the `fel` bound
`(i + 1)%r * pr_dstate` is negative for `i < -1`, so under EasyCrypt#1105 the
per-query judgment is false for such counter values; the bound becomes
`(max 0 i + 1)%r * pr_dstate` (as EasyCrypt#1105 does in Strong_RP_RF.eca) and the
sum is rewritten back with `eq_big_seq`.

Test: tests/phoare-rnd-neg-bound.ec (explicit event, inferred event, and a
loop-free variant). The remaining goal `forall &hr, 0%r <= -1%r` is
introduced with `move=> &hr` (which fails when the goal is not emitted) and
asserted unprovable with `fail (by smt())`.

Co-Authored-By: Claude Fable 5.1 <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.

pHL seq permits negative probabilities

3 participants