The package was missing every script behind the KM (lrn.) curves and both learned table rows: exp_learned's driver, the merge that folds the learned rows into sec_compare.csv and refresh_summary.csv, and the report that reads the learned numbers back. It was also missing sec_keylen_perm.csv, so Fig. 3 could not be regenerated at all, and the two diagnostics that answer why a fixed key beats a learned one here and where a learned mask would win instead. The learned artifacts themselves are regenerated. They were trained on the cross-entropy alone, which drifts to disjoint sparse supports: 99 percent of each key's energy on about six of the 64 entries, so a digit is decided over a sixth of its period and the key set is a choice of support rather than a dense direction in R^L. They are now the regularized keys of Section V-C, and check_consistency asserts which of the two families the figures draw. Verified from inside this repository: replot_security.py rebuilds all seven result figures, make_tables.py reproduces both result tables, and check_consistency.py passes every check that does not need the manuscript. The README now lists what ships. Its run list, layout and figure map had none of the learned pipeline, named two tables the manuscript renders as prose, and gave Fig. 3 no data file for its permutation curve.
907 lines
40 KiB
Python
907 lines
40 KiB
Python
"""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=256, 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 sys
|
|
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)
|
|
|
|
|
|
MAIN_D = 256 # embedding dimension of the main configuration
|
|
|
|
|
|
def get_model(P=4, vu=16, d=MAIN_D, 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 base_keys(U: int, Lp: int) -> torch.Tensor:
|
|
"""The structured key family: U non-constant rows of a Walsh-Hadamard
|
|
matrix, truncated to Lp entries.
|
|
|
|
Row 0 of the Sylvester construction is the all-ones vector, which any
|
|
adversary can write down without searching, so the users take rows
|
|
1..U. The construction exists at power-of-two orders, so for other
|
|
key lengths the next power-of-two order is truncated to Lp entries.
|
|
That truncation keeps the entries unit modulus and, at every length
|
|
the evaluation uses, keeps the rows exactly orthogonal as well; the
|
|
measured cross-correlation is reported alongside every sweep point.
|
|
Requires U <= Lp - 1 non-constant rows to exist."""
|
|
n = 1 << max(math.ceil(math.log2(max(Lp, U + 1))), 1)
|
|
H = hadamard(n)
|
|
if H.shape[0] - 1 < U:
|
|
raise ValueError(f"key length {Lp} admits only {H.shape[0]-1} "
|
|
f"non-constant rows, fewer than U={U}")
|
|
return torch.tensor(H[1:U + 1, :Lp].copy(), dtype=torch.float32)
|
|
|
|
|
|
def main_model(iters=4000, P=4, vu=16, d=MAIN_D, U=4):
|
|
"""The main configuration used by every stage below.
|
|
|
|
The keys are frozen to the structured Walsh-Hadamard family rather
|
|
than learned. Unconstrained mask training converges to disjoint
|
|
sparse supports, that is, to an orthogonal slot allocation, which
|
|
collapses the superposition into OMA and leaves the key space far
|
|
smaller than a dense direction in R^L. The structured family is
|
|
dense, exactly orthogonal, and unit modulus, which is also the
|
|
condition the key-refresh invariance argument requires."""
|
|
return get_model(P=P, vu=vu, d=d, U=U, iters=iters,
|
|
freeze_W=base_keys(U, d // P))
|
|
|
|
|
|
def stage_N():
|
|
"""Fig. 2's learned-family curves.
|
|
|
|
The structured family is enumerable and closed under the elementwise
|
|
product, the learned one is neither, so the paper reports both. This
|
|
stage runs the same SNR sweep as stage_A with the keys trained in
|
|
R^L under the regularized loss instead of frozen to Walsh-Hadamard
|
|
rows, at the same frame count, so the two are directly comparable.
|
|
"""
|
|
print("[N] security vs SNR, learned key family ...")
|
|
# the regularized loss of Section V-C, not the cross-entropy alone:
|
|
# see exp_learned.learned_model for why the unpenalized keys are not
|
|
# the family the paper claims
|
|
m = get_model_reg(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1)
|
|
snr = [float(v) for v in range(0, 21, 2)]
|
|
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)
|
|
write_csv(DATA / "sec_snr_learned.csv",
|
|
["snr_db", "legit", "eve_wrong"],
|
|
[(s, legit[i], eve_w[i]) for i, s in enumerate(snr)])
|
|
print(" legit:", [f"{v:.2e}" for v in legit])
|
|
|
|
|
|
def stage_A():
|
|
print("[A] security vs SNR (V=65536) ...")
|
|
m = main_model()
|
|
snr = [float(v) for v in range(0, 21, 2)]
|
|
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_keylen(m.L, s, bits=int(math.log2(m.V))) for s in snr]
|
|
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=MAIN_D, 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 oma_ser_keylen(L, snr_db, bits=16, n_grid=200_000):
|
|
"""Resource-matched OMA reference for the key-length sweep.
|
|
|
|
The OMA user owns d/U = L exclusive real dimensions for its 16 index
|
|
bits at the same per-dimension SNR. For L >= 16 the best use of the
|
|
allocation is antipodal signaling on 16 dimensions with the frame
|
|
energy concentrated on them, an energy gain of L/16 per bit. For
|
|
L < 16 the user must pack 16/L bits per dimension, a 2^(16/L)-ary
|
|
pulse-amplitude constellation, defined when 16/L is an integer and
|
|
reported as nan otherwise.
|
|
"""
|
|
import numpy as np
|
|
if L >= bits:
|
|
return oma_ser([snr_db + 10.0 * math.log10(L / bits)], bits=bits)[0]
|
|
if bits % L:
|
|
return float("nan")
|
|
M = 2 ** (bits // L)
|
|
# M-PAM levels +-A, +-3A, ..., +-(M-1)A with unit AVERAGE symbol energy
|
|
# give A^2 = 3/(M^2-1), so the distance to the decision boundary is A
|
|
# and the Q-function argument is h*sqrt(3*g/(M^2-1)). Using 6 instead
|
|
# of 3 would assume an average energy of two per dimension.
|
|
x = (np.arange(n_grid) + 0.5) / n_grid
|
|
h = np.sqrt(-np.log(1.0 - x))
|
|
g = 10.0 ** (snr_db / 10.0)
|
|
arg = np.clip(h * math.sqrt(3.0 * g / (M * M - 1.0)), 0, 38)
|
|
q = (1.0 - 1.0 / M) * np.array([math.erfc(v / math.sqrt(2.0))
|
|
for v in arg])
|
|
q = np.clip(q, 0.0, 1.0)
|
|
return float(np.mean(1.0 - (1.0 - q) ** L))
|
|
|
|
|
|
def stage_B():
|
|
print("[B] key length (dense grid so the curve is smooth) ...")
|
|
rows = []
|
|
# L = d/P. Lengths 6, 10 and 14 are dropped because the
|
|
# truncated Walsh-Hadamard rows are not exactly orthogonal
|
|
# there, and L=4 admits only three non-constant rows for
|
|
# U=4 users.
|
|
for d in [32, 48, 64, 80, 96, 128, 192, 256]:
|
|
m = main_model(d=d) # same structured family as Fig. 2
|
|
lg = eval_ser_sse(m, [10.0], frames=500_000)[0]
|
|
# Proposition 1 is a statement about the substitute-key ENSEMBLE, so
|
|
# the eavesdropper is averaged over eight draws. A single draw makes
|
|
# the curve jump wherever one key happens to land luckily, which is
|
|
# a property of that draw and not of the key length.
|
|
ev = sum(eval_ser_eve(
|
|
m, eve_wrong_mask(m.users, m.L,
|
|
seed=20260813 + 101 * k).to(DEVICE),
|
|
[10.0], frames=500_000 // 8)[0]
|
|
for k in range(8)) / 8.0
|
|
xc = mean_abs_xcorr(m.masks().detach())
|
|
oma = oma_ser_keylen(m.L, 10.0)
|
|
rows.append((m.L, d, lg, ev, xc, oma))
|
|
print(f" L={m.L:4d} legit={lg:.2e} eve={ev:.3f} xcorr={xc:.4f} "
|
|
f"oma={oma:.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 = main_model()
|
|
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, MAIN_D, 4
|
|
Lp = d // P
|
|
fams = {}
|
|
# random fixed masks
|
|
set_seed(7); fams["random"] = random_mask(U, Lp)
|
|
# Walsh-Hadamard rows (orthogonal). Row 0 of the Sylvester
|
|
# construction is the all-ones vector, which any adversary can write
|
|
# down, so it is excluded and the users take rows 1 to U.
|
|
Hd = base_keys(U, Lp) # the main configuration's key family
|
|
fams["hadamard"] = Hd
|
|
ones = torch.ones(U, Lp) # the cheapest possible guess
|
|
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]
|
|
ev1 = eval_ser_eve(m, ones, [10.0], frames=500_000)[0]
|
|
xc = mean_abs_xcorr(m.masks().detach())
|
|
rows.append((name, lg, ev, ev1, xc))
|
|
print(f" {name:9s} legit={lg:.2e} eve={ev:.3f} ones={ev1:.3f} "
|
|
f"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]
|
|
ev1 = eval_ser_eve(m, ones, [10.0], frames=500_000)[0]
|
|
xc = mean_abs_xcorr(m.masks().detach())
|
|
rows.append(("learned", lg, ev, ev1, xc))
|
|
print(f" {'learned':9s} legit={lg:.2e} eve={ev:.3f} ones={ev1:.3f} "
|
|
f"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]
|
|
evr1 = eval_ser_eve(mr, ones, [10.0], frames=500_000)[0]
|
|
xcr = mean_abs_xcorr(mr.masks().detach())
|
|
rows.append(("learned_reg", lgr, evr, evr1, xcr))
|
|
print(f" {'learn_reg':9s} legit={lgr:.2e} eve={evr:.3f} ones={evr1:.3f} "
|
|
f"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", "eve_ones_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 = main_model()
|
|
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
|
|
lg5 = oma_ser_keylen(m.L, 10.0, bits=int(math.log2(m.V)))
|
|
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_perms=None):
|
|
"""Eve for S3: sees the per-user permuted tx, holds the PUBLIC masks
|
|
but not the permutation, decodes user 0 raw. When eve_perms is given,
|
|
Eve first undoes the permutation she believes was used, which models
|
|
an attacker holding a partially recovered permutation key."""
|
|
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)
|
|
if eve_perms is not None:
|
|
inv = torch.argsort(eve_perms[0]).to(r.device)
|
|
r = r.reshape(n, d)[:, inv].reshape(n, model.P, model.L)
|
|
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 = main_model()
|
|
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 partial_perm(true_perm: torch.Tensor, frac: float, gen: torch.Generator):
|
|
"""A permutation that agrees with true_perm on a fraction frac of the
|
|
positions and is scrambled on the rest, which is what an attacker
|
|
holding part of a permutation key would have."""
|
|
d = true_perm.numel()
|
|
k = int(round(frac * d))
|
|
idx = torch.randperm(d, generator=gen)
|
|
keep, rest = idx[:k], idx[k:]
|
|
out = true_perm.clone()
|
|
if rest.numel() > 1:
|
|
out[rest] = true_perm[rest][torch.randperm(rest.numel(), generator=gen)]
|
|
return out
|
|
|
|
|
|
TRIALS_PERM = 60
|
|
|
|
|
|
def stage_I():
|
|
"""Key sensitivity of three schemes on one axis.
|
|
|
|
The axis is the fraction of the key the attacker has recovered. For
|
|
the proposed scheme that fraction is the normalized correlation
|
|
between the guessed and the true mask. For the permutation scheme it
|
|
is the fraction of positions the guessed permutation places
|
|
correctly. For the index cipher it is the fraction of pad bits the
|
|
attacker knows. Its error rate is the closed form
|
|
1 - (1-p_ch) 2^{-(1-f) log2 V}, the probability of decoding the
|
|
ciphered index over the channel times the probability that the
|
|
unknown pad bits, which stay uniform, are all guessed right.
|
|
"""
|
|
print("[I] key sensitivity across schemes ...")
|
|
m = main_model()
|
|
F = 600_000 # more frames per point for a smooth curve
|
|
TRIALS_MASK = 12 # independent substitute keys per point
|
|
d = m.P * m.L
|
|
true_m = m.masks().detach().cpu()
|
|
gen = torch.Generator().manual_seed(31)
|
|
gp = torch.Generator().manual_seed(11)
|
|
gperm = torch.randperm(d, generator=gp)
|
|
perms = gperm[None].repeat(m.users, 1)
|
|
# a marker grid comparable to the other result figures, with the
|
|
# spacing tightened only where the curves fall
|
|
fracs = [0.0, 0.2, 0.4, 0.6, 0.75, 0.85, 0.9, 0.92, 0.94, 0.955,
|
|
0.97, 0.985, 1.0]
|
|
bits = math.log2(m.V)
|
|
# the channel success of a public-mask receiver, which the cipher
|
|
# cannot exceed even with the full pad
|
|
lg1 = eval_scheme(m, 10.0, 200_000)
|
|
rows = []
|
|
for f in fracs:
|
|
acc_m = []
|
|
for t in range(TRIALS_MASK):
|
|
mt = correlated_masks(true_m, f, gen)
|
|
acc_m.append(eval_ser_eve(m, mt, [10.0],
|
|
frames=F // TRIALS_MASK,
|
|
seed=777 + 17 * t)[0])
|
|
ser_mask = sum(acc_m) / len(acc_m)
|
|
# a partial permutation is combinatorially lumpy, so the point
|
|
# is averaged over independent draws of which positions the
|
|
# attacker holds
|
|
acc = []
|
|
for t in range(TRIALS_PERM):
|
|
pp = partial_perm(gperm, f, gen)
|
|
pperms = pp[None].repeat(m.users, 1)
|
|
acc.append(eval_scheme_permuted_eve(m, 10.0, F // TRIALS_PERM,
|
|
perms, eve_perms=pperms,
|
|
seed=777 + 13 * t))
|
|
ser_perm = sum(acc) / len(acc)
|
|
ser_pad = 1.0 - (1.0 - lg1) * 2.0 ** (-(1.0 - f) * bits)
|
|
rows.append((f, ser_mask, ser_perm, ser_pad))
|
|
print(f" f={f:.3f} mask={ser_mask:.4f} perm={ser_perm:.4f} "
|
|
f"pad={ser_pad:.4f}")
|
|
write_csv(DATA / "sec_sens_cmp.csv",
|
|
["frac", "ser_mask", "ser_perm", "ser_pad"], rows)
|
|
|
|
|
|
def stage_J():
|
|
"""Brute-force search against three schemes at the same key length.
|
|
|
|
Keyed masking: K random unit keys, keep the best correlation, map it
|
|
through the measured sensitivity curve of stage I.
|
|
Permutation key: K random permutations of the d positions, keep the
|
|
one that places the most positions correctly, map the resulting
|
|
fraction through the same sensitivity curve.
|
|
Index cipher: K random pads out of the 2^{log2 V} possible pads, so
|
|
the attacker holds the right pad with probability K/V and still has
|
|
to decode the ciphered index over the channel.
|
|
"""
|
|
print("[J] brute-force search across schemes ...")
|
|
import numpy as np
|
|
cmp_rows = list(csv_rows(DATA / "sec_sens_cmp.csv"))
|
|
f_arr = np.array([float(r["frac"]) for r in cmp_rows])
|
|
mask_arr = np.array([float(r["ser_mask"]) for r in cmp_rows])
|
|
perm_arr = np.array([float(r["ser_perm"]) for r in cmp_rows])
|
|
|
|
d, L, V = MAIN_D, MAIN_D // 4, 65536
|
|
# channel floor of the cipher receiver, read from the stage-I curve
|
|
# at a fully known pad so both figures share one source
|
|
lg1 = 1.0 - (1.0 - float(cmp_rows[-1]["ser_pad"]))
|
|
ks = [1, 3, 10, 30, 100, 300, 1_000, 3_000, 10_000, 30_000, 65_536,
|
|
100_000, 300_000, 1_000_000]
|
|
rng = np.random.default_rng(2026)
|
|
trials = 400
|
|
rows = []
|
|
for K in ks:
|
|
# keyed masking: best |first coordinate| of K random unit vectors
|
|
best_kappa = np.empty(trials)
|
|
best_frac = np.empty(trials)
|
|
for t in range(trials):
|
|
# |first coordinate| of a uniform random unit vector in R^L:
|
|
# its square is Beta(1/2, (L-1)/2), so the best of K draws
|
|
# needs K scalars rather than K*L Gaussians
|
|
best_kappa[t] = np.sqrt(rng.beta(0.5, (L - 1) / 2.0,
|
|
size=K).max())
|
|
# permutation: fraction of fixed points, Binomial(d, 1/d) per
|
|
# draw, so the best of K draws is the max of K such counts
|
|
best_frac[t] = rng.binomial(d, 1.0 / d, size=K).max() / d
|
|
ser_mask = float(np.mean(np.interp(best_kappa, f_arr, mask_arr)))
|
|
ser_perm = float(np.mean(np.interp(best_frac, f_arr, perm_arr)))
|
|
ser_pad = 1.0 - min(1.0, K / V) * (1.0 - lg1)
|
|
rows.append((K, ser_mask, ser_perm, ser_pad,
|
|
float(best_kappa.mean()), float(best_frac.mean())))
|
|
print(f" K={K:8d} mask={ser_mask:.4f} perm={ser_perm:.4f} "
|
|
f"pad={ser_pad:.4f}")
|
|
write_csv(DATA / "sec_brute_cmp.csv",
|
|
["K", "ser_mask", "ser_perm", "ser_pad",
|
|
"best_kappa", "best_frac"], rows)
|
|
|
|
|
|
def csv_rows(path):
|
|
import csv as _csv
|
|
with open(path) as f:
|
|
yield from _csv.DictReader(f)
|
|
|
|
|
|
def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, d=256, n_grid=4096):
|
|
"""OMA under a jammer that concentrates on the victim's slots.
|
|
|
|
An OMA user occupies L = d/U exclusive real dimensions that are
|
|
public, and repeats each of its 16 index bits over L/bits of them,
|
|
combining coherently for an amplitude gain of sqrt(L/bits) per bit.
|
|
This spread allocation is the configuration that serves the OMA user
|
|
best under a jammer, so it is the one the comparison grants it. A
|
|
jammer needs no key to find those public dimensions, but it must
|
|
cover all L of them. With unit energy per real dimension and a total
|
|
jammer energy of rho times the frame energy, spreading over L of the
|
|
d dimensions gives a per-dimension jammer variance of (d/L)*rho,
|
|
which is U*rho.
|
|
|
|
The jammer reaches the victim through its own Rayleigh channel, the
|
|
same convention eval_scheme uses for every simulated scheme, so the
|
|
victim sees an effective noise variance of 1/snr + U*rho*hJ**2 with
|
|
E[hJ**2]=1. Averaging over the independent signal and jammer gains
|
|
uses a product of exponential quantile grids.
|
|
"""
|
|
q = (torch.arange(n_grid, dtype=torch.float64) + 0.5) / n_grid
|
|
h2 = -torch.log1p(-q) # |h|^2 ~ Exp(1)
|
|
hj2 = h2.clone() # |hJ|^2 ~ Exp(1), independent
|
|
h = h2.sqrt()[:, None] # (n,1) signal amplitude
|
|
snr = 10.0 ** (snr_db / 10.0)
|
|
gain = math.sqrt((d / U) / bits) # antipodal amplitude
|
|
out = []
|
|
for jsr_db in jsr_db_list:
|
|
rho = 10.0 ** (jsr_db / 10.0)
|
|
var = (1.0 / snr + U * rho * hj2)[None, :] # (1,n)
|
|
arg = (h * gain / var.sqrt()).clamp(0, 38)
|
|
pe = 0.5 * torch.erfc(arg / math.sqrt(2.0)) # per-bit error
|
|
out.append(float((1.0 - (1.0 - pe) ** bits).mean()))
|
|
return out
|
|
|
|
|
|
def stage_M():
|
|
"""Why the permutation-key scheme shares one permutation.
|
|
|
|
The manuscript asserts that a per-user permutation breaks the trained
|
|
separation, which is the reason the compared scheme is granted a
|
|
shared one. That assertion needs a measurement of its own.
|
|
"""
|
|
print("[M] shared against per-user permutation ...")
|
|
m = main_model()
|
|
d = m.P * m.L
|
|
F = 200_000
|
|
g = torch.Generator().manual_seed(11)
|
|
shared = torch.randperm(d, generator=g)[None].repeat(m.users, 1)
|
|
peruser = torch.stack([torch.randperm(d, generator=g)
|
|
for _ in range(m.users)])
|
|
rows = [("shared", eval_scheme(m, 10.0, F, perms=shared)),
|
|
("per_user", eval_scheme(m, 10.0, F, perms=peruser))]
|
|
for k, v in rows:
|
|
print(" %-9s legit=%.4f" % (k, v))
|
|
write_csv(DATA / "perm_variant.csv", ["variant", "legit_ser"], rows)
|
|
|
|
|
|
def stage_L():
|
|
"""Jamming comparison across schemes at 10 dB.
|
|
|
|
proposed blind : the strongest jammer the proposed scheme admits
|
|
while the key stays secret
|
|
public matched : the jammer a public-mask scheme always faces
|
|
permutation blind: the shuffling-style scheme, whose secret
|
|
permutation also denies the jammer a target
|
|
OMA targeted : the jammer an orthogonal scheme faces, since its
|
|
slot assignment is public and needs no key
|
|
"""
|
|
print("[L] jamming across schemes ...")
|
|
m = main_model()
|
|
F = 300_000
|
|
d = m.P * m.L
|
|
gp = torch.Generator().manual_seed(11)
|
|
perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1)
|
|
jsr = [float(v) for v in range(-10, 21, 2)]
|
|
oma = oma_ser_jammed(10.0, jsr, bits=int(math.log2(m.V)),
|
|
U=m.users, d=m.d)
|
|
rows = []
|
|
for i, j in enumerate(jsr):
|
|
blind = eval_scheme(m, 10.0, F, jam_w="blind", jsr_db=j)
|
|
matched = eval_scheme(m, 10.0, F, jam_w="matched", jsr_db=j)
|
|
perm = eval_scheme(m, 10.0, F, perms=perms, jam_w="blind", jsr_db=j)
|
|
rows.append((j, blind, matched, perm, oma[i]))
|
|
print(f" JSR={j:6.1f} blind={blind:.4f} matched={matched:.4f} "
|
|
f"perm={perm:.4f} oma={oma[i]:.4f}")
|
|
write_csv(DATA / "sec_jam_cmp.csv",
|
|
["jsr_db", "blind", "matched", "perm_blind", "oma_targeted"],
|
|
rows)
|
|
stage_L_gap(rows)
|
|
|
|
|
|
def stage_L_gap(rows):
|
|
"""Store the blind-vs-matched power gap as a raw artifact.
|
|
|
|
For every error level both curves reach, the gap is the extra JSR the
|
|
blind jammer needs to inflict it. Both curves are interpolated on the
|
|
dense grid, so the quoted range comes from a stored file rather than
|
|
from a hand interpolation.
|
|
"""
|
|
import numpy as np
|
|
j = np.array([r[0] for r in rows])
|
|
blind = np.array([r[1] for r in rows])
|
|
matched = np.array([r[2] for r in rows])
|
|
lo = max(blind.min(), matched.min())
|
|
hi = min(blind.max(), matched.max())
|
|
ser = np.linspace(lo, hi, 200)
|
|
jb = np.interp(ser, blind, j)
|
|
jm = np.interp(ser, matched, j)
|
|
gap = jb - jm
|
|
write_csv(DATA / "sec_jam_gap.csv", ["ser", "gap_db"],
|
|
list(zip(ser.tolist(), gap.tolist())))
|
|
print(f" gap: {gap.min():.2f} to {gap.max():.2f} dB "
|
|
f"over SER {lo:.3f} to {hi:.3f}")
|
|
|
|
|
|
CHAIN = ["stage_A", "stage_B", "stage_C", "stage_D", "stage_E", "stage_F",
|
|
"stage_I", "stage_J", "stage_L", "stage_M", "stage_N"]
|
|
|
|
|
|
def main(names=None):
|
|
"""Run the named stages, or the whole chain when none are named.
|
|
|
|
The README maps every figure and table to its stage, so a reader
|
|
reproducing one figure runs that stage alone:
|
|
python code/exp_full.py stage_B
|
|
"""
|
|
print(f"device={DEVICE}")
|
|
todo = names or CHAIN
|
|
unknown = [n for n in todo if n not in CHAIN]
|
|
if unknown:
|
|
raise SystemExit("unknown stage(s): %s\nknown: %s"
|
|
% (", ".join(unknown), ", ".join(CHAIN)))
|
|
for n in todo:
|
|
globals()[n]()
|
|
print("[done] security CSVs in", DATA)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main(sys.argv[1:])
|