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))) and round(max(rt), 2) == 1.54, "%.3f to %.3f" % (min(rt), max(rt)))
# the three secrets named in the setup # the three secrets named in the setup
chk("secret sizes UL=256, perm 256, pad 16", chk("secret sizes: per-user direction, perm 256, pad 16",
all(t in tex for t in ["$UL=256$ key entries", all(t in tex for t in ["length-$64$ key direction per user",
"one permutation of $256$ positions", "one permutation of $256$",
"$16$ pad\nbits per user"]), "$16$ pad bits per user"]),
"searched tex", needs_tex=True) "searched tex", needs_tex=True)
chk("no stale d=64 configuration in tex", 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) 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) 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 --------------------------------------------------------- # --- abstract ---------------------------------------------------------
a = (tex.split(r"\begin{abstract}")[1].split(r"\end{abstract}")[0].strip() a = (tex.split(r"\begin{abstract}")[1].split(r"\end{abstract}")[0].strip()
if HAVE_TEX else "") if HAVE_TEX else "")
+21 -5
View File
@@ -31,7 +31,7 @@ def support99(w):
return set(order[:k].tolist()), k return set(order[:k].tolist()), k
def describe(name, W): def describe(name, W, rows=None):
L = W.shape[1] L = W.shape[1]
sups, ks = [], [] sups, ks = [], []
for u in range(W.shape[0]): for u in range(W.shape[0]):
@@ -43,17 +43,33 @@ def describe(name, W):
for j in range(i + 1, len(sups)): for j in range(i + 1, len(sups)):
ov.append(len(sups[i] & sups[j]) / max(1, min(len(sups[i]), ov.append(len(sups[i] & sups[j]) / max(1, min(len(sups[i]),
len(sups[j])))) len(sups[j]))))
mo = sum(ov) / len(ov)
print("%-14s L=%3d 99%%-energy entries per key: %s " print("%-14s L=%3d 99%%-energy entries per key: %s "
"mean pairwise support overlap %.2f" "mean pairwise support overlap %.2f" % (name, L, ks, mo))
% (name, L, ks, sum(ov) / len(ov))) 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(): def main():
print("main configuration d=%d" % MAIN_D) print("main configuration d=%d" % MAIN_D)
rows = []
m_free = get_model(iters=4000) # keys learned, nothing frozen 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() 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()
print("A degenerate key set shows few entries per key and near-zero") 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.") 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] 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=()): def save(fig, name, insets=()):
"""Write the figure and assert that no axis label is clipped. """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. # same discipline as the clipping guard above.
leg = ax.get_legend() leg = ax.get_legend()
if leg is not None: 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(): for line in ax.get_lines():
# full-span reference lines (axhline/axvline) carry axes- # full-span reference lines (axhline/axvline) carry axes-
# fraction endpoints [0,1]; they are not data curves and, # fraction endpoints [0,1]; they are not data curves and,
@@ -171,6 +188,10 @@ def save(fig, name, insets=()):
print("[OK]", name) 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"): def main_legit(snr_db="10"):
"""The legitimate SER of the main configuration, read from the curve """The legitimate SER of the main configuration, read from the curve
the main configuration produced rather than looked up by key length.""" 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) raise KeyError("no %s dB row in sec_snr.csv" % snr_db)
def place_legend(ax, cands=("lower left", "center left", "center right", def place_legend(ax, cands=("lower left", "upper left", "center left",
"lower center", "upper right", "upper center", "center right", "lower center", "upper right",
"center", "lower right"), "upper center", "center", "lower right"),
sizes=(7.6, 7.2, 6.8, 6.4, 6.0)): sizes=(7.0,), ncol=1):
"""Choose the location and font size whose box the fewest curve points """Choose the location and font size whose box the fewest curve points
fall inside, scored on rendered geometry rather than guessed from the fall inside, scored on rendered geometry rather than guessed from the
data. The size sweep is what makes a long label set placeable: a 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 The axes rectangle is applied first, because save() enforces the same
test after applying it. Scoring the default layout and then checking test after applying it. Scoring the default layout and then checking
a different one is how a placement that looked clear here failed 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) ax.figure.subplots_adjust(**AXES_RECT)
if PL_FORCED is not None:
sizes = (PL_FORCED,)
best = None best = None
for size in sizes: for size in sizes:
for loc in cands: 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() ax.figure.canvas.draw()
lb = leg.get_window_extent() lb = _inflate(leg.get_window_extent(), ax.figure)
hits = 0 hits = 0
for line in ax.get_lines(): for line in ax.get_lines():
xy = line.get_xydata() 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]: if best is None or hits < best[2]:
best = (loc, size, hits) best = (loc, size, hits)
if hits == 0: 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 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 return best
@@ -245,6 +279,9 @@ def fig_snr():
ax.set_xlabel("SNR (dB)") ax.set_xlabel("SNR (dB)")
ax.set_ylabel("SER") ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x)) 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) place_legend(ax)
save(fig, "fig_sec_snr") save(fig, "fig_sec_snr")
@@ -263,6 +300,7 @@ def fig_keylen():
marker="^", ls=":", label=LBL["oma"]) marker="^", ls=":", label=LBL["oma"])
ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--", ax.semilogy(x, col(r, "eve_ser"), color=C_EVE, marker="s", ls="--",
label=LBL["eve_key"]) label=LBL["eve_key"])
ax.set_ylim(top=6.0) # headroom above the flat eavesdropper curve
ax.set_xlabel("Key length $L$") ax.set_xlabel("Key length $L$")
ax.set_ylabel("SER") ax.set_ylabel("SER")
ax.set_xscale("log", base=2) 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="-.", 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"] + ", blind", **OVER)
nojam = float(load("sec_jam.csv")[0]["nojam"]) nojam = float(load("sec_jam.csv")[0]["nojam"])
ax.axhline(nojam, color=C_OMA, ls=(0, (1, 3)), lw=0.9, # the unjammed reference is named in the caption rather than in the
label=LBL["nojam"]) # 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_xlabel("JSR (dB)")
ax.set_ylabel("SER") ax.set_ylabel("SER")
ax.set_xlim(min(x), max(x)) 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"]) markevery=(2, 3), lw=1.2, mfc="none", label=LBL["pad"])
chance = 1.0 - (1.0 / 16.0) ** 4 chance = 1.0 - (1.0 / 16.0) ** 4
ax.axhline(chance, color=C_CH, ls=":", lw=0.9, label=LBL["chance"]) 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_xlabel("Fraction of the key recovered")
ax.set_ylabel("Eavesdropper SER") ax.set_ylabel("Eavesdropper SER")
ax.set_xlim(0, 1) 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="-", ax.semilogx(x, col(r, "ser_mask"), color=C_LEGIT, marker="o", ls="-",
label=LBL["mask"]) label=LBL["mask"])
legit = main_legit() 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_xlabel("Number of key guesses $K$")
ax.set_ylabel("Eavesdropper SER") ax.set_ylabel("Eavesdropper SER")
ax.set_ylim(0.0, 1.05) # keep the reference line off the spine 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 # in the main configuration, rather than the user-1 convention of the
# scheme-comparison table # scheme-comparison table
legit = main_legit() 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_xlabel("Known-plaintext frames $N$")
ax.set_ylabel("Eavesdropper SER") ax.set_ylabel("Eavesdropper SER")
ax.set_xscale("log", base=2) ax.set_xscale("log", base=2)
@@ -401,7 +445,7 @@ def fig_kpa():
save(fig, "fig_sec_kpa") save(fig, "fig_sec_kpa")
def main(): def run_all():
fig_snr() fig_snr()
fig_keylen() fig_keylen()
fig_jam() fig_jam()
@@ -418,6 +462,21 @@ def main():
fig_kpa() fig_kpa()
except FileNotFoundError: except FileNotFoundError:
print("[skip] known-plaintext CSV not present yet") 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) 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.