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 keeps its DFR below 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, 80 in a 138-bit block, 331 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 simulates the error vector at HQC-1 parameters, truncated to the 17,664 bits the code uses. Its weight concentrates between roughly 5,900 and 6,300 (Gaborit et al., 2026, sec. 6.1.1), against the worst case of 10,260. 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 bound, at HQC-1, comes from analysis of that concatenated decoder (Gaborit et al., 2026, sec. 6.1), not from the worst-case noise bound.
Exercise 3
Section titled “Exercise 3”Published sizes (bytes), grouped by the NIST security level each parameter set claims. Each row takes one set from each scheme: mceliece348864, HQC-1 and ML-KEM-512 at level 1, mceliece460896, HQC-3 and ML-KEM-768 at level 3, and mceliece8192128, HQC-5 and ML-KEM-1024 at level 5:
| Claimed 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 |
A row groups sets by the level each claims and does not assert that its three sets are equally secure. For the Classic McEliece claims, Chapter 20 gives NIST’s caveat of March 2025 on mceliece460896 beside its parameter table, and the structural analyses of 2026 in its cryptanalysis section.
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 ratio of McEliece’s public key to HQC’s 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.75 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, 1769): print(f"r = {r:5d} log2 P(block fails) = {block_failure_log2(r, 345, 1000):7.1f}")# ==> r = 17 log2 P(block fails) = -3.4# ==> r = 138 log2 P(block fails) = -12.9# ==> r = 801 log2 P(block fails) = -62.9# ==> r = 1769 log2 P(block fails) = -134.0A 128-bit message needs 128 blocks, so a union bound wants each block below to reach an overall , the bound the specification gives HQC-1. That lands at : a codeword of bits, nearly thirteen 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 meets the same bound. That factor of nearly thirteen 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.