Main configuration d=256, L=64: all data, figures and checks re-run

Every OMA reference takes the L/16 combining gain so the comparison
stays resource matched, four hardcoded copies of the configuration are
replaced by MAIN_D or the main curve, and stage_J's K-by-L Gaussian
draw becomes its exact scalar Beta equivalent.
This commit is contained in:
KiHoLee
2026-08-18 14:40:48 +09:00
parent fef629a218
commit 8f26bf9bc9
34 changed files with 691 additions and 544 deletions
+47 -27
View File
@@ -47,15 +47,24 @@ lg = [float(x["legit"]) for x in sn]
om = [float(x["oma"]) for x in sn]
rel = [(a - b) / b * 100 for a, b in zip(lg, om)]
chk("legit below OMA at every SNR", max(rel) < 0, "max relative %+.2f%%" % max(rel))
chk("gain 1.3 to 7.9 percent",
round(-max(rel), 1) == 1.3 and round(-min(rel), 1) == 7.9,
chk("gain 24 to 35 percent",
round(-max(rel)) == 24 and round(-min(rel)) == 35,
"%.2f to %.2f percent" % (-max(rel), -min(rel)))
chk("1.3 and 7.9 in tex", "$1.3$ to\n$7.9$~percent" in tex or "$1.3$ to $7.9$~percent" in tex,
chk("24 and 35 in tex", "$24$ to\n$35$~percent" in tex or "$24$ to $35$~percent" in tex,
"searched tex", needs_tex=True)
ew = [float(x["eve_wrong"]) for x in sn]
ch = float(sn[0]["chance"])
chk("outsider at chance to 2e-5", max(abs(x - ch) for x in ew) < 2e-5,
"max deviation %.2e" % max(abs(x - ch) for x in ew))
dev = max(abs(x - ch) for x in ew)
chk("outsider at chance to 3.5e-4", dev < 3.6e-4, "max deviation %.2e" % dev)
chk("3.5e-4 in tex", "$3.5\\times10^{-4}$" in tex, "searched tex",
needs_tex=True)
# the main configuration's legitimate rate, the reference every later
# assertion compares against; taken from the curve the main
# configuration produced rather than looked up by key length
MAIN_LEGIT = [float(x["legit"]) for x in sn if float(x["snr_db"]) == 10][0]
chk("main legitimate 0.053", round(MAIN_LEGIT, 3) == 0.053,
"%.4f" % MAIN_LEGIT)
# --- Fig. 3: key-length ratio ----------------------------------------
k = rows("sec_keylen.csv")
@@ -70,11 +79,10 @@ chk("keys exactly orthogonal in the sweep",
# --- Fig. 4: jamming --------------------------------------------------
g = col("sec_jam_gap.csv", "gap_db")
chk("gap 5.5-6.3 dB", round(min(g), 1) == 5.5 and round(max(g), 1) == 6.3,
chk("gap 10.1-11.1 dB", round(min(g), 1) == 10.1 and round(max(g), 1) == 11.1,
"%.3f to %.3f" % (min(g), max(g)))
lin = (10 ** (min(g) / 10), 10 ** (max(g) / 10))
chk("about four times power", lin[0] < 4.5 and lin[1] > 3.4,
"%.2f to %.2f" % lin)
chk("more than ten times power", lin[0] > 10.0, "%.2f to %.2f" % lin)
j = rows("sec_jam_cmp.csv")
dmax = max(abs(float(r["blind"]) - float(r["perm_blind"])) for r in j)
chk("within 0.002", dmax <= 0.002, "%.5f" % dmax)
@@ -83,32 +91,40 @@ chk("no stale 8.1 dB", "$8.1$~dB" not in tex, "searched tex", needs_tex=True)
# --- Fig. 6: brute force ---------------------------------------------
b = rows("sec_brute_cmp.csv")
sm = float(b[-1]["ser_mask"])
chk("brute 0.59 at 1e6", round(sm, 2) == 0.59, "%.4f" % sm)
chk("0.59 in tex", "$0.59$" in tex, "searched tex", needs_tex=True)
pad0 = next((x["K"] for x in b if float(x["ser_pad"]) < 0.27), None)
chk("brute 0.67 at 1e6", round(sm, 2) == 0.67, "%.4f" % sm)
chk("0.67 in tex", "$0.67$" in tex, "searched tex", needs_tex=True)
closed = (ch - sm) / (ch - MAIN_LEGIT)
chk("brute closes about a third", 0.30 < closed < 0.40, "%.3f" % closed)
bf = float(b[-1]["best_frac"]) * 100
chk("permutation 3.4 percent of positions", round(bf, 1) == 3.4, "%.2f" % bf)
pad0 = next((x["K"] for x in b if float(x["ser_pad"]) < 0.1), None)
chk("index cipher collapses at 65536", pad0 == "65536", str(pad0))
# --- Fig. 7: known plaintext -----------------------------------------
kp = rows("kpa.csv")
legit = float([x for x in k if int(x["L"]) == 16][0]["legit_ser"])
legit = MAIN_LEGIT
thr = legit * 1.02
first20 = next((x["n_frames"] for x in kp
if int(x["snr_db"]) == 20 and float(x["eve_ser"]) <= thr), None)
first10 = next((x["n_frames"] for x in kp
if int(x["snr_db"]) == 10 and float(x["eve_ser"]) <= thr), None)
chk("KPA five frames at 20 dB", first20 == "5", "first N = %s" % first20)
chk("KPA twenty-four frames at 10 dB", first10 == "24", "first N = %s" % first10)
chk("KPA three frames at 20 dB", first20 == "3", "first N = %s" % first20)
chk("KPA ten frames at 10 dB", first10 == "10", "first N = %s" % first10)
kp0 = [x for x in kp if int(x["snr_db"]) == 0]
w0 = float(kp0[-1]["eve_ser"]) / legit
chk("0 dB no longer holds", w0 < 1.03, "64 frames reach %.3f of legitimate" % w0)
pk = rows("pkpa.csv")
p6 = float([x for x in pk if x["n_frames"] == "6"][0]["eve_ser"])
chk("perm KPA at N=6 near its own 0.258", abs(p6 - 0.258) < 0.005, "%.4f" % p6)
chk("perm KPA at N=6 near its own legitimate",
abs(p6 - MAIN_LEGIT) < 0.005, "%.4f" % p6)
# --- refresh ----------------------------------------------------------
rs = {x["scheme"]: x for x in rows("refresh_summary.csv")}
chk("refresh 64.8 bits",
round(float(rs["Invariant"]["entropy_bits"]), 1) == 64.8,
chk("refresh 364.6 bits",
round(float(rs["Invariant"]["entropy_bits"]), 1) == 364.6,
"%.3f" % float(rs["Invariant"]["entropy_bits"]))
chk("fixed key 15.0 bits",
round(float(rs["None (fixed key)"]["entropy_bits"]), 1) == 15.0,
chk("fixed key 23.8 bits",
round(float(rs["None (fixed key)"]["entropy_bits"]), 1) == 23.8,
"%.4f" % float(rs["None (fixed key)"]["entropy_bits"]))
chk("invariant refresh free",
abs(float(rs["Invariant"]["legit"]) - float(rs["None (fixed key)"]["legit"]))
@@ -119,8 +135,8 @@ chk("invariant refresh free",
import json
st = json.loads((base / "data" / "real_sec_stats.json").read_text())
rec = st["recovery"]["28"]
chk("headline recovery 78 vs 76 percent",
round(rec["legit"] * 100) == 78 and round(rec["oma"] * 100) == 76,
chk("headline recovery 96 vs 93 percent",
round(rec["legit"] * 100) == 96 and round(rec["oma"] * 100) == 93,
"%.1f vs %.1f" % (rec["legit"] * 100, rec["oma"] * 100))
chk("legit leads OMA at every point",
all(st["recovery"][s]["legit"] > st["recovery"][s]["oma"]
@@ -137,19 +153,23 @@ chk("L=8 crowding, proposal behind OMA",
chk("0.949 and 0.685 in tex", "0.949" in tex and "0.685" in tex,
"searched tex", needs_tex=True)
# the Fig. 2 inset plots this ratio, so its stated span must hold
# the OMA-to-proposed ratio the narration quotes
sr = rows("sec_snr.csv")
rt = [float(r["oma"]) / float(r["legit"]) for r in sr]
chk("inset ratio spans 1.01 to 1.09", 1.005 < min(rt) and max(rt) < 1.095,
"%.3f to %.3f" % (min(rt), max(rt)))
chk("ratio spans 1.32 to 1.54", round(min(rt), 2) == 1.32
and round(max(rt), 2) == 1.54, "%.3f to %.3f" % (min(rt), max(rt)))
# the three secrets named in the setup
chk("secret sizes UL=64, perm 64, pad 16",
all(t in tex for t in ["$UL=64$ key entries",
"one permutation of $64$ positions",
chk("secret sizes UL=256, perm 256, pad 16",
all(t in tex for t in ["$UL=256$ key entries",
"one permutation of $256$ positions",
"$16$ pad\nbits per user"]),
"searched tex", needs_tex=True)
chk("no stale d=64 configuration in tex",
"$d=64$ real dimensions" not in tex and "$d/U=16$" not in tex,
"searched tex", needs_tex=True)
# Fig. 5 shows the permutation curve tracking the mask curve
sc = rows("sec_sens_cmp.csv")
dv = max(abs(float(r["ser_mask"]) - float(r["ser_perm"])) for r in sc)
+8 -9
View File
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
"""Where does the legitimate advantage over OMA go?
An ideal M-ary receiver at the main configuration should reach 0.199 at
10 dB against the 0.275 of resource-matched OMA, a factor of 1.38, while
the system measures 0.257, a factor of 1.07. This script splits the
shortfall into its two causes: residual multi-user interference, which
orthogonal keys do not remove because masking is elementwise, and the
distance the trained unit codebook falls short of an orthogonal set.
The legitimate curve sits above the single-user M-ary bound, and this
script splits the distance into its two possible causes: residual
multi-user interference, which orthogonal keys need not remove because
masking is elementwise, and the distance the trained unit codebook falls
short of an orthogonal set. Run it against whichever configuration
exp_full.MAIN_D currently names.
"""
import math
import sys
@@ -17,7 +17,7 @@ import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sse_lib as L
from sse_lib import DEVICE, snr_to_sigma2, rayleigh_gain
from exp_full import main_model
from exp_full import main_model, oma_ser_keylen
SNR_DB = 10.0
FRAMES = 400_000
@@ -80,8 +80,7 @@ def main():
print("user-0 SER, all four users transmitting : %.4f" % four)
print("user-0 SER, other users silent : %.4f" % solo)
print("OMA, resource matched (closed form) : %.4f"
% L.oma_ser([SNR_DB])[0])
print("ideal 16-ary orthogonal (separate MC) : 0.1986")
% oma_ser_keylen(m.L, SNR_DB))
if __name__ == "__main__":
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Does unconstrained key training still degenerate at the main configuration?
The manuscript justifies fixing the keys by a measured failure: with the
keys free, training drives them to disjoint sparse supports, which is an
orthogonal slot allocation rather than a superposition, and which shrinks
the key space to the choice of a support. That was measured at d=64 and
has to be re-measured whenever the configuration moves, because it is
the reason the structured family is the main one.
Reported per user key: the number of entries holding 99 percent of the
energy, and the pairwise overlap of those supports. A dense key spreads
its energy over most of the L entries and the supports coincide; a
degenerate one concentrates on a few and the supports are disjoint.
"""
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
from exp_full import get_model, main_model, MAIN_D
def support99(w):
"""Smallest set of entries carrying 99 percent of the key energy."""
e = w.pow(2)
order = torch.argsort(e, descending=True)
c = torch.cumsum(e[order], 0) / e.sum()
k = int((c < 0.99).sum()) + 1
return set(order[:k].tolist()), k
def describe(name, W):
L = W.shape[1]
sups, ks = [], []
for u in range(W.shape[0]):
sup, k = support99(W[u])
sups.append(sup)
ks.append(k)
ov = []
for i in range(len(sups)):
for j in range(i + 1, len(sups)):
ov.append(len(sups[i] & sups[j]) / max(1, min(len(sups[i]),
len(sups[j]))))
print("%-14s L=%3d 99%%-energy entries per key: %s "
"mean pairwise support overlap %.2f"
% (name, L, ks, sum(ov) / len(ov)))
def main():
print("main configuration d=%d" % MAIN_D)
m_free = get_model(iters=4000) # keys learned, nothing frozen
describe("learned", m_free.masks().detach().cpu())
m_fix = main_model()
describe("Walsh-Hadamard", m_fix.masks().detach().cpu())
print()
print("A degenerate key set shows few entries per key and near-zero")
print("overlap; a dense one shows most entries and overlap near one.")
if __name__ == "__main__":
main()
+14 -13
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""Does an orthogonal unit codebook recover the shortfall?
diag_interference shows the gap to the ideal M-ary receiver is not
diag_interference shows the gap to the single-user M-ary bound is not
multi-user interference but the geometry of the trained unit codebook,
whose Gram matrix carries a root-mean-square off-diagonal of 0.45 where
an orthogonal set would carry zero. Vu = L = 16 admits an exactly
orthogonal set, so this measures what installing one buys.
whose Gram matrix carries a large root-mean-square off-diagonal where an
orthogonal set would carry zero. Vu <= L admits an exactly orthogonal
set, so this measures what installing one buys.
Two orthogonal sets are tried, because the choice is not free. The
Walsh-Hadamard set collides with the keys: the rows are closed under the
@@ -22,7 +22,7 @@ import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
import sse_lib as L
from sse_lib import DEVICE, SSE
from exp_full import hadamard, base_keys
from exp_full import hadamard, base_keys, oma_ser_keylen, MAIN_D
from diag_interference import ser
SNR = [0.0, 10.0, 20.0]
@@ -39,13 +39,13 @@ def fixed_model(B, P=4, vu=16, d=64, U=4):
return m
def hadamard_book(vu=16, Lp=16):
def hadamard_book(vu=16, Lp=MAIN_D // 4):
B = torch.zeros(vu, Lp)
B[:, :vu] = torch.tensor(hadamard(vu).copy(), dtype=torch.float32)
return B
def random_ortho_book(vu=16, Lp=16, seed=7):
def random_ortho_book(vu=16, Lp=MAIN_D // 4, seed=7):
g = torch.Generator().manual_seed(seed)
A = torch.randn(Lp, Lp, generator=g)
Q, _ = torch.linalg.qr(A)
@@ -68,13 +68,14 @@ def main():
% ("unit codebook", "max|off|", "0 dB", "10 dB", "20 dB"))
report("Walsh-Hadamard", fixed_model(hadamard_book()))
report("random orthogonal", fixed_model(random_ortho_book()))
print("%-22s %-10s %-9s %-9s %-9s"
% ("trained (paper)", "0.887", "0.8822", "0.2576", "0.0307"))
from exp_full import main_model
report("trained", main_model())
Lp = MAIN_D // 4
print("%-22s %-10s %-9s %-9s %-9s"
% ("OMA, resource matched", "-",
"%.4f" % L.oma_ser([0.0])[0],
"%.4f" % L.oma_ser([10.0])[0],
"%.4f" % L.oma_ser([20.0])[0]))
"%.4f" % oma_ser_keylen(Lp, 0.0),
"%.4f" % oma_ser_keylen(Lp, 10.0),
"%.4f" % oma_ser_keylen(Lp, 20.0)))
@@ -97,7 +98,7 @@ def solo_check():
print("%-22s %-12.4f %-12.4f"
% (name, ser(m, 10.0, FRAMES, solo=True),
ser(m, 10.0, FRAMES, solo=False)))
print("single-user ideal M-ary bound (separate MC): 0.1986")
print("(solo isolates the candidate set from the superposition)")
if __name__ == "__main__":
+30 -21
View File
@@ -2,7 +2,7 @@
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
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
@@ -136,7 +136,11 @@ def eve_wrong_mask(U, Lp, seed):
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=""):
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)
@@ -169,7 +173,7 @@ def base_keys(U: int, Lp: int) -> torch.Tensor:
return torch.tensor(H[1:U + 1, :Lp].copy(), dtype=torch.float32)
def main_model(iters=4000, P=4, vu=16, d=64, U=4):
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
@@ -195,7 +199,7 @@ def stage_A():
# 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)))
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",
@@ -236,7 +240,7 @@ def train_sse_reg(m: SSE, iters=4000, batch=256, lr=3e-3, seed=1,
return m
def get_model_reg(P=4, vu=16, d=64, U=4, iters=4000, seed=1):
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)
@@ -314,7 +318,7 @@ def stage_C():
def stage_D():
print("[D] mask families ...")
P, vu, d, U = 4, 16, 64, 4
P, vu, d, U = 4, 16, MAIN_D, 4
Lp = d // P
fams = {}
# random fixed masks
@@ -475,8 +479,7 @@ def stage_E():
# 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]
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",
@@ -688,7 +691,7 @@ def stage_J():
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 = 64, 16, 65536
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"]))
@@ -702,9 +705,11 @@ def stage_J():
best_kappa = np.empty(trials)
best_frac = np.empty(trials)
for t in range(trials):
g = rng.standard_normal((K, L))
g /= np.linalg.norm(g, axis=1, keepdims=True)
best_kappa[t] = np.abs(g[:, 0]).max()
# |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
@@ -726,14 +731,16 @@ def csv_rows(path):
yield from _csv.DictReader(f)
def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, n_grid=4096):
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 d/U exclusive real dimensions that are public,
so a jammer needs no key to put all of its power there. With unit
energy per real dimension and a total jammer energy of rho times the
frame energy, concentrating on d/U of the d dimensions gives a
per-dimension jammer variance of U*rho.
An OMA user occupies L = d/U exclusive real dimensions that are
public, and drives its 16 index bits on 16 of them with the whole
allocation energy, an amplitude gain of sqrt(L/bits) per bit. A
jammer needs no key to put all of its power on those same public
dimensions. With unit energy per real dimension and a total jammer
energy of rho times the frame energy, concentrating on bits of the d
dimensions gives a per-dimension jammer variance of (d/bits)*rho.
The jammer reaches the victim through its own Rayleigh channel, the
same convention eval_scheme uses for every simulated scheme, so the
@@ -746,11 +753,12 @@ def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, n_grid=4096):
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 / var.sqrt()).clamp(0, 38)
var = (1.0 / snr + (d / bits) * 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
@@ -774,7 +782,8 @@ def stage_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)
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)
+12 -5
View File
@@ -29,7 +29,7 @@ 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 main_model, eve_wrong_mask
from exp_full import main_model, eve_wrong_mask, MAIN_D
SNR_GRID = [0, 4, 8, 12, 16, 20, 24, 28]
# headline recovery is meaningful only where the legitimate user clears
@@ -101,15 +101,22 @@ def wrong_keyed(model: SSE, digits_all, snr_db, seed, rx_masks=None,
@torch.no_grad()
def wrong_oma(ids_all, snr_db, seed, bits=16):
"""Antipodal signaling on the actual token bits, same frame energy."""
def wrong_oma(ids_all, snr_db, seed, bits=16, d=256, users=4):
"""Antipodal signaling on the actual token bits, same frame energy.
The OMA user owns d/U exclusive dimensions for its 16 bits and puts
the whole allocation energy on them, so the antipodal amplitude
carries a factor sqrt((d/U)/bits) over the one-bit-per-dimension
case. Without it the reference would spend only a quarter of the
energy the proposed user spends."""
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)))
gain = math.sqrt((d / users) / bits)
h = rayleigh_gain((N, Uu, 1))
y = h * b + sigma * torch.randn(N, Uu, bits, device=DEVICE)
y = gain * h * b + sigma * torch.randn(N, Uu, bits, device=DEVICE)
return ((y * b) < 0).any(dim=2).cpu()
@@ -136,7 +143,7 @@ def main():
f"distinct tokens, max id {int(ids_all.max())}")
# keys and codebook trained on uniform indices, reused unchanged
model = main_model(P=P_MAX, vu=VU, d=64, U=U)
model = main_model(P=P_MAX, vu=VU, d=MAIN_D, U=U)
model.eval()
eve_m = eve_wrong_mask(U, model.L, seed=20260813) # outsider
+3 -3
View File
@@ -26,7 +26,7 @@ orthogonal. Two constructions are compared here.
the codebook together, which is a relabeling, log2(L!) bits
3. a permutation of which user holds which row, log2(U!) bits
At L=16 and U=4 that is 16 + 44.25 + 4.58 = 64.8 bits per block, and
At L=64 and U=4 that is 64 + 296.0 + 4.58 = 364.6 bits per block, and
each transformation is verified below to leave the legitimate error
rate unchanged.
@@ -45,7 +45,7 @@ import numpy as np
import torch
from sse_lib import DATA, DEVICE, SSE, write_csv, eval_ser_sse
from exp_full import (hadamard, get_model, base_keys, eval_ser_eve,
from exp_full import (MAIN_D, hadamard, get_model, base_keys, eval_ser_eve,
eve_wrong_mask)
from exp_kpa import collect_known_plaintext, solve_keys
@@ -92,7 +92,7 @@ def install(model: SSE, keys: torch.Tensor, codebook: torch.Tensor,
def main():
P, VU, D, U = 4, 16, 64, 4
P, VU, D, U = 4, 16, MAIN_D, 4
Lp = D // P
print(f"[K] refresh: L={Lp}, U={U}, "
f"{entropy_bits(U, Lp):.1f} bits per block from the invariance group")
+81 -33
View File
@@ -129,6 +129,24 @@ def save(fig, name, insets=()):
raise RuntimeError(
f"{name}: a data curve passes under the legend "
f"box; move the legend or shrink it")
for t in ax.texts:
tb = t.get_window_extent()
if (lb.x0 < tb.x1 and tb.x0 < lb.x1
and lb.y0 < tb.y1 and tb.y0 < lb.y1):
raise RuntimeError(
f"{name}: the annotation {t.get_text()!r} sits under "
f"the legend box; move one of them")
for t in ax.texts:
tb = t.get_window_extent()
for line in ax.get_lines():
xy = line.get_xydata()
if len(xy) == 0:
continue
for px, py in ax.transData.transform(xy):
if tb.x0 <= px <= tb.x1 and tb.y0 <= py <= tb.y1:
raise RuntimeError(
f"{name}: a curve is drawn through the "
f"annotation {t.get_text()!r}; move it")
for ins in insets:
ib = ins.get_window_extent()
for a in fig.axes:
@@ -148,6 +166,53 @@ def save(fig, name, insets=()):
print("[OK]", name)
def main_legit(snr_db="10"):
"""The legitimate SER of the main configuration, read from the curve
the main configuration produced rather than looked up by key length."""
for r in load("sec_snr.csv"):
if float(r["snr_db"]) == float(snr_db):
return float(r["legit"])
raise KeyError("no %s dB row in sec_snr.csv" % snr_db)
def place_legend(ax, cands=("lower left", "center left", "center right",
"lower center", "upper right", "upper center",
"center", "lower right"),
sizes=(6.6, 6.2, 5.8, 5.4, 5.0)):
"""Choose the location and font size whose box the fewest curve points
fall inside, scored on rendered geometry rather than guessed from the
data. The size sweep is what makes a long label set placeable: a
five-entry legend of full scheme names has no clear corner at the
default size on every figure."""
best = None
for size in sizes:
for loc in cands:
leg = ax.legend(loc=loc, prop={"size": size})
ax.figure.canvas.draw()
lb = leg.get_window_extent()
hits = 0
for line in ax.get_lines():
xy = line.get_xydata()
if len(xy) == 0:
continue
for px, py in ax.transData.transform(xy):
if lb.x0 <= px <= lb.x1 and lb.y0 <= py <= lb.y1:
hits += 1
for t in ax.texts:
tb = t.get_window_extent()
if (lb.x0 < tb.x1 and tb.x0 < lb.x1
and lb.y0 < tb.y1 and tb.y0 < lb.y1):
hits += 50 # an annotation hidden is worse than a
# few curve points clipped
if best is None or hits < best[2]:
best = (loc, size, hits)
if hits == 0:
ax.legend(loc=loc, prop={"size": size})
return best
ax.legend(loc=best[0], prop={"size": best[1]})
return best
def fig_snr():
r = load("sec_snr.csv")
x = col(r, "snr_db")
@@ -167,22 +232,8 @@ def fig_snr():
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x))
ax.legend(loc="lower left")
# the gap is a coding gain of a few percent, invisible against two
# decades of SER, so an inset reports it as a ratio
lg, om = col(r, "legit"), col(r, "oma")
ins = ax.inset_axes([0.57, 0.58, 0.39, 0.25])
ins.plot(x, [o / l for l, o in zip(lg, om)], color=C_OMA, lw=1.0,
marker="^", ms=2.4, markevery=2)
ins.axhline(1.0, color="0.55", lw=0.6, ls="--")
ins.set_xlim(min(x), max(x))
ins.set_ylim(0.995, 1.105)
ins.set_yticks([1.00, 1.05, 1.10])
ins.set_xticks([0, 10, 20])
ins.tick_params(labelsize=5.2, length=1.8, pad=1.0)
ins.set_title("OMA / proposed SER", fontsize=5.6, pad=1.5)
save(fig, "fig_sec_snr", insets=[ins])
place_legend(ax)
save(fig, "fig_sec_snr")
def fig_keylen():
@@ -204,7 +255,7 @@ def fig_keylen():
ax.set_xscale("log", base=2)
# the curves sweep the upper-left to lower-right diagonal, leaving the
# lower-left corner empty
ax.legend(loc="lower left")
place_legend(ax)
save(fig, "fig_sec_keylen")
@@ -228,14 +279,13 @@ def fig_jam():
ax.plot(x, col(r, "perm_blind"), color=C_EVE, marker="s", ls="-.",
markevery=(me // 2, me), label=LBL["perm"] + ", blind", **OVER)
nojam = float(load("sec_jam.csv")[0]["nojam"])
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9)
ax.text(max(x) - 0.6, nojam + 0.02, LBL["nojam"], ha="right",
va="bottom", fontsize=7.4, color="#555555")
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), 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="center right", bbox_to_anchor=(0.985, 0.47))
ax.set_ylim(0.8 * nojam, 1.02)
place_legend(ax)
save(fig, "fig_sec_jam")
@@ -257,7 +307,7 @@ def fig_sens():
ax.set_xlabel("Fraction of the key recovered")
ax.set_ylabel("Eavesdropper SER")
ax.set_xlim(0, 1)
ax.legend(loc="lower left")
place_legend(ax)
save(fig, "fig_sec_sens")
@@ -273,13 +323,12 @@ def fig_brute():
label=LBL["pad"], **OVER)
ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
label=LBL["mask"])
kl = load("sec_keylen.csv")
legit = float([q for q in kl if int(q["L"]) == 16][0]["legit_ser"])
legit = main_legit()
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
ax.set_xlabel("Number of key guesses $K$")
ax.set_ylabel("Eavesdropper SER")
ax.set_ylim(0.2, 1.05)
ax.legend(loc="lower left")
ax.set_ylim(0.8 * legit, 1.05)
place_legend(ax)
save(fig, "fig_sec_brute")
@@ -299,7 +348,7 @@ def fig_real():
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("TER")
ax.set_xlim(min(x), max(x))
ax.legend(loc="lower left")
place_legend(ax)
save(fig, "fig_sec_real")
@@ -325,10 +374,9 @@ def fig_kpa():
print("[skip] pkpa.csv not present yet")
# legitimate reference measured with the SAME estimator as the
# eavesdropper curves, namely the four-user average of eval_ser_sse
# at L=16, taken from sec_keylen.csv rather than from the user-1
# convention of the scheme-comparison table
kl = load("sec_keylen.csv")
legit = float([r for r in kl if int(r["L"]) == 16][0]["legit_ser"])
# in the main configuration, rather than the user-1 convention of the
# scheme-comparison table
legit = main_legit()
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
ax.set_xlabel("Known-plaintext frames $N$")
ax.set_ylabel("Eavesdropper SER")
@@ -336,7 +384,7 @@ def fig_kpa():
# the 0 dB curve sweeps the upper-right, so anchor the legend at the
# top edge past the steep drops, above every curve at large N
ax.set_ylim(top=1.18)
ax.legend(loc="upper right", bbox_to_anchor=(1.0, 1.04))
place_legend(ax)
save(fig, "fig_sec_kpa")
+1 -1
View File
@@ -35,7 +35,7 @@ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# ----------------------------------------------------------------------
# global configuration
# ----------------------------------------------------------------------
D = 64 # embedding dimension (real)
D = 256 # embedding dimension (real)
U = 4 # users
VU = 16 # unit codebook size
P_MAX = 4 # periods for the main configuration, V = 16^4 = 65536