Skip to content

Circuit Composition

Here, we explore how to compose circuits from smaller components—a key pattern for building complex applications.

When you build larger circuits, you'll want to reuse smaller components and combine them effectively. To make this work smoothly, circuit building functions follow consistent patterns that enable clean composition. Let's examine these patterns and see how they work in practice.

Function Naming - Circuit building functions use descriptive names:

pub fn circuit_<operation_name>(
    builder: &CircuitBuilder,
    config_params...,     // Lengths, sizes, constants
    input_wires...,       // Caller provides values
) -> OutputWires {        // Returns output wires
    // Create internal wires
    let internal = builder.add_witness();
    
    // Build constraints
    // ...
    
    // Return output wires
    output_wires
}

Parameter Order Convention:

  1. builder: &CircuitBuilder
  2. Configuration parameters (sizes, constants)
  3. Input wires
  4. (Return output wires)

Wire Ownership - Understanding who manages which wires is crucial for proper circuit composition. Circuit building functions establish clear responsibilities between callers and the functions they invoke:

The pattern works like this: the caller creates and populates input wires, then reads output wires from the return value. Meanwhile, the function creates any internal wires it needs, builds constraints between wires, and returns output wires. This separation ensures proper witness population and prevents confusion about wire management.

// Caller creates inputs
let input_a = builder.add_witness();
let input_b = builder.add_witness();
 
// Function returns output
let result = some_operation(builder, input_a, input_b);
 
// Caller populates witness
witness[input_a] = Word(0x12345678);
witness[input_b] = Word(0x9ABCDEF0);
 
// After evaluation, read output
let output_value = witness[result];

Document wire relationships and preconditions:

/// Combines two 32-bit values into 64-bit.
///
/// # Returns
/// Wire containing `(high << 32) | low`
fn combine_u32(
    builder: &CircuitBuilder,
    high: Wire,
    low: Wire,
) -> Wire {
    // Mask low to 32 bits
    let low_masked = builder.band(low, builder.add_constant_64(0xFFFFFFFF));
    
    // Shift high and combine
    let high_shifted = builder.shl(high, 32);
    builder.bor(high_shifted, low_masked)
}

Subcircuits

For complex circuits with many components, the subcircuit method creates named hierarchical sections that organize constraints into logical groups. This helps with debugging and provides clearer statistics when analyzing circuit performance:

pub fn process_blocks(
    builder: &CircuitBuilder, 
    blocks: &[BlockData],
) -> Vec<Wire> {
    let mut results = Vec::new();
    
    for (i, block) in blocks.iter().enumerate() {
        // Create named subcircuit for debugging
        let sub = builder.subcircuit(format!("block[{i}]"));
        
        // Build within subcircuit context
        let processed = process_single_block(&sub, block);
        results.push(processed);
    }
    
    results
}

Subcircuits appear in debug output and circuit statistics, aiding debugging and analysis.

Composition Example

Circuit composition chains operations by passing output wires from one function as input wires to another. This creates a pipeline where data flows through multiple transformations:

use binius_circuits::bytes::{swap_bytes, swap_bytes_32};
 
fn double_swap(
    builder: &CircuitBuilder,
    input: Wire,
) -> Wire {
    // First operation
    let swapped_32 = swap_bytes_32(builder, input);
    
    // Second operation uses first output
    swap_bytes(builder, swapped_32)
}