Keyed masking for secure multi-user semantic communication

Reproducibility package for the TIFS submission: transmit and receive
core, security stages (eavesdropper, jamming, key families, attack
difficulty, known-plaintext), real BERT token streams, closed-form
verification, and the scripts that regenerate every figure and table
from the released CSVs.
This commit is contained in:
KiHoLee
2026-08-13 21:01:32 +09:00
commit 37392bc38f
34 changed files with 2293 additions and 0 deletions
+523
View File
@@ -0,0 +1,523 @@
"""Full-scale security evaluation for paper 11 (run under WSL CUDA).
Reuses the SSE transmit/receive core from sse_lib.py and adds an
eavesdropper receiver, a jammer channel, and structured mask families.
Main configuration d=64, P=4, Vu=16 (V=Vu^P=65,536), U=4 users, matching
the language-model token vocabulary scale.
Stages (each writes a CSV to ../data; figures come from replot_security.py
and the two result tables from make_tables.py):
A security vs SNR -> sec_snr.csv (Fig. 2)
B key length -> sec_keylen.csv (Fig. 3)
C jamming vs JSR -> sec_jam.csv (Fig. 4)
D mask families -> sec_maskfam.csv (key-family table)
E scheme comparison -> sec_compare.csv (comparison table)
F attack difficulty -> sec_sens.csv, sec_brute.csv (Figs. 5-6)
Experiment scripts write CSV only, never draw. Fixed seeds.
"""
from __future__ import annotations
import math
import numpy as np
import torch
import sse_lib as L
from sse_lib import (SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed,
eval_ser_sse, oma_ser, DATA, DEVICE)
# ----------------------------------------------------------------------
# eavesdropper: correlate the transmitted (true-mask) frame with a
# substitute mask the eavesdropper does not truly hold.
# ----------------------------------------------------------------------
@torch.no_grad()
def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list,
frames: int, chunk: int = 100_000, seed: int = 777):
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
eve_masks = eve_masks.to(DEVICE)
c = model.c
out = []
for snr_db in snr_list:
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
e = Bn[digits] / math.sqrt(model.P)
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
h = rayleigh_gain((n, model.users), device=DEVICE)
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
cand = Bn[None, :, :] * eve_masks[:, None, :]
scores = torch.einsum("nupl,uvl->nupv", r, cand)
wrong = (scores.argmax(-1) != digits).any(dim=2)
err += int(wrong.sum()); tot += n * model.users
out.append(err / tot)
return out
@torch.no_grad()
def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int,
chunk: int = 100_000, seed: int = 777, mode: str = "blind",
target: int = 0):
"""Returns the target-user SER (user `target`, the user a mask-matched
jammer aims at). The mask-matched jammer aligns with the target key,
which a mask-blind jammer cannot do. Reporting the target-user SER,
rather than the user average, isolates how efficiently each jammer can
degrade a chosen victim (Proposition 2)."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
c = model.c
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
# matched jammer aligns with the target user's masked codeword mean
# direction (needs that user's secret key)
w_fixed = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1)
w_fixed = w_fixed / w_fixed.norm()
out = []
for jsr_db in jsr_db_list:
jsr = 10.0 ** (jsr_db / 10.0)
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
e = Bn[digits] / math.sqrt(model.P)
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
h = rayleigh_gain((n, model.users), device=DEVICE)
hJ = rayleigh_gain((n,), device=DEVICE)
if mode == "matched":
w = w_fixed[None].expand(n, model.P, model.L)
else:
w = torch.randn(n, model.P, model.L, device=DEVICE)
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
jam = (hJ * math.sqrt(jsr))[:, None, None] * w
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
y_rx = h[:, :, None, None] * y[:, None] + jam[:, None] + sigma * noise
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
cand = Bn[None, :, :] * true_m[:, None, :]
scores = torch.einsum("nupl,uvl->nupv", r, cand)
wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U)
err += int(wrong[:, target].sum()); tot += n
out.append(err / tot)
return out
def hadamard(n: int) -> np.ndarray:
"""Sylvester construction, n a power of two."""
H = np.array([[1.0]])
while H.shape[0] < n:
H = np.block([[H, H], [H, -H]])
return H
def mean_abs_xcorr(masks: torch.Tensor) -> float:
m = masks / masks.norm(dim=1, keepdim=True).clamp_min(1e-8)
G = (m @ m.T).abs()
U = m.shape[0]
off = G[~torch.eye(U, dtype=torch.bool, device=G.device)]
return float(off.mean())
def random_mask(U, Lp):
W = torch.randn(U, Lp)
return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp)
def eve_wrong_mask(U, Lp, seed):
g = torch.Generator().manual_seed(seed)
W = torch.randn(U, Lp, generator=g)
return W / W.norm(dim=1, keepdim=True) * math.sqrt(Lp)
def get_model(P=4, vu=16, d=64, U=4, iters=4000, seed=1, freeze_W=None, tag=""):
"""Train an SSE model, optionally with fixed (frozen) masks."""
set_seed(seed)
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
if freeze_W is not None:
with torch.no_grad():
m.W.copy_(freeze_W.to(DEVICE))
m.W.requires_grad_(False)
L.train_sse(m, iters=iters, batch=256, lr=3e-3, seed=seed)
m.calibrate_power()
return m
def stage_A():
print("[A] security vs SNR (V=65536) ...")
m = get_model(iters=4000)
snr = [0.0, 4.0, 8.0, 12.0, 16.0, 20.0]
frames = 800_000
legit = eval_ser_sse(m, snr, frames=frames)
ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE)
eve_w = eval_ser_eve(m, ew, snr, frames=frames)
eve_n = eval_ser_eve(m, torch.ones(m.users, m.L), snr, frames=frames)
# conventional public-mask scheme: the eavesdropper holds the same
# (public) masks and decodes exactly like a legitimate user
eve_p = eval_ser_eve(m, m.masks().detach().cpu(), snr, frames=frames)
oma = oma_ser(snr, bits=int(math.log2(m.V)))
chance = 1.0 - (1.0 / m.vu) ** m.P
write_csv(DATA / "sec_snr.csv",
["snr_db", "legit", "eve_wrong", "eve_none", "eve_public",
"oma", "chance"],
[(s, legit[i], eve_w[i], eve_n[i], eve_p[i], oma[i], chance)
for i, s in enumerate(snr)])
print(" legit:", [f"{v:.2e}" for v in legit])
print(" eve :", [f"{v:.3f}" for v in eve_w])
def train_sse_reg(m: SSE, iters=4000, batch=256, lr=3e-3, seed=1,
lam_orth=1.0, lam_flat=0.1):
"""Regularized key learning for improved spreading and de-spreading.
Adds to the digit-wise cross entropy (i) an orthogonality penalty on
the off-diagonal key Gram entries, which reduces cross-user
interference and residual leakage, and (ii) a constant-modulus
penalty that flattens the key spectrum, which maximizes the spreading
of a mask-blind jammer (Proposition 2: the jammer concentration on
candidate i is sum_k w_k^2 e_{i,k}^2 weighted through the key, and a
flat key removes any low-energy entries a jammer could exploit)."""
set_seed(seed)
m.to(DEVICE)
opt = torch.optim.Adam(m.parameters(), lr=lr)
ce = torch.nn.CrossEntropyLoss()
for it in range(1, iters + 1):
digits = torch.randint(m.vu, (batch, m.users, m.P), device=DEVICE)
snr = torch.empty(batch).uniform_(0.0, 20.0)
m.calibrate_power(8192)
scores = m(digits, snr) * m.logit_scale.exp()
loss = ce(scores.reshape(-1, m.vu), digits.reshape(-1))
mk = m.masks()
G = (mk @ mk.T) / m.L
off = G - torch.eye(m.users, device=G.device)
loss = loss + lam_orth * off.pow(2).sum()
loss = loss + lam_flat * (mk.pow(2) - 1.0).pow(2).mean()
opt.zero_grad(); loss.backward(); opt.step()
m.calibrate_power()
return m
def get_model_reg(P=4, vu=16, d=64, U=4, iters=4000, seed=1):
set_seed(seed)
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
train_sse_reg(m, iters=iters, seed=seed)
return m
def stage_B():
print("[B] key length (dense grid so the curve is smooth) ...")
oma10 = oma_ser([10.0], bits=16)[0]
rows = []
for d in [16, 24, 32, 48, 64, 96, 128, 192, 256]:
m = get_model(d=d, iters=4000)
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE)
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
xc = mean_abs_xcorr(m.masks().detach())
rows.append((m.L, d, lg, ev, xc, oma10))
print(f" L={m.L:4d} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
write_csv(DATA / "sec_keylen.csv",
["L", "d", "legit_ser", "eve_ser", "mask_xcorr", "oma"], rows)
def stage_C():
print("[C] jamming vs JSR ...")
m = get_model(iters=4000)
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
blind = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="blind", target=0)
matched = eval_ser_jam(m, 10.0, jsr, frames=500_000, mode="matched", target=0)
# target-user SER with no jammer, for the reference line
nojam = eval_ser_jam(m, 10.0, [-40.0], frames=500_000, mode="blind",
target=0)[0]
write_csv(DATA / "sec_jam.csv",
["jsr_db", "blind", "matched", "nojam"],
[(j, blind[i], matched[i], nojam) for i, j in enumerate(jsr)])
print(f" target no-jam={nojam:.2e}")
print(" blind :", [f"{v:.3f}" for v in blind])
print(" matched:", [f"{v:.3f}" for v in matched])
def stage_D():
print("[D] mask families ...")
P, vu, d, U = 4, 16, 64, 4
Lp = d // P
fams = {}
# random fixed masks
set_seed(7); fams["random"] = random_mask(U, Lp)
# Walsh-Hadamard rows (orthogonal)
Hd = torch.tensor(hadamard(Lp)[:U], dtype=torch.float32) # ||row||=sqrt(Lp)
fams["hadamard"] = Hd
rows = []
for name, W in fams.items():
m = get_model(P=P, vu=vu, d=d, U=U, iters=4000, freeze_W=W)
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE)
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
xc = mean_abs_xcorr(m.masks().detach())
rows.append((name, lg, ev, xc))
print(f" {name:9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
# learned masks (plain cross entropy)
m = get_model(P=P, vu=vu, d=d, U=U, iters=4000)
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
ew = eve_wrong_mask(U, Lp, seed=20260813).to(DEVICE)
ev = eval_ser_eve(m, ew, [10.0], frames=500_000)[0]
xc = mean_abs_xcorr(m.masks().detach())
rows.append(("learned", lg, ev, xc))
print(f" {'learned':9s} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f}")
# regularized key learning (orthogonality + constant modulus)
mr = get_model_reg(P=P, vu=vu, d=d, U=U, iters=4000)
lgr = eval_ser_sse(mr, [10.0], frames=500_000)[0]
evr = eval_ser_eve(mr, ew, [10.0], frames=500_000)[0]
xcr = mean_abs_xcorr(mr.masks().detach())
rows.append(("learned_reg", lgr, evr, xcr))
print(f" {'learn_reg':9s} legit={lgr:.2e} eve={evr:.3f} xcorr={xcr:.4f}")
# jamming robustness of plain vs regularized keys (blind jammer)
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
jb_plain = eval_ser_jam(m, 10.0, jsr, frames=300_000, mode="blind")
jb_reg = eval_ser_jam(mr, 10.0, jsr, frames=300_000, mode="blind")
write_csv(DATA / "sec_regjam.csv",
["jsr_db", "plain", "regularized"],
[(j, jb_plain[i], jb_reg[i]) for i, j in enumerate(jsr)])
write_csv(DATA / "sec_maskfam.csv",
["family", "legit_ser", "eve_ser", "mask_xcorr"], rows)
@torch.no_grad()
def eval_scheme(model: SSE, snr_db, frames, *, rx_masks=None, perms=None,
jam_w=None, jsr_db=None, target=0, chunk=100_000, seed=777,
decode_user=0):
"""Generic evaluator for the comparison schemes.
rx_masks: masks used at the decoding receiver (None = true masks).
perms: (U,d-index) per-user secret permutations applied at tx to
x_u; the decoder for `decode_user` inverse-permutes first.
rx side without the permutation just decodes raw.
jam_w: None or 'matched'/'blind' jammer aimed at `target`.
Returns SER of `decode_user` (frame error over its P digits)."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
c = model.c
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
d = model.P * model.L
if perms is not None:
inv = torch.argsort(perms, dim=1)
if jam_w == "matched":
wf = (Bn[target][None, :] * true_m[target][None, :]).repeat(model.P, 1)
if perms is not None:
wfl = wf.reshape(-1)[perms[target]]
wf = wfl.reshape(model.P, model.L)
wf = wf / wf.norm()
jsr = 10.0 ** (jsr_db / 10.0) if jsr_db is not None else 0.0
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
e = Bn[digits] / math.sqrt(model.P)
x = e * true_m[None, :, None, :] # (n,U,P,L)
if perms is not None:
xf = x.reshape(n, model.users, d)
xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1)
x = xf.reshape(n, model.users, model.P, model.L)
y = x.sum(dim=1) / c # (n,P,L)
h = rayleigh_gain((n,), device=DEVICE) # decode_user channel
y_rx = h[:, None, None] * y # (n,P,L)
if jam_w is not None:
hJ = rayleigh_gain((n,), device=DEVICE)
if jam_w == "matched":
w = wf[None].expand(n, model.P, model.L)
else:
w = torch.randn(n, model.P, model.L, device=DEVICE)
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
y_rx = y_rx + (hJ * math.sqrt(jsr))[:, None, None] * w
y_rx = y_rx + sigma * torch.randn(n, model.P, model.L, device=DEVICE)
r = y_rx / h[:, None, None].clamp_min(1e-6)
if perms is not None:
rf = r.reshape(n, d)[:, inv[decode_user]]
r = rf.reshape(n, model.P, model.L)
m_rx = true_m if rx_masks is None else rx_masks.to(DEVICE)
cand = Bn * m_rx[decode_user][None, :] # (Vu,L)
scores = torch.einsum("npl,vl->npv", r, cand)
wrong = (scores.argmax(-1) != digits[:, decode_user]).any(dim=1)
err += int(wrong.sum()); tot += n
return err / tot
def stage_E():
"""Comparison across five schemes at 10 dB, V=65,536, user-0 metrics.
Columns: legitimate SER; outsider-eavesdropper SER; insider SER (a
curious legitimate user of the SAME system decoding user 0 with its
own credentials); target-user SER under the strongest jammer the
attacker can BUILD from public knowledge at JSR 0 dB (matched if the
masks are public, blind if the PHY structure is secret)."""
print("[E] scheme comparison ...")
m = get_model(iters=4000)
F = 400_000
d = m.P * m.L
set_seed(20260813)
ew = eve_wrong_mask(m.users, m.L, seed=20260813)
# shuffling-style multi-user adaptation: one GLOBAL secret permutation
# shared by all users (per-user permutations break the trained
# multi-user separation, so the shared key is the fair extension)
gp = torch.Generator().manual_seed(11)
gperm = torch.randperm(d, generator=gp)
perms = gperm[None].repeat(m.users, 1)
chance = 1.0 - (1.0 / m.vu) ** m.P
insider_masks = torch.roll(m.masks().detach().cpu(), 1, 0) # user 1's key
rows = []
# S1 proposed keyed masking: per-user secret masks
lg = eval_scheme(m, 10.0, F)
ev = eval_scheme(m, 10.0, F, rx_masks=ew)
ins = eval_scheme(m, 10.0, F, rx_masks=insider_masks)
jm = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=0.0)
rows.append(("proposed", lg, ev, ins, jm))
# S2 public-mask superposition (no key): everyone decodes, attacker
# builds the matched jammer
jm2 = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=0.0)
rows.append(("public_mask", lg, lg, lg, jm2))
# S3 global permutation key over public masks (shuffling-style): the
# outsider lacks the permutation, but every insider holds it and the
# masks are public, so insiders decode each other
lg3 = eval_scheme(m, 10.0, F, perms=perms)
ev3 = eval_scheme_permuted_eve(m, 10.0, F, perms)
jm3 = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=0.0)
rows.append(("perm_key", lg3, ev3, lg3, jm3))
# S4 per-user index cipher (one-time pad on the digits) over public
# masks: content protected from outsiders and insiders, but the PHY
# is public so the matched jammer remains buildable
rows.append(("index_cipher", lg, chance, chance, jm2))
# S5 OMA digital, no encryption: open to everyone
from sse_lib import oma_ser
lg5 = oma_ser([10.0], bits=int(math.log2(m.V)))[0]
rows.append(("oma_plain", lg5, lg5, lg5, float("nan")))
write_csv(DATA / "sec_compare.csv",
["scheme", "legit_ser", "eve_out", "eve_in", "jam0_ser"], rows)
for r in rows:
print(" ", r)
@torch.no_grad()
def eval_scheme_permuted_eve(model: SSE, snr_db, frames, perms,
chunk=100_000, seed=777):
"""Eve for S3: sees the per-user permuted tx, holds the PUBLIC masks
but not the permutation, decodes user 0 raw."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
c = model.c
d = model.P * model.L
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
e = Bn[digits] / math.sqrt(model.P)
x = e * true_m[None, :, None, :]
xf = x.reshape(n, model.users, d)
xf = torch.stack([xf[:, u][:, perms[u]] for u in range(model.users)], 1)
y = xf.reshape(n, model.users, model.P, model.L).sum(dim=1) / c
h = rayleigh_gain((n,), device=DEVICE)
y_rx = h[:, None, None] * y + sigma * torch.randn(
n, model.P, model.L, device=DEVICE)
r = y_rx / h[:, None, None].clamp_min(1e-6)
cand = Bn * true_m[0][None, :]
scores = torch.einsum("npl,vl->npv", r, cand)
wrong = (scores.argmax(-1) != digits[:, 0]).any(dim=1)
err += int(wrong.sum()); tot += n
return err / tot
def correlated_masks(true_m: torch.Tensor, rho: float, gen: torch.Generator):
"""Substitute masks with prescribed normalized correlation rho to the
true keys: mtil = rho*m + sqrt(1-rho^2)*m_perp, ||mtil|| = ||m||."""
U, Lp = true_m.shape
out = torch.empty_like(true_m)
for u in range(U):
m = true_m[u]
p = torch.randn(Lp, generator=gen)
p = p - (p @ m) / (m @ m) * m
p = p / p.norm() * m.norm()
out[u] = rho * m + math.sqrt(max(0.0, 1 - rho * rho)) * p
return out
def stage_F():
"""Attack difficulty in the style of standard security evaluations.
(i) Key sensitivity: Eve SER against the correlation rho between her
guess and the true key (avalanche-style curve).
(ii) Brute-force key search: expected Eve SER against the number of
random key guesses K, where for each trial the attacker keeps the
guess with the LARGEST correlation to the true key (a genie-aided
upper bound on any selection rule). The best-guess correlation
rho_max(K, L) is sampled by Monte Carlo and mapped through the
measured sensitivity curve of (i)."""
print("[F] attack difficulty ...")
m = get_model(iters=4000)
F = 200_000
true_m = m.masks().detach().cpu()
gen = torch.Generator().manual_seed(31)
# (i) sensitivity curve, densest where the curve falls steeply
rhos = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.65, 0.7, 0.75,
0.8, 0.84, 0.88, 0.90, 0.92, 0.94, 0.96, 0.97, 0.98,
0.99, 0.995, 1.0]
sens = []
for rho in rhos:
mt = correlated_masks(true_m, rho, gen)
ser = eval_ser_eve(m, mt, [10.0], frames=F)[0]
sens.append((rho, ser))
print(f" rho={rho:.2f} eve_ser={ser:.4f}")
write_csv(DATA / "sec_sens.csv", ["rho", "eve_ser"], sens)
# (ii) brute-force: sample rho_max(K, L) and interpolate SER(rho)
import numpy as np
r_arr = np.array([r for r, _ in sens])
s_arr = np.array([s for _, s in sens])
def ser_of_rho(r):
return float(np.interp(abs(r), r_arr, s_arr))
ks = [1, 10, 100, 1_000, 10_000, 100_000, 1_000_000]
rows = []
rng = np.random.default_rng(2026)
for Lp in [8, 16, 32, 64]:
for K in ks:
trials = 400
# rho of a random unit guess vs a fixed key in R^L is the
# first coordinate of a random unit vector; sample K per trial
best = np.empty(trials)
for t in range(trials):
g = rng.standard_normal((K, Lp))
g /= np.linalg.norm(g, axis=1, keepdims=True)
best[t] = np.abs(g[:, 0]).max()
ser_est = float(np.mean([ser_of_rho(b) for b in best]))
rows.append((Lp, K, float(best.mean()), ser_est))
print(f" L={Lp} done")
write_csv(DATA / "sec_brute.csv",
["L", "K", "best_rho", "eve_ser"], rows)
def main():
print(f"device={DEVICE}")
stage_A()
stage_B()
stage_C()
stage_D()
stage_E()
stage_F()
print("[done] full-scale security CSVs in", DATA)
if __name__ == "__main__":
main()
+155
View File
@@ -0,0 +1,155 @@
"""Stage H: known-plaintext attack on the keyed masking.
The masking is linear in the keys, so an attacker who knows the indices
carried by some frames can write one linear equation per dimension per
frame and solve for the keys by least squares. This script measures how
much known plaintext the attacker needs before the recovered key is good
enough to decode, and how receiver noise slows that recovery down.
Per dimension k the observation of frame n is
y_k(n) = (1/c) sum_u e_{s_u(n),k} m_{u,k} + noise,
so stacking N frames gives A m_k = y_k with A(n,u) = e_{s_u(n),k}/c, an
N-by-U system that is solvable once N >= U in the noiseless case. The
attacker solves it per dimension, then correlates the estimate with the
true key and runs the correlation receiver with the estimated key.
Outputs:
kpa.csv : key correlation and eavesdropper SER against the number of
known-plaintext frames, at several SNRs
"""
from __future__ import annotations
import math
import numpy as np
import torch
from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2,
set_seed, write_csv, eval_ser_sse)
from exp_full import get_model, eval_ser_eve
SNRS = [0.0, 10.0, 20.0]
NFRAMES = [1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 24, 32, 48, 64]
SEED = 4242
EVAL_FRAMES = 50_000
# The spread across independent key-recovery attempts dominates the
# spread across channel realizations within one attempt, so the curve is
# smoothed by drawing many attempts rather than by lengthening each one.
TRIALS = 40
@torch.no_grad()
def collect_known_plaintext(model: SSE, n_frames: int, snr_db: float,
gen: torch.Generator):
"""Return (digits, raw observations, channel gains) for an attacker
that knows the transmitted indices.
The raw observation is returned rather than an equalized one. A
maximum-likelihood attacker keeps the channel gain in the design
matrix instead of dividing by it, which weights every frame by its
own quality and is the strongest use of the collected material. It
also avoids the numerical blow-up that equalizing a deep fade would
cause.
"""
digits = torch.randint(model.vu, (n_frames, model.users, model.P),
generator=gen).to(DEVICE)
Bn = model.unit_codebook()
m = model.masks()
e = Bn[digits] / math.sqrt(model.P)
y = (e * m[None, :, None, :]).sum(dim=1) / model.c # (N,P,L)
h = rayleigh_gain((n_frames,), device=DEVICE)
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
noise = torch.randn(n_frames, model.P, model.L, device=DEVICE)
obs = h[:, None, None] * y + sigma * noise
return digits, obs, h
@torch.no_grad()
def solve_keys(model: SSE, digits, obs, h):
"""Maximum-likelihood key estimate from known plaintext.
Each period of each frame is an independent observation of the same
per-period key, so the P periods multiply the effective number of
equations. For entry l the system is A x = b with
A[(n,p), u] = h(n) e_{digit(n,u,p), l} / (c sqrt(P)) and b the raw
observation, so a frame in a deep fade contributes a small row on
both sides and is downweighted rather than amplified.
"""
Bn = model.unit_codebook() # (Vu, L)
N, U, P = digits.shape
L = model.L
c = float(model.c)
est = torch.zeros(U, L, device=DEVICE)
for l in range(L):
# design matrix over all (frame, period) pairs
A = Bn[digits, l] / (c * math.sqrt(P)) # (N,U,P)
A = A * h[:, None, None]
A = A.permute(0, 2, 1).reshape(N * P, U) # (N*P, U)
b = obs[:, :, l].reshape(N * P, 1) # (N*P, 1)
# A pseudo-inverse with an absolute tolerance is used instead of
# a least-squares driver. Training can leave a codebook entry
# numerically dead, with every codeword value below the smallest
# normal float. That entry carries no information about the
# digit, and inverting its system would amplify noise without
# bound, so the absolute tolerance discards it and the estimate
# for that entry stays at zero, which is what a careful attacker
# would do.
sol = torch.linalg.pinv(A.double(), atol=1e-12, rtol=0.0) @ b.double()
est[:, l] = sol[:, 0].float()
est = torch.nan_to_num(est)
# normalize to the key norm convention
est = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9) * math.sqrt(L)
return est
def key_correlation(est: torch.Tensor, true: torch.Tensor) -> float:
"""Mean absolute normalized correlation over the users."""
a = est / est.norm(dim=1, keepdim=True).clamp_min(1e-9)
b = true / true.norm(dim=1, keepdim=True).clamp_min(1e-9)
return float((a * b).sum(dim=1).abs().mean())
def main():
set_seed(SEED)
model = get_model(iters=4000)
model.eval()
true_m = model.masks().detach()
legit = eval_ser_sse(model, [10.0], frames=200_000)[0]
print(f"[kpa] legitimate SER at 10 dB = {legit:.4g}, U={model.users}, "
f"L={model.L}")
# Nested known-plaintext sets with common random numbers. Within a
# trial the attacker collects one pool of frames and the N-frame
# estimate uses the first N of them, so more material can only help,
# exactly as an attacker accumulating traffic would experience. The
# evaluation noise is also shared across N within a trial. Both
# choices remove the between-point variance that would otherwise make
# the averaged curve jagged, without changing what is being measured.
nmax = max(NFRAMES)
rows = []
for snr in SNRS:
acc = {n: [[], []] for n in NFRAMES}
for t in range(TRIALS):
gen = torch.Generator(device="cpu").manual_seed(
SEED + int(snr) + 1000 * t)
digits, obs, h = collect_known_plaintext(model, nmax, snr, gen)
eval_seed = 777 + 31 * t + int(snr)
for n in NFRAMES:
est = solve_keys(model, digits[:n], obs[:n], h[:n])
acc[n][0].append(key_correlation(est, true_m))
acc[n][1].append(eval_ser_eve(model, est.cpu(), [10.0],
frames=EVAL_FRAMES,
seed=eval_seed)[0])
for n in NFRAMES:
ks, ss = acc[n]
kappa = sum(ks) / len(ks)
ser = sum(ss) / len(ss)
rows.append((snr, n, kappa, ser))
print(f" snr={snr:4.1f} N={n:5d} kappa={kappa:.4f} "
f"eve_ser={ser:.4f}")
write_csv(DATA / "kpa.csv",
["snr_db", "n_frames", "kappa", "eve_ser"], rows)
print("[done] kpa.csv")
if __name__ == "__main__":
main()
+195
View File
@@ -0,0 +1,195 @@
"""Stage G: security on real language-model token streams.
AG News test headlines are tokenized with the bert-base-uncased
WordPiece tokenizer (vocabulary 30,522). Four users carry four disjoint
headline streams, each frame transmits one token per user, and the token
identifier is carried by its base-16 digits, so the digit space
16^4 = 65,536 covers the vocabulary. The keys and codebook trained on
uniform indices are reused unchanged, so this stage tests the design on
a real, highly non-uniform source without retraining.
Two metrics are reported. The token error rate is the symbol-level
measure used in the rest of the paper. The headline recovery rate is a
meaning-level measure: the fraction of complete headlines a receiver
reconstructs without a single token error, which is what an
eavesdropper actually needs to read the message.
Outputs:
real_sec_ter.csv : token error rate vs SNR for legitimate, outsider
eavesdropper, insider, and OMA
real_sec_stats.json: stream statistics and headline recovery rates
"""
from __future__ import annotations
import json
import math
import torch
import sse_lib as L
from sse_lib import (DATA, DEVICE, SSE, rayleigh_gain, snr_to_sigma2,
set_seed, write_csv)
from exp_full import get_model, eve_wrong_mask
SNR_GRID = [0, 4, 8, 12, 16, 20, 24, 28]
# headline recovery is meaningful only where the legitimate user clears
# most tokens, since a headline averages tens of tokens and needs every
# one of them correct
REC_SNR = (20, 24, 28)
N_TEXTS = 2000
REPEATS = 8
REC_RUNS = 4
SEED_EVAL = 777
P_MAX, VU, U = 4, 16, 4
def load_streams():
from datasets import load_dataset
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("bert-base-uncased")
ds = load_dataset("fancyzhx/ag_news", split="test")
texts = [ds[i]["text"] for i in range(N_TEXTS)]
streams = [[] for _ in range(U)]
bounds = [[] for _ in range(U)] # (start, end) per headline
for i, t in enumerate(texts):
ids = tok(t, add_special_tokens=False)["input_ids"]
u = i % U
s = len(streams[u])
streams[u].extend(ids)
bounds[u].append((s, s + len(ids)))
n = min(len(s) for s in streams)
streams = [s[:n] for s in streams]
bounds = [[(a, b) for (a, b) in bu if b <= n] for bu in bounds]
return streams, bounds, tok.vocab_size
def ids_to_digits(ids: torch.Tensor) -> torch.Tensor:
"""(N,U) token ids -> (N,U,P) base-16 digits, most significant first."""
d, x = [], ids.clone()
for _ in range(P_MAX):
d.append(x % VU)
x = x // VU
return torch.stack(d[::-1], dim=-1)
@torch.no_grad()
def wrong_keyed(model: SSE, digits_all, snr_db, seed, rx_masks=None,
chunk=50_000):
"""Per-frame per-user error indicator (N,U) for a receiver that
correlates with rx_masks. rx_masks=None means the legitimate keys."""
torch.manual_seed(seed)
Bn = model.unit_codebook()
true_m = model.masks()
rx = true_m if rx_masks is None else rx_masks.to(DEVICE)
c = model.c
N = digits_all.shape[0]
wrong = torch.zeros(N, U, dtype=torch.bool)
sigma = snr_to_sigma2(snr_db, model.d).to(DEVICE).sqrt()
for n0 in range(0, N, chunk):
dg = digits_all[n0:n0 + chunk].to(DEVICE)
n = dg.shape[0]
e = Bn[dg] / math.sqrt(model.P)
y = (e * true_m[None, :, None, :]).sum(dim=1) / c
h = rayleigh_gain((n, U), device=DEVICE)
noise = torch.randn(n, U, model.P, model.L, device=DEVICE)
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
cand = Bn[None, :, :] * rx[:, None, :]
sc = torch.einsum("nupl,uvl->nupv", r, cand)
wrong[n0:n0 + chunk] = (sc.argmax(-1) != dg).any(dim=2).cpu()
return wrong
@torch.no_grad()
def wrong_oma(ids_all, snr_db, seed, bits=16):
"""Antipodal signaling on the actual token bits, same frame energy."""
torch.manual_seed(seed)
N, Uu = ids_all.shape
b = ((ids_all[..., None] >> torch.arange(bits)) & 1).float() * 2 - 1
b = b.to(DEVICE)
sigma = math.sqrt(1.0 / (10.0 ** (snr_db / 10.0)))
h = rayleigh_gain((N, Uu, 1))
y = h * b + sigma * torch.randn(N, Uu, bits, device=DEVICE)
return ((y * b) < 0).any(dim=2).cpu()
def headline_recovery(wrong: torch.Tensor, bounds) -> tuple[int, int]:
"""A headline counts as recovered only if every token is correct."""
ok = tot = 0
for u in range(U):
wu = wrong[:, u]
for (a, b) in bounds[u]:
tot += 1
ok += int(not bool(wu[a:b].any()))
return ok, tot
def main():
set_seed(SEED_EVAL)
streams, bounds, vocab = load_streams()
ids_all = torch.tensor(list(zip(*streams)), dtype=torch.long) # (N,U)
digits_all = ids_to_digits(ids_all)
N = ids_all.shape[0]
assert int(ids_all.max()) < VU ** P_MAX
print(f"[real] {N} frames, {int(torch.unique(ids_all).numel())} "
f"distinct tokens, max id {int(ids_all.max())}")
# keys and codebook trained on uniform indices, reused unchanged
model = get_model(P=P_MAX, vu=VU, d=64, U=U, iters=4000)
model.eval()
eve_m = eve_wrong_mask(U, model.L, seed=20260813) # outsider
ins_m = model.masks().detach().roll(1, 0).cpu() # insider
schemes = {
"legit": lambda s, k: wrong_keyed(model, digits_all, s, k),
"eve": lambda s, k: wrong_keyed(model, digits_all, s, k, eve_m),
"insider": lambda s, k: wrong_keyed(model, digits_all, s, k, ins_m),
"oma": lambda s, k: wrong_oma(ids_all, s, k),
}
rows = []
for s in SNR_GRID:
ter = {}
for name, fn in schemes.items():
e = 0
for r in range(REPEATS):
e += int(fn(s, SEED_EVAL + 1000 * r + int(10 * s)).sum())
ter[name] = e / (N * U * REPEATS)
rows.append((s, ter["legit"], ter["eve"], ter["insider"], ter["oma"]))
print("[real]", [f"{v:.4g}" for v in rows[-1]])
write_csv(DATA / "real_sec_ter.csv",
["snr_db", "ter_legit", "ter_eve", "ter_insider", "ter_oma"],
rows)
rec = {}
for s in REC_SNR:
rec[str(s)] = {}
for name, fn in schemes.items():
ok = tot = 0
for r in range(REC_RUNS):
w = fn(s, SEED_EVAL + 5000 * r + int(10 * s))
o, t = headline_recovery(w, bounds)
ok += o; tot += t
rec[str(s)][name] = ok / tot
print(f"[rec] {s} dB {name}: {ok}/{tot} = {ok/tot:.4g}")
stats = {
"vocab_size": vocab,
"n_texts": N_TEXTS,
"frames": N,
"repeats": REPEATS,
"decisions_per_point": N * U * REPEATS,
"distinct_tokens": int(torch.unique(ids_all).numel()),
"max_token_id": int(ids_all.max()),
"headlines_scored": sum(len(b) for b in bounds),
"headline_runs": REC_RUNS,
"recovery": rec,
}
(DATA / "real_sec_stats.json").write_text(json.dumps(stats, indent=1))
print(json.dumps(stats, indent=1))
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
"""Feasibility study for paper 11 (TIFS): the per-user mask as a
physical-layer key.
Three questions, all under the shared-embedding multiple-access model of
sse_lib.py (real-vector convention, flat Rayleigh fading):
Q1 (encryption): a legitimate receiver knows its mask mu_u; an
eavesdropper (Eve) does not. How far above chance can Eve decode?
We measure the legitimate symbol error rate (SER) against Eve's SER
when Eve applies (a) a wrong mask drawn from the same distribution,
(b) no mask (mu = 1), (c) the average mask. Chance level is
(Vu-1)/Vu per digit, 1-(1/Vu)^P per frame.
Q2 (key entropy vs dimension): as the per-period length L grows, two
independently drawn unit-norm masks become more nearly orthogonal,
so Eve's residual after de-masking with a wrong key grows. We sweep
L and report Eve's SER and the mean absolute mask cross-correlation.
Q3 (jamming robustness): a jammer adds h_J * w to the frame, where w is
an arbitrary unit waveform (worst case: aligned with the victim's
masked codeword direction; and random). We sweep the
jammer-to-signal ratio (JSR) and report the legitimate SER, to show
the mask spreads a mismatched jammer and bounds its effect.
This is a CPU-sized feasibility run (small V), not the final experiment.
Seeds fixed; results written to ../data as CSV.
"""
from __future__ import annotations
import math
import numpy as np
import torch
import sse_lib as L
from sse_lib import SSE, rayleigh_gain, snr_to_sigma2, write_csv, set_seed, DATA, DEVICE
# ----------------------------------------------------------------------
# Eve: apply a chosen (wrong) set of masks to the SAME received frame the
# legitimate users see, then run the correlation receiver.
# ----------------------------------------------------------------------
@torch.no_grad()
def eval_ser_eve(model: SSE, eve_masks: torch.Tensor, snr_list,
frames: int = 400_000, chunk: int = 50_000, seed: int = 777):
"""eve_masks: (U, L) the masks Eve uses in place of the true ones.
Eve observes the same physically transmitted frame (true masks used at
the transmitter) but correlates with eve_masks."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
eve_masks = eve_masks.to(DEVICE)
c = model.c
out = []
for snr_db in snr_list:
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
# transmit with the TRUE masks
e = Bn[digits] / math.sqrt(model.P)
y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L)
h = rayleigh_gain((n, model.users), device=DEVICE)
sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt()
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
y_rx = h[:, :, None, None] * y[:, None] + sigma * noise
r = y_rx / h[:, :, None, None].clamp_min(1e-6) # (n,U,P,L)
# Eve correlates with her (wrong) masks
cand = Bn[None, :, :] * eve_masks[:, None, :] # (U,Vu,L)
scores = torch.einsum("nupl,uvl->nupv", r, cand)
wrong = (scores.argmax(-1) != digits).any(dim=2)
err += int(wrong.sum()); tot += n * model.users
out.append(err / tot)
return out
@torch.no_grad()
def eval_ser_jam(model: SSE, snr_db, jsr_db_list, frames: int = 400_000,
chunk: int = 50_000, seed: int = 777, mode: str = "aligned"):
"""Legitimate SER with an added jammer h_J * sqrt(JSR) * w.
mode='aligned': w points along user 0's masked mean codeword direction
(a structured, mask-matched worst case for user 0).
mode='random': w is an isotropic random unit frame each transmission."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
true_m = model.masks()
c = model.c
sigma = snr_to_sigma2(snr_db).to(DEVICE).sqrt()
# aligned jammer direction: mask-0 applied to a fixed unit codeword,
# i.e. what an attacker would build if it copied the public codebook
# but guessed the (secret) mask wrong -> here we give it mask 0 exactly
# as the strongest realistic structured jammer.
w_fixed = (Bn[0][None, :] * true_m[0][None, :]).repeat(model.P, 1) # (P,L)
w_fixed = w_fixed / w_fixed.norm()
out = []
for jsr_db in jsr_db_list:
jsr = 10.0 ** (jsr_db / 10.0)
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * jsr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
e = Bn[digits] / math.sqrt(model.P)
y = (e * true_m[None, :, None, :]).sum(dim=1) / c # (n,P,L)
h = rayleigh_gain((n, model.users), device=DEVICE)
hJ = rayleigh_gain((n,), device=DEVICE)
if mode == "aligned":
w = w_fixed[None].expand(n, model.P, model.L)
else:
w = torch.randn(n, model.P, model.L, device=DEVICE)
w = w / w.reshape(n, -1).norm(dim=1)[:, None, None].clamp_min(1e-8)
jam = (hJ * math.sqrt(jsr))[:, None, None] * w # (n,P,L)
noise = torch.randn(n, model.users, model.P, model.L, device=DEVICE)
y_rx = (h[:, :, None, None] * y[:, None]
+ h[:, :, None, None] * 0 # keep shape clarity
+ jam[:, None] + sigma * noise)
r = y_rx / h[:, :, None, None].clamp_min(1e-6)
cand = Bn[None, :, :] * true_m[:, None, :]
scores = torch.einsum("nupl,uvl->nupv", r, cand)
wrong = (scores.argmax(-1) != digits).any(dim=2)
err += int(wrong.sum()); tot += n * model.users
out.append(err / tot)
return out
def mean_abs_cross_corr(masks: torch.Tensor) -> float:
"""Mean |<mu_i, mu_j>| / (||mu_i|| ||mu_j||) over i<j."""
m = masks / masks.norm(dim=1, keepdim=True).clamp_min(1e-8)
G = (m @ m.T).abs()
U = m.shape[0]
off = G[~torch.eye(U, dtype=torch.bool, device=G.device)]
return float(off.mean())
def main():
set_seed(1)
# CPU-sized feasibility configuration: V = Vu^P = 16^2 = 256
P, VU, D, U = 2, 16, 64, 4
snr_eval = [0.0, 5.0, 10.0, 15.0, 20.0]
chance_frame = 1.0 - (1.0 / VU) ** P
model = SSE(P=P, vu=VU, d=D, users=U).to(DEVICE)
print(f"[train] SSE P={P} Vu={VU} d={D} U={U} V={model.V} on {DEVICE}")
L.TRAIN_SNR_DB = (0.0, 20.0)
model_iters = 1500
curve = L.train_sse(model, iters=model_iters, batch=256, lr=3e-3,
log_every=0, seed=1)
model.calibrate_power()
legit = L.eval_ser_sse(model, snr_eval, frames=400_000)
print("[Q1] legitimate SER:", [f"{v:.3g}" for v in legit])
# Eve variants
set_seed(20260813)
eve_wrong = torch.randn(U, model.L) / math.sqrt(model.L)
eve_wrong = eve_wrong / eve_wrong.norm(dim=1, keepdim=True) * math.sqrt(model.L)
eve_none = torch.ones(U, model.L)
eve_avg = model.masks().mean(dim=0, keepdim=True).repeat(U, 1).cpu()
eve_w = eval_ser_eve(model, eve_wrong, snr_eval, frames=400_000)
eve_n = eval_ser_eve(model, eve_none, snr_eval, frames=400_000)
eve_a = eval_ser_eve(model, eve_avg, snr_eval, frames=400_000)
print("[Q1] Eve wrong-mask SER:", [f"{v:.3g}" for v in eve_w])
print("[Q1] Eve no-mask SER:", [f"{v:.3g}" for v in eve_n])
print(f"[Q1] chance frame SER = {chance_frame:.4f}")
write_csv(DATA / "feas_q1_eavesdrop.csv",
["snr_db", "legit", "eve_wrong", "eve_none", "eve_avg", "chance"],
[(s, legit[i], eve_w[i], eve_n[i], eve_a[i], chance_frame)
for i, s in enumerate(snr_eval)])
# Q2: key entropy vs per-period length L (grow d at fixed P)
print("[Q2] sweeping period length L ...")
q2_rows = []
for d in [16, 32, 64, 128, 256]:
set_seed(1)
mdl = SSE(P=P, vu=VU, d=d, users=U).to(DEVICE)
L.train_sse(mdl, iters=model_iters, batch=256, lr=3e-3, seed=1)
mdl.calibrate_power()
set_seed(20260813)
ew = torch.randn(U, mdl.L) / math.sqrt(mdl.L)
ew = ew / ew.norm(dim=1, keepdim=True) * math.sqrt(mdl.L)
lg = L.eval_ser_sse(mdl, [10.0], frames=300_000)[0]
ev = eval_ser_eve(mdl, ew, [10.0], frames=300_000)[0]
xc = mean_abs_cross_corr(mdl.masks().detach().cpu())
q2_rows.append((mdl.L, d, lg, ev, xc))
print(f" L={mdl.L:4d} legit={lg:.3g} eve={ev:.3g} |xcorr|={xc:.3f}")
write_csv(DATA / "feas_q2_keyentropy.csv",
["L", "d", "legit_ser", "eve_ser", "mask_xcorr"], q2_rows)
# Q3: jamming robustness at SNR=10 dB
print("[Q3] jamming sweep at SNR=10 dB ...")
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
jam_al = eval_ser_jam(model, 10.0, jsr, frames=300_000, mode="aligned")
jam_rd = eval_ser_jam(model, 10.0, jsr, frames=300_000, mode="random")
print("[Q3] aligned-jammer SER:", [f"{v:.3g}" for v in jam_al])
print("[Q3] random-jammer SER:", [f"{v:.3g}" for v in jam_rd])
write_csv(DATA / "feas_q3_jamming.csv",
["jsr_db", "ser_aligned", "ser_random"],
[(j, jam_al[i], jam_rd[i]) for i, j in enumerate(jsr)])
print("\n[done] feasibility CSVs written to", DATA)
if __name__ == "__main__":
main()
+55
View File
@@ -0,0 +1,55 @@
"""Generate the LaTeX rows of the two result tables from the CSVs, so
that every table in the paper is reproducible from data/ (TIFS mandate).
Prints the tabular body; paste into main.tex without edits.
"""
from __future__ import annotations
import csv
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / "data"
NAME = {
"proposed": r"\textbf{Proposed keyed masking}",
"public_mask": "Public masks",
"perm_key": r"Global-permutation key~\cite{chen2023shuffling}",
"index_cipher": "Per-user index cipher",
"oma_plain": "OMA (no encryption)",
"random": "Random",
"hadamard": "Walsh-Hadamard",
"learned": "Learned",
}
def f3(x: str) -> str:
try:
return f"{float(x):.3f}"
except ValueError:
return "--"
def compare_table():
print("% Table: scheme comparison (from sec_compare.csv)")
rows = list(csv.DictReader(open(DATA / "sec_compare.csv")))
order = ["public_mask", "perm_key", "index_cipher", "oma_plain", "proposed"]
rows = sorted(rows, key=lambda r: order.index(r["scheme"]))
for r in rows:
cells = [f3(r["legit_ser"]), f3(r["eve_out"]), f3(r["eve_in"]),
f3(r["jam0_ser"])]
if r["scheme"] == "proposed":
cells = [rf"$\mathbf{{{c}}}$" for c in cells]
else:
cells = [f"${c}$" if c != "--" else "--" for c in cells]
print(f"{NAME[r['scheme']]} & " + " & ".join(cells) + r" \\")
def maskfam_table():
print("% Table: key families (from sec_maskfam.csv)")
for r in csv.DictReader(open(DATA / "sec_maskfam.csv")):
print(f"{NAME[r['family']]} & ${f3(r['legit_ser'])}$ & "
f"${f3(r['eve_ser'])}$ & ${f3(r['mask_xcorr'])}$" + r" \\")
if __name__ == "__main__":
compare_table()
print()
maskfam_table()
+253
View File
@@ -0,0 +1,253 @@
"""Canonical replot script for paper 11: regenerates every result figure
from ../data/*.csv and writes paper-ready PDFs to ../fig/. No experiment
is rerun. All result plots share one canvas and axes rectangle (8:6 box).
Label dictionary is fixed here and copied verbatim into tables and prose.
fig_sec_snr.pdf : legitimate vs eavesdropper SER vs SNR (Fig. 2)
fig_sec_keylen.pdf : SER vs key length L (Fig. 3)
fig_sec_jam.pdf : target-user SER vs JSR (Fig. 4)
fig_sec_sens.pdf : Eve SER vs key correlation (Fig. 5)
fig_sec_brute.pdf : Eve SER vs number of key guesses (Fig. 6)
"""
from __future__ import annotations
from pathlib import Path
import csv
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
FIG = ROOT / "fig"
FIG.mkdir(exist_ok=True)
plt.rcParams.update({
"font.family": "serif",
"font.serif": ["DejaVu Serif", "Times New Roman"],
"font.size": 9,
"axes.labelsize": 9,
"legend.fontsize": 6.6,
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"axes.grid": True,
"grid.linestyle": "--",
"grid.linewidth": 0.4,
"grid.alpha": 0.6,
"lines.linewidth": 1.3,
"lines.markersize": 3.4,
"figure.figsize": (3.15, 2.36),
"pdf.fonttype": 42,
})
AXES_RECT = dict(left=0.185, right=0.965, top=0.955, bottom=0.195)
C_LEGIT = "#c0392b"
C_EVE = "#2c5fa8"
C_OMA = "#7f8c8d"
C_CH = "#95a5a6"
C_MATCH = "#8e44ad"
C_PUB = "#16a085"
# fixed label dictionary: tables and prose copy these strings verbatim
LBL = {
"legit": "Legitimate",
"oma": "OMA",
"eve_pub": "Eve, public masks",
"eve_key": "Eve, wrong key",
"chance": "Random guess",
"jam_m": "Matched jammer (public masks)",
"jam_b": "Blind jammer (proposed)",
"nojam": "No jammer",
}
def load(name):
with open(DATA / name) as f:
return list(csv.DictReader(f))
def col(rows, k, f=float):
return [f(r[k]) for r in rows]
def save(fig, name):
"""Write the figure and assert that no axis label is clipped.
A long y label, or wide minor tick labels such as 6x10^-1 on a log
axis that spans less than a decade, silently pushes the label off
the canvas under the fixed axes rectangle. Reading the plotting code
cannot reveal this, so the check is made on the rendered geometry.
"""
fig.subplots_adjust(**AXES_RECT)
fig.canvas.draw()
fbox = fig.get_window_extent()
for ax in fig.axes:
for lbl in (ax.yaxis.label, ax.xaxis.label):
if not lbl.get_text():
continue
b = lbl.get_window_extent()
if (b.x0 < fbox.x0 or b.y0 < fbox.y0
or b.x1 > fbox.x1 or b.y1 > fbox.y1):
raise RuntimeError(
f"{name}: axis label '{lbl.get_text()}' is clipped "
f"(label {b} outside figure {fbox}); shorten the "
f"label or widen the margin")
fig.savefig(FIG / f"{name}.pdf")
plt.close(fig)
print("[OK]", name)
def fig_snr():
r = load("sec_snr.csv")
x = col(r, "snr_db")
fig, ax = plt.subplots()
ax.semilogy(x, col(r, "legit"), color=C_LEGIT, marker="o", ls="-",
label=LBL["legit"])
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
label=LBL["oma"])
ax.semilogy(x, col(r, "eve_public"), color=C_PUB, marker="v",
ls="none", markersize=5.2, markerfacecolor="none",
label=LBL["eve_pub"])
ax.semilogy(x, col(r, "eve_wrong"), color=C_EVE, marker="s", ls="--",
label=LBL["eve_key"])
ax.plot(x, col(r, "chance"), color=C_CH, ls="-.", lw=0.9,
label=LBL["chance"])
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x))
ax.legend(loc="lower left")
save(fig, "fig_sec_snr")
def fig_keylen():
r = load("sec_keylen.csv")
x = col(r, "L", int)
fig, ax = plt.subplots()
ax.semilogy(x, col(r, "legit_ser"), color=C_LEGIT, marker="o", ls="-",
label=LBL["legit"])
ax.semilogy(x, col(r, "oma"), color=C_OMA, marker="^", ls=":",
label=LBL["oma"])
ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--",
label=LBL["eve_key"])
ax.set_xlabel("Key length $L$")
ax.set_ylabel("SER")
ax.set_xscale("log", base=2)
ax.legend(loc="center right", bbox_to_anchor=(0.98, 0.72))
save(fig, "fig_sec_keylen")
def fig_jam():
# the target-user SER spans 0.3 to 1.0, less than one decade, so a
# linear axis is used: a log axis here produces wide minor tick
# labels (6x10^-1) that crowd out the y label under the fixed
# axes rectangle
r = load("sec_jam.csv")
x = col(r, "jsr_db")
fig, ax = plt.subplots()
ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--",
label=LBL["jam_m"])
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-",
label=LBL["jam_b"])
nojam = col(r, "nojam")[0]
ax.axhline(nojam, color=C_OMA, ls=":", lw=0.9, label=LBL["nojam"])
ax.set_xlabel("JSR (dB)")
ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x))
ax.set_ylim(0.2, 1.02)
ax.legend(loc="lower right")
save(fig, "fig_sec_jam")
def fig_sens():
r = load("sec_sens.csv")
x = col(r, "rho")
fig, ax = plt.subplots()
ax.plot(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="-",
label=LBL["eve_key"])
chance = 1.0 - (1.0 / 16.0) ** 4
ax.axhline(chance, color=C_CH, ls="-.", lw=0.9, label=LBL["chance"])
ax.set_xlabel(r"Key correlation $\kappa$")
ax.set_ylabel("Eavesdropper SER")
ax.set_xlim(0, 1)
ax.legend(loc="lower left")
save(fig, "fig_sec_sens")
def fig_brute():
r = load("sec_brute.csv")
fig, ax = plt.subplots()
sty = {8: ("#c0392b", "o"), 16: ("#2c5fa8", "s"),
32: ("#16a085", "v"), 64: ("#8e44ad", "P")}
for Lp in [8, 16, 32, 64]:
rows = [row for row in r if int(row["L"]) == Lp]
ks = [float(row["K"]) for row in rows]
ser = [float(row["eve_ser"]) for row in rows]
c, mk = sty[Lp]
ax.semilogx(ks, ser, color=c, marker=mk, ls="-",
label=f"$L={Lp}$")
ax.set_xlabel("Number of key guesses $K$")
ax.set_ylabel("Eavesdropper SER")
ax.legend(loc="lower left")
save(fig, "fig_sec_brute")
def fig_real():
r = load("real_sec_ter.csv")
x = col(r, "snr_db")
fig, ax = plt.subplots()
ax.semilogy(x, col(r, "ter_legit"), color=C_LEGIT, marker="o", ls="-",
label=LBL["legit"])
ax.semilogy(x, col(r, "ter_oma"), color=C_OMA, marker="^", ls=":",
label=LBL["oma"])
ax.semilogy(x, col(r, "ter_insider"), color=C_PUB, marker="v", ls="-.",
label="Insider")
ax.semilogy(x, col(r, "ter_eve"), color=C_EVE, marker="s", ls="--",
label=LBL["eve_key"])
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("TER")
ax.set_xlim(min(x), max(x))
ax.legend(loc="lower left")
save(fig, "fig_sec_real")
def fig_kpa():
r = load("kpa.csv")
fig, ax = plt.subplots()
sty = {0.0: ("#c0392b", "o"), 10.0: ("#2c5fa8", "s"),
20.0: ("#16a085", "v")}
for snr, (c, mk) in sty.items():
rows = [row for row in r if float(row["snr_db"]) == snr]
n = [float(row["n_frames"]) for row in rows]
ser = [float(row["eve_ser"]) for row in rows]
ax.semilogx(n, ser, color=c, marker=mk, ls="-",
label=f"{int(snr)} dB")
ax.axhline(0.304, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
ax.set_xlabel("Known-plaintext frames $N$")
ax.set_ylabel("Eavesdropper SER")
ax.set_xscale("log", base=2)
ax.legend(loc="upper right")
save(fig, "fig_sec_kpa")
def main():
fig_snr()
fig_keylen()
fig_jam()
try:
fig_sens()
fig_brute()
except FileNotFoundError:
print("[skip] attack-difficulty CSVs not present yet")
try:
fig_real()
except FileNotFoundError:
print("[skip] real-token CSV not present yet")
try:
fig_kpa()
except FileNotFoundError:
print("[skip] known-plaintext CSV not present yet")
print("[done] figures in", FIG)
if __name__ == "__main__":
main()
+388
View File
@@ -0,0 +1,388 @@
"""Shared library for the scalable shared embedding (SSE) letter.
System model (real-vector convention, declared in the paper):
d-dimensional real embedding frame, U users, single transmitter.
Proposed SSE: the frame is split into P periods of length L = d/P and
one unit codebook B in R^{Vu x L} is reused in every period, so the
vocabulary size is V = Vu^P while the codebook stores only Vu*L
numbers. Index v maps to base-Vu digits (i_1,...,i_P).
Per-user periodic masks mu_u in R^L are repeated over the P periods.
Tx: y = (1/c) * sum_u e(s_u) .* m_u, c fixes unit average frame power.
Channel: user u sees h_u * y + n, h_u^2 ~ Exp(1) (Rayleigh magnitude,
known at the receiver), n ~ N(0, sigma^2 I), sigma^2 = 1/(d*snr).
Rx u: equalize by h_u, per period correlate with the masked unit
codewords b_i .* mu_u and take the argmax digit.
Device: CUDA when available (run under WSL), CPU fallback.
"""
from __future__ import annotations
import math
import time
from pathlib import Path
import numpy as np
import torch
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
FIG = ROOT / "fig"
DATA.mkdir(exist_ok=True)
FIG.mkdir(exist_ok=True)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# ----------------------------------------------------------------------
# global configuration
# ----------------------------------------------------------------------
D = 64 # embedding dimension (real)
U = 4 # users
VU = 16 # unit codebook size
P_MAX = 4 # periods for the main configuration, V = 16^4 = 65536
SEED = 1
TRAIN_ITERS = 4000
TRAIN_BATCH = 256
TRAIN_SNR_DB = (0.0, 20.0)
LR = 3e-3
def set_seed(seed: int = SEED) -> None:
torch.manual_seed(seed)
np.random.seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
def snr_to_sigma2(snr_db: torch.Tensor | float,
d: int = D) -> torch.Tensor | float:
"""Per-dimension noise variance for unit frame power and E[h^2]=1.
Pass the model's actual frame dimension d when it differs from the
module default, otherwise the effective SNR shifts with d."""
snr = 10.0 ** (torch.as_tensor(snr_db, dtype=torch.float64) / 10.0)
return (1.0 / (d * snr)).float()
def rayleigh_gain(shape, device=DEVICE) -> torch.Tensor:
"""|g| with g ~ CN(0,1): h^2 ~ Exp(1), E[h^2] = 1."""
u = torch.rand(shape, device=device).clamp_min(1e-12)
return torch.sqrt(-torch.log(u))
# ----------------------------------------------------------------------
# proposed scalable shared embedding
# ----------------------------------------------------------------------
class SSE(torch.nn.Module):
"""Periodic unit-codebook shared embedding with per-user periodic masks."""
def __init__(self, P: int = P_MAX, vu: int = VU, d: int = D, users: int = U):
super().__init__()
assert d % P == 0, "embedding dimension must split into P periods"
self.P, self.vu, self.d, self.users = P, vu, d, users
self.L = d // P
self.B = torch.nn.Parameter(torch.randn(vu, self.L) / math.sqrt(self.L))
self.W = torch.nn.Parameter(torch.randn(users, self.L) / math.sqrt(self.L))
self.logit_scale = torch.nn.Parameter(torch.tensor(2.0))
# transmit power normalizer, calibrated after training (buffer)
self.register_buffer("c", torch.tensor(1.0))
@property
def V(self) -> int:
return self.vu ** self.P
def unit_codebook(self) -> torch.Tensor:
return self.B / self.B.norm(dim=1, keepdim=True).clamp_min(1e-8)
def masks(self) -> torch.Tensor:
return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.L)
def tx_frame(self, digits: torch.Tensor) -> torch.Tensor:
"""digits: (N, U, P) ints -> unnormalized tx frame (N, P, L)."""
Bn = self.unit_codebook()
e = Bn[digits] / math.sqrt(self.P) # (N,U,P,L)
m = self.masks() # (U,L)
x = e * m[None, :, None, :]
return x.sum(dim=1) # (N,P,L)
def calibrate_power(self, n: int = 65536) -> None:
with torch.no_grad():
digits = torch.randint(self.vu, (n, self.users, self.P), device=self.B.device)
y = self.tx_frame(digits)
self.c.fill_(float(y.pow(2).sum(dim=(1, 2)).mean().sqrt()))
def scores(self, r: torch.Tensor) -> torch.Tensor:
"""r: equalized rx frame (N,P,L) -> scores (N,U,P,Vu)."""
Bn = self.unit_codebook() # (Vu,L)
m = self.masks() # (U,L)
cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L)
return torch.einsum("npl,uvl->nupv", r, cand)
def forward(self, digits: torch.Tensor, snr_db: torch.Tensor,
h: torch.Tensor | None = None):
"""digits (N,U,P), snr_db (N,) -> per-user scores and rx frames."""
N = digits.shape[0]
y = self.tx_frame(digits) / self.c # (N,P,L)
if h is None:
h = rayleigh_gain((N, self.users), device=y.device)
sigma = snr_to_sigma2(snr_db, self.d).to(y.device).sqrt() # (N,)
n = torch.randn(N, self.users, self.P, self.L, device=y.device)
y_rx = h[:, :, None, None] * y[:, None] + sigma[:, None, None, None] * n
r = y_rx / h[:, :, None, None].clamp_min(1e-6) # equalized (N,U,P,L)
Bn = self.unit_codebook()
m = self.masks()
cand = Bn[None, :, :] * m[:, None, :] # (U,Vu,L)
scores = torch.einsum("nupl,uvl->nupv", r, cand)
return scores
def n_params(self) -> int:
return self.B.numel() + self.W.numel()
# ----------------------------------------------------------------------
# conventional scheme: full unstructured codebook (prior shared embedding)
# ----------------------------------------------------------------------
class FullCodebook(torch.nn.Module):
"""Directly trained V x d codebook with full-d per-user masks."""
def __init__(self, V: int, d: int = D, users: int = U):
super().__init__()
self.Vc, self.d, self.users = V, d, users
self.E = torch.nn.Parameter(torch.randn(V, d) / math.sqrt(d))
self.W = torch.nn.Parameter(torch.randn(users, d) / math.sqrt(d))
self.logit_scale = torch.nn.Parameter(torch.tensor(2.0))
self.register_buffer("c", torch.tensor(1.0))
@property
def V(self) -> int:
return self.Vc
def codebook(self) -> torch.Tensor:
return self.E / self.E.norm(dim=1, keepdim=True).clamp_min(1e-8)
def masks(self) -> torch.Tensor:
return self.W / self.W.norm(dim=1, keepdim=True).clamp_min(1e-8) * math.sqrt(self.d)
def tx_frame(self, idx: torch.Tensor) -> torch.Tensor:
En = self.codebook()
e = En[idx] # (N,U,d)
m = self.masks()
return (e * m[None]).sum(dim=1) # (N,d)
def calibrate_power(self, n: int = 65536) -> None:
with torch.no_grad():
idx = torch.randint(self.Vc, (n, self.users), device=self.E.device)
y = self.tx_frame(idx)
self.c.fill_(float(y.pow(2).sum(dim=1).mean().sqrt()))
def forward(self, idx: torch.Tensor, snr_db: torch.Tensor,
h: torch.Tensor | None = None):
N = idx.shape[0]
y = self.tx_frame(idx) / self.c # (N,d)
if h is None:
h = rayleigh_gain((N, self.users), device=y.device)
sigma = snr_to_sigma2(snr_db).to(y.device).sqrt()
n = torch.randn(N, self.users, self.d, device=y.device)
y_rx = h[:, :, None] * y[:, None] + sigma[:, None, None] * n
r = y_rx / h[:, :, None].clamp_min(1e-6) # (N,U,d)
En = self.codebook()
m = self.masks()
cand = En[None, :, :] * m[:, None, :] # (U,V,d)
scores = torch.einsum("nud,uvd->nuv", r, cand)
return scores
def n_params(self) -> int:
return self.E.numel() + self.W.numel()
# ----------------------------------------------------------------------
# training
# ----------------------------------------------------------------------
def train_sse(model: SSE, iters: int = TRAIN_ITERS, batch: int = TRAIN_BATCH,
lr: float = LR, log_every: int = 0, eval_snr: float = 10.0,
eval_frames: int = 20000, seed: int = SEED):
"""Digit-wise cross entropy under superposition; cost independent of V."""
set_seed(seed)
model.to(DEVICE)
opt = torch.optim.Adam(model.parameters(), lr=lr)
ce = torch.nn.CrossEntropyLoss()
curve = []
t0 = time.time()
for it in range(1, iters + 1):
digits = torch.randint(model.vu, (batch, model.users, model.P), device=DEVICE)
snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB)
model.calibrate_power(8192)
scores = model(digits, snr) * model.logit_scale.exp()
loss = ce(scores.reshape(-1, model.vu), digits.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
if log_every and (it % log_every == 0 or it == 1):
ser = eval_ser_sse(model, [eval_snr], frames=eval_frames)[0]
curve.append((it, time.time() - t0, float(loss.detach()), ser))
model.calibrate_power()
return curve
def train_full(model: FullCodebook, iters: int = TRAIN_ITERS,
batch: int = TRAIN_BATCH, lr: float = LR, log_every: int = 0,
eval_snr: float = 10.0, eval_frames: int = 20000,
seed: int = SEED):
set_seed(seed)
model.to(DEVICE)
opt = torch.optim.Adam(model.parameters(), lr=lr)
ce = torch.nn.CrossEntropyLoss()
curve = []
t0 = time.time()
for it in range(1, iters + 1):
idx = torch.randint(model.Vc, (batch, model.users), device=DEVICE)
snr = torch.empty(batch).uniform_(*TRAIN_SNR_DB)
model.calibrate_power(8192)
scores = model(idx, snr) * model.logit_scale.exp()
loss = ce(scores.reshape(-1, model.Vc), idx.reshape(-1))
opt.zero_grad(); loss.backward(); opt.step()
if log_every and (it % log_every == 0 or it == 1):
ser = eval_ser_full(model, [eval_snr], frames=eval_frames)[0]
curve.append((it, time.time() - t0, float(loss.detach()), ser))
model.calibrate_power()
return curve
# ----------------------------------------------------------------------
# evaluation (Monte Carlo)
# ----------------------------------------------------------------------
@torch.no_grad()
def eval_ser_sse(model: SSE, snr_list, frames: int = 2_000_000,
chunk: int = 100_000, seed: int = 777):
"""Frame (vocabulary-symbol) error rate: any wrong digit is an error."""
model.eval().to(DEVICE)
out = []
for snr_db in snr_list:
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
digits = torch.randint(model.vu, (n, model.users, model.P),
generator=g).to(DEVICE)
snr = torch.full((n,), float(snr_db))
scores = model(digits, snr)
wrong = (scores.argmax(-1) != digits).any(dim=2) # (n,U)
err += int(wrong.sum()); tot += n * model.users
out.append(err / tot)
return out
@torch.no_grad()
def eval_ser_full(model: FullCodebook, snr_list, frames: int = 2_000_000,
chunk: int = 50_000, seed: int = 777):
model.eval().to(DEVICE)
if model.Vc >= 4096:
chunk = max(512, (1 << 23) // model.Vc)
out = []
for snr_db in snr_list:
g = torch.Generator(device="cpu").manual_seed(seed + int(10 * snr_db))
err = tot = 0
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
idx = torch.randint(model.Vc, (n, model.users), generator=g).to(DEVICE)
snr = torch.full((n,), float(snr_db))
scores = model(idx, snr)
err += int((scores.argmax(-1) != idx).sum()); tot += n * model.users
out.append(err / tot)
return out
# ----------------------------------------------------------------------
# conventional digital scheme: OMA with BPSK per real dimension (QPSK
# per complex subcarrier under the declared real-imaginary stacking)
# ----------------------------------------------------------------------
def oma_ser(snr_db_list, bits: int = 16, n_grid: int = 200_000):
"""Semi-analytic expression under the real-dimension convention.
All bits of a vocabulary symbol share the same flat-fading gain, so
SER = E_h[1 - (1 - Q(h sqrt(snr)))^bits] with h^2 ~ Exp(1); the
expectation is evaluated by numerical integration on a dense grid."""
x = (np.arange(n_grid) + 0.5) / n_grid # uniform quantiles
h = np.sqrt(-np.log(1.0 - x)) # inverse-CDF transform
out = []
for s in snr_db_list:
g = 10.0 ** (s / 10.0)
q = 0.5 * np.array([math.erfc(v / math.sqrt(2.0)) for v in
np.clip(h * math.sqrt(g), 0, 38)])
out.append(float(np.mean(1.0 - (1.0 - q) ** bits)))
return out
@torch.no_grad()
def oma_ser_mc(snr_db_list, bits: int = 16, frames: int = 2_000_000,
chunk: int = 200_000, seed: int = 777):
"""Monte Carlo check of the closed form (same channel conventions)."""
out = []
for s in snr_db_list:
sigma = math.sqrt(1.0 / (10.0 ** (s / 10.0))) # per-dim, unit Es
err = tot = 0
torch.manual_seed(seed + int(10 * s))
for n0 in range(0, frames, chunk):
n = min(chunk, frames - n0)
h = rayleigh_gain((n, 1))
b = torch.randint(0, 2, (n, bits), device=DEVICE) * 2.0 - 1.0
y = h * b + sigma * torch.randn(n, bits, device=DEVICE)
wrong = ((y * b) < 0).any(dim=1)
err += int(wrong.sum()); tot += n
out.append(err / tot)
return out
# ----------------------------------------------------------------------
# analysis: union bound with residual-interference Gaussian approximation
# ----------------------------------------------------------------------
@torch.no_grad()
def sse_union_bound(model: SSE, snr_db_list, n_mc_h: int = 200_000):
"""P_e <= 1 - (1 - min(1, sum_pairs)) ... digit union bound averaged
over the Rayleigh gain, with measured residual interference treated
as additional Gaussian noise (approximation stated in the paper)."""
model.eval().to(DEVICE)
Bn = model.unit_codebook()
m = model.masks()
c = float(model.c)
# residual interference power per dimension at user u (measured)
digits = torch.randint(model.vu, (65536, model.users, model.P), device=DEVICE)
e = Bn[digits] / math.sqrt(model.P)
x = e * m[None, :, None, :] # (N,U,P,L)
y = x.sum(dim=1) / c # (N,P,L)
# per-user: signal = x_u/c, interference = (y - x_u/c)
interf_pw = []
dmin2 = []
for u in range(model.users):
su = x[:, u] / c # (N,P,L)
iu = y - su
# project interference onto the normalized candidate directions
cand = Bn * m[u][None, :] # (Vu,L)
cn = cand / cand.norm(dim=1, keepdim=True).clamp_min(1e-8)
proj = torch.einsum("npl,vl->npv", iu, cn)
interf_pw.append(float(proj.pow(2).mean()))
# pairwise distances of the scaled candidate set (tx side scaling)
cs = cand / (c * math.sqrt(model.P))
dd = torch.cdist(cs, cs)
dmin2.append(float((dd + torch.eye(model.vu, device=DEVICE) * 1e9).min() ** 2))
res = []
g = rayleigh_gain(n_mc_h)
for s in snr_db_list:
sig2 = float(snr_to_sigma2(torch.tensor(s)))
pe_users = []
for u in range(model.users):
# effective noise per dim after equalization: sig2/h^2 + interf
sig_eff2 = sig2 / g.pow(2) + interf_pw[u]
arg = torch.sqrt(torch.clamp(torch.tensor(dmin2[u], device=DEVICE)
/ (4.0 * sig_eff2), min=0.0))
q = 0.5 * torch.erfc(arg / math.sqrt(2.0))
p_digit = torch.clamp((model.vu - 1) * q, max=1.0)
p_frame = 1.0 - (1.0 - p_digit) ** model.P
pe_users.append(float(p_frame.mean()))
res.append(sum(pe_users) / len(pe_users))
return res
def write_csv(path: Path, header: list[str], rows) -> None:
with open(path, "w") as f:
f.write(",".join(header) + "\n")
for row in rows:
f.write(",".join(f"{v:.10g}" if isinstance(v, float) else str(v)
for v in row) + "\n")
print("[csv]", path)
+201
View File
@@ -0,0 +1,201 @@
"""Numerical verification of the closed forms in Sections IV and V of
paper 11 (mask-as-key encryption and jamming robustness).
Every claim that enters the manuscript is checked here against Monte
Carlo, with a PASS/FAIL verdict and the achieved agreement level printed.
Real-vector convention, dimension d, U users, codebook of V unit-norm
codewords, per-user masks with zero-mean entries normalized to
||m||^2 = d (so E[m_k^2] = 1).
Notation matches the tex:
y = (1/c) sum_u e_{s_u} .* m_u transmit frame
legit score z_{u,i} = r_u^T (e_i .* m_u), r_u = y + n_u/h_u
eve score zE_{u,i} = (y_E/h_E)^T (e_i .* mtil_u)
jammer adds h_J sqrt(rho) w to the victim observation
Run on CPU (NumPy); no training involved, pure algebra checks.
"""
from __future__ import annotations
import numpy as np
RNG = np.random.default_rng(2026)
D, U, V = 64, 4, 256
def unit_codebook(V, d, rng):
E = rng.standard_normal((V, d))
return E / np.linalg.norm(E, axis=1, keepdims=True)
def masks(U, d, rng):
"""Zero-mean entries normalized so ||m_u||^2 = d."""
M = rng.standard_normal((U, d))
return M / np.linalg.norm(M, axis=1, keepdims=True) * np.sqrt(d)
def report(tag, claim, emp, tol, extra=""):
err = abs(claim - emp)
ok = err <= tol
print(f"[{'PASS' if ok else 'FAIL'}] {tag}: claim={claim:.5g} "
f"emp={emp:.5g} |err|={err:.2g} tol={tol:g} {extra}")
return ok
def v1_legit_self_alignment():
"""Claim: E[e_s^T diag(m^2) e_s] = 1 (signal self-correlation)."""
E = unit_codebook(V, D, RNG)
vals = []
for _ in range(4000):
m = masks(1, D, RNG)[0]
s = RNG.integers(V)
vals.append(float((E[s] ** 2) @ (m ** 2)))
return report("V1 legit self-alignment", 1.0, float(np.mean(vals)), 2e-2)
def v2_eve_uninformed():
"""Claim: with an independent substitute mask, the eavesdropper's
correct-index correlation has the same mean as any wrong index, so
the mean advantage is zero and the eavesdropper SER = (V-1)/V,
independent of SNR."""
E = unit_codebook(V, D, RNG)
# mean advantage of the true index over the wrong indices, noiseless
adv = []
ser_by_snr = {}
for snr_db in [0.0, 10.0, 20.0, 80.0]: # 80 dB stands in for noiseless
sigma = np.sqrt(1.0 / (D * 10 ** (snr_db / 10.0)))
err = 0
trials = 6000
for _ in range(trials):
s = RNG.integers(V, size=U)
M = masks(U, D, RNG)
c = 1.0 # scale-invariant for argmax
y = np.zeros(D)
for u in range(U):
y += E[s[u]] * M[u]
y /= np.sqrt(U) # any fixed scale
# eavesdropper targets user 0 with an independent wrong mask
mtil = masks(1, D, RNG)[0]
hE = np.sqrt(-np.log(RNG.random()))
rE = y + (sigma / hE) * RNG.standard_normal(D)
scores = (E * mtil) @ rE # (V,)
if snr_db == 80.0:
adv.append(scores[s[0]] - scores.mean())
if scores.argmax() != s[0]:
err += 1
ser_by_snr[snr_db] = err / trials
chance = (V - 1) / V
ok1 = report("V2a eve mean advantage", 0.0, float(np.mean(adv)), 3e-3)
ok2 = True
for snr_db, ser in ser_by_snr.items():
tag = f"V2b eve SER @ {int(snr_db)}dB"
ok2 &= report(tag, chance, ser, 1.5e-2)
return ok1 and ok2
def v3_leakage_vs_correlation():
"""Claim: if the substitute mask has normalized correlation
rho = <m,mtil>/d with the true mask, the eavesdropper's true-index
bias grows linearly in rho; independent random masks give
E|rho| = O(1/sqrt(d)); orthogonal masks give rho = 0."""
E = unit_codebook(V, D, RNG)
# (a) bias vs prescribed rho
slopes = []
for rho in [0.0, 0.25, 0.5, 0.75, 1.0]:
bias = []
for _ in range(3000):
m = masks(1, D, RNG)[0]
mp = masks(1, D, RNG)[0]
mp = mp - (mp @ m) / (m @ m) * m # orthogonalize
mp = mp / np.linalg.norm(mp) * np.sqrt(D)
mtil = rho * m + np.sqrt(1 - rho ** 2) * mp
s = RNG.integers(V)
# noiseless single-user useful alignment for the true index
bias.append(float((E[s] ** 2) @ (m * mtil)))
slopes.append((rho, float(np.mean(bias))))
# claim: bias(rho) = rho * bias(1); check linearity
b1 = slopes[-1][1]
lin_ok = all(abs(b - rho * b1) <= 3e-2 for rho, b in slopes)
print(f"[{'PASS' if lin_ok else 'FAIL'}] V3a bias linear in rho: "
+ ", ".join(f"rho={r:.2f}->{b:.3f}" for r, b in slopes))
# (b) random independent mask correlation: E|corr| = sqrt(2/(pi d))
# (the folded-normal mean of a N(0, 1/d) variable)
corrs = []
for _ in range(5000):
m = masks(1, D, RNG)[0]
mt = masks(1, D, RNG)[0]
corrs.append(abs((m @ mt) / D))
emp = float(np.mean(corrs))
claim = float(np.sqrt(2.0 / (np.pi * D)))
ok_b = report("V3b random mask E|corr|", claim, emp, 0.1 * claim)
return lin_ok and ok_b
def v4_blind_jammer_spread():
"""Claim: a mask-blind jammer (w independent of m_u) contributes a
zero-mean term to every candidate score with variance
(hJ^2/hu^2) rho * sum_k w_k^2 e_{i,k}^2, i.e. it is spread with no
systematic bias toward any index."""
E = unit_codebook(V, D, RNG)
rho = 1.0
# (a) structural claim: the mask projection g_i = (w .* m)^T e_i is
# zero-mean, decoupled from the positive gain factor hJ/hu.
g, var_emp, var_cl = [], [], []
for _ in range(20000):
m = masks(1, D, RNG)[0]
w = RNG.standard_normal(D); w /= np.linalg.norm(w)
i = RNG.integers(V)
gi = (w * m) @ E[i]
g.append(gi)
var_emp.append(gi ** 2)
var_cl.append(np.sum(w ** 2 * E[i] ** 2))
ok1 = report("V4a blind jammer projection mean", 0.0,
float(np.mean(g)), 3e-3)
# (b) variance of the projection matches sum_k w_k^2 e_{i,k}^2; the
# full contribution scales this by (hJ^2/hu^2) rho.
ok2 = report("V4b blind jammer projection variance",
float(np.mean(var_cl)), float(np.mean(var_emp)),
0.05 * float(np.mean(var_cl)))
return ok1 and ok2
def v5_matched_jammer_concentrates():
"""Claim: a mask-matched jammer aligned with the victim key for a
target index t creates a bias of order one on index t, while the
blind-jammer projection has zero mean and RMS of order 1/sqrt(d).
The physically meaningful separation is matched bias over blind RMS,
which is sqrt(d) (the sample mean of the blind bias estimates zero
and is pure Monte Carlo noise, so it is NOT a valid denominator)."""
E = unit_codebook(V, D, RNG)
bias_matched, blind_sq = [], []
for _ in range(3000):
m = masks(1, D, RNG)[0]
t = RNG.integers(V)
wm = E[t] * m; wm /= np.linalg.norm(wm) # matched (needs m)
wb = RNG.standard_normal(D); wb /= np.linalg.norm(wb) # blind
bias_matched.append(float((wm * m) @ E[t]))
blind_sq.append(float(((wb * m) @ E[t]) ** 2))
bm = float(np.mean(bias_matched))
brms = float(np.sqrt(np.mean(blind_sq)))
ratio = bm / brms
ok = abs(ratio - np.sqrt(D)) <= 0.25 * np.sqrt(D) and bm > 0.9
print(f"[{'PASS' if ok else 'FAIL'}] V5 matched bias / blind RMS: "
f"matched={bm:.3f} blind_rms={brms:.4f} ratio={ratio:.1f} "
f"(claim sqrt(d)={np.sqrt(D):.1f})")
return ok
def main():
print(f"config d={D} U={U} V={V}\n")
results = {
"V1": v1_legit_self_alignment(),
"V2": v2_eve_uninformed(),
"V3": v3_leakage_vs_correlation(),
"V4": v4_blind_jammer_spread(),
"V5": v5_matched_jammer_concentrates(),
}
print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()})
print("ALL PASS" if all(results.values()) else "SOME FAILED")
if __name__ == "__main__":
main()