Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/octra-labs/pvac_hfhe_cpp/llms.txt

Use this file to discover all available pages before exploring further.

Circuit depth is critical to performance and correctness in PVAC-HFHE. This guide explains how depth affects ciphertexts and how to manage it.

What is circuit depth?

Circuit depth is the longest path of multiplications from inputs to output:
Depth 0: Fresh encryptions, additions only
Depth 1: One multiplication  (e.g., a * b)
Depth 2: Two multiplications (e.g., (a * b) * c)
Depth 3: Three multiplications (e.g., ((a * b) * c) * d)
Additions and subtractions do not increase depth. Only multiplications increase depth by 1.

Depth vs performance

From benchmark data (benchmarks/README.md:88-98):
DepthTime (ms)CT sizeGrowth
d0-42 KB1.0x
d12.6834 KB0.8x
d210.34136 KB3.2x
d331.46441 KB10.5x
d497.111359 KB32x
d5285.834112 KB98x
Ciphertext size and computation time grow exponentially with depth. At d4, PVAC-HFHE ciphertexts exceed BFV in size.

Depth examples

Depth 1: Single multiplication

Cipher ca = enc_value(pk, sk, 7);
Cipher cb = enc_value(pk, sk, 6);
Cipher product = ct_mul(pk, ca, cb);  // Depth 1
From examples/basic_usage.cpp:150-158:
Cipher cx_1 = enc_value(pk, sk, 2);
Cipher cx_2 = ct_mul(pk, cx_1, cx_1);  // 2^2 = 4, depth 1
Cipher cx_4 = ct_mul(pk, cx_2, cx_2);  // 2^4 = 16, depth 2
Cipher cx_8 = ct_mul(pk, cx_4, cx_4);  // 2^8 = 256, depth 3

assert(dec_value(pk, sk, cx_8).lo == 256);
std::cout << "edges: x^1 = " << cx_1.E.size() 
          << ", x^2 = " << cx_2.E.size()
          << ", x^4 = " << cx_4.E.size() 
          << ", x^8 = " << cx_8.E.size() << "\n";

Depth 3: Polynomial evaluation

