The Semver Trap: How a Version Number Derailed a Bellperson Fork

In the middle of implementing Phase 2 of the cuzk proving engine — a pipelined SNARK proving daemon for Filecoin's Curio project — the assistant encountered a frustrating but instructive failure. After carefully copying the entire bellperson-0.26.0 crate source into a local fork, making four targeted modifications to expose internal synthesis/GPU split APIs, and wiring up a [patch.crates-io] section in the workspace manifest, the build command produced a cryptic warning: "Patch bellperson v0.26.0-cuzk.1 was not used in the crate graph." The subject message, <msg id=422>, is the assistant's diagnosis and fix for this problem — a single paragraph of reasoning followed by a one-line edit. Yet this brief moment captures a deep interaction between Rust's semver resolution algorithm, Cargo's patch mechanics, and the often-invisible assumptions that developers make about version numbering.

The Message

The assistant wrote:

The patch wasn't used because the version doesn't match. Cargo.lock has bellperson 0.26.0 but the fork is 0.26.0-cuzk.1. For [patch.crates-io] to work, the version field needs to satisfy the dependency requirement. The dependency is bellperson = "0.26.0" (via filecoin-proofs-api's transitive deps). A pre-release version 0.26.0-cuzk.1 doesn't match 0.26.0 in semver.

>

Let me fix the version in the fork: [edit] /home/theuser/curio/extern/bellperson/Cargo.toml Edit applied successfully.

The Context: Why This Message Exists

To understand why this message was written, we must trace the chain of reasoning that led to it. The cuzk project (short for "Curio SNARK proving daemon") is a multi-phase effort to build a pipelined proving engine for Filecoin's proof-of-replication (PoRep) system. The core insight driving Phase 2 is that Groth16 proof generation can be split into two distinct phases: a CPU-bound circuit synthesis phase and a GPU-bound proving phase. In the existing architecture, these phases are fused together inside bellperson::groth16::create_random_proof_batch, meaning the GPU cannot start working on one proof while the CPU synthesizes the next. By exposing the split as a public API, the cuzk engine can pipeline multiple proofs: the CPU thread pool continuously synthesizes circuits and queues the intermediate state, while GPU workers consume from the queue and generate proofs. This pipelining reduces idle time on both CPU and GPU resources, potentially delivering significant throughput improvements.

The assistant had already done extensive preparatory work. A 791-line design document (cuzk-phase2-design.md) had been written, analyzing the memory budget (the intermediate ProvingAssignment state for 10 PoRep partitions is approximately 100+ GiB), the per-partition pipeline strategy, and the SRS manager design. The bellperson internals had been thoroughly studied: the assistant discovered that the synthesis/GPU split already existed internally as synthesize_circuits_batch() and the GPU-phase code in create_proof_batch_priority_inner, but these were private. The fork strategy was deliberately minimal — only ~130 lines of changes were needed: making ProvingAssignment and its fields public, exposing synthesize_circuits_batch(), and adding prove_from_assignments() for the GPU phase.

The fork was created by copying the entire bellperson-0.26.0 source from the Cargo registry cache into extern/bellperson/. The version was set to 0.26.0-cuzk.1 to distinguish it from the upstream crate. The workspace Cargo.toml was patched with a [patch.crates-io] section pointing to the local path. Everything seemed correct. Then the build command was run, and the patch was silently ignored.

The Diagnosis: Semver Resolution and Cargo's Patch Mechanics

The assistant's diagnosis is concise but reveals a sophisticated understanding of Cargo's dependency resolution. The core issue is that Cargo's [patch.crates-io] mechanism does not simply substitute any crate with a matching name. Instead, it requires that the patched crate's version satisfy the dependency requirement specified by the dependent crates. This is a subtle point that frequently trips up developers.

The dependency chain is: filecoin-proofs-api transitively depends on bellperson = "0.26.0". In Cargo's semver interpretation, "0.26.0" is an exact version requirement — it means ^0.26.0, which matches any version >= 0.26.0, < 0.27.0. The fork's version 0.26.0-cuzk.1 is a pre-release identifier. Under semver, pre-release versions have lower precedence than the release version. More importantly, 0.26.0-cuzk.1 does not satisfy ^0.26.0 because the pre-release tag makes it a different version entirely — Cargo treats it as 0.26.0-cuzk.1, which is not >= 0.26.0 in the semver ordering (pre-release versions are only matched if the requirement explicitly includes a pre-release tag or if the pre-release is higher than the release version, which is not the case here).

The warning message from Cargo was already pointing in this direction: "Check that the patched package version and available features are compatible with the dependency requirements." But the assistant went further, correctly identifying the root cause: the pre-release suffix -cuzk.1 breaks the semver match. The fix is to change the version back to 0.26.0 — the same version as upstream — so that the patch satisfies the dependency requirement exactly.

Assumptions Made and the Mistake

This message reveals an interesting assumption that the assistant made, and then corrected. The initial choice of 0.26.0-cuzk.1 as the fork version was motivated by a desire to distinguish the fork from the upstream crate — a common practice when maintaining a private fork. The assumption was that Cargo's patch mechanism would substitute the crate based on name alone, or that the pre-release version would still satisfy the ^0.26.0 requirement. Both assumptions turned out to be incorrect.

The mistake is subtle and understandable. In many package managers (npm, pip, etc.), pre-release versions are often treated as "compatible enough" with the base version, or the patch/substitution mechanism works by name alone. Cargo's semver resolution is stricter: it requires an exact semver match between the patch version and the dependency requirement. The assistant had to learn (or recall) this detail through the build failure.

There is also a second-order assumption at play: that the [patch.crates-io] mechanism would produce a clear error message if the version didn't match. Instead, Cargo produced a warning that the patch "was not used in the crate graph" — a message that could be interpreted as a path issue, a feature flag mismatch, or a lockfile staleness problem. The assistant correctly ruled out these alternatives by reasoning about the semver semantics, but the warning itself is not maximally helpful. It says to "run cargo update to use the new version," which would not have fixed the underlying semver mismatch — it would only have updated the lockfile to include the pre-release version, but the dependency requirement would still not be satisfied.

Input Knowledge Required

To understand this message, the reader needs knowledge of several domains:

Rust's Cargo dependency system: Understanding how [patch.crates-io] works, how semver version requirements are specified, and how Cargo resolves dependency graphs. The key concept is that patches are version-aware substitutions, not name-based overrides.

Semver pre-release semantics: The convention that pre-release identifiers (like -cuzk.1) create a version that is distinct from the base release version in the semver ordering. Pre-release versions are only matched if the dependency requirement explicitly allows them (e.g., ^0.26.0-cuzk.1 or >=0.26.0-cuzk.1).

The cuzk project architecture: Understanding that Phase 2 requires forking bellperson to expose internal APIs, that the fork is placed in extern/bellperson/, and that the workspace uses [patch.crates-io] to substitute the fork for the upstream crate across all transitive dependencies.

The Groth16 proof pipeline: Knowing that bellperson is the Groth16 proving library used by Filecoin, that it has a supraseal backend for GPU acceleration, and that the synthesis phase (circuit construction and constraint generation) is CPU-bound while the proving phase (multiexponentiation and pairings) is GPU-bound.

Output Knowledge Created

This message creates several pieces of knowledge:

A corrected Cargo.toml: The fork's version is changed from 0.26.0-cuzk.1 to 0.26.0, which will allow the patch to be applied correctly in the next build attempt.

A debugging pattern: The assistant demonstrates a method for diagnosing patch failures: check the version compatibility between the patch and the dependency requirement, verify that the lockfile doesn't have a conflicting entry, and ensure that feature flags don't cause the dependency to be optional.

A documented constraint: The message implicitly documents that the bellperson fork must maintain the same version number as the upstream crate for the patch mechanism to work. This has implications for future maintenance — if the upstream releases 0.27.0, the fork must either update to match or use a different patching strategy.

The Thinking Process

The assistant's thinking process, visible in the reasoning, follows a clear diagnostic path:

  1. Observation: The build produced a warning that the patch was not used.
  2. Hypothesis generation: The assistant considers possible causes — version mismatch, lockfile staleness, feature incompatibility.
  3. Evidence gathering: The assistant notes that Cargo.lock has bellperson 0.26.0 but the fork is 0.26.0-cuzk.1.
  4. Root cause identification: The pre-release version 0.26.0-cuzk.1 does not satisfy the dependency requirement bellperson = "0.26.0" under semver rules.
  5. Solution formulation: Change the fork's version back to 0.26.0 so it matches the dependency requirement.
  6. Action: Apply the edit to the fork's Cargo.toml. This is a textbook debugging process: observe, hypothesize, gather evidence, identify root cause, formulate solution, act. The brevity of the message (two sentences of reasoning plus an edit command) reflects the assistant's confidence in the diagnosis — once the semver mismatch is identified, the fix is trivial.

Broader Significance

While this message is only a few lines long, it captures a moment of genuine learning and correction. The assistant made a reasonable assumption (that a pre-release version suffix would work with Cargo's patch mechanism), tested it (by running the build), observed the failure, diagnosed the root cause, and applied the fix. This is the kind of micro-feedback loop that drives robust engineering.

The message also illustrates a recurring theme in the cuzk project: the tension between minimal changes and compatibility. The entire fork philosophy is "minimal changes, maximal impact" — only ~130 lines of modifications to expose existing internal APIs. Yet even this minimal fork was derailed by a single character in a version string. The semver trap is a reminder that in complex dependency graphs, the smallest details can have outsized consequences.

For the broader cuzk project, this fix is a prerequisite for everything that follows. Without the patch working correctly, the bellperson fork would remain unused, and the Phase 2 pipelined prover would have no API to split synthesis from proving. The corrected version number is the key that unlocks the entire Phase 2 implementation.