Skip to content

[CHIA-3854] Add INTERNED_GENERATOR cost model to BlockBuilder#1413

Closed
richardkiss wants to merge 1 commit intomainfrom
block-builder-interned-cost
Closed

[CHIA-3854] Add INTERNED_GENERATOR cost model to BlockBuilder#1413
richardkiss wants to merge 1 commit intomainfrom
block-builder-interned-cost

Conversation

@richardkiss
Copy link
Copy Markdown
Contributor

@richardkiss richardkiss commented Mar 31, 2026

Summary

  • BlockBuilder now accepts ConsensusFlags and uses the interned tree cost model when INTERNED_GENERATOR is set, matching what run_block_generator2 computes during validation
  • Bump maturin to ~=1.8 for self-contained Rust bootstrapping — pip install from a git branch now auto-installs the Rust toolchain if missing (no manual setup needed in CI)

BlockBuilder changes

When INTERNED_GENERATOR is active:

  • add_spend_bundles() computes incremental cost via intern_tree_limited + total_cost_from_tree on the live allocator tree, instead of ser.size() * cost_per_byte
  • finalize() re-parses the serialized bytes and interns for exact cost, matching the validator
  • Wire format (backrefs serialization) is unchanged — only the cost accounting differs

Python API change

BlockBuilder()BlockBuilder(flags) where flags comes from get_flags_for_height_and_constants(). This keeps the height-to-flags derivation in one place.

Test

test_build_block is parameterized for both flag states, verifying assert_eq!(conds.cost, cost) against run_block_generator2 with matching flags.

Context

Addresses Arvid's review feedback on PR #1377: after the INTERNED_GENERATOR hard fork, chia-blockchain needs to be able to build blocks with correct cost estimates. This PR updates BlockBuilder so create_block_generator2 (the production farming path) will produce correct costs.

Depends on #1377.

Test plan

  • cargo check -p chia-consensus passes
  • cargo test -p chia-consensus -- build_compressed_block passes (ignored tests need test-bundles/ data)
  • CI green

Made with Cursor


Note

Medium Risk
Changes block cost accounting to match the post-fork INTERNED_GENERATOR validation model and updates the Python constructor signature, which could affect block packing behavior and downstream callers if mis-estimated.

Overview
Adds flag-driven generator byte-cost accounting to BlockBuilder so it can estimate and finalize block cost using the INTERNED_GENERATOR interned-tree model instead of serialized-size-per-byte when the flag is set.

BlockBuilder::new now takes ConsensusFlags (and Python BlockBuilder(flags) follows suit), tracks the current spend-list head for incremental cost estimation, and finalize() re-parses serialized bytes to compute an exact interned-tree cost matching the validator.

Tests are refactored to run the expensive block-building round-trip under both flag modes, and the Python wheel build bumps maturin to ~=1.8.

Written by Cursor Bugbot for commit 15804bb. This will update automatically on new commits. Configure here.

Copilot AI review requested due to automatic review settings March 31, 2026 07:36
@richardkiss richardkiss marked this pull request as draft March 31, 2026 07:37
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Updates BlockBuilder cost accounting to support the INTERNED_GENERATOR hard-fork cost model, and bumps the wheel build tooling to a newer maturin version.

Changes:

  • Add ConsensusFlags to BlockBuilder and switch generator cost calculation to interned-tree cost when INTERNED_GENERATOR is set.
  • Recompute exact generator cost during finalize() by re-parsing + interning serialized bytes (validator-aligned).
  • Bump maturin requirement for wheel builds to ~=1.8.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.

File Description
wheel/pyproject.toml Bumps maturin version requirement for building the Python wheel.
crates/chia-consensus/src/build_compressed_block.rs Adds flags-aware BlockBuilder and implements INTERNED_GENERATOR cost accounting + updated tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +114 to +134
/// Compute the generator (byte) cost. When INTERNED_GENERATOR is active,
/// interns the live allocator tree rooted at the current spend list and
/// returns the deduplicated storage cost. Otherwise falls back to the
/// serialized byte count plus the two closing bytes.
fn compute_byte_cost(&self, constants: &ConsensusConstants) -> Result<u64> {
if self.flags.contains(ConsensusFlags::INTERNED_GENERATOR) {
// Build a temporary (q . current_spend_list) root for interning.
// We can't mutate self.allocator here, so we clone enough context
// by using intern_tree_limited on the existing allocator. The
// current_spend_list still ends with sentinel (a pair), which
// contributes a tiny negligible amount to cost — acceptable for
// an in-progress estimate.
let interned =
intern_tree_limited(&self.allocator, self.current_spend_list, u32::MAX as usize)
.map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
})?;
Ok(total_cost_from_tree(&interned))
} else {
Ok((self.ser.size() + 2) * constants.cost_per_byte)
}
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

