Spaces:
Paused
Paused
Fix Play: Gradio Plot drops Plotly frames; drive playback server-side with gr.Timer
Browse files
app.py
CHANGED
|
@@ -69,7 +69,7 @@ and with lost messages. The merge rules guarantee that everyone who has heard th
|
|
| 69 |
evidence every honest drone can check for itself, then **dropped automatically**. The rest keep going, and each round
|
| 70 |
ends with a **receipt** that a quorum of drones co-signs and anyone can check afterwards.
|
| 71 |
|
| 72 |
-
**Watch the mission.** Press **Run mission**, then hit **Play**
|
| 73 |
"""
|
| 74 |
|
| 75 |
S4 = f"""
|
|
@@ -291,9 +291,11 @@ def _frame_traces(sw, upto):
|
|
| 291 |
return left, right, left_note, right_note
|
| 292 |
|
| 293 |
|
| 294 |
-
def _anim_compare(sw):
|
| 295 |
-
"""
|
|
|
|
| 296 |
n_rounds = len(sw.rounds)
|
|
|
|
| 297 |
xr, yr = _axis_range(sw)
|
| 298 |
fig = make_subplots(
|
| 299 |
rows=1, cols=2, shared_xaxes=False, shared_yaxes=True,
|
|
@@ -303,121 +305,101 @@ def _anim_compare(sw):
|
|
| 303 |
),
|
| 304 |
horizontal_spacing=0.06,
|
| 305 |
)
|
| 306 |
-
|
| 307 |
-
# Seed with final frame so static view is complete; frames animate from round 1
|
| 308 |
-
left, right, _, _ = _frame_traces(sw, n_rounds)
|
| 309 |
for tr in left:
|
| 310 |
fig.add_trace(tr, row=1, col=1)
|
| 311 |
for tr in right:
|
| 312 |
fig.add_trace(tr, row=1, col=2)
|
| 313 |
-
|
| 314 |
-
frames = []
|
| 315 |
-
for upto in range(1, n_rounds + 1):
|
| 316 |
-
left, right, ln, rn = _frame_traces(sw, upto)
|
| 317 |
-
data = left + right
|
| 318 |
-
frames.append(go.Frame(
|
| 319 |
-
data=data, name=str(upto),
|
| 320 |
-
layout=go.Layout(title_text=f"Mission animation · round {upto} of {n_rounds}"),
|
| 321 |
-
))
|
| 322 |
-
fig.frames = frames
|
| 323 |
-
|
| 324 |
-
sliders = [dict(
|
| 325 |
-
active=n_rounds - 1,
|
| 326 |
-
currentvalue=dict(prefix="Round ", font=dict(size=14)),
|
| 327 |
-
pad=dict(t=40, b=10),
|
| 328 |
-
steps=[dict(method="animate",
|
| 329 |
-
args=[[str(i)], dict(mode="immediate", frame=dict(duration=0, redraw=True), transition=dict(duration=0))],
|
| 330 |
-
label=str(i)) for i in range(1, n_rounds + 1)],
|
| 331 |
-
)]
|
| 332 |
fig.update_layout(
|
| 333 |
-
title=dict(text="
|
| 334 |
-
height=560, margin=dict(l=40, r=20, t=
|
| 335 |
template="plotly_white",
|
| 336 |
legend=dict(orientation="h", y=1.12, x=0),
|
| 337 |
-
|
| 338 |
-
type="buttons", showactive=False, direction="left",
|
| 339 |
-
x=1, xanchor="right", y=1.14, yanchor="top",
|
| 340 |
-
buttons=[
|
| 341 |
-
dict(label="▶ Play", method="animate",
|
| 342 |
-
args=[None, dict(frame=dict(duration=700, redraw=True), fromcurrent=True,
|
| 343 |
-
transition=dict(duration=400, easing="cubic-in-out"),
|
| 344 |
-
mode="immediate")]),
|
| 345 |
-
dict(label="⏸ Pause", method="animate",
|
| 346 |
-
args=[[None], dict(frame=dict(duration=0, redraw=False), mode="immediate",
|
| 347 |
-
transition=dict(duration=0))]),
|
| 348 |
-
dict(label="⟲ Restart", method="animate",
|
| 349 |
-
args=[["1"], dict(frame=dict(duration=0, redraw=True), mode="immediate",
|
| 350 |
-
transition=dict(duration=0))]),
|
| 351 |
-
],
|
| 352 |
-
)],
|
| 353 |
-
sliders=sliders,
|
| 354 |
)
|
| 355 |
fig.update_xaxes(title_text="metres east", range=xr, row=1, col=1)
|
| 356 |
fig.update_xaxes(title_text="metres east", range=xr, row=1, col=2)
|
| 357 |
fig.update_yaxes(title_text="metres north", range=yr, scaleanchor="x", scaleratio=1, row=1, col=1)
|
| 358 |
fig.update_yaxes(range=yr, scaleanchor="x2", scaleratio=1, row=1, col=2)
|
| 359 |
-
# Stronger subplot title styling
|
| 360 |
for ann in fig.layout.annotations:
|
| 361 |
-
if "
|
| 362 |
-
ann.font = dict(size=13, color="#1f6feb")
|
| 363 |
-
elif "WITHOUT" in (ann.text or ""):
|
| 364 |
ann.font = dict(size=13, color="#d62828")
|
|
|
|
|
|
|
| 365 |
return fig
|
| 366 |
|
| 367 |
|
| 368 |
-
def _err_anim(sw):
|
| 369 |
-
"""
|
| 370 |
n_rounds = len(sw.rounds)
|
|
|
|
| 371 |
xs = [r["round"] for r in sw.rounds]
|
| 372 |
te = [r["trusted_err"] if r["trusted_err"] is not None else 0 for r in sw.rounds]
|
| 373 |
le = [r["lead_err"] if r["lead_err"] is not None else 0 for r in sw.rounds]
|
| 374 |
lead_miss = [r["lead_err"] is None for r in sw.rounds]
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
texts.append("leader lost")
|
| 385 |
-
else:
|
| 386 |
-
texts.append("")
|
| 387 |
-
return [
|
| 388 |
-
go.Bar(x=xs, y=te_v, name="Trusted (WITH)", marker_color="#1f6feb", legendgroup="t"),
|
| 389 |
-
go.Bar(x=xs, y=le_v, name="Leader-based (WITHOUT)", marker_color="#d62828", legendgroup="l",
|
| 390 |
-
text=texts, textposition="outside"),
|
| 391 |
-
]
|
| 392 |
-
|
| 393 |
-
fig = go.Figure(data=bar_frame(n_rounds))
|
| 394 |
-
fig.frames = [go.Frame(data=bar_frame(u), name=str(u)) for u in range(1, n_rounds + 1)]
|
| 395 |
fig.update_layout(
|
| 396 |
barmode="group",
|
| 397 |
-
title="Error from truth
|
| 398 |
height=340, margin=dict(l=40, r=20, t=60, b=40), template="plotly_white",
|
| 399 |
-
xaxis=dict(title="round", dtick=1
|
|
|
|
| 400 |
legend=dict(orientation="h", y=-0.22),
|
| 401 |
-
|
| 402 |
-
type="buttons", showactive=False, direction="left", x=1, xanchor="right", y=1.15, yanchor="top",
|
| 403 |
-
buttons=[
|
| 404 |
-
dict(label="▶ Play", method="animate",
|
| 405 |
-
args=[None, dict(frame=dict(duration=550, redraw=True), fromcurrent=True,
|
| 406 |
-
transition=dict(duration=300), mode="immediate")]),
|
| 407 |
-
dict(label="⏸ Pause", method="animate",
|
| 408 |
-
args=[[None], dict(frame=dict(duration=0), mode="immediate", transition=dict(duration=0))]),
|
| 409 |
-
],
|
| 410 |
-
)],
|
| 411 |
-
sliders=[dict(
|
| 412 |
-
active=n_rounds - 1, currentvalue=dict(prefix="Round "),
|
| 413 |
-
steps=[dict(method="animate",
|
| 414 |
-
args=[[str(i)], dict(mode="immediate", frame=dict(duration=0, redraw=True), transition=dict(duration=0))],
|
| 415 |
-
label=str(i)) for i in range(1, n_rounds + 1)],
|
| 416 |
-
)],
|
| 417 |
)
|
| 418 |
return fig
|
| 419 |
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
def _kpis(sw, n, att):
|
| 422 |
rs = sw.rounds; acc = sum(r["accepted"] for r in rs)
|
| 423 |
te = [r["trusted_err"] for r in rs if r["trusted_err"] is not None]
|
|
@@ -459,8 +441,10 @@ def run(n, poisoner, equivocator, impersonator, loss, lose_lead, rounds, seed):
|
|
| 459 |
"cosigners": last["cosigners"], "cosignatures": last["cosignatures"], "public_keys": sw.pki}
|
| 460 |
path = tempfile.NamedTemporaryFile("w", suffix="-receipt.json", delete=False).name
|
| 461 |
json.dump(rec, open(path, "w"), indent=2)
|
| 462 |
-
|
| 463 |
-
|
|
|
|
|
|
|
| 464 |
|
| 465 |
|
| 466 |
def verify(sw):
|
|
@@ -488,10 +472,16 @@ with gr.Blocks(title="Trusted Swarms: leaderless, poison-proof shared mission st
|
|
| 488 |
with gr.Column(scale=3):
|
| 489 |
story = gr.Markdown()
|
| 490 |
kpis = gr.Markdown()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
with gr.Row():
|
| 492 |
-
anim = gr.Plot(label="Side-by-side mission animation")
|
| 493 |
with gr.Row():
|
| 494 |
-
er = gr.Plot(label="Error over rounds")
|
| 495 |
with gr.Row():
|
| 496 |
with gr.Column():
|
| 497 |
gr.Markdown("**Trust in each drone** (E4 trust layer; ≤ 0.1 in any dimension = dropped by every honest drone)")
|
|
@@ -510,8 +500,13 @@ with gr.Blocks(title="Trusted Swarms: leaderless, poison-proof shared mission st
|
|
| 510 |
with gr.Column(elem_classes="sec"):
|
| 511 |
gr.Markdown(S4); gr.Markdown(S5); gr.Markdown(S6); gr.Markdown(FOOT)
|
| 512 |
ins = [n, p, e, i, loss, ll, rounds, seed]
|
| 513 |
-
outs = [story, kpis, anim, er, tt, lg, rj, fl, state, vo]
|
| 514 |
go_btn.click(run, ins, outs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
vb.click(verify, state, vo)
|
| 516 |
demo.load(run, ins, outs)
|
| 517 |
|
|
|
|
| 69 |
evidence every honest drone can check for itself, then **dropped automatically**. The rest keep going, and each round
|
| 70 |
ends with a **receipt** that a quorum of drones co-signs and anyone can check afterwards.
|
| 71 |
|
| 72 |
+
**Watch the mission.** Press **Run mission**, then hit **▶ Play** above the animation. Left = **WITH** Trusted Swarms (converges, attackers dropped). Right = **WITHOUT** — leader-based (poisoned picture, then blank when the leader is lost).
|
| 73 |
"""
|
| 74 |
|
| 75 |
S4 = f"""
|
|
|
|
| 291 |
return left, right, left_note, right_note
|
| 292 |
|
| 293 |
|
| 294 |
+
def _anim_compare(sw, upto=None):
|
| 295 |
+
"""Side-by-side mission at round `upto` (1..N): WITH trusted vs WITHOUT leader-based.
|
| 296 |
+
Gradio's Plot component drops Plotly `frames`, so playback is driven server-side by gr.Timer."""
|
| 297 |
n_rounds = len(sw.rounds)
|
| 298 |
+
upto = n_rounds if upto is None else max(1, min(int(upto), n_rounds))
|
| 299 |
xr, yr = _axis_range(sw)
|
| 300 |
fig = make_subplots(
|
| 301 |
rows=1, cols=2, shared_xaxes=False, shared_yaxes=True,
|
|
|
|
| 305 |
),
|
| 306 |
horizontal_spacing=0.06,
|
| 307 |
)
|
| 308 |
+
left, right, _, _ = _frame_traces(sw, upto)
|
|
|
|
|
|
|
| 309 |
for tr in left:
|
| 310 |
fig.add_trace(tr, row=1, col=1)
|
| 311 |
for tr in right:
|
| 312 |
fig.add_trace(tr, row=1, col=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
fig.update_layout(
|
| 314 |
+
title=dict(text=f"Mission · round {upto} of {n_rounds}", x=0.02, xanchor="left"),
|
| 315 |
+
height=560, margin=dict(l=40, r=20, t=90, b=40),
|
| 316 |
template="plotly_white",
|
| 317 |
legend=dict(orientation="h", y=1.12, x=0),
|
| 318 |
+
uirevision="mission",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
)
|
| 320 |
fig.update_xaxes(title_text="metres east", range=xr, row=1, col=1)
|
| 321 |
fig.update_xaxes(title_text="metres east", range=xr, row=1, col=2)
|
| 322 |
fig.update_yaxes(title_text="metres north", range=yr, scaleanchor="x", scaleratio=1, row=1, col=1)
|
| 323 |
fig.update_yaxes(range=yr, scaleanchor="x2", scaleratio=1, row=1, col=2)
|
|
|
|
| 324 |
for ann in fig.layout.annotations:
|
| 325 |
+
if "WITHOUT" in (ann.text or ""):
|
|
|
|
|
|
|
| 326 |
ann.font = dict(size=13, color="#d62828")
|
| 327 |
+
elif "WITH" in (ann.text or ""):
|
| 328 |
+
ann.font = dict(size=13, color="#1f6feb")
|
| 329 |
return fig
|
| 330 |
|
| 331 |
|
| 332 |
+
def _err_anim(sw, upto=None):
|
| 333 |
+
"""Error bars built up to round `upto`."""
|
| 334 |
n_rounds = len(sw.rounds)
|
| 335 |
+
upto = n_rounds if upto is None else max(1, min(int(upto), n_rounds))
|
| 336 |
xs = [r["round"] for r in sw.rounds]
|
| 337 |
te = [r["trusted_err"] if r["trusted_err"] is not None else 0 for r in sw.rounds]
|
| 338 |
le = [r["lead_err"] if r["lead_err"] is not None else 0 for r in sw.rounds]
|
| 339 |
lead_miss = [r["lead_err"] is None for r in sw.rounds]
|
| 340 |
+
te_v = te[:upto] + [0] * (n_rounds - upto)
|
| 341 |
+
le_v = le[:upto] + [0] * (n_rounds - upto)
|
| 342 |
+
texts = ["leader lost" if (i < upto and lead_miss[i]) else "" for i in range(n_rounds)]
|
| 343 |
+
ymax = max(te + le + [1]) * 1.2
|
| 344 |
+
fig = go.Figure(data=[
|
| 345 |
+
go.Bar(x=xs, y=te_v, name="Trusted (WITH)", marker_color="#1f6feb"),
|
| 346 |
+
go.Bar(x=xs, y=le_v, name="Leader-based (WITHOUT)", marker_color="#d62828",
|
| 347 |
+
text=texts, textposition="outside"),
|
| 348 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
fig.update_layout(
|
| 350 |
barmode="group",
|
| 351 |
+
title=f"Error from truth, rounds 1–{upto} (metres; lower is better)",
|
| 352 |
height=340, margin=dict(l=40, r=20, t=60, b=40), template="plotly_white",
|
| 353 |
+
xaxis=dict(title="round", dtick=1, range=[0.5, n_rounds + 0.5]),
|
| 354 |
+
yaxis=dict(title="error (m)", range=[0, ymax]),
|
| 355 |
legend=dict(orientation="h", y=-0.22),
|
| 356 |
+
uirevision="err",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
)
|
| 358 |
return fig
|
| 359 |
|
| 360 |
|
| 361 |
+
def _render(sw, rnd):
|
| 362 |
+
if sw is None:
|
| 363 |
+
return gr.update(), gr.update()
|
| 364 |
+
return _anim_compare(sw, rnd), _err_anim(sw, rnd)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def play(sw, rnd):
|
| 368 |
+
if sw is None:
|
| 369 |
+
return gr.update(), gr.update(), gr.update(), gr.Timer(active=False)
|
| 370 |
+
n_rounds = len(sw.rounds)
|
| 371 |
+
rnd = int(rnd or 1)
|
| 372 |
+
if rnd >= n_rounds: # at the end: start over
|
| 373 |
+
rnd = 1
|
| 374 |
+
a, e = _render(sw, rnd)
|
| 375 |
+
return a, e, gr.update(value=rnd, maximum=n_rounds), gr.Timer(active=True)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def tick(sw, rnd):
|
| 379 |
+
if sw is None:
|
| 380 |
+
return gr.update(), gr.update(), gr.update(), gr.Timer(active=False)
|
| 381 |
+
n_rounds = len(sw.rounds)
|
| 382 |
+
rnd = min(int(rnd or 0) + 1, n_rounds)
|
| 383 |
+
a, e = _render(sw, rnd)
|
| 384 |
+
return a, e, gr.update(value=rnd), gr.Timer(active=rnd < n_rounds)
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def pause():
|
| 388 |
+
return gr.Timer(active=False)
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def restart(sw):
|
| 392 |
+
if sw is None:
|
| 393 |
+
return gr.update(), gr.update(), gr.update(), gr.Timer(active=False)
|
| 394 |
+
a, e = _render(sw, 1)
|
| 395 |
+
return a, e, gr.update(value=1), gr.Timer(active=False)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def scrub(sw, rnd):
|
| 399 |
+
a, e = _render(sw, rnd)
|
| 400 |
+
return a, e, gr.Timer(active=False)
|
| 401 |
+
|
| 402 |
+
|
| 403 |
def _kpis(sw, n, att):
|
| 404 |
rs = sw.rounds; acc = sum(r["accepted"] for r in rs)
|
| 405 |
te = [r["trusted_err"] for r in rs if r["trusted_err"] is not None]
|
|
|
|
| 441 |
"cosigners": last["cosigners"], "cosignatures": last["cosignatures"], "public_keys": sw.pki}
|
| 442 |
path = tempfile.NamedTemporaryFile("w", suffix="-receipt.json", delete=False).name
|
| 443 |
json.dump(rec, open(path, "w"), indent=2)
|
| 444 |
+
nr = len(sw.rounds)
|
| 445 |
+
return (_story(sw), _kpis(sw, n, att), _anim_compare(sw, nr), _err_anim(sw, nr), sw.trust_table(), log, rec, path, sw,
|
| 446 |
+
"Receipt not yet checked. Press **Verify the last receipt**.",
|
| 447 |
+
gr.update(value=nr, maximum=nr), gr.Timer(active=False))
|
| 448 |
|
| 449 |
|
| 450 |
def verify(sw):
|
|
|
|
| 472 |
with gr.Column(scale=3):
|
| 473 |
story = gr.Markdown()
|
| 474 |
kpis = gr.Markdown()
|
| 475 |
+
with gr.Row(equal_height=True):
|
| 476 |
+
play_btn = gr.Button("▶ Play", variant="primary", scale=1, min_width=100)
|
| 477 |
+
pause_btn = gr.Button("⏸ Pause", scale=1, min_width=100)
|
| 478 |
+
restart_btn = gr.Button("⟲ Restart", scale=1, min_width=100)
|
| 479 |
+
rnd = gr.Slider(1, 8, value=8, step=1, label="Round (drag to scrub)", scale=4)
|
| 480 |
+
timer = gr.Timer(0.9, active=False)
|
| 481 |
with gr.Row():
|
| 482 |
+
anim = gr.Plot(label="Side-by-side mission animation", show_label=False)
|
| 483 |
with gr.Row():
|
| 484 |
+
er = gr.Plot(label="Error over rounds", show_label=False)
|
| 485 |
with gr.Row():
|
| 486 |
with gr.Column():
|
| 487 |
gr.Markdown("**Trust in each drone** (E4 trust layer; ≤ 0.1 in any dimension = dropped by every honest drone)")
|
|
|
|
| 500 |
with gr.Column(elem_classes="sec"):
|
| 501 |
gr.Markdown(S4); gr.Markdown(S5); gr.Markdown(S6); gr.Markdown(FOOT)
|
| 502 |
ins = [n, p, e, i, loss, ll, rounds, seed]
|
| 503 |
+
outs = [story, kpis, anim, er, tt, lg, rj, fl, state, vo, rnd, timer]
|
| 504 |
go_btn.click(run, ins, outs)
|
| 505 |
+
play_btn.click(play, [state, rnd], [anim, er, rnd, timer], show_progress="hidden")
|
| 506 |
+
timer.tick(tick, [state, rnd], [anim, er, rnd, timer], show_progress="hidden")
|
| 507 |
+
pause_btn.click(pause, None, timer, show_progress="hidden")
|
| 508 |
+
restart_btn.click(restart, state, [anim, er, rnd, timer], show_progress="hidden")
|
| 509 |
+
rnd.release(scrub, [state, rnd], [anim, er, timer], show_progress="hidden")
|
| 510 |
vb.click(verify, state, vo)
|
| 511 |
demo.load(run, ins, outs)
|
| 512 |
|