Skip to content

fix pruning bug #391 - #403

Merged
apoelstra merged 5 commits into
BlockstreamResearch:masterfrom
stringhandler:st-fix-pruning-391
Nov 3, 2026
Merged

apoelstra merged 5 commits into
BlockstreamResearch:masterfrom
stringhandler:st-fix-pruning-391

Conversation

@stringhandler

@stringhandler stringhandler commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

So after a few different approaches to fixing this bug, I settled on this one for now. Basically, run the program on a Bitmachine with a tracker which stores which IHRs were called. Then go top down along the DAG and store kept IHRs, and for cases, mark their children according to how we will hide them.

Then, rebuild the tree from the kept nodes, using the hiding information to replace case nodes, if necessary. Finally, run finalize on the new tree to determine the correct types.

There is an extra step added in the second last commit, which runs the code again like consensus would, and fails if there are any un-run nodes. This can be removed if deemed unnecessary.

Also, because pruning is done by IHR, a step is added to the beginning to remove that share an IHR but are different in memory (reduces to Max Sharing instead of Internal Sharing). For decoded programs, this is always the case anyway, but for programs edited in memory, this may not be a valid assumption.

Moves out the Converter interfaces and pruning logic into their own file
in preparation for adding another converter and logic to pruning.

No change in behaviour.

Assisted-by: Claude
Pruning assumes that a program has a single node per IHR, as a decoded
program does. A program built in memory can have several nodes with
the same IHR, and as the IHR does not cover the types inside a node,
those nodes can contain case nodes with different IHRs. The tracker
records the branches of each of those case nodes separately, so
pruning can hide a different branch in each, while the encoding has a
single node for all of them, which then reaches a pruned branch.

Reduce the program to maximal sharing before running it on the Bit
Machine, so that the tracker records branches for the nodes which the
encoding will have.

Assisted-by: Claude
@stringhandler
stringhandler marked this pull request as ready for review October 30, 2026 14:45
@stringhandler

Copy link
Copy Markdown
Contributor Author

I made a few other attempts at this, and created some good fuzzing programs for pruning, which we don't really have. I'll add them in a separate PR

