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:
KiHoLee
2026-08-22 14:47:17 +09:00
parent 0b0796df55
commit 58a85c30f6
17 changed files with 250 additions and 111 deletions
+36 -2
View File
@@ -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 "")
-49
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+97
View File
@@ -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")