Skip to content

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.

k=⌊97/17⌋=5k = \lfloor 97/17 \rfloor = 5, so each seed runs 25=322^5 = 32 messages and the full sweep is 20⋅32=64020 \cdot 32 = 640 trials. Running the chapter’s multi-seed harness with NN 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, 17
K = N // R
successes = 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: 2

Two failures in 640, or 0.31%, against the chapter’s 2 in 320 at n=83n = 83, 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 rr 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 2−1272^{-127} at n=17,669n = 17{,}669. The toys exist to show that the structural mechanism, repetition decoding on top of a quasi-cyclic syndrome, works at all.

Worst case: 2⋅76⋅67+76=10,2602 \cdot 76 \cdot 67 + 76 = 10{,}260 errors in n=17,669n = 17{,}669 positions. Spread uniformly, that is an error density, not a per-block count:

10,26017,669≈0.581\frac{10{,}260}{17{,}669} \approx 0.581

so a length-rr block collects about 0.581 r0.581\,r errors whatever rr 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, GF(2)\text{GF}(2) products cancel. The bound wrww_r w on the weight of r2⋅xr_2 \cdot x assumes all wrww_r w 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 x⋅r2−r1⋅y+ex \cdot r_2 - r_1 \cdot y + e 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 0.3450.345 than 0.5810.581, and that gap is what makes the scheme possible at all.

Second, the per-block error count concentrates. It is a sum of rr 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, 2−127.92^{-127.9} at HQC-1, comes from analysis of that concatenated decoder (Gaborit et al., 2026, sec. 6.1), not from the worst-case noise bound.

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 levelMcEliece pk + ctHQC pk + ctML-KEM pk + ct
1261120 + 96 = 2612162241 + 4433 = 6674800 + 768 = 1568
3524160 + 156 = 5243164514 + 8978 = 134921184 + 1088 = 2272
51357824 + 208 = 13580327237 + 14421 = 216581568 + 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: 1,357,824/7,237≈188×1{,}357{,}824 / 7{,}237 \approx 188\times, since McEliece scales as k(n−k)k(n-k) while HQC scales linearly in nn.

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.6

For r=3,5,7,11,17r = 3, 5, 7, 11, 17:

rrcorrection ⌊(r−1)/2⌋\lfloor(r-1)/2\rfloorrate 1/r1/r
310.333
520.200
730.143
1150.091
1780.059

Now push rr up against HQC-1’s noise. Exercise 2 turned the worst-case bound into a density of about 0.5810.581, and a length-rr block collects 0.581 r0.581\,r errors against a capacity of ⌊(r−1)/2⌋≈0.5 r\lfloor (r-1)/2 \rfloor \approx 0.5\,r. Both scale with rr, 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 rr, and the failure probability climbs from 0.75 at r=17r = 17 toward 1 as rr 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 p≈0.345p \approx 0.345 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.0

A 128-bit message needs 128 blocks, so a union bound wants each block below 2−1342^{-134} to reach an overall 2−1272^{-127}, the bound the specification gives HQC-1. That lands at r=1,769r = 1{,}769: a codeword of 128⋅1,769=226,432128 \cdot 1{,}769 = 226{,}432 bits, nearly thirteen times the 17,669-bit ring HQC actually works in. HQC’s concatenated code carries the same 128 bits in n1n2′=46⋅384=17,664n_1 n_2' = 46 \cdot 384 = 17{,}664 bits, a rate of 1/1381/138, 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 GF(28)\text{GF}(2^8); the inner code is the duplicated first-order Reed-Muller code RM(1,7)=[128,8,64]\text{RM}(1, 7) = [128, 8, 64], 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 rr and why that lever runs out.

Gaborit, P., Aguilar-Melchor, C., Aragon, N., Bettaieb, S., Bidoux, L., Blazy, O., Deneuville, J.-C., Persichetti, E., Zémor, G., Bos, J., Dion, A., Lacan, J., Robert, J.-M., Véron, P., Barreto, P. S. L. M., Ghosh, S., Gueron, S., Güneysu, T., Misoczki, R., … Vasseur, V. (2026). HQC: Hamming Quasi-Cyclic. https://pqc-hqc.org/doc/hqc_specifications_2026_10_12.pdf