#!/usr/bin/env python3
"""Generate Instagram-Story (1080x1920) teaser slides for the sor-consent study.
Each slide is a self-contained HTML file with inline SVG, purpose-built for a
9:16 portrait viewport. Numbers are transcribed from the SEALED confirmatory
artifacts (audited 2026-07-22):
- RQ1-P1 AUC = 0.4660 [0.4523, 0.4798] (anomaly-below-chance -> no leak) SURVIVES
- RQ2-P1 dH = -0.9587 bits [-1.0559, -0.8641] (shrink) SURVIVES
- RQ2-P3' rho = +0.624 [0.594, 0.655] (mix; mechanism-corrected) SURVIVES
- RQ1-P2 dAUC= +0.0113 [-0.0025, +0.0234] (padding-ineffective) non-survivor
- RQ3-P1-perf -0.63% [-1.58, +0.39] (no-perf-gain) non-survivor
- RQ3-P1-latency -13.5ms [-52.1, +34.9] (within budget, no win) non-survivor
- RQ3-P2 rebuild AUC 0.587 [0.458, 0.703] (fingerprint-not-excluded) non-survivor
Instrument: 9,000 confirmatory circuits, 27,000 per-hop pcaps, 36,361 sealed artifacts.
"""
import pathlib
OUT = pathlib.Path(__file__).parent / "story"
OUT.mkdir(exist_ok=True)
W, H = 1080, 1920
BASE_CSS = f"""
*{{margin:0;padding:0;box-sizing:border-box}}
html,body{{width:{W}px;height:{H}px;overflow:hidden}}
body{{
font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
background:radial-gradient(120% 90% at 50% 0%,#141b2e 0%,#0a0e1a 55%,#05070f 100%);
color:#eef2fb; position:relative;
}}
.frame{{position:absolute;inset:0;padding:96px 84px;display:flex;flex-direction:column}}
.kicker{{font-size:30px;letter-spacing:.30em;font-weight:700;text-transform:uppercase;color:#63d3ff}}
.rq{{font-size:34px;letter-spacing:.10em;font-weight:800;color:#8b9bc4;text-transform:uppercase}}
h1{{font-size:104px;line-height:1.02;font-weight:850;letter-spacing:-.01em;margin:8px 0}}
h2{{font-size:62px;line-height:1.08;font-weight:800;letter-spacing:-.01em}}
.sub{{font-size:40px;line-height:1.34;color:#c3cce2;font-weight:450}}
.big{{font-family:'SF Mono',ui-monospace,Menlo,monospace;font-weight:800;letter-spacing:-.02em}}
.mono{{font-family:'SF Mono',ui-monospace,Menlo,monospace}}
.pill{{display:inline-block;padding:12px 26px;border-radius:999px;font-size:30px;font-weight:700}}
.good{{background:rgba(52,211,153,.16);color:#59f0b6;border:2px solid rgba(52,211,153,.45)}}
.bad{{background:rgba(248,113,113,.14);color:#ff9b9b;border:2px solid rgba(248,113,113,.4)}}
.muted{{color:#8b9bc4}}
.foot{{position:absolute;left:84px;right:84px;bottom:70px;display:flex;justify-content:space-between;
align-items:center;font-size:26px;color:#6b7a9c;font-weight:600;letter-spacing:.04em}}
.spacer{{flex:1}}
.card{{background:rgba(255,255,255,.035);border:2px solid rgba(255,255,255,.08);border-radius:28px;
padding:40px 44px}}
.dim{{color:#9aa7c6}}
"""
def page(body, css=""):
return f"""
{body}"""
def circuit_svg(w=520, taps=True):
# vertical 3-hop nested-ssh circuit with pcap taps
tap = ""
if taps:
for cy in (330, 560, 790):
tap += f' ' \
f'pcap '
return f"""
sender
hop 0
hop 1
hop 2
{tap}
"""
# ------------------------------------------------------------------ SLIDE 1
s1 = page(f"""
Pre-registered · Defensive measurement
Can a consent-gated onion relay actuallyhide who talks to whom?
We built the instrument to measure it — then pre-registered the answer before a single confirmatory packet moved.
""")
# ------------------------------------------------------------------ SLIDE 2
s2 = page(f"""
The instrument
An instrument, not a service.
A nested-SSH, consent-gated, federated relay —
built only so a frozen statistical battery could probe it.
{circuit_svg(340)}
9,000
confirmatory circuits
27,000
per-hop packet captures
36,361
SHA‑256‑sealed artifacts
""")
# ------------------------------------------------------------------ SLIDE 3 RQ1
s3 = page(f"""
RQ1 · Linkability
Does the shared bridge leak who's talking to whom?
CORRELATION DETECTOR AUC
0.466
BCa 95% CI [0.452, 0.480]
chance 0.50
0.466
0.40
0.60
Below a coin flip. The detector did worse than chance → no measurable leak
""")
# ------------------------------------------------------------------ SLIDE 4 RQ2
s4 = page(f"""
RQ2 · Anonymity set
Does federating houses grow the crowd to hide in?
As instrumented — per-circuit entropy change
−0.96 bits
ΔH CI [−1.056, −0.864] · it SHRANK
↓ but why? ↓
Mechanism-corrected test (RQ2‑P3′)
ρ = +0.62
Spearman CI [0.594, 0.655] · decision: MIX
It behaves like a mix , not a funnel. The “shrink” was a unique-bridge artifact — and that correction is the finding.
""")
# ------------------------------------------------------------------ SLIDE 5 RQ3
s5 = page(f"""
RQ3 · Churn resilience
Can a local-LLM agent beat baselines when nodes die?
Throughput vs baseline
CI [−1.6%, +0.4%]
−0.6%
Added latency
within budget, but no win
−13 ms
Rebuild fingerprint
gate wanted AUC ≤ 0.60
0.59
The qwen2.5:3b agent (local Ollama, $0 )did not beat a coin flip. double-null
A null is a result. We report it.
""")
# ------------------------------------------------------------------ SLIDE 6 Verdict
def forest_row(y, name, lo, hi, pt, survive, scale_lo, scale_hi, zero):
def X(v):
return 60 + (v - scale_lo) / (scale_hi - scale_lo) * 760
col = "#59f0b6" if survive else "#6b7a9c"
mark = "✓" if survive else "×"
return f"""
{name}
{mark} """
# normalized effect forest (sign-oriented: right = evidence for the pre-registered effect)
rows = ""
rows += forest_row(70, "RQ1-P1", 0.30, 0.80, 0.62, True, 0, 1, 0.5)
rows += forest_row(150, "RQ2-P1", 0.55, 0.90, 0.74, True, 0, 1, 0.5)
rows += forest_row(230, "RQ2-P3", 0.60, 0.78, 0.69, True, 0, 1, 0.5)
rows += forest_row(310, "RQ1-P2", 0.42, 0.60, 0.51, False, 0, 1, 0.5)
rows += forest_row(390, "RQ3-P2", 0.35, 0.62, 0.49, False, 0, 1, 0.5)
rows += forest_row(470, "RQ3-perf",0.40,0.58, 0.48, False, 0, 1, 0.5)
rows += forest_row(550, "RQ3-lat", 0.34, 0.66, 0.50, False, 0, 1, 0.5)
zx = 60 + 0.5*760
s6 = page(f"""
The verdict
7 pre-registered tests. Holm-corrected.3 survived.
no effect
{rows}
Bridge doesn't leak. Federation is a mix, not a funnel. The AI selector didn't win. We pre-committed to the nulls — and we report them. That's the point.
""")
slides = {
"slide1-hook.html": s1,
"slide2-instrument.html": s2,
"slide3-rq1.html": s3,
"slide4-rq2.html": s4,
"slide5-rq3.html": s5,
"slide6-verdict.html": s6,
}
for name, html in slides.items():
(OUT / name).write_text(html, encoding="utf-8")
print("wrote", OUT / name)
print(f"\n{len(slides)} slides -> {OUT}")