Audit round: stored degeneracy measurement, figure guards, 44 assertions

diag_maskdegen writes data/maskdegen.csv so the claim it supports is
traceable; the legend guard inflates by the marker radius and refuses a
legend that leaves the canvas; one legend size on every figure.
This commit is contained in:
KiHoLee
2026-08-19 15:08:38 +09:00
parent 2fb64bee3a
commit e243b29d29
11 changed files with 143 additions and 24 deletions
+45 -4
View File
@@ -160,10 +160,10 @@ 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=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"]),
chk("secret sizes: per-user direction, perm 256, pad 16",
all(t in tex for t in ["length-$64$ key direction per user",
"one permutation of $256$",
"$16$ pad bits per user"]),
"searched tex", needs_tex=True)
chk("no stale d=64 configuration in tex",
@@ -175,6 +175,47 @@ sc = rows("sec_sens_cmp.csv")
dv = max(abs(float(r["ser_mask"]) - float(r["ser_perm"])) for r in sc)
chk("permutation tracks mask in Fig. 5", dv < 0.06, "max gap %.3f" % dv)
# --- the audit round's corrected quantities ---------------------------
mf = {r["family"]: r for r in rows("sec_maskfam.csv")}
fam_pct = (float(mf["random"]["legit_ser"])
/ float(mf["hadamard"]["legit_ser"]) - 1) * 100
chk("continuous family 29 percent worse", round(fam_pct) == 29,
"%.1f percent" % fam_pct)
chk("no stale 2.5 factor in tex", "factor of $2.5$" not in tex,
"searched tex", needs_tex=True)
sc2 = rows("sec_sens_cmp.csv")
worst04 = min(min(float(r["ser_mask"]), float(r["ser_perm"]),
float(r["ser_pad"])) for r in sc2
if float(r["frac"]) <= 0.4)
chk("all three above 0.95 to 40 percent of key", worst04 > 0.95,
"min %.4f" % worst04)
rf = rows("refresh.csv")
res = max(1 - float(r["eve_invariant"]) for r in rf)
chk("refresh residual below 2.4e-3", res < 2.4e-3, "max %.2e" % res)
rk = rows("refresh_kpa.csv")
nb = max(0.9999847412 - float(r["ser_next_block"]) for r in rk)
chk("next block within 6e-4 of chance", nb < 6e-4, "max %.2e" % nb)
bc = rows("sec_brute_cmp.csv")
pm = min(float(r["ser_perm"]) for r in bc)
chk("permutation floor 0.9996", pm > 0.9996, "min %.5f" % pm)
md = {r["family"]: r for r in rows("maskdegen.csv")}
ks = [int(x) for x in md["learned"]["support99_per_key"].split("/")]
chk("learned keys degenerate: 5 to 8 of 64 entries",
min(ks) == 5 and max(ks) == 8 and int(md["learned"]["L"]) == 64,
md["learned"]["support99_per_key"])
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,
"searched tex", needs_tex=True)
# --- abstract ---------------------------------------------------------
a = (tex.split(r"\begin{abstract}")[1].split(r"\end{abstract}")[0].strip()
if HAVE_TEX else "")
+21 -5
View File
@@ -31,7 +31,7 @@ def support99(w):
return set(order[:k].tolist()), k
def describe(name, W):
def describe(name, W, rows=None):
L = W.shape[1]
sups, ks = [], []
for u in range(W.shape[0]):
@@ -43,17 +43,33 @@ def describe(name, W):
for j in range(i + 1, len(sups)):
ov.append(len(sups[i] & sups[j]) / max(1, min(len(sups[i]),
len(sups[j]))))
mo = sum(ov) / len(ov)
print("%-14s L=%3d 99%%-energy entries per key: %s "
"mean pairwise support overlap %.2f"
% (name, L, ks, sum(ov) / len(ov)))
"mean pairwise support overlap %.2f" % (name, L, ks, mo))
if rows is not None:
rows.append([name, L, "/".join(str(k) for k in ks), "%.4f" % mo])
def write_rows(rows):
"""Store the measurement so the manuscript sentence it justifies is
traceable to an artifact in data/ like every other quoted number."""
import csv
out = Path(__file__).resolve().parents[1] / "data" / "maskdegen.csv"
with open(out, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["family", "L", "support99_per_key", "mean_overlap"])
w.writerows(rows)
print("[csv]", out)
def main():
print("main configuration d=%d" % MAIN_D)
rows = []
m_free = get_model(iters=4000) # keys learned, nothing frozen
describe("learned", m_free.masks().detach().cpu())
describe("learned", m_free.masks().detach().cpu(), rows)
m_fix = main_model()
describe("Walsh-Hadamard", m_fix.masks().detach().cpu())
describe("Walsh-Hadamard", m_fix.masks().detach().cpu(), rows)
write_rows(rows)
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.")
+74 -15
View File
@@ -91,6 +91,17 @@ def col(rows, k, f=float):
return [f(r[k]) for r in rows]
def _inflate(box, fig):
"""Grow a bounding box by the marker radius plus the line width, in
pixels, so a marker whose CENTER clears the box cannot still touch
its frame."""
pad = (plt.rcParams["lines.markersize"] / 2.0
+ plt.rcParams["lines.linewidth"]) * fig.dpi / 72.0
from matplotlib.transforms import Bbox
return Bbox.from_extents(box.x0 - pad, box.y0 - pad,
box.x1 + pad, box.y1 + pad)
def save(fig, name, insets=()):
"""Write the figure and assert that no axis label is clipped.
@@ -118,7 +129,13 @@ def save(fig, name, insets=()):
# same discipline as the clipping guard above.
leg = ax.get_legend()
if leg is not None:
lb = leg.get_window_extent()
raw = leg.get_window_extent()
if (raw.x0 < fbox.x0 or raw.y0 < fbox.y0
or raw.x1 > fbox.x1 or raw.y1 > fbox.y1):
raise RuntimeError(
f"{name}: the legend box leaves the canvas "
f"({raw} outside {fbox}); narrow or move it")
lb = _inflate(raw, fig)
for line in ax.get_lines():
# full-span reference lines (axhline/axvline) carry axes-
# fraction endpoints [0,1]; they are not data curves and,
@@ -171,6 +188,10 @@ def save(fig, name, insets=()):
print("[OK]", name)
PL_CHOSEN = [] # sizes the sweep settled on, one per figure
PL_FORCED = None # set by main() on its second pass
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."""
@@ -180,10 +201,10 @@ def main_legit(snr_db="10"):
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=(7.6, 7.2, 6.8, 6.4, 6.0)):
def place_legend(ax, cands=("lower left", "upper left", "center left",
"center right", "lower center", "upper right",
"upper center", "center", "lower right"),
sizes=(7.0,), 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
@@ -193,14 +214,22 @@ def place_legend(ax, cands=("lower left", "center left", "center right",
The axes rectangle is applied first, because save() enforces the same
test after applying it. Scoring the default layout and then checking
a different one is how a placement that looked clear here failed
there."""
there.
When PL_FORCED is set, only that size is tried: the driver runs every
figure once to learn the smallest size any of them needs, then reruns
them all at that one size so the legends print uniformly."""
ax.figure.subplots_adjust(**AXES_RECT)
if PL_FORCED is not None:
sizes = (PL_FORCED,)
best = None
for size in sizes:
for loc in cands:
leg = ax.legend(loc=loc, prop={"size": size})
leg = ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
handlelength=1.4, columnspacing=0.9,
handletextpad=0.5)
ax.figure.canvas.draw()
lb = leg.get_window_extent()
lb = _inflate(leg.get_window_extent(), ax.figure)
hits = 0
for line in ax.get_lines():
xy = line.get_xydata()
@@ -218,9 +247,14 @@ def place_legend(ax, cands=("lower left", "center left", "center right",
if best is None or hits < best[2]:
best = (loc, size, hits)
if hits == 0:
ax.legend(loc=loc, prop={"size": size})
ax.legend(loc=loc, prop={"size": size}, ncol=ncol,
handlelength=1.4, columnspacing=0.9,
handletextpad=0.5)
PL_CHOSEN.append(size)
return best
ax.legend(loc=best[0], prop={"size": best[1]})
ax.legend(loc=best[0], prop={"size": best[1]}, ncol=ncol,
handlelength=1.4, columnspacing=0.9, handletextpad=0.5)
PL_CHOSEN.append(best[1])
return best
@@ -245,6 +279,9 @@ def fig_snr():
ax.set_xlabel("SNR (dB)")
ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x))
# most of a decade below the data leaves the lower-left genuinely
# empty, which is what gives the four-entry legend a clear berth
ax.set_ylim(bottom=8e-4)
place_legend(ax)
save(fig, "fig_sec_snr")
@@ -263,6 +300,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_xlabel("Key length $L$")
ax.set_ylabel("SER")
ax.set_xscale("log", base=2)
@@ -292,8 +330,9 @@ 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,
label=LBL["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)
ax.set_xlabel("JSR (dB)")
ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x))
@@ -317,6 +356,9 @@ def fig_sens():
markevery=(2, 3), lw=1.2, mfc="none", label=LBL["pad"])
chance = 1.0 - (1.0 / 16.0) ** 4
ax.axhline(chance, color=C_CH, ls=":", lw=0.9, label=LBL["chance"])
# the narration reads these curves against the legitimate rate
ax.axhline(main_legit(), color=C_OMA, ls=(0, (4, 2)), lw=0.9,
label=LBL["legit"])
ax.set_xlabel("Fraction of the key recovered")
ax.set_ylabel("Eavesdropper SER")
ax.set_xlim(0, 1)
@@ -337,7 +379,8 @@ def fig_brute():
ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
label=LBL["mask"])
legit = main_legit()
ax.axhline(legit, color=C_OMA, ls=":", lw=0.9, label=LBL["legit"])
ax.axhline(legit, color=C_OMA, ls=(0, (4, 2)), lw=0.9,
label=LBL["legit"])
ax.set_xlabel("Number of key guesses $K$")
ax.set_ylabel("Eavesdropper SER")
ax.set_ylim(0.0, 1.05) # keep the reference line off the spine
@@ -390,7 +433,8 @@ def fig_kpa():
# 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.axhline(legit, color=C_OMA, ls=(0, (4, 2)), lw=0.9,
label=LBL["legit"])
ax.set_xlabel("Known-plaintext frames $N$")
ax.set_ylabel("Eavesdropper SER")
ax.set_xscale("log", base=2)
@@ -401,7 +445,7 @@ def fig_kpa():
save(fig, "fig_sec_kpa")
def main():
def run_all():
fig_snr()
fig_keylen()
fig_jam()
@@ -418,6 +462,21 @@ def main():
fig_kpa()
except FileNotFoundError:
print("[skip] known-plaintext CSV not present yet")
def main():
"""Two passes: the first learns the smallest legend size any figure
needs, the second forces that one size everywhere so the legends
print uniformly, which the figure standard requires."""
global PL_FORCED
PL_FORCED = None
PL_CHOSEN.clear()
run_all()
if PL_CHOSEN:
PL_FORCED = min(PL_CHOSEN)
print("[uniform] legend size %.1f pt on every figure" % PL_FORCED)
PL_CHOSEN.clear()
run_all()
print("[done] figures in", FIG)
+3
View File
@@ -0,0 +1,3 @@
family,L,support99_per_key,mean_overlap
learned,64,5/5/8/7,0.0952
Walsh-Hadamard,64,64/64/64/64,1.0000
1 family L support99_per_key mean_overlap
2 learned 64 5/5/8/7 0.0952
3 Walsh-Hadamard 64 64/64/64/64 1.0000
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.