Comment thread src/node/prune.rs
(Inner::Case(_, right), Hide::Left) => stack.push(right),
(Inner::Case(left, _), Hide::Right) => stack.push(left),
_ => {
stack.extend(node.left_child());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 43bde49:

This is a neat idiom. It pushes node.left_child() if it is Some.

Comment thread src/node/prune.rs Resolved
@apoelstra

Copy link
Copy Markdown
Collaborator

In 23ae4bf:

This approach (just fail) is certainly better than producing a broken program. But we should revisit and try to make pruning work unconditionally.

Refactors pruning to use a different approach. First, walk top down from
the root, and mark kept nodes and what needs to be hidden. Then, rebuild
the DAG from bottom up (the usual way), only adding back kept nodes, and
replacing cases with hidden, if necessary. Only finalize on the rebuilt
program, which should be more reliable with determining the correct
types.

Because we use IHRs, we need to make sure that there are no different
nodes in the tree, with the same IHR, but potentially different types,
which is why we reduced the tree to maximal sharing in a previous
commit.

Fixes BlockstreamResearch#391.

Assisted-by: Claude
Pruning can produce a program which consensus rejects. Case nodes and
assertions whose IHRs only become equal once pruned are a single node in
the encoding, which then hides a branch that one of them uses, or keeps a
branch that never runs.

After pruning a program, encode it, decode it, run it, and check that
every node and both branches of every case node ran, as libsimplicity
does. If not, fail with the new ExecutionError::PruningFailed rather
than return the program. This runs the program a second time.

Assisted-by: Claude
@stringhandler

Copy link
Copy Markdown
Contributor Author

This approach (just fail) is certainly better than producing a broken program. But we should revisit and try to make pruning work unconditionally.

Agree

@apoelstra apoelstra left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK 21878a5; successfully ran local tests

@ikripaka

ikripaka commented Nov 3, 2026 •

Copy link
Copy Markdown

Guys, would you mind adding #[non_exhaustive] property to the ExecutionError?
When I tried to use this commit as a patch, Simplex failed to compile.
In this case, I assume new errors can be added later without breaking users' code, or is the current approach better?

@stringhandler

Copy link
Copy Markdown
Contributor Author

I don't mind but could you provide the actual error?

@ikripaka

ikripaka commented Nov 3, 2026

Copy link
Copy Markdown
error[E0004]: non-exhaustive patterns: `&ExecutionError::PruningFailed` not covered
   --> crates/test/src/fuzz/outcome.rs:57:92
    |
 57 |             ProgramError::Pruning(execution) | ProgramError::Execution(execution) => match execution {
    |                                                                                            ^^^^^^^^^ pattern `&ExecutionError::PruningFailed` not covered

<shorten it a little bit>

550 | pub enum ExecutionError {
    | ^^^^^^^^^^^^^^^^^^^^^^^
...
564 |     PruningFailed,
    |     ------------- not covered
    = note: the matched value is of type `&ExecutionError`

@ikripaka

ikripaka commented Nov 3, 2026

Copy link
Copy Markdown

This error isn't complex; it's easy to fix, but this library was published on Friday.
In the non_exhaustive case, we can use a wildcard pattern to handle it gracefully from our side; any additional errors would be handled like that.

@apoelstra

Copy link
Copy Markdown
Collaborator

Yeah, agreed we should add #[non_exhaustive] to this error (and probably all our error enums). But can wait for a followup.

@apoelstra
apoelstra merged commit 2c5a066 into BlockstreamResearch:master Nov 3, 2026
26 checks passed
@stringhandler

Copy link
Copy Markdown
Contributor Author

In the case that you're using the error, I would say that it is better to not add the attribute. It looks like you are fuzz testing something and genuinely need to manually update the case. (I'm guessing, since the path says crates/test/src/fuzz/outcome.rs:57:92)

@apoelstra

Copy link
Copy Markdown
Collaborator

Interesting point.

In rust-bitcoin we have started adding the attribute to almost all our error enums so that post-1.0 we will still have room to extend the errors without semver-breaking changes.

But maybe because this crate is so young and nowhere near 1.0, we want to take the opposite tack, and try to alert people via compiler breakage when we've extended error cases.

I'm unsure.

@stringhandler

Copy link
Copy Markdown
Contributor Author

I don't mind adding it at some point, but I think it may be too early for ExecutionError in particular. If there are new ways an execution can fail, it would most likely be better for wallets and downstream to handle it explicitly, and know about it.

@apoelstra

Copy link
Copy Markdown
Collaborator

Okay, agreed.

apoelstra added a commit that referenced this pull request Nov 6, 2026
698a2b0 fuzz: add a type-directed program generator and a pruning target (stringhandler)
cc4c4a7 ci(fuzz): use the workflow's nightly in generate-files.sh (stringhandler)

Pull request description:

  In #403 we added a fix for pruning. This PR adds code to fuzz pruning of programs, which didn't exist before. It is LLM generated. It is also built on top of #403, so it needs that PR to be merged first. 
  
  ## Results 
  
  Measured on 20,000 generated programs, from the inputs that `pseudorandom_inputs` gives (the CI test uses the first 1,000):
  
  | | |
  |---|---|
  | Programs that pass every check | 99.97% (the other 0.03% give `PruningFailed`) |
  | Program size | median 200 nodes, 90th percentile 523 |
  | Pruning hides at least one branch | 95% (4.4 branches on average) |
  | A case node keeps both branches | 43% |
  | Pruning shrinks the witness data | 88% |
  | A hidden branch shares a node with kept code | 91% |
  | Contains a `disconnect` node | 89% (of the CI test's 1,000) |
  
  On the commit before the #391 fix, 64 of the CI test's 1,000 programs fail, the first at input 1. libFuzzer, with the `-len_control=0 -max_len=4096` that `fuzz.sh` now passes, finds the bug from an empty corpus in 7, 12 and 32 seconds (3 runs).
  
  Coverage of `src/node/prune.rs`, over the CI test's inputs and a 2,712-input libFuzzer corpus: 97.6% of lines and all 23 functions. The uncovered lines are error paths: programs that fail to run, and the rejections in the check after pruning.
  
  Not covered: the generator never produces separate nodes with one IHR but different types inside them (0 of 20,000), so only unit tests cover merging such nodes before pruning.


ACKs for top commit:
  apoelstra:
    ACK 698a2b0; successfully ran local tests


Tree-SHA512: f1d95140cab40f3208339dbbaa884a3afef7a4970b79e34f7ca68f7451b4a97c65618b07100398f5fb44d0eadb4acb6f4c256600e2daa87c870f71bc0f11900d
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.

3 participants