Ship the learned-key pipeline, without which six figures cannot be rebuilt

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.
This commit is contained in:
KiHoLee
2026-08-28 23:58:01 +09:00
parent ffe56b4b25
commit 138897aa8d
35 changed files with 853 additions and 168 deletions
+34 -12
View File
@@ -55,8 +55,11 @@ chk("24 and 35 in tex", "$24$ to\n$35$~percent" in tex or "$24$ to $35$~percent"
ew = [float(x["eve_wrong"]) for x in sn]
ch = float(sn[0]["chance"])
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",
_ewl = [float(x["eve_wrong"]) for x in rows("sec_snr_learned.csv")]
dev = max(dev, max(abs(x - ch) for x in _ewl))
chk("outsider at chance to 4e-4, both families", dev < 4.0e-4,
"max deviation %.2e" % dev)
chk("4e-4 in tex", "$4\\times10^{-4}$" in tex, "searched tex",
needs_tex=True)
# the main configuration's legitimate rate, the reference every later
@@ -212,8 +215,8 @@ 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 "overlapping by $0.10$" in " ".join(tex.split()),
"$5$ to $8$ of the $64$ entries" in " ".join(tex.split())
and "only $0.10$ of the smaller of any two such sets" in " ".join(tex.split()),
"searched tex", needs_tex=True)
# --- why the permutation key is granted a shared permutation ---------
@@ -224,7 +227,7 @@ chk("per-user permutation legitimate rate",
abs(pv["per_user"] - 0.129) < 1e-3, "%.5f" % pv["per_user"])
if HAVE_TEX:
chk("quoted permutation cost in tex",
"from $0.053$ to $0.129$" in " ".join(tex.split()),
"at $0.129$ against $0.053$" in " ".join(tex.split()),
"searched tex", needs_tex=True)
@@ -299,22 +302,40 @@ chk("learned family tracks the structured one over the SNR range",
for r in _sl),
max(float(r["legit"]) / _sn[float(r["snr_db"])]
for r in _sl)))
chk("learned 0.064 at 10 dB",
abs([float(r["legit"]) for r in _sl
if float(r["snr_db"]) == 10.0][0] - 0.064) < 5e-4,
"%.5f" % [float(r["legit"]) for r in _sl
if float(r["snr_db"]) == 10.0][0])
_l10 = [float(r["legit"]) for r in _sl if float(r["snr_db"]) == 10.0][0]
chk("learned 0.061 at 10 dB", abs(_l10 - 0.061) < 5e-4, "%.5f" % _l10)
# The learned curves must be the regularized keys, not the unpenalized
# ones. Both are measured in sec_maskfam.csv and they differ by 0.003,
# which is larger than the spread of either, so matching the right row
# pins which family every figure draws. Cross-entropy alone drifts to a
# slot allocation whose key space is a support rather than a sphere, so
# drawing it would not support the key-space claim.
_fam = {r["family"]: float(r["legit_ser"]) for r in rows("sec_maskfam.csv")}
chk("the plotted learned family is the regularized one",
abs(_l10 - _fam["learned_reg"]) < abs(_l10 - _fam["learned"])
and abs(_l10 - _fam["learned_reg"]) < 1.5e-3,
"plotted %.5f, reg %.5f, plain %.5f"
% (_l10, _fam["learned_reg"], _fam["learned"]))
_bl = {int(r["K"]): float(r["ser_mask"])
for r in rows("sec_brute_learned.csv")}
chk("learned key resists a million random guesses",
abs(_bl[1_000_000] - 0.72) < 5e-3, "%.4f" % _bl[1_000_000])
abs(_bl[1_000_000] - 0.71) < 5e-3, "%.4f" % _bl[1_000_000])
_kl = {(float(r["snr_db"]), int(r["n_frames"])): float(r["eve_ser"])
for r in rows("kpa_learned.csv")}
chk("learned key falls to known plaintext like the structured one",
_kl[(10.0, 4)] < 0.08 and _kl[(10.0, 1)] > 0.9,
"N=1 %.3f, N=4 %.4f" % (_kl[(10.0, 1)], _kl[(10.0, 4)]))
_rs = {float(r["snr_db"]): float(r["ter_legit"])
for r in rows("real_sec_ter.csv")}
_rl = {float(r["snr_db"]): float(r["ter_legit"])
for r in rows("real_sec_ter_learned.csv")}
_rat = [_rl[k] / _rs[k] for k in _rs]
chk("learned keeps its uniform-source distance on real text",
all(1.10 < v < 1.25 for v in _rat),
"ratio %.2f to %.2f" % (min(_rat), max(_rat)))
# --- trends, which the value assertions above cannot see ---------------
_snr = rows("sec_snr.csv")
_lg = [float(r["legit"]) for r in _snr]
@@ -387,7 +408,8 @@ for _k, _c in [("V8 cross-period remainder", 0.0005),
and float(_vm[_k]["abs_err"]) <= _c,
_vm[_k]["empirical"] if _k in _vm else "row missing")
chk("format-matched OMA quoted as 0.055",
"$0.055$ at $10$~dB against the proposed" in tex, "Section VI-B",
"$0.055$ at $10$~dB" in tex and "the proposed $0.053$" in tex,
"Section VI-B",
needs_tex=True)
# --- tables against their generator -----------------------------------
+4 -2
View File
@@ -28,7 +28,7 @@ from pathlib import Path
import torch
from exp_full import MAIN_D, base_keys, get_model, main_model
from exp_full import MAIN_D, base_keys, get_model_reg, main_model
from sse_lib import DEVICE, rayleigh_gain, snr_to_sigma2, write_csv
DATA = Path(__file__).resolve().parents[1] / "data"
@@ -117,7 +117,9 @@ def run():
torch.manual_seed(SEED)
rows = []
_sweep(main_model(), "structured", rows) # keys frozen to Walsh
_sweep(get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1),
# the regularized keys of Section V-C, which are the learned family
# every figure draws; the unpenalized ones are a slot allocation
_sweep(get_model_reg(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1),
"learned", rows) # keys trained in R^L
write_csv(DATA / "family_enum.csv",
["family", "keying", "snr_db", "n_frames",
+181
View File
@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""Does learning the mask buy anything, and where would it?
Nothing in the manuscript rests on this; it answers a design question
the paper does not raise. Output in data/jscc.csv.
What the run found, at 10 dB over three training seeds. A: with one
user the mask does not matter, a Walsh row and no mask at all landing
at 0.0133 and 0.0132 against 0.0148 for a learned key, so the mask
carries no part of the source-channel map and only separates users.
B: at the six key lengths where truncated Walsh rows are not exactly
orthogonal, learning wins once, at L=14 with kappa 0.048, and loses at
L=16, 20, 22 and 24 where the rows ARE orthogonal. C: raising the load
to U=20 at L=16 gives learning its second win, by 1e-5 on an error
rate of 0.9998, which is no win at all because both families have
already collapsed. Every other point is a tie inside the seed spread.
So the room for a learned mask is real but narrow, and it is where
exact orthogonality does not exist rather than where the load is high.
A learned mask beating a fixed one wants a loss that is not digit
cross-entropy, or a source that is not uniform, or a channel that is
not a scalar the receiver divides out.
A joint source-channel view says a learned mask should beat a fixed one,
since the fixed one lies inside the search space. It does not here, and
these experiments say why, and where the picture changes.
A. What job does the mask actually do? Run one user. With a single user
there is nobody to separate from, so if the mask carried any part of
the source-channel map its choice would still matter. Compare a Walsh
row, no mask at all, and a learned key, each with the codebook
trained around it. Equal error rates mean the mask is not part of
that map: the codebook is, and the mask only separates users. This
also has a one-line proof. A unit-modulus key has m^2 = 1, so it
cancels from the signal self-term and from the noise projection
alike, and the score is unchanged.
B. Where is the structured family no longer optimal? The construction
supplies exactly orthogonal unit-modulus rows only at the lengths
where truncation preserves orthogonality. At L = 6, 10, 14, 18, 20
and 22 the truncated rows correlate, so no exactly orthogonal
unit-modulus family is available and learning has room to find a
better packing. Every length is run at several seeds, because a
single training run is not evidence of a family being better.
C. Overload. Beyond U = L - 1 no orthogonal set of non-constant rows
exists at all, so the structured family has to reuse rows and the
comparison is decided by whatever packing learning finds.
Run on a GPU host: python code/diag_jscc.py
"""
from __future__ import annotations
import csv
import statistics
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
from exp_full import (MAIN_D, base_keys, get_model_reg, # noqa: E402
mean_abs_xcorr)
from sse_lib import DATA, DEVICE, SSE, eval_ser_sse, set_seed # noqa: E402
import sse_lib as L # noqa: E402
SNR = 10.0
SEEDS = [1, 2, 3]
FRAMES = 400_000
def train_with_key(W0, d, P, vu, U, iters=4000, seed=1, frozen=True):
"""Train the codebook around a given key, optionally holding it."""
set_seed(seed)
m = SSE(P=P, vu=vu, d=d, users=U).to(DEVICE)
with torch.no_grad():
m.W.copy_(W0.to(DEVICE))
m.W.requires_grad_(not frozen)
L.train_sse(m, iters=iters, batch=256, lr=3e-3, seed=seed)
m.calibrate_power()
return m
def ms(vals):
"""Mean and, when there is more than one, the sample spread."""
if len(vals) == 1:
return vals[0], 0.0
return statistics.mean(vals), statistics.stdev(vals)
def part_a(rows):
"""One user: does the choice of mask matter at all?"""
print("-- A. one user, d=%d, L=%d, %d seeds --"
% (MAIN_D, MAIN_D // 4, len(SEEDS)))
Lp = MAIN_D // 4
cases = [("Walsh row", base_keys(1, Lp), True),
("all ones (no mask)", torch.ones(1, Lp), True),
("learned, free", base_keys(1, Lp), False)]
for name, W0, frozen in cases:
v = [eval_ser_sse(train_with_key(W0, MAIN_D, 4, 16, 1, seed=s,
frozen=frozen),
[SNR], frames=FRAMES)[0] for s in SEEDS]
mu, sd = ms(v)
print(" %-20s SER %.5f +- %.5f" % (name, mu, sd))
rows.append(["A one user", name, "%.5f" % mu, "%.5f" % sd, "", ""])
def part_b(rows):
"""Key lengths where no exactly orthogonal unit-modulus family exists."""
print("\n-- B. key length, U=4, %d seeds --" % len(SEEDS))
print(" %-4s %-9s %-18s %-18s %s"
% ("L", "kappa str", "structured SER", "learned SER", "verdict"))
for Lp in [6, 8, 10, 12, 14, 16, 18, 20, 22, 24]:
d = 4 * Lp
try:
W0 = base_keys(4, Lp)
except ValueError as e:
print(" %-4d skipped: %s" % (Lp, e))
continue
ks = mean_abs_xcorr(W0)
vs = [eval_ser_sse(train_with_key(W0, d, 4, 16, 4, seed=s),
[SNR], frames=FRAMES)[0] for s in SEEDS]
vl = [eval_ser_sse(get_model_reg(P=4, vu=16, d=d, U=4, iters=4000,
seed=s),
[SNR], frames=FRAMES)[0] for s in SEEDS]
(mus, sds), (mul, sdl) = ms(vs), ms(vl)
# a win only counts when it clears the spread of both runs
win = "learned" if mul + sdl < mus - sds else (
"structured" if mus + sds < mul - sdl else "tie")
print(" %-4d %-9.5f %.5f +- %.5f %.5f +- %.5f %s"
% (Lp, ks, mus, sds, mul, sdl, win))
rows.append(["B key length", "L=%d" % Lp, "%.5f" % mus,
"%.5f" % sds, "%.5f" % mul, "%.5f/%s" % (ks, win)])
def part_c(rows):
"""Overload: more users than the construction has orthogonal rows."""
print("\n-- C. load at L=16, %d seeds --" % len(SEEDS))
Lp, d = 16, 64
print(" %-4s %-9s %-18s %-18s %s"
% ("U", "kappa str", "structured SER", "learned SER", "verdict"))
for U in [4, 8, 12, 15, 16, 20]:
try:
W0 = base_keys(U, Lp)
except ValueError:
# beyond the orthogonal rows the construction has to reuse
# them, which is the honest structured fallback
H = base_keys(Lp - 1, Lp)
W0 = H[[i % (Lp - 1) for i in range(U)]]
ks = mean_abs_xcorr(W0)
vs = [eval_ser_sse(train_with_key(W0, d, 4, 16, U, seed=s),
[SNR], frames=FRAMES)[0] for s in SEEDS]
vl = [eval_ser_sse(get_model_reg(P=4, vu=16, d=d, U=U, iters=4000,
seed=s),
[SNR], frames=FRAMES)[0] for s in SEEDS]
(mus, sds), (mul, sdl) = ms(vs), ms(vl)
win = "learned" if mul + sdl < mus - sds else (
"structured" if mus + sds < mul - sdl else "tie")
print(" %-4d %-9.5f %.5f +- %.5f %.5f +- %.5f %s"
% (U, ks, mus, sds, mul, sdl, win))
rows.append(["C load", "U=%d" % U, "%.5f" % mus, "%.5f" % sds,
"%.5f" % mul, "%.5f/%s" % (ks, win)])
def main():
print("device", DEVICE)
rows = []
part_a(rows)
part_b(rows)
part_c(rows)
out = DATA / "jscc.csv"
with open(out, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["part", "case", "structured_ser", "structured_sd",
"learned_ser", "kappa_and_verdict"])
w.writerows(rows)
print("\n[csv]", out)
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""Why do structured keys beat learned ones on the legitimate error rate?
The gap is 0.053 against 0.064 at 10 dB, and a reader may reasonably
suspect that the learned keys are handicapped, since they alone are
trained under the channel while the structured ones are fixed by
construction. This measures where the gap comes from.
Three questions, one experiment each.
1. Is the gap a channel-adaptation failure? If it were, the two families
would differ by more at some channel qualities than at others. The
ratio across the SNR sweep answers this from data already on disk.
2. Is the structured key a point that training can improve on? Start
training from the Walsh-Hadamard keys with the masks unfrozen and let
Adam move them. If the structured point is a genuine optimum, the
error rate stays or rises; if training is merely under-converged from
its random start, it falls.
3. What does the learned key lose? Two candidates, measured directly:
residual cross-user correlation, which the analysis names as the
first-order leakage and interference term, and departure from unit
modulus, which spreads the key energy unevenly across the entries so
that a digit is decided over an effectively shorter support.
Run: python code/diag_whygap.py
"""
from __future__ import annotations
import csv
import math
import sys
from pathlib import Path
import torch
sys.path.insert(0, str(Path(__file__).resolve().parent))
from exp_full import (MAIN_D, base_keys, get_model, main_model, # noqa: E402
mean_abs_xcorr)
from sse_lib import DATA, DEVICE, eval_ser_sse # noqa: E402
import sse_lib as L # noqa: E402
FRAMES = 300_000
SNR = 10.0
def modulus_stats(W):
"""How far the key entries are from unit modulus, per user.
A unit-modulus key puts the same energy on every entry, so the
signal term does not depend on the key and every entry of the period
carries its share of the decision. The ratio below is the effective
fraction of the L entries the key actually uses, by the
participation ratio (sum a^2)^2 / (L sum a^4) with a the entry
magnitudes. It is one for a unit-modulus key and 1/L for a key that
puts everything on one entry.
"""
a2 = W.pow(2)
pr = a2.sum(dim=1).pow(2) / (W.shape[1] * a2.pow(2).sum(dim=1))
return pr
def report(name, model, rows):
W = model.masks().detach().cpu()
ser = eval_ser_sse(model, [SNR], frames=FRAMES)[0]
kap = mean_abs_xcorr(model.masks().detach())
pr = modulus_stats(W)
print("%-24s SER %.5f kappa-bar %.5f entry use %.3f"
% (name, ser, kap, float(pr.mean())))
rows.append([name, "%.5f" % ser, "%.5f" % kap, "%.4f" % float(pr.mean())])
return ser
def main():
rows = []
print("main configuration d=%d, L=%d, 10 dB, %d frames\n"
% (MAIN_D, MAIN_D // 4, FRAMES))
print("-- the two families as the paper plots them --")
fix = main_model()
s_fix = report("structured (frozen)", fix, rows)
free = get_model(iters=4000)
s_free = report("learned (free start)", free, rows)
print("\n-- question 2: can training improve on the structured key? --")
# same trainer, same iterations, same seed, but the masks start at
# the Walsh-Hadamard point and are free to move
from sse_lib import SSE, set_seed
set_seed(1)
m = SSE(P=4, vu=16, d=MAIN_D, users=4).to(DEVICE)
with torch.no_grad():
m.W.copy_(base_keys(4, MAIN_D // 4).to(DEVICE))
m.W.requires_grad_(True)
L.train_sse(m, iters=4000, batch=256, lr=3e-3, seed=1)
m.calibrate_power()
s_warm = report("learned (Walsh start)", m, rows)
print("\nreading:")
print(" free start %+.1f percent against the structured key"
% (100.0 * (s_free - s_fix) / s_fix))
print(" Walsh start %+.1f percent against the structured key"
% (100.0 * (s_warm - s_fix) / s_fix))
if s_warm > s_fix:
print(" training moves off the structured point and pays for it,")
print(" so the structured key is not a point learning improves on.")
else:
print(" training improves on the structured point, so the gap is")
print(" under-convergence from the random start, not geometry.")
out = DATA / "whygap.csv"
with open(out, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["family", "legit_ser", "kappa_bar", "entry_use"])
w.writerows(rows)
print("\n[csv]", out)
if __name__ == "__main__":
main()
+6 -3
View File
@@ -194,11 +194,14 @@ def stage_N():
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 instead of frozen to Walsh-Hadamard rows, at the same frame
count, so the two are directly comparable.
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 ...")
m = get_model(P=4, vu=16, d=MAIN_D, U=4, iters=4000, seed=1)
# 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)
+54 -11
View File
@@ -11,7 +11,8 @@ ones.
Every evaluation mirrors its structured counterpart exactly: same SNR,
same frame counts, same seeds, same evaluators. Only the key family
differs.
differs. The learned keys are the regularized ones of Section V-C, not
the unpenalized ones: see learned_model below for why.
"""
from __future__ import annotations
@@ -21,16 +22,28 @@ from pathlib import Path
import torch
import exp_kpa
import exp_refresh
from exp_full import (MAIN_D, eval_ser_eve, eval_ser_jam, eve_wrong_mask,
get_model, mean_abs_xcorr, oma_ser_keylen)
get_model_reg, mean_abs_xcorr, oma_ser_keylen)
from sse_lib import DATA, DEVICE, eval_ser_sse, write_csv
SEED = 1
def learned_model(d=MAIN_D, P=4, vu=16, U=4, iters=4000, seed=SEED):
"""The learned counterpart of main_model: same everything, keys free."""
return get_model(P=P, vu=vu, d=d, U=U, iters=iters, seed=seed)
"""The learned counterpart of main_model: same everything, keys free.
The keys are trained under the two penalties of the regularized loss
rather than under the cross-entropy alone. Cross-entropy on its own
has an attractor at disjoint sparse supports, which is an orthogonal
slot allocation: the keys it reaches carry 99 percent of their
energy on about six of the L 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. The penalties are the design of
Section V-C and hold that drift back, which is the realization the
paper claims for the learned family.
"""
return get_model_reg(P=P, vu=vu, d=d, U=U, iters=iters, seed=seed)
def keylen():
@@ -56,7 +69,7 @@ def jamming():
"""Fig. 4's learned curves."""
print("[learned] jamming ...")
m = learned_model()
jsr = [-10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0]
jsr = [float(v) for v in range(-10, 21, 2)] # the grid Fig. 4's other curves use
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)
@@ -106,7 +119,7 @@ def refresh():
W0, B0 = m.W.detach().clone(), m.B.detach().clone()
base = eval_ser_sse(m, [10.0], frames=300_000)[0]
out = []
for b in range(8):
for b in range(exp_refresh.BLOCKS):
g = torch.Generator(device=DEVICE).manual_seed(5150 + b)
xi = torch.randperm(m.L, generator=g, device=DEVICE)
eps = torch.randint(2, (m.L,), generator=g, device=DEVICE) * 2.0 - 1.0
@@ -133,11 +146,15 @@ def compare():
print("[learned] scheme comparison ...")
m = learned_model()
F = 300_000
legit = eval_ser_sse(m, [10.0], frames=F)[0]
out = eval_ser_eve(m, eve_wrong_mask(m.users, m.L,
seed=20260813).to(DEVICE),
[10.0], frames=F)[0]
ins = eval_ser_eve(m, m.masks().detach().roll(1, 0), [10.0], frames=F)[0]
# the table caption states a user-1 convention and every structured
# row honours it, so this row uses the same evaluator rather than the
# four-user average eval_ser_eve returns
from exp_full import eval_scheme
legit = eval_scheme(m, 10.0, F)
out = eval_scheme(m, 10.0, F,
rx_masks=eve_wrong_mask(m.users, m.L,
seed=20260813).to(DEVICE))
ins = eval_scheme(m, 10.0, F, rx_masks=m.masks().detach().roll(1, 0))
jam = eval_ser_jam(m, 10.0, [0.0], frames=F, mode="blind", target=0)[0]
write_csv(DATA / "compare_learned.csv",
["scheme", "legit_ser", "eve_out", "eve_in", "jam0_ser"],
@@ -235,3 +252,29 @@ def real():
for n, blob in saved.items():
(DATA / n).write_bytes(blob)
print(" learned artifacts written, structured ones restored")
def keylen_perm():
"""Fig. 3's permutation-key curves.
The permutation scheme keeps the masks public and hides the frame
order instead, so its legitimate receiver inverts the permutation
and decodes as the public-mask receiver does, while its eavesdropper
holds the public masks but not the order. Both are swept over the
same key lengths as the structured family so the figure carries the
comparison scheme at every point rather than only at L = 64.
"""
import torch
from exp_full import (eval_scheme, eval_scheme_permuted_eve, main_model)
print("[learned] key length, permutation key ...")
rows = []
for d in [32, 48, 64, 80, 96, 128, 192, 256]:
m = main_model(d=d)
gp = torch.Generator().manual_seed(11)
perms = torch.randperm(d, generator=gp)[None].repeat(m.users, 1)
lg = eval_scheme(m, 10.0, 500_000, perms=perms)
ev = eval_scheme_permuted_eve(m, 10.0, 500_000, perms)
rows.append((m.L, d, lg, ev))
print(" L=%3d legit %.4f eve %.4f" % (m.L, lg, ev))
write_csv(DATA / "sec_keylen_perm.csv",
["L", "d", "legit_ser", "eve_ser"], rows)
+73
View File
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
"""Fold the learned-key rows into the two table sources.
Table IV reads sec_compare.csv and Table V reads refresh_summary.csv,
and both are written by the structured stages, which know nothing about
the learned family. Its rows were appended by hand, so a rerun of the
learned stages left the tables behind. This does the fold, so both files
are derived from data/ like every other table source.
Run after code/run_learned_reg.py, before code/make_tables.py.
"""
from __future__ import annotations
import csv
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / "data"
def read(name):
with open(DATA / name) as f:
r = csv.DictReader(f)
return r.fieldnames, list(r)
def write(name, fields, rows):
with open(DATA / name, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
def upsert(rows, key, value, row):
"""Replace the row carrying key==value, or append it."""
for i, r in enumerate(rows):
if r[key] == value:
rows[i] = row
return rows
rows.append(row)
return rows
def main():
# Table IV: the learned scheme row, measured by exp_learned.compare
fields, rows = read("sec_compare.csv")
_, learned = read("compare_learned.csv")
assert len(learned) == 1, "compare_learned.csv should carry one row"
rows = upsert(rows, "scheme", "proposed_learned",
{k: learned[0][k] for k in fields})
write("sec_compare.csv", fields, rows)
print("sec_compare.csv proposed_learned jam0 %s"
% learned[0]["jam0_ser"])
# Table V: the learned refresh row, averaged over the blocks that
# exp_learned.refresh measured, at the same entropy as the
# structured refresh because the invariance group is the same
fields, rows = read("refresh_summary.csv")
_, blocks = read("refresh_learned.csv")
lg = sum(float(r["legit_ser"]) for r in blocks) / len(blocks)
ev = sum(float(r["eve_ser"]) for r in blocks) / len(blocks)
ent = next(r["entropy_bits"] for r in rows
if r["scheme"] == "Invariant, KM (str.)")
rows = upsert(rows, "scheme", "Invariant, KM (lrn.)",
{"scheme": "Invariant, KM (lrn.)",
"legit": "%.6f" % lg, "eve": "%.6f" % ev,
"entropy_bits": ent})
write("refresh_summary.csv", fields, rows)
print("refresh_summary.csv Invariant, KM (lrn.) legit %.5f eve %.5f"
% (lg, ev))
if __name__ == "__main__":
main()
+22 -11
View File
@@ -365,7 +365,18 @@ def fig_keylen():
ax.semilogy(col(rl, "L", int), col(rl, "legit_ser"), **STY["km_lrn"], label=LBL["legit_learned"])
op = [(l, v) for l, v in zip(x, col(r, "oma")) if not math.isnan(v)]
ax.semilogy([p[0] for p in op], [p[1] for p in op], **STY["oma"], label=LBL["oma"])
ax.semilogy(x, col(r, "eve_ser"), **STY["eve"], label=LBL["eve_key"])
ax.semilogy(x, col(r, "eve_ser"), **STY["eve"], markevery=(0, 2),
label=LBL["eve_key"], **UNDER)
# the permutation key shares this physical layer, so its legitimate
# curve lies on the structured one and appears at every key length
# rather than only in the tables. Its outsider measures 0.99997 to
# 0.99999 and would lie on the outsider curve already drawn, in the
# same style as this one and with no legend entry of its own, so the
# caption says where it sits instead.
rp = load("sec_keylen_perm.csv")
assert min(float(r["eve_ser"]) for r in rp) > 0.999, "the permutation outsider left the random-guess level"
ax.semilogy(col(rp, "L", int), col(rp, "legit_ser"), **STY["perm"],
markevery=(1, 2), label=LBL["perm"], **OVER)
ax.set_ylim(top=22.0) # headroom above the flat eavesdropper curve
# an error rate cannot exceed one, and the room below the data holds
# the legend, since every curve decays to the right
@@ -409,7 +420,7 @@ def fig_jam():
# 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(6e-3, 1.4)
ax.set_ylim(2.5e-2, 1.4)
# a log axis spanning little more than a decade prints minor labels
# like 6x10^-1 that consume the left margin, so only the decades are
# labelled
@@ -478,20 +489,20 @@ def fig_real():
r = load("real_sec_ter.csv")
x = col(r, "snr_db")
fig, ax = plt.subplots()
# insider and outsider still nearly coincide and are layered; the
# legitimate and OMA curves are separate at this frame
# two pairs nearly coincide here, the two legitimate realizations
# within a fifth of each other and the two adversaries both at the
# top, so each pair is layered and its markers staggered
ax.semilogy(x, col(r, "ter_legit"), **STY["km_str"],
markevery=(0, 2), label=LBL["legit"], **UNDER)
markevery=(0, 3), label=LBL["legit"], **UNDER)
rt = load("real_sec_ter_learned.csv")
ax.semilogy(col(rt, "snr_db"), col(rt, "ter_legit"),
**STY["km_lrn"], markevery=(1, 2),
label=LBL["legit_learned"])
ax.semilogy(col(rt, "snr_db"), col(rt, "ter_legit"), **STY["km_lrn"],
markevery=(1, 3), label=LBL["legit_learned"], **OVER)
ax.semilogy(x, col(r, "ter_oma"), **STY["oma"],
markevery=(1, 2), label=LBL["oma"], **OVER)
markevery=(2, 3), label=LBL["oma"])
ax.semilogy(x, col(r, "ter_insider"), **STY["insider"],
markevery=(0, 2), label=LBL["insider"], **UNDER)
markevery=(0, 3), label=LBL["insider"], **UNDER)
ax.semilogy(x, col(r, "ter_eve"), **STY["eve"],
markevery=(1, 2), label=LBL["outsider"], **OVER)
markevery=(2, 3), label=LBL["outsider"], **OVER)
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("TER")
ax.set_xlim(min(x), max(x))
+106
View File
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""Every learned-family number the manuscript quotes, read from data/.
Switching the learned family from the unpenalized keys to the
regularized ones of Section V-C moves every learned value in the paper.
This prints them next to their structured counterparts so the sentences
that carry them can be updated from one place, and so a later rerun can
be checked against what is printed.
Run: python code/report_learned.py
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / "data"
def rows(name):
with open(DATA / name) as f:
return list(csv.DictReader(f))
def at(rs, key, val, col):
for r in rs:
if abs(float(r[key]) - val) < 1e-9:
return float(r[col])
raise KeyError("%s=%s not in the sweep" % (key, val))
def main():
print("== Fig. 2, SER against SNR ==")
s = rows("sec_snr.csv")
l = rows("sec_snr_learned.csv")
ratios = []
for a, b in zip(s, l):
r = float(b["legit"]) / float(a["legit"])
ratios.append(r)
print(" %5s dB str %.5f lrn %.5f ratio %.3f"
% (a["snr_db"], float(a["legit"]), float(b["legit"]), r))
print(" ratio range %.3f to %.3f" % (min(ratios), max(ratios)))
print("\n== Fig. 3, SER against key length ==")
s = rows("sec_keylen.csv")
l = rows("sec_keylen_learned.csv")
for a, b in zip(s, l):
print(" L=%-4s str %.5f lrn %.5f ratio %.3f kappa %.5f"
% (a["L"], float(a["legit_ser"]), float(b["legit_ser"]),
float(b["legit_ser"]) / float(a["legit_ser"]),
float(b["mask_xcorr"])))
print(" learned outsider floor %.5f"
% min(float(r["eve_ser"]) for r in l))
print("\n== Fig. 4, jamming at JSR 0 dB ==")
print(" str blind %.4f" % at(rows("sec_jam.csv"), "jsr_db", 0.0,
"blind"))
print(" lrn blind %.4f" % at(rows("sec_jam_learned.csv"), "jsr_db",
0.0, "blind"))
print("\n== Fig. 6, best of K=1e6 guesses ==")
print(" str %.4f" % at(rows("sec_brute_cmp.csv"), "K", 1e6, "ser_mask"))
print(" lrn %.4f" % at(rows("sec_brute_learned.csv"), "K", 1e6,
"ser_mask"))
print("\n== Fig. 7, known plaintext at 10 dB ==")
for name in ("kpa.csv", "kpa_learned.csv"):
r = [x for x in rows(name) if float(x["snr_db"]) == 10.0]
print(" %-16s N=2 %.4f N=8 %.4f N=64 %.4f"
% (name, at(r, "n_frames", 2, "eve_ser"),
at(r, "n_frames", 8, "eve_ser"),
at(r, "n_frames", 64, "eve_ser")))
print("\n== Fig. 8, real token streams ==")
s = rows("real_sec_ter.csv")
l = rows("real_sec_ter_learned.csv")
gaps = []
for a, b in zip(s, l):
for col in ("ter_eve", "ter_insider"):
gaps.append(abs(float(a[col]) - float(b[col])))
print(" %4s dB legit str %.5f lrn %.5f ratio %.3f"
% (a["snr_db"], float(a["ter_legit"]), float(b["ter_legit"]),
float(b["ter_legit"]) / float(a["ter_legit"])))
print(" largest adversary gap between families %.2e" % max(gaps))
for name in ("real_sec_stats.json", "real_sec_stats_learned.json"):
if (DATA / name).exists():
d = json.loads((DATA / name).read_text())
print(" %-28s %s" % (name, {k: d[k] for k in list(d)[:6]}))
print("\n== Table IV, scheme comparison ==")
for name in ("sec_compare.csv", "compare_learned.csv"):
for r in rows(name):
print(" %-20s %s" % (name, dict(r)))
print("\n== Table V, refresh ==")
l = rows("refresh_learned.csv")
print(" lrn legit %.5f to %.5f"
% (min(float(r["legit_ser"]) for r in l),
max(float(r["legit_ser"]) for r in l)))
print(" lrn eve %.5f" % (sum(float(r["eve_ser"]) for r in l)
/ len(l)))
if __name__ == "__main__":
main()
+21
View File
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
"""Rerun the four measurements the audit found mis-specified.
refresh the learned rows ran 8 blocks while the table says 24
jamming the learned curve ran a 5 dB grid inside a 2 dB figure
compare the learned row averaged four users inside a user-1 table
enum the enumeration attacks ran on the unpenalized learned keys
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp_learned as E
import check_family_enum as F
if __name__ == "__main__":
for fn in (E.refresh, E.jamming, E.compare, F.run):
print("=" * 60)
print("stage", fn.__module__ + "." + fn.__name__, flush=True)
fn()
print("[done] audit reruns complete")
+28
View File
@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
"""Regenerate every learned-key artifact under the regularized loss.
learned_model now trains under the two penalties of Section V-C, so
every *_learned file has to be rebuilt from it. sens runs before brute
because brute re-reads the sensitivity sweep it produced.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp_learned as E
import exp_full as F
# stage_N is Fig. 2's learned SNR sweep and lives in exp_full, so it is
# named here rather than in exp_learned's own list
STAGES = [F.stage_N, E.keylen, E.jamming, E.kpa, E.refresh, E.compare,
E.sens, E.brute, E.real]
if __name__ == "__main__":
only = sys.argv[1:]
for fn in STAGES:
if only and fn.__name__ not in only:
continue
print("=" * 60)
print("stage", fn.__name__, flush=True)
fn()
print("[done] every learned artifact rebuilt")