Skip to content

tooling(recursion): count keccak hashes to verify a proof (excl. grin… - #987

Open
ColoCarletti wants to merge 1 commit into
mainfrom
metric/recursion-hash-count
Open

tooling(recursion): count keccak hashes to verify a proof (excl. grin…#987
ColoCarletti wants to merge 1 commit into
mainfrom
metric/recursion-hash-count

Conversation

@ColoCarletti

Copy link
Copy Markdown
Collaborator

Recursion hash-count metric (excl. grinding)

What

A host-only diagnostic that counts the keccak-256 hashes done to verify a proof —
a fast, deterministic proxy for the recursion guest's dominant cost (Merkle path
hashing + Fiat-Shamir). Lets you tell whether a prover-side change actually reduces
the work the recursion verifier does, without building or running the guest.

How it works

  • Counters in a new crypto::hash_metrics module, incremented at th
    PlatformKeccak256 primitive on every keccak-256 finalize — so it catches
    all verify-side hashing: Merkle trees, the Fiat-Shamir transcript,
    program-id/ELF fold.
  • Grinding is excluded at its call site: the verifier wraps the
    is_valid_nonce check with hash_metrics::disable() / re-enable().
  • The Merkle backend adds two sub-counters, so the total splits into
    (nodes = auth-path compressions, leaves) vs transcript+other.
  • Not a Cargo feature or CLI flag — counting is a runtime toggle
    (enable/disable), off by default (one relaxed atomic load per hash when
    on). Compiled out entirely on the riscv64 guest
    (#[cfg(not(target_arch = "riscv64"))]): the guest pays nothing and its keccak
    stays byte-identical (the counter is a pure side effect).

How to use

  1. Dump a proof blob once (the slow step — it proves):
    RECURSION_DUMP_PRESET=blowup4 RECURSION_DUMP_EPOCH_LOG2=22
    RECURSION_DUMP_INNER_ELF=.elf RECURSION_DUMP_INNER_INPUT=.bin
    cargo test --release -p lambda-vm-prover --lib
    test_dump_recursion_input -- --ignored
    → writes /tmp/recursion_input.bin.

  2. Count (fast, ~1s — verify only):
    RECURSION_DUMP_PRESET=blowup4
    cargo test --release -p lambda-vm-prover --lib
    test_count_recursion_hashes -- --ignored --nocapture
    → prints, e.g.:
    [hash-count] preset=blowup4 blob=B fri_queries=110 | total(excl. grinding)=900311
    | merkle=896220 (nodes=810830 leaves=85390) | transcript+other=

Loop: change the prover → re-dump → re-count → compare total.

Env vars: RECURSION_DUMP_PRESET (must match the dump, else verify
RECURSION_INPUT_PATH (default /tmp/recursion_input.bin).

Notes / limitations

  • Counts keccak-256 finalizes (one per leaf / node / transcript
    internal permutations.
  • The example number above is from a sample proof; the count depends
    (preset, query count, epochs, and which prover produced it).

Files

  • crypto/crypto/src/hash_metrics.rs (new) — counters + enable/disa
  • crypto/crypto/src/hash/platform_keccak.rs — host PlatformKeccak256 wraps
    sha3::Keccak256 and counts on finalize.
  • crypto/crypto/src/merkle_tree/backends/field_element_vector.rs — Merkle
    node/leaf sub-counters.
  • crypto/stark/src/verifier.rs — grinding exclusion.
  • prover/src/tests/recursion_smoke_test.rs — `test_count_recursion

…ding)

A host-only metric for the recursion guest's dominant cost. crypto::hash_metrics
counters fire on every keccak-256 finalize (the host PlatformKeccak256 wrapper) —
Merkle trees, the Fiat-Shamir transcript, the program-id/ELF fold — EXCEPT the
grinding proof-of-work check, excluded at its call site in the verifier. The
Merkle backend splits its share into node (auth-path) vs leaf finalizes.

test_count_recursion_hashes verifies the dumped recursion blob
(/tmp/recursion_input.bin) and prints total(excl. grinding) / merkle(nodes,leaves)
/ transcript+other. Loop: change the prover, re-dump, re-count, compare.

Zero-cost on the riscv64 guest (compiled out); disabled by default on the host
(one relaxed atomic load per hash when on).
@ColoCarletti

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • Low — Concurrent verification corrupts hash metrics. In verifier.rs, excluding grinding toggles a process-global flag. Concurrent verifiers can suppress each other’s legitimate hashes or re-enable counting during another grinding check. Other tests running alongside the diagnostic also contribute to its counters. Use a scoped, thread-local measurement session for this synchronous diagnostic so reported counts remain attributable and reproducible.

No security or execution-correctness issues found in the reviewed changes.

Comment on lines +81 to +99
impl Update for PlatformKeccak256 {
fn update(&mut self, data: &[u8]) {
Update::update(&mut self.0, data);
}
}

impl FixedOutput for PlatformKeccak256 {
fn finalize_into(self, out: &mut Output<Self>) {
crate::hash_metrics::count_total();
FixedOutput::finalize_into(self.0, out);
}
}

impl Reset for PlatformKeccak256 {
fn reset(&mut self) {
Reset::reset(&mut self.0);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium (performance): none of the forwarding methods are #[inline]. They are non-generic inherent trait impls defined in crypto, so downstream crates (stark, prover) call them as real cross-crate calls unless LTO kicks in — whereas today PlatformKeccak256 = sha3::Keccak256 resolves to digest's CoreWrapper methods, which are #[inline].

That matters most for update: the Merkle leaf path streams field elements 8 bytes at a time (element.stream_bytes(sink)), so this adds one call per chunk, not per hash, in the prover's hottest loop. And finalize_into now moves the ~200-byte sponge by value through an extra newtype layer — the exact shape the DO-NOT-REFACTOR note in field_element_vector.rs:32-41 says was measured slower.

At minimum add #[inline(always)] to all six forwarding methods. Better, since this is a host-only diagnostic: keep pub type PlatformKeccak256 = sha3::Keccak256; as the default and put the counting wrapper behind a cargo feature, so a normal prover build is provably unchanged.

Comment on lines +46 to +48
// Metric: a Merkle finalize (leaf or node) — the total keccak count is taken
// at the primitive; this is the Merkle sub-count. No-op on guest / disabled.
crate::hash_metrics::count_merkle();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low/Medium (metric accuracy): the Merkle sub-counters only cover this file. FieldElementBackend (backends/field_element.rs, aliased as Keccak256Backend / FriMerkleTreeBackend in stark/src/config.rs:10) hashes via its own hasher.finalize() and is never counted — those hashes land in total and therefore get reported as transcript+other. It looks unused in the current verify path, but the split will silently misreport the day a tree switches backends.

Also, count_merkle here fires for any D, while count_total only fires for keccak. With a non-keccak backend (Poseidon, Sha3_256, Keccak512 in the crypto tests) merkle can exceed total, and the test's total.saturating_sub(merkle) prints a plausible-looking 0 instead of flagging the inconsistency.

Cheapest fix: count in field_element.rs too, and have the test assert merkle <= total / nodes <= merkle rather than saturating.

Comment on lines +1667 to 1678
// Exclude the proof-of-work check from the host hash metric
// (`crypto::hash_metrics`): the metric asks for "all hashes except
// grinding". No-op on the guest and whenever counting is off.
let hm_was = crypto::hash_metrics::is_enabled();
crypto::hash_metrics::disable();
let nonce_is_valid = proof.nonce().is_some_and(|nonce_value| {
grinding::is_valid_nonce(&challenges.grinding_seed, nonce_value, grinding_factor)
});
if hm_was {
crypto::hash_metrics::enable();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low (simplicity): this whole exclusion — 12 lines of global save/restore in shared verifier code, plus the is_enabled API and its guest stub — removes exactly 2 hashes per proof. is_valid_nonce is get_inner_hash (1 finalize) + is_valid_nonce_for_inner_hash (1 finalize); against the ~900k in the PR description that is noise, and grinding verification is work the recursion guest actually does, so excluding it makes the proxy slightly less faithful, not more.

Suggest dropping the exclusion entirely (and is_enabled with it): it keeps measurement state out of the verifier, and the headline number stays "every keccak the verifier does".

Secondary, if it stays: the toggle is a process-global, so a rayon-parallel verify would drop any hashes other threads finalize inside this window, and a concurrent second verify would race the restore.

Comment on lines +1070 to +1071
let preset_name =
std::env::var("RECURSION_DUMP_PRESET").unwrap_or_else(|_| "blowup4".to_string());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low (usability): two footguns for the documented loop.

  1. This defaults to blowup4, but test_dump_recursion_input defaults to min — running the pair with no env at all produces a verify failure rather than a count. Default to "min" to match the producer.
  2. It always calls verify_continuation_and_attest, which only accepts a continuation blob. A dump made without RECURSION_DUMP_EPOCH_LOG2 (the dump test's default branch) fails inside rkyv::access and surfaces as .expect("verify_continuation_and_attest errored") with an opaque validation message. Worth saying "requires a dump made with RECURSION_DUMP_EPOCH_LOG2 set" in the doc comment, and pointing the panic message at that.

Also: enable() is not restored if the verify below panics, leaving counting on for the rest of the test process. Minor, but a disable() before the assert!/expect (or just disabling first thing after snapshot) avoids it.

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Review: keccak hash-count metric

Useful, well-scoped diagnostic, and the guest-side reasoning checks out: everything is #[cfg]-ed out on riscv64, the syscall passthrough and its TypeId specializations are untouched, so the guest hash bytes and cycle counts are unaffected. No safety/soundness issues — no unsafe, no new panics outside the --ignored test.

Findings (all inline):

Medium — performance: the host PlatformKeccak256 newtype forwards without #[inline]. Today the alias resolves to digest's inlined CoreWrapper methods; after this PR update (called per 8-byte chunk in the Merkle leaf path) and finalize_into (moving the ~200-byte sponge through an extra layer) become cross-crate calls on the prover's hot path — the same shape field_element_vector.rs:32-41 documents as measured slower. Add #[inline(always)] to the six forwarding methods, or feature-gate the wrapper so a default prover build is provably unchanged.

Low/Medium — metric accuracy: count_merkle/count_merkle_node only instrument field_element_vector.rs. FieldElementBackend (Keccak256Backend/FriMerkleTreeBackend) is uncounted, so its hashes silently report as transcript+other; and count_merkle fires for any digest while count_total only fires for keccak, so merkle can exceed total and saturating_sub prints a plausible 0.

Low — simplicity: the grinding exclusion (12 lines of global save/restore in shared verifier code + the is_enabled API + guest stub) excludes exactly 2 hashes per proof, and grinding verification is work the guest genuinely does. Dropping it removes state juggling from the verifier and makes the number simpler to explain.

Low — usability: the new test defaults to blowup4 while test_dump_recursion_input defaults to min, and it silently requires a continuation dump (RECURSION_DUMP_EPOCH_LOG2); the mismatch surfaces as an opaque rkyv failure. Also enable() is not restored if the verify panics.

Nit: the guest stubs omit reset/snapshot, so the module's API differs by target — fine today (only the host test calls them), just noting the asymmetry is deliberate-looking but undocumented.

@github-actions

Copy link
Copy Markdown

AI Review

PR #987 · 6 changed files

Findings

Status Sev Location Finding Found by
confirmed medium crypto/crypto/src/merkle_tree/backends/field_element_vector.rs:48 Merkle counters fire for non-keccak digests, breaking total/merkle invariant kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low crypto/stark/src/verifier.rs:1670 Hash-metrics disable/enable around grinding is not exception-safe kimi
openrouter/moonshotai/kimi-k2.7-code

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-002: Merkle counters fire for non-keccak digests, breaking total/merkle invariant
  • Status: confirmed
  • Severity: medium
  • Location: crypto/crypto/src/merkle_tree/backends/field_element_vector.rs:48
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

count_merkle() and count_merkle_node() are called unconditionally in hash_streamed and hash_new_parent_bytes, which are generic over D: Digest. When the backend is instantiated with a non-keccak digest (e.g., the existing tests using Sha3_256, Sha512, Keccak512, or raw sha3::Keccak256), the Merkle counters increment but count_total() (which only lives inside the host PlatformKeccak256 wrapper) does not. This violates the documented invariant that merkle is a subset of total and makes total − merkle negative/meaningless.

Evidence

hash_streamed calls crate::hash_metrics::count_merkle() before any TypeId check (field_element_vector.rs:48), and hash_new_parent_bytes calls count_merkle_node() before its TypeId check (field_element_vector.rs:84). The test file crypto/crypto/src/tests/field_element_vector_tests.rs uses FieldElementVectorBackend&lt;F, Sha3_256, 32&gt;, Sha3_512, Keccak512, Sha512, and raw sha3::Keccak256 — none of which go through the PlatformKeccak256 wrapper that calls count_total().

Suggested fix

Only count Merkle activity when the digest is actually the platform keccak hasher. Import PlatformKeccak256 and TypeId unconditionally and guard the two counter calls with TypeId::of::&lt;D&gt;() == TypeId::of::&lt;PlatformKeccak256&gt;() (guest stubs are no-ops, so the guard is harmless there).

AI-004: Hash-metrics disable/enable around grinding is not exception-safe
  • Status: confirmed
  • Severity: low
  • Location: crypto/stark/src/verifier.rs:1670
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The verifier disables hash_metrics around the grinding check and re-enables it only on the happy path. If grinding::is_valid_nonce (or the surrounding closure) panicked, enable() would never run and the global metrics switch would stay off for the rest of the process, silently corrupting any later measurement. is_valid_nonce contains a debug_assert! on the grinding-factor range, so an out-of-range value in a debug build would trigger this leak.

Evidence

crypto/stark/src/verifier.rs:1670-1677 stores the prior state, calls disable(), runs the closure, and conditionally re-enables only if hm_was was true. There is no guard object to restore state on unwind.

Suggested fix

Replace the manual disable/enable pair with a small scope guard whose Drop impl restores the prior enabled state, so metrics are re-enabled even if the closure panics.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 0
kimi openrouter/moonshotai/kimi-k2.7-code general success 3
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 2

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 2 2 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (2) — rejected by the verifier
  • Missing guest stubs for reset() and snapshot() in hash_metrics.rs (crypto/crypto/src/hash_metrics.rs:95, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b, kimi:openrouter/moonshotai/kimi-k2.7-code) — The grep for hash_metrics::reset and hash_metrics::snapshot shows they are only called from prover/src/tests/recursion_smoke_test.rs (a host-only #[test]), never from guest or shared code. The comment at hash_metrics.rs:93-94 explicitly states guest stubs exist 'so the shared verifier code (which wraps the grinding check) compiles for the guest without #[cfg] noise.' The shared verifier code only calls is_enabled/enable/disable — all of which have stubs. reset() and snapshot() are host-only diagnostic functions; adding empty guest stubs for them would be dead code.
  • Race condition in verifier.rs when disabling/enabling metrics around grinding check (crypto/stark/src/verifier.rs:1670, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The verifier (verifier.rs) does not import rayon or any threading/parallel primitives. 'multi_verify' refers to verifying multiple AIR tables sequentially within a single thread, not concurrent verification. The verifier inherently cannot run concurrently because each verification step feeds the Fiat-Shamir transcript, which must be sequential. With only single-threaded access to the ENABLED AtomicBool, Relaxed ordering cannot produce a race condition — all operations are program-ordered within a single thread. The claim about 'batch verification' running concurrently is speculative and unsupported by the code.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

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.

1 participant