Sync with the audited manuscript
The jammed OMA model now matches the transmit chain the paper describes, the key-length sweep averages the eavesdropper over eight substitute-key draws, and verify_math gains the coded-OMA outage reference, symbolic checks of the three algebraic identities, and the cross-period remainder of Proposition 2. The README records the energy and channel conventions.
This commit is contained in:
@@ -212,10 +212,44 @@ chk("learned support overlap 0.10",
|
||||
round(float(md["learned"]["mean_overlap"]), 2) == 0.10,
|
||||
md["learned"]["mean_overlap"])
|
||||
chk("degeneracy numbers in tex",
|
||||
"$5$ to $8$ of the $64$ entries" in tex and "overlap of\n$0.10$" in tex
|
||||
or "$5$ to $8$ of the $64$ entries" in tex and "overlap of $0.10$" in tex,
|
||||
"$5$ to $8$ of the $64$ entries" in tex
|
||||
and "overlapping by $0.10$ on average over user pairs" in " ".join(tex.split()),
|
||||
"searched tex", needs_tex=True)
|
||||
|
||||
# --- key-length sweep floor ------------------------------------------
|
||||
# The eavesdropper column is an average over eight substitute-key draws,
|
||||
# so the quoted floor must track the data and not one lucky draw.
|
||||
kl = rows("sec_keylen.csv")
|
||||
floor = min(float(r["eve_ser"]) for r in kl)
|
||||
chk("eavesdropper floor over key length", abs(floor - 0.9984) < 5e-4,
|
||||
"%.6f" % floor)
|
||||
if HAVE_TEX:
|
||||
chk("quoted eavesdropper floor in tex", "$0.9984$" in tex,
|
||||
"searched tex", needs_tex=True)
|
||||
|
||||
|
||||
# --- tables against their generator -----------------------------------
|
||||
# Every printed table cell must be the one make_tables.py derives from
|
||||
# data/, so a rerun that moves a number cannot leave the manuscript behind.
|
||||
if HAVE_TEX:
|
||||
import io
|
||||
import contextlib
|
||||
import make_tables
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
make_tables.compare_table()
|
||||
make_tables.maskfam_table()
|
||||
make_tables.refresh_tables()
|
||||
rows = [r.strip() for r in buf.getvalue().split("\n")
|
||||
if r.rstrip().endswith(r"\\")]
|
||||
flat = " ".join(tex.split())
|
||||
lost = [r for r in rows if " ".join(r.split()) not in flat]
|
||||
chk("table rows match the generator", not lost,
|
||||
"%d rows, %d missing" % (len(rows), len(lost)), needs_tex=True)
|
||||
for r in lost:
|
||||
print(" missing:", r[:78])
|
||||
|
||||
|
||||
# --- abstract ---------------------------------------------------------
|
||||
a = (tex.split(r"\begin{abstract}")[1].split(r"\end{abstract}")[0].strip()
|
||||
if HAVE_TEX else "")
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Guard against mangled TeX control sequences.
|
||||
|
||||
Shell heredocs silently turn a backslash escape into the control
|
||||
character it names, so \times becomes a tab followed by "imes" and \ref
|
||||
becomes a carriage return followed by "ef". LaTeX compiles both without
|
||||
an error and prints the wreckage, so the build log cannot catch this.
|
||||
This scan can.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TEX = Path(__file__).resolve().parents[1] / "main.tex"
|
||||
CTRL = {"\t": "TAB", "\r": "CR", "\x08": "BS", "\x0c": "FF",
|
||||
"\x07": "BEL", "\x0b": "VT", "\x00": "NUL"}
|
||||
# a macro name shorn of its first letter, which is what the escape ate
|
||||
STUBS = ["ef{", "abel{", "ite{", "extbf{", "extit{", "ext{", "imes",
|
||||
"rac{", "eft(", "ight)", "ho_", "elta", "psilon", "ambda",
|
||||
"igma", "ewline", "otag", "uad", "nderline", "ag{", "egin{",
|
||||
"nd{", "aption{", "ilde{", "ar{", "at{", "ec{"]
|
||||
PAT = re.compile("(?<![" + chr(92)*2 + "A-Za-z0-9])(" +
|
||||
"|".join(re.escape(x) for x in STUBS) + ")")
|
||||
|
||||
|
||||
def main():
|
||||
if not TEX.exists():
|
||||
print(" SKIP tex health :: main.tex not in this package")
|
||||
return 0
|
||||
lines = TEX.read_text(encoding="utf-8").split("\n")
|
||||
hits = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
for ch, name in CTRL.items():
|
||||
if ch in line:
|
||||
hits.append("CTRL %s line %d: %r" % (name, i, line[:90]))
|
||||
for m in PAT.finditer(line):
|
||||
seg = line[max(0, m.start() - 30):m.start() + 30]
|
||||
hits.append("STUB %r line %d: %r" % (m.group(1), i, seg))
|
||||
for h in hits:
|
||||
print(" FAIL " + h)
|
||||
if hits:
|
||||
print("tex health: %d suspicious sequences" % len(hits))
|
||||
return 1
|
||||
print(" PASS tex health :: no mangled control sequences")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+41
-9
@@ -288,8 +288,15 @@ def stage_B():
|
||||
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]
|
||||
ew = eve_wrong_mask(m.users, m.L, seed=20260813).to(DEVICE)
|
||||
ev = eval_ser_eve(m, ew, [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))
|
||||
@@ -735,12 +742,15 @@ 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 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.
|
||||
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
|
||||
@@ -757,13 +767,35 @@ def oma_ser_jammed(snr_db, jsr_db_list, bits=16, U=4, d=256, n_grid=4096):
|
||||
out = []
|
||||
for jsr_db in jsr_db_list:
|
||||
rho = 10.0 ** (jsr_db / 10.0)
|
||||
var = (1.0 / snr + (d / bits) * rho * hj2)[None, :] # (1,n)
|
||||
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.
|
||||
|
||||
|
||||
+28
-20
@@ -34,16 +34,17 @@ FIG.mkdir(exist_ok=True)
|
||||
plt.rcParams.update({
|
||||
"font.family": "serif",
|
||||
"font.serif": ["DejaVu Serif", "Times New Roman"],
|
||||
# The manuscript includes each result figure at 0.70 of a 3.455 in
|
||||
# column while the canvas is 3.15 in, a printed scale of 0.768. Every
|
||||
# The manuscript includes each result figure at 0.74 of a 3.455 in
|
||||
# column while the canvas is 3.15 in, a printed scale of 0.812. Every
|
||||
# size below is therefore pre-divided by that scale so the PRINTED
|
||||
# sizes are 8 pt labels, 7 pt ticks and a 5.8 pt legend. Change the
|
||||
# include width and these must change with it.
|
||||
"font.size": 10.4,
|
||||
"axes.labelsize": 10.4,
|
||||
"legend.fontsize": 7.6,
|
||||
"xtick.labelsize": 9.2,
|
||||
"ytick.labelsize": 9.2,
|
||||
# sizes are 8 pt labels, 7.6 pt ticks and a 6 pt legend at the
|
||||
# smallest rung. Change the include width and these must change
|
||||
# with it.
|
||||
"font.size": 9.9,
|
||||
"axes.labelsize": 9.9,
|
||||
"legend.fontsize": 9.2,
|
||||
"xtick.labelsize": 9.4,
|
||||
"ytick.labelsize": 9.4,
|
||||
"axes.grid": True,
|
||||
"grid.linestyle": "--",
|
||||
"grid.linewidth": 0.4,
|
||||
@@ -67,7 +68,7 @@ LBL = {
|
||||
"legit": "Legitimate",
|
||||
"oma": "OMA",
|
||||
"eve_pub": "Eavesdropper, public masks",
|
||||
"eve_key": "Eavesdropper, wrong key",
|
||||
"eve_key": "Eavesdropper", # the wrong-key condition is in the caption
|
||||
"chance": "Random guess",
|
||||
"nojam": "No jammer",
|
||||
"mask": "Keyed masking",
|
||||
@@ -204,7 +205,7 @@ def main_legit(snr_db="10"):
|
||||
def place_legend(ax, cands=("lower left", "upper left", "center left",
|
||||
"center right", "lower center", "upper right",
|
||||
"upper center", "center", "lower right"),
|
||||
sizes=(7.6, 7.2, 6.8, 6.4, 6.0), ncol=1):
|
||||
sizes=(9.2, 8.8, 8.4, 8.0, 7.6, 7.2), ncol=1):
|
||||
"""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
|
||||
@@ -227,7 +228,8 @@ def place_legend(ax, cands=("lower left", "upper left", "center left",
|
||||
for loc in cands:
|
||||
leg = ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
|
||||
handlelength=1.4, columnspacing=0.9,
|
||||
handletextpad=0.5, borderaxespad=0.55)
|
||||
handletextpad=0.5, borderaxespad=0.55,
|
||||
framealpha=1.0)
|
||||
ax.figure.canvas.draw()
|
||||
lb = _inflate(leg.get_window_extent(), ax.figure)
|
||||
hits = 0
|
||||
@@ -249,12 +251,13 @@ def place_legend(ax, cands=("lower left", "upper left", "center left",
|
||||
if hits == 0:
|
||||
ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
|
||||
handlelength=1.4, columnspacing=0.9,
|
||||
handletextpad=0.5, borderaxespad=0.55)
|
||||
handletextpad=0.5, borderaxespad=0.55,
|
||||
framealpha=1.0)
|
||||
PL_CHOSEN.append(size)
|
||||
return best
|
||||
ax.legend(loc=best[0], prop={"size": best[1]}, ncol=ncol,
|
||||
handlelength=1.4, columnspacing=0.9, handletextpad=0.5,
|
||||
borderaxespad=0.55)
|
||||
borderaxespad=0.55, framealpha=1.0)
|
||||
PL_CHOSEN.append(best[1])
|
||||
return best
|
||||
|
||||
@@ -303,7 +306,7 @@ def fig_keylen():
|
||||
marker="^", ls=":", label=LBL["oma"])
|
||||
ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--",
|
||||
label=LBL["eve_key"])
|
||||
ax.set_ylim(top=6.0) # headroom above the flat eavesdropper curve
|
||||
ax.set_ylim(top=22.0) # headroom above the flat eavesdropper curve
|
||||
ax.set_xlabel("Key length $L$")
|
||||
ax.set_ylabel("SER")
|
||||
ax.set_xscale("log", base=2)
|
||||
@@ -322,18 +325,23 @@ def fig_jam():
|
||||
me = max(1, len(x) // 8)
|
||||
fig, ax = plt.subplots()
|
||||
ax.plot(x, col(r, "matched"), color=C_MATCH, marker="P", ls="--",
|
||||
markevery=me, label=LBL["mask"] + ", matched")
|
||||
markevery=me, label="Public masks")
|
||||
ax.plot(x, col(r, "oma_targeted"), color=C_PUB, marker="^", ls=":",
|
||||
markevery=me, label=LBL["oma"] + ", targeted")
|
||||
markevery=me, label=LBL["oma"])
|
||||
# the two blind curves agree to 0.002; deliberate layering
|
||||
ax.plot(x, col(r, "blind"), color=C_LEGIT, marker="o", ls="-",
|
||||
markevery=(0, me), label=LBL["mask"] + ", blind", **UNDER)
|
||||
markevery=(0, me), label=LBL["mask"], **UNDER)
|
||||
ax.plot(x, col(r, "perm_blind"), color=C_EVE, marker="s", ls="-.",
|
||||
markevery=(me // 2, me), label=LBL["perm"] + ", blind", **OVER)
|
||||
markevery=(me // 2, me), label=LBL["perm"], **OVER)
|
||||
nojam = float(load("sec_jam.csv")[0]["nojam"])
|
||||
# the unjammed reference is named in the caption rather than in the
|
||||
# legend, which keeps the folded legend two rows tall
|
||||
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9)
|
||||
# Behind the legend rather than through it. The placement guard skips
|
||||
# axis-spanning lines, so it cannot move the legend off this one, and
|
||||
# a reference drawn along the legend frame reads as part of the box.
|
||||
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9, zorder=0)
|
||||
ax.set_ylim(-0.42, 1.05)
|
||||
ax.set_yticks([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])
|
||||
ax.set_xlabel("JSR (dB)")
|
||||
ax.set_ylabel("SER")
|
||||
ax.set_xlim(min(x), max(x))
|
||||
|
||||
@@ -188,6 +188,100 @@ def v5_matched_jammer_concentrates():
|
||||
return ok
|
||||
|
||||
|
||||
def v6_coded_oma_outage():
|
||||
"""The genie reference the manuscript concedes: an OMA user coding at
|
||||
the Rayleigh outage limit of its own allocation.
|
||||
|
||||
The user owns L = d/U real dimensions, so d/(2U) complex uses, and
|
||||
carries log2(V) bits. Its per-dimension SNR is the frame SNR, the
|
||||
same convention snr_to_sigma2 sets. Outage is the probability that
|
||||
the instantaneous mutual information falls below that rate."""
|
||||
import math
|
||||
U, d, V = 4, 256, 65536
|
||||
for snr_db in (10.0,):
|
||||
g = 10.0 ** (snr_db / 10.0)
|
||||
uses = d / (2.0 * U) # complex channel uses
|
||||
rate = math.log2(V) / uses # bits per complex use
|
||||
thr = (2.0 ** rate - 1.0) / g # |h|^2 threshold
|
||||
pout = 1.0 - math.exp(-thr) # |h|^2 ~ Exp(1)
|
||||
print(f"V6 coded-OMA outage @ {snr_db:.0f} dB: {pout:.4f} "
|
||||
f"(rate {rate:.3f} bit/use)")
|
||||
ROWS.append(("V6 coded-OMA outage @ %.0f dB" % snr_db,
|
||||
"%.4f" % pout, "%.6f" % pout, "0", "0", "REFERENCE"))
|
||||
return True
|
||||
|
||||
|
||||
def v7_symbolic_identities():
|
||||
"""Symbolic verification of the three algebraic identities. Monte
|
||||
Carlo cannot check an identity, only an instance of it."""
|
||||
try:
|
||||
import sympy as sp
|
||||
except ImportError:
|
||||
print("V7 symbolic: sympy not installed, SKIPPED")
|
||||
ROWS.append(("V7 symbolic identities", "-", "-", "-", "-", "SKIPPED"))
|
||||
return True
|
||||
ok = True
|
||||
|
||||
# refresh entropy: L sign bits, an entry permutation, a user permutation
|
||||
L, Uu = 64, 4
|
||||
ent = sp.Integer(L) + sp.log(sp.factorial(L), 2) + sp.log(sp.factorial(Uu), 2)
|
||||
ok &= abs(float(ent) - 364.6) < 0.05
|
||||
print("V7a refresh entropy %.3f bits/block" % float(ent))
|
||||
|
||||
# fixed-key entropy: ordered choices of U of the L-1 non-constant rows
|
||||
fixed = sp.log(sp.factorial(L - 1) / sp.factorial(L - 1 - Uu), 2)
|
||||
ok &= abs(float(fixed) - 23.8) < 0.05
|
||||
ok &= sp.factorial(L - 1) / sp.factorial(L - 1 - Uu) == 14295960
|
||||
print("V7b fixed-key entropy %.3f bits, %d choices"
|
||||
% (float(fixed), sp.factorial(L - 1) / sp.factorial(L - 1 - Uu)))
|
||||
|
||||
# the termwise Hadamard identity behind eq:hadamard
|
||||
n = 4
|
||||
e = sp.Matrix(sp.symbols("e1:%d" % (n + 1)))
|
||||
m = sp.Matrix(sp.symbols("m1:%d" % (n + 1)))
|
||||
f = sp.Matrix(sp.symbols("f1:%d" % (n + 1)))
|
||||
lhs = sum((sp.matrix_multiply_elementwise(e, m))[k] *
|
||||
(sp.matrix_multiply_elementwise(f, m))[k] for k in range(n))
|
||||
rhs = (e.T * sp.diag(*[m[k] ** 2 for k in range(n)]) * f)[0]
|
||||
ok &= sp.simplify(lhs - rhs) == 0
|
||||
print("V7c (e*m).(f*m) == e^T diag(m^2) f :",
|
||||
sp.simplify(lhs - rhs) == 0)
|
||||
|
||||
ROWS.append(("V7 symbolic identities", "exact", "exact", "0", "0",
|
||||
"PASS" if ok else "FAIL"))
|
||||
return ok
|
||||
|
||||
|
||||
def v8_cross_period_terms():
|
||||
"""Proposition 2 keeps only the diagonal of the jammer projection.
|
||||
The periodic key makes entries one period apart identical, so the
|
||||
cross-period terms do not vanish termwise. They are zero mean over
|
||||
the codebook, which is the claim the proof rests on."""
|
||||
import math
|
||||
import torch
|
||||
from exp_full import main_model
|
||||
torch.manual_seed(7)
|
||||
m = main_model()
|
||||
Bn = m.unit_codebook().detach().cpu()
|
||||
pat = m.masks().detach().cpu()[0]
|
||||
L, P, d = m.L, m.P, m.d
|
||||
rel = []
|
||||
for _ in range(300):
|
||||
w = torch.randn(d)
|
||||
w /= w.norm()
|
||||
i = torch.randint(m.vu, (P,))
|
||||
e = (Bn[i] / math.sqrt(P)).reshape(-1)
|
||||
a = (w * e).reshape(P, L) * pat[None, :]
|
||||
diag = float((a ** 2).sum())
|
||||
rel.append((float((a.sum(0) ** 2).sum()) - diag) / diag)
|
||||
mean = sum(rel) / len(rel)
|
||||
ok = abs(mean) < 0.01
|
||||
print("V8 cross-period remainder, mean %+.4f of the retained term" % mean)
|
||||
ROWS.append(("V8 cross-period remainder", "0.0", "%.6f" % mean,
|
||||
"%.6f" % abs(mean), "0.01", "PASS" if ok else "FAIL"))
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
print(f"config d={D} U={U} V={V}\n")
|
||||
results = {
|
||||
@@ -196,6 +290,9 @@ def main():
|
||||
"V3": v3_leakage_vs_correlation(),
|
||||
"V4": v4_blind_jammer_spread(),
|
||||
"V5": v5_matched_jammer_concentrates(),
|
||||
"V6": v6_coded_oma_outage(),
|
||||
"V7": v7_symbolic_identities(),
|
||||
"V8": v8_cross_period_terms(),
|
||||
}
|
||||
print("\nsummary:", {k: ("PASS" if v else "FAIL") for k, v in results.items()})
|
||||
print("ALL PASS" if all(results.values()) else "SOME FAILED")
|
||||
|
||||
Reference in New Issue
Block a user