diff --git a/code/check_consistency.py b/code/check_consistency.py index 689df64..3381f0b 100644 --- a/code/check_consistency.py +++ b/code/check_consistency.py @@ -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 "") diff --git a/code/diag_maskdegen.py b/code/diag_maskdegen.py index 8a8b96f..d440c0e 100644 --- a/code/diag_maskdegen.py +++ b/code/diag_maskdegen.py @@ -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.") diff --git a/code/replot_security.py b/code/replot_security.py index 5bd9ec8..8ba4439 100644 --- a/code/replot_security.py +++ b/code/replot_security.py @@ -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) diff --git a/data/maskdegen.csv b/data/maskdegen.csv new file mode 100644 index 0000000..6583215 --- /dev/null +++ b/data/maskdegen.csv @@ -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 diff --git a/fig/fig_sec_brute.pdf b/fig/fig_sec_brute.pdf index 809c1d7..153a554 100644 Binary files a/fig/fig_sec_brute.pdf and b/fig/fig_sec_brute.pdf differ diff --git a/fig/fig_sec_jam.pdf b/fig/fig_sec_jam.pdf index b6c0194..d7a1be5 100644 Binary files a/fig/fig_sec_jam.pdf and b/fig/fig_sec_jam.pdf differ diff --git a/fig/fig_sec_keylen.pdf b/fig/fig_sec_keylen.pdf index f43dcc5..51dbe2b 100644 Binary files a/fig/fig_sec_keylen.pdf and b/fig/fig_sec_keylen.pdf differ diff --git a/fig/fig_sec_kpa.pdf b/fig/fig_sec_kpa.pdf index 0fd0e10..8446f9f 100644 Binary files a/fig/fig_sec_kpa.pdf and b/fig/fig_sec_kpa.pdf differ diff --git a/fig/fig_sec_real.pdf b/fig/fig_sec_real.pdf index a6b10ee..4a49d2d 100644 Binary files a/fig/fig_sec_real.pdf and b/fig/fig_sec_real.pdf differ diff --git a/fig/fig_sec_sens.pdf b/fig/fig_sec_sens.pdf index 960be1d..f6426bb 100644 Binary files a/fig/fig_sec_sens.pdf and b/fig/fig_sec_sens.pdf differ diff --git a/fig/fig_sec_snr.pdf b/fig/fig_sec_snr.pdf index 2bc7b6d..f76bf1a 100644 Binary files a/fig/fig_sec_snr.pdf and b/fig/fig_sec_snr.pdf differ