In the INTERNED_GENERATOR path, compute_byte_cost() interns self.current_spend_list, which is the inner spend list ending in sentinel and does not include the outer list + (q . ...) wrapper that run_block_generator2() interns from serialized bytes. This systematically underestimates generator cost vs the validator and can allow add_spend_bundles() to accept a block that later exceeds max_block_cost_clvm (potentially tripping the assert! in finalize()). Consider computing the estimate by interning the current serialized bytes (self.ser.get_ref()) like finalize() does, or otherwise ensure the estimate includes the wrapper + nil-termination and is never lower than the final cost.

Copilot uses AI. Check for mistakes.
Comment on lines +128 to +129
.map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

intern_tree_limited(...).map_err(|_| Error::Custom("intern_tree_limited failed"...)) discards the underlying error, which makes debugging failures (e.g. depth/size limits) much harder. Preserve the original error message/context in the custom error (or plumb through a dedicated error variant) so callers can see why interning failed.

Suggested change
.map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
.map_err(|e| {
crate::error::Error::Custom(format!("intern_tree_limited failed: {e}"))

Copilot uses AI. Check for mistakes.

impl BlockBuilder {
pub fn new() -> Result<Self> {
pub fn new(flags: ConsensusFlags) -> Result<Self> {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

BlockBuilder::new() now requires flags, but there are existing Rust call sites still using the old no-arg constructor (e.g. fuzz targets under crates/chia-consensus/fuzz/fuzz_targets/). Update those call sites (likely ConsensusFlags::empty() or derived flags) or add a compatibility constructor to avoid breaking builds that include fuzz targets.

Suggested change
pub fn new(flags: ConsensusFlags) -> Result<Self> {
/// Compatibility constructor that uses empty consensus flags.
/// This preserves the older `BlockBuilder::new()` API for callers
/// (e.g. fuzz targets) that don't specify flags explicitly.
pub fn new() -> Result<Self> {
Self::new_with_flags(ConsensusFlags::empty())
}
/// Constructor that allows specifying consensus flags explicitly.
pub fn new_with_flags(flags: ConsensusFlags) -> Result<Self> {

Copilot uses AI. Check for mistakes.
#[new]
pub fn py_new() -> PyResult<Self> {
Ok(Self::new()?)
pub fn py_new(flags: u32) -> PyResult<Self> {
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

The Python #[new] constructor now requires a flags argument. There are existing Python call sites in this repo still constructing BlockBuilder() with no args (e.g. tests/test_block_builder.py), which will break at runtime. Either update those call sites/tests to pass flags derived from get_flags_for_height_and_constants(), or provide a default value for flags to preserve backwards compatibility if the break is not intended.

Suggested change
pub fn py_new(flags: u32) -> PyResult<Self> {
#[pyo3(signature = (flags=None))]
pub fn py_new(flags: Option<u32>) -> PyResult<Self> {
let flags = flags.unwrap_or(0);

Copilot uses AI. Check for mistakes.
Comment on lines +246 to +248
let serialized = self.ser.get_ref().clone();
let mut decode_alloc = Allocator::new();
let root = node_from_bytes_backrefs(&mut decode_alloc, &serialized)?;
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

In finalize() INTERNED_GENERATOR path, let serialized = self.ser.get_ref().clone(); makes a full copy of the generator bytes. You can avoid this extra allocation by taking ownership of the buffer once (e.g. via into_inner()), computing cost from that slice, and then returning the same Vec<u8> to the caller.

Suggested change
let serialized = self.ser.get_ref().clone();
let mut decode_alloc = Allocator::new();
let root = node_from_bytes_backrefs(&mut decode_alloc, &serialized)?;
let serialized = self.ser.get_ref();
let mut decode_alloc = Allocator::new();
let root = node_from_bytes_backrefs(&mut decode_alloc, serialized)?;

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +251
intern_tree_limited(&decode_alloc, root, u32::MAX as usize).map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Same as above: the map_err(|_| Error::Custom("intern_tree_limited failed"...)) here drops the underlying interning error. Including the original error details would make failures diagnosable.

Suggested change
intern_tree_limited(&decode_alloc, root, u32::MAX as usize).map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
intern_tree_limited(&decode_alloc, root, u32::MAX as usize).map_err(|e| {
crate::error::Error::Custom(format!("intern_tree_limited failed: {e}"))

Copilot uses AI. Check for mistakes.
flags: ConsensusFlags,

// the cost of the block we've built up so far, not including the byte-cost.
// That's seprated out into the byte_cost member.
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

Typo in the comment: "That's seprated out" → "That's separated out".

Suggested change
// That's seprated out into the byte_cost member.
// That's separated out into the byte_cost member.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix this issue. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

.map_err(|_| {
crate::error::Error::Custom("intern_tree_limited failed".to_string())
})?;
Ok(total_cost_from_tree(&interned))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Interned cost estimate only reflects latest batch, not cumulative

High Severity

When INTERNED_GENERATOR is active, compute_byte_cost interns self.current_spend_list, but each add_spend_bundles call rebuilds the spend list starting from self.sentinel (line 169), not from the previous current_spend_list. In the allocator, the latest batch's list (e.g. (item_b . sentinel)) is disconnected from prior batches' nodes (e.g. (item_a . sentinel)). So intern_tree_limited only traverses the latest batch's subtree, missing all previously added items. This causes byte_cost to be severely underestimated, allowing blocks to be overfilled. The non-interned path works correctly because self.ser.size() tracks cumulative size. In finalize, the exact cost from the full serialized tree can then exceed max_block_cost_clvm, triggering the assertion panic.

Additional Locations (1)
Fix in Cursor Fix in Web

@arvidn
Copy link
Copy Markdown
Contributor

arvidn commented Mar 31, 2026

thinking about this; I think it might be simpler to add a separate builder class for building blocks with the new cost model. I believe almost all of the complexity of the current BlockBuilder is related to computing the current cost based on the current compression mechanism.

With the new cost model, the serialization isn't an important part anymore, we can just use the original function to serialize a complete generator at the end (which is simpler). I think the majority of complexity with creating blocks with the new model is unrelated to the existing builder. I think you, essentially, need an incremental version of the interning function. You need to add bundles one at a time until you exceed the cost, and then undo the last one.

@richardkiss richardkiss force-pushed the block-builder-interned-cost branch from 15804bb to 67390ac Compare March 31, 2026 22:58
@richardkiss
Copy link
Copy Markdown
Contributor Author

Rewrote per Arvid's feedback — now a separate InternedBlockBuilder (204 lines) instead of modifying the existing BlockBuilder.

Key differences:

  • No Serializer, no sentinel, no restore() — just an allocator with spends
  • Cost computed via total_cost_from_tree(intern_tree_limited(...)) after each add_spend_bundles
  • Serialization happens once in finalize() via node_to_bytes_backrefs
  • Existing BlockBuilder unchanged

Also bumps maturin ~=1.7~=1.8 for self-contained Rust bootstrapping (CI can pip install from a git branch without manually installing Rust).

@richardkiss richardkiss force-pushed the pure-storage-model-v2 branch from c6c9941 to f2dbd51 Compare April 3, 2026 19:00
@richardkiss richardkiss force-pushed the block-builder-interned-cost branch from 67390ac to 150491e Compare April 6, 2026 19:51
@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented Apr 6, 2026

Coverage Report for CI Build 24760798673

Coverage decreased (-0.5%) to 80.172%

Details

  • Coverage decreased (-0.5%) from the base build.
  • Patch coverage: 121 uncovered changes across 1 file (0 of 121 lines covered, 0.0%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
crates/chia-consensus/src/build_interned_block.rs 121 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 18524
Covered Lines: 14851
Line Coverage: 80.17%
Coverage Strength: 12174320.65 hits per line

💛 - Coveralls

@danieljperry danieljperry changed the title Add INTERNED_GENERATOR cost model to BlockBuilder [CHIA-3854] Add INTERNED_GENERATOR cost model to BlockBuilder Apr 13, 2026
Base automatically changed from pure-storage-model-v2 to main April 16, 2026 21:16
A clean, separate builder that avoids the Serializer/sentinel/restore
complexity of BlockBuilder. Cost is computed from total_cost_from_tree
on the interned quoted generator tree after each add; serialization
happens once in finalize() via node_to_bytes_backrefs. Includes Python
bindings matching the BlockBuilder interface.

Made-with: Cursor
@richardkiss richardkiss force-pushed the block-builder-interned-cost branch from 150491e to 84cf5f5 Compare April 22, 2026 04:50
@richardkiss
Copy link
Copy Markdown
Contributor Author

Absorbed into #1418 (unified HF2 scratch PR). InternedBlockBuilder commit cherry-picked there.

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