blend: show the clock design is not handicapped by the adversary assumption

The jitter-vs-clock verdict rested on handing the clock adversary the tick
schedule, which is the conservative choice but also the harsher one -- a real
observer sees only ticks that released something. Rather than assume, both are
now measurable, and they are provably equivalent.

A silent tick implies nothing was pending at it, and any arrival older than the
previous release has demonstrably already left. So the candidate window bounded by
the true previous tick and the one bounded by the previous observed release hold
the same arrivals. Confirmed identical to 1e-9 in both the sparse regime (one hold
per relay) and the dense one (56 holds per relay), where the two coincide for
different reasons.

That removes the objection to the comparison: jitter beats the clock at a matched
delay budget on merit, not because the clock was given a stronger opponent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-06 12:46:31 +02:00 committed by Marcin Pawlowski
parent 4915444c3e
commit 0c72a39e38
No known key found for this signature in database
2 changed files with 50 additions and 7 deletions

View File

@ -261,7 +261,8 @@ def traffic_metrics(win: TrafficWindow, config: SimConfig,
def timing_linkability(win: TrafficWindow, config: SimConfig,
max_blend_delay: int | None = None,
min_blend_delay: int | None = None,
release_mode: str | None = None) -> dict:
release_mode: str | None = None,
adversary_knows_schedule: bool = True) -> dict:
"""Can an observer match a relay's outgoing message to the incoming one, from timing alone?
This is the attack that distinguishes a *blended* message from a merely *relayed* one: a relay
@ -277,6 +278,13 @@ def timing_linkability(win: TrafficWindow, config: SimConfig,
weighted by the delay density (exponential here). Nothing is quantised, so the weights decay
smoothly and the posterior concentrates on whichever arrival is closest to the expected lag.
``adversary_knows_schedule`` selects how much the clock design concedes. A free-running clock
ticks whether or not anything is held, but only ticks that *release* something are visible. The
strong adversary (default) is handed the true schedule and can exclude everything before the
previous tick; the weak one sees only the node's previous release, so silent ticks widen its
candidate window. Jitter has no schedule to know, so the switch does not affect it -- which is
exactly why the comparison has to be run both ways.
``linked_frac`` is the share of releases whose set collapses to one candidate: the message is
then linked with certainty, whatever the nominal delay was.
@ -301,14 +309,20 @@ def timing_linkability(win: TrafficWindow, config: SimConfig,
for node, holds in by_node.items():
arrivals = np.sort(np.array([h.arrived for h in holds]))
true_src = {h.released: h.arrived for h in holds} # the arrival that really produced it
for r in sorted({h.released for h in holds}):
rel_times = sorted({h.released for h in holds})
for r in rel_times:
if mode == "clock":
clock = win.clocks.get(node)
prev = 0.0
if clock is not None:
earlier = [t for t in clock.ticks_in(-1e18, r) if t < r]
if earlier:
prev = earlier[-1]
if adversary_knows_schedule:
clock = win.clocks.get(node)
if clock is not None:
earlier = [t for t in clock.ticks_in(-1e18, r) if t < r]
if earlier:
prev = earlier[-1]
else:
seen = [t for t in rel_times if t < r] # only releases are observable
if seen:
prev = seen[-1]
cand = arrivals[(arrivals > prev) & (arrivals <= r + 1e-12)]
n_c = max(len(cand), 1)
sets.append(float(n_c)) # uniform posterior -> perplexity = n

View File

@ -88,3 +88,32 @@ def test_perplexity_flatters_jitter_more_than_the_best_guess_does():
map_gain = (1 - j["map_success"]) / (1 - c["map_success"])
assert set_gain > 1.0 and map_gain > 1.0 # jitter wins on both
assert set_gain > map_gain # but the set measure overstates by how much
def test_knowing_the_tick_schedule_gains_the_adversary_nothing():
"""The clock design concedes the same whether or not the observer knows the tick times.
A silent tick implies nothing was pending at it, and any arrival older than the previous
release has demonstrably already left. So the candidate window bounded by the true previous
tick and the one bounded by the previous observed release contain the same arrivals. This
matters because it removes the obvious objection to the jitter-vs-clock comparison: the clock
design is not being handicapped by a generous adversary assumption.
"""
for n, rate, slots in ((2000, 1.0, 120), (200, 32.0, 90)): # sparse and dense
cfg = SimConfig(n_nodes=n, degree=8, blend_hops=3, max_blend_delay=30,
release_mode="clock", cover_rate_mult=rate)
g = build_graph(cfg)
w = simulate_window(g, cfg, np.random.default_rng(5), slots)
strong = timing_linkability(w, cfg, adversary_knows_schedule=True)
weak = timing_linkability(w, cfg, adversary_knows_schedule=False)
assert abs(strong["timing_set_mean"] - weak["timing_set_mean"]) < 1e-9
assert abs(strong["map_success"] - weak["map_success"]) < 1e-9
def test_jitter_beats_the_clock_at_a_matched_delay_budget():
"""The verdict, on the measure a heavy tail cannot flatter: at equal mean delay the
independent-draw design leaves the adversary's best guess wrong more often."""
_, c = _run("clock", rate=64.0, slots=60)
_, j = _run("jitter", rate=64.0, slots=60)
assert j["map_success"] < c["map_success"]
assert j["timing_set_mean"] > c["timing_set_mean"]