Appendix D: Solutions for Chapter 21
This page collects solutions and editorial notes for the exercises in Chapter 21: HQC, a pedagogical implementation. Compute and derivation exercises have worked solutions; open-ended exercises have an editorial note describing what a strong answer addresses.
The fuller versions of these routines are in the hqc package under solutions/ch21-hqc. From a clone of the companion repository, pytest tests/ch21 runs its suite. Appendix C has the setup.
Exercise 1
Section titled “Exercise 1”, so each seed runs messages and the full sweep is trials. Running the chapter’s multi-seed harness with changed to 97 gives:
import random
def poly_add(a, b): return [ai ^ bi for ai, bi in zip(a, b)]
def poly_mul(a, b, n): c = [0] * n for i in range(n): if a[i] == 0: continue for j in range(n): if b[j]: c[(i + j) % n] ^= 1 return c
def sample_sparse(n, w, rng): positions = rng.sample(range(n), w) vec = [0] * n for p in positions: vec[p] = 1 return vec
def rep_encode(message, r, n): codeword = [] for bit in message: codeword.extend([bit] * r) codeword.extend([0] * (n - len(codeword))) return codeword
def rep_decode(received, r, n): k = n // r message = [] for i in range(k): block = received[i * r : (i + 1) * r] message.append(1 if sum(block) > r // 2 else 0) return message
N, W, W_R, W_E, R = 97, 3, 3, 3, 17K = N // Rsuccesses = total = 0
for seed in range(20): rng_k = random.Random(seed) s = [rng_k.randint(0, 1) for _ in range(N)] x = sample_sparse(N, W, rng_k) y = sample_sparse(N, W, rng_k) h = poly_add(x, poly_mul(s, y, N))
for mi in range(2**K): m = [(mi >> b) & 1 for b in range(K)] rng_e = random.Random(seed * 1000 + mi + 5000) r1 = sample_sparse(N, W_R, rng_e) r2 = sample_sparse(N, W_R, rng_e) e = sample_sparse(N, W_E, rng_e) u = poly_add(r1, poly_mul(r2, s, N)) cw = rep_encode(m, R, N) v = poly_add(poly_add(poly_mul(r2, h, N), cw), e) noisy = poly_add(v, poly_mul(u, y, N)) total += 1 if rep_decode(noisy, R, N) == m: successes += 1
print(f"k = {K}")print(f"{successes}/{total} round-trips succeeded")print(f"failures: {total - successes}")# ==> k = 5# ==> 638/640 round-trips succeeded# ==> failures: 2Two failures in 640, or 0.31%, against the chapter’s 2 in 320 at , or 0.63%. The rate halves. Both are deterministic smoke-test observations against one fixed seed schedule rather than cryptographic DFR estimates, and 2 failures is far too few to estimate a rate from. What the comparison shows is direction, not magnitude. Widening the ring from 83 to 97 while holding all four weights and fixed spreads the same noise budget over more positions, so each 17-bit block collects fewer errors on average.
Real HQC-1 targets DFR at . The toys exist to show that the structural mechanism, repetition decoding on top of a quasi-cyclic syndrome, works at all.
Exercise 2
Section titled “Exercise 2”Worst case: errors in positions. Spread uniformly, that is an error density, not a per-block count:
so a length- block collects about errors whatever is: roughly 10 in a 17-bit block, 78 in a 138-bit block, 322 in a 570-bit block. Exercise 4 uses that scaling. Note that real HQC-1 decodes a concatenated Reed-Solomon and duplicated Reed-Muller code rather than repetition blocks, so this is a thought experiment about the noise, not a description of the decoder.
This calculation overestimates the actual failure rate of real HQC for three reasons.
First, products cancel. The bound on the weight of assumes all cross-terms land on distinct exponents. Whenever two land on the same one they XOR to zero, and at these weights that happens constantly. The specification’s own simulation of the error vector at HQC-1 parameters concentrates its weight between roughly 5,800 and 6,200 (Gaborit et al., 2025, sec. 6.1.1), against the worst case of 9,975. The realistic density is nearer than , and that gap is what makes the scheme possible at all.
Second, the per-block error count concentrates. It is a sum of nearly independent indicator variables, so it clusters around its mean, and the probability of exceeding a capacity set above that mean falls exponentially in the gap. A bound on the mean says nothing about the tail. The tail is the DFR, and it is where all the design margin lives.
Third, HQC does not decode pure repetition blocks. Its concatenated decoder corrects far more errors at the same rate than majority vote does, so a repetition-block figure is a loose upper bound rather than the real failure model. The published target comes from analysis of that concatenated decoder (Gaborit et al., 2025, sec. 6.1), not from the worst-case noise bound.
Exercise 3
Section titled “Exercise 3”Published sizes (bytes) at NIST levels 1, 3, 5:
| Level | McEliece pk + ct | HQC pk + ct | ML-KEM pk + ct |
|---|---|---|---|
| 1 | 261120 + 96 = 261216 | 2241 + 4433 = 6674 | 800 + 768 = 1568 |
| 3 | 524160 + 156 = 524316 | 4514 + 8978 = 13492 | 1184 + 1088 = 2272 |
| 5 | 1357824 + 208 = 1358032 | 7237 + 14421 = 21658 | 1568 + 1568 = 3136 |
At every level, ML-KEM has the smallest pk + ct, then HQC, then McEliece by a large margin. HQC never beats ML-KEM in this comparison. The McEliece-to-HQC key-compression ratio is largest at Level 5: , since McEliece scales as while HQC scales linearly in .
mceliece = [(261120, 96), (524160, 156), (1357824, 208)]hqc = [(2241, 4433), (4514, 8978), (7237, 14421)]mlkem = [(800, 768), (1184, 1088), (1568, 1568)]for lvl, m, h, k in zip([1, 3, 5], mceliece, hqc, mlkem): print(lvl, sum(m), sum(h), sum(k), round(m[0] / h[0], 1))# ==> 1 261216 6674 1568 116.5# ==> 3 524316 13492 2272 116.1# ==> 5 1358032 21658 3136 187.6Exercise 4
Section titled “Exercise 4”For :
| correction | rate | |
|---|---|---|
| 3 | 1 | 0.333 |
| 5 | 2 | 0.200 |
| 7 | 3 | 0.143 |
| 11 | 5 | 0.091 |
| 17 | 8 | 0.059 |
Now push up against HQC-1’s noise. Exercise 2 turned the worst-case bound into a density of about , and a length- block collects errors against a capacity of . Both scale with , and the errors scale faster. Raising the repetition factor buys nothing: at the worst-case bound, majority vote fails in the average block at every , and the failure probability climbs from 0.71 at toward 1 as grows. Repetition needs an error density below one half, and the worst-case bound is above it.
The realistic density is what saves the scheme. Taking from the specification’s simulated error weight, majority vote does work, but it converges slowly:
from math import comb, log2
def block_failure_log2(r, num, den): """log2 P(more than floor((r-1)/2) errors in an r-bit block), p = num/den.""" cap = (r - 1) // 2 q = den - num total = sum(comb(r, i) * num**i * q ** (r - i) for i in range(cap + 1, r + 1)) if total == 0: return float("-inf") scale = den**r a, b = total.bit_length(), scale.bit_length() shift_a, shift_b = max(0, a - 60), max(0, b - 60) return log2((total >> shift_a) / (scale >> shift_b)) + shift_a - shift_b
for r in (17, 138, 801, 1667): print(f"r = {r:5d} log2 P(block fails) = {block_failure_log2(r, 34, 100):7.1f}")# ==> r = 17 log2 P(block fails) = -3.6# ==> r = 138 log2 P(block fails) = -13.7# ==> r = 801 log2 P(block fails) = -67.0# ==> r = 1667 log2 P(block fails) = -135.0A 128-bit message needs 128 blocks, so a union bound wants each block below to reach an overall . That lands at : a codeword of bits, twelve times the 17,669-bit ring HQC actually works in. HQC’s concatenated code carries the same 128 bits in bits, a rate of , and hits the same target. That factor of twelve is what the concatenation buys.
It gets there by dividing the labor. The outer code is a shortened Reed-Solomon code over ; the inner code is the duplicated first-order Reed-Muller code , with each bit repeated 3 times at level 1 and 5 times at levels 3 and 5. Each duplicated Reed-Muller block is maximum-likelihood decoded to one symbol, and the Reed-Solomon layer then corrects the symbols the inner decoder got wrong. The inner code drives the per-symbol error rate down at a modest rate cost; the outer code mops up the rare symbols that survive. Pure repetition has no second layer to fall back on, which is why its only lever is and why that lever runs out.