diff --git a/tools/simulators/blend/src/blend/traffic.py b/tools/simulators/blend/src/blend/traffic.py index 2eacd8d..fc81f46 100644 --- a/tools/simulators/blend/src/blend/traffic.py +++ b/tools/simulators/blend/src/blend/traffic.py @@ -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 diff --git a/tools/simulators/blend/tests/test_timing.py b/tools/simulators/blend/tests/test_timing.py index 2a20244..15ace32 100644 --- a/tools/simulators/blend/tests/test_timing.py +++ b/tools/simulators/blend/tests/test_timing.py @@ -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"]