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
+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)