Evaluating f(x) = x³ + 2x² + 3x + 4 requires depth 3:
x^2 = x * x           (depth 1)
x^3 = x^2 * x         (depth 2)
term1 = x^3           (depth 2)
term2 = 2 * x^2       (depth 1, constant mul)
term3 = 3 * x         (depth 0, constant mul)
term4 = 4             (depth 0)
result = sum of terms (depth 2, additions don't increase depth)
Actual depth is 2 due to parallel evaluation.

Depth 4: x^16

From examples/basic_usage.cpp:159-162:
Cipher cx_16 = ct_mul(pk, cx_8, cx_8);  // 2^16 = 65536, depth 4
assert(dec_value(pk, sk, cx_16).lo == 65536);
std::cout << "edges = " << cx_16.E.size() 
          << ", layers = " << cx_16.L.size() << "\n";

Ciphertext growth

Ciphertexts grow because multiplication creates product layers:

Edge count growth

For multiplication C = ct_mul(A, B) with S=8 edges per layer:
|E_C| = |E_A| + |E_B| + (|L_A| × |L_B| × S)
Example:
  • Fresh encryption: ~300 edges, 1 layer
  • After 1 mul: ~900 edges, 2 layers
  • After 2 muls: ~2700 edges, 4 layers
  • After 3 muls: ~8100 edges, 8 layers
The edge budget parameter (default 1,200,000) triggers automatic compaction when exceeded.

Layer count growth

|L_C| = |L_A| + |L_B| + (|L_A| × |L_B|)
Layers grow quadratically with each multiplication.

Noise budget

PVAC-HFHE uses an entropy-based noise budget. From include/pvac/ops/encrypt.hpp:200-213:
struct Budget {
    int n2;  // Number of 2-tuples
    int n3;  // Number of 3-tuples
    
    static Budget compute(const Params& p, int d) {
        double cap = p.noise_entropy_bits + p.depth_slope_bits * std::max(0, d);
        double c2 = 2.0 * std::log2(static_cast<double>(p.B));
        double c3 = 3.0 * std::log2(static_cast<double>(p.B));
        
        int q2 = std::max(0, static_cast<int>(std::floor(cap * p.tuple2_fraction / std::max(1e-6, c2))));
        int q3 = std::max(0, static_cast<int>(std::floor(cap * (1.0 - p.tuple2_fraction) / std::max(1e-6, c3))));
        
        return { q2, q3 };
    }
};

Default parameters

  • noise_entropy_bits = 120.0
  • tuple2_fraction = 0.55
  • depth_slope_bits = 16.0
Budget at depth d:
cap = 120 + 16 * d
DepthEntropy budgetn2n3
0120 bits~10~7
1136 bits~11~8
2152 bits~12~9
3168 bits~13~10
Higher depth allocates more noise terms, increasing encryption time but enabling deeper computations.

Depth hints

Use enc_value_depth to preallocate noise budget:
// Encrypt with depth hint 3
Cipher ct = enc_value_depth(pk, sk, 42, 3);
From include/pvac/ops/encrypt.hpp:732-738:
inline Cipher enc_value_depth(const PubKey& pk, const SecKey& sk, uint64_t v, int d) {
    std::vector<Fp> vals = {fp_from_u64(v)};
    std::vector<Fp> m = {field::Op::rnd()};
    return combine_ciphers(pk,
        enc_fp_depth(pk, sk, field::Op::add(vals, m), d),
        enc_fp_depth(pk, sk, field::Op::neg(m), d));
}

When to use depth hints

HintUse caseExample
0Additions onlySummations, linear functions
1-2Shallow circuitsSimple polynomials, dot products
3-4Medium depthQuadratic forms, decision trees
5+Deep circuitsNeural networks, recursive algorithms
Over-estimating depth wastes computation time and increases ciphertext size. Under-estimating may cause decryption failures.

Compaction strategies

PVAC-HFHE automatically compacts ciphertexts when edge budget is exceeded:

Edge compaction

From include/pvac/ops/encrypt.hpp:658-660:
inline void compact_edges(const PubKey& pk, Cipher& C) {
    C.E = reduction::merge(alg::Carrier<Edge>{ std::move(C.E) }, pk).unwrap();
}
Merges edges with the same (layer_id, idx, ch) triple.

Layer compaction

From include/pvac/ops/encrypt.hpp:662-707: Removes unused layers and renumbers layer IDs.

Budget guard

From include/pvac/ops/encrypt.hpp:709-714:
inline void guard_budget(const PubKey& pk, Cipher& C, const char* ctx) {
    if (C.E.size() > pk.prm.edge_budget) {
        if (g_dbg) std::cout << "[guard] " << ctx << ": " << C.E.size() << " -> compact\n";
        compact_edges(pk, C);
    }
}
Automatically triggered after every arithmetic operation.

Benchmarking depth

From examples/basic_usage.cpp:246-265:
// 100 additions (depth 0)
auto t1 = std::chrono::high_resolution_clock::now();
Cipher perf_sum = enc_value(pk, sk, 0);
for (int i = 0; i < 100; i++) 
    perf_sum = ct_add(pk, perf_sum, enc_value(pk, sk, i));
auto t2 = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

assert(dec_value(pk, sk, perf_sum).lo == 4950);
std::cout << "time = " << ms << " ms, edges = " << perf_sum.E.size() << "\n";

// 10 multiplications (depth 10)
t1 = std::chrono::high_resolution_clock::now();
Cipher perf_prod = enc_value(pk, sk, 1);
for (int i = 0; i < 10; i++) 
    perf_prod = ct_mul(pk, perf_prod, enc_value(pk, sk, 2));
t2 = std::chrono::high_resolution_clock::now();
ms = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1).count();

assert(dec_value(pk, sk, perf_prod).lo == 1024);
std::cout << "time = " << ms << " ms, edges = " << perf_prod.E.size() 
          << ", layers = " << perf_prod.L.size() << "\n";

Comparison with RLWE schemes

From benchmark data:
DepthPVAC-HFHEBFVBGVCKKSFastest
d12.68ms19.54ms17.40ms35.85msPVAC 7.3x
d210.34ms14.38ms15.11ms31.22msPVAC 1.4x
d331.46ms13.98ms14.39ms30.71msBFV 2.3x
d497.11ms13.84ms11.10ms21.83msBGV 8.7x
d5285.83ms11.37ms9.50ms18.93msBGV 30x
PVAC-HFHE has exponential degradation at deep depths, while RLWE schemes maintain near-constant time via modulus switching. PVAC excels at shallow circuits (d ≤ 2).

Optimization tips

Minimize depth

// Bad: depth 3
Cipher bad = ct_mul(pk, ct_mul(pk, ct_mul(pk, a, b), c), d);

// Better: depth 2 via parallelization
Cipher ab = ct_mul(pk, a, b);
Cipher cd = ct_mul(pk, c, d);
Cipher good = ct_mul(pk, ab, cd);

Use additions freely

// Additions don't increase depth or cost
Cipher sum = a;
for (int i = 0; i < 1000; i++) {
    sum = ct_add(pk, sum, b);  // Still depth 0
}

Batch multiplications

// Compute a·b + c·d + e·f at depth 1
Cipher result = ct_add(pk,
    ct_add(pk, ct_mul(pk, a, b), ct_mul(pk, c, d)),
    ct_mul(pk, e, f)
);

Next steps

Performance tuning

Advanced optimization techniques

Arithmetic operations

Master ct_mul and ct_square

Build docs developers (and LLMs) love