Rename the simulator and report from pd to blend

The study started as a peering-degree question and grew well past it: propagation,
adversary exposure, deanonymization and time-to-link, reliability under uniform
and correlated churn, messaging redundancy, and cover traffic. The pd name no
longer describes it.

tools/simulators/blend/pd/ -> tools/simulators/blend/, package src/pd -> src/blend,
and reports/blend/pd/ -> reports/blend/. Moved with git mv so history follows.

The text substitutions are deliberately narrow. pd is also the conventional pandas
alias, and pandas genuinely has a pd.plotting submodule, so a blanket pd. -> blend.
rewrite would have corrupted four files. Only package-unambiguous forms were
changed: from pd.X, -m pd.X, pd.<our module>, PD_BYTES_BUDGET, src/pd, and the
pyproject name. All four import pandas as pd lines are untouched and verified.

Both READMEs reframed: peering degree is now presented as the primary axis that
ties the others together rather than as the subject, and the relative links, which
lost a directory level in the move, are corrected.

Verified after the move: ruff clean, 101 tests, 45 verify anchors, make targets,
the script shims, an end-to-end smoke run, and data/report_numbers.py still
reproducing the report tables from the checked-in evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcin Pawlowski 2026-08-06 12:20:07 +02:00 committed by Marcin Pawlowski
parent a93311013b
commit f51630e509
No known key found for this signature in database
93 changed files with 62 additions and 59 deletions

View File

@ -1,8 +1,8 @@
# Peering degree in the Blend network — a Monte-Carlo study
# The Blend network — a Monte-Carlo study
*Static-graph network simulation of the Blend message cascade. Simulator: [`pd`](../../../tools/simulators/blend/pd). All delays in **milliseconds**; the free-running mix clock's maximum interval (`max_blend_delay`) is in **whole seconds**. Adversary and deanonymization metrics are exact at every network size; propagation is Monte-Carlo over random senders.*
*Network simulation of the Blend message cascade. Simulator: [`blend`](../../tools/simulators/blend). All delays in **milliseconds**; the free-running mix clock's maximum interval (`max_blend_delay`) is in **whole seconds**. Adversary and deanonymization metrics are exact at every network size; propagation is Monte-Carlo over random senders.*
This report quantifies how a node's **peering degree** — the number of symmetric peers it keeps — trades off four properties of the Blend network at once: how fast a message propagates, how much of the network an adversary observes, how often a message is deanonymized, and how reliably messages are delivered when a fraction of nodes go dark. The headline is a single tension: **raising the peering degree improves propagation speed, eclipse resistance, and churn resilience, but *worsens* observation and sender deanonymization.** The anonymity axis has its own, degree-independent control — the **blend-path length** — so the two knobs separate cleanly: set the degree for the transport goals, set the path length for the anonymity goal. Two further questions follow from the deanonymization rates: *how long* an adversary needs to link an emitter to its messages and to learn its stake — which scales inversely with the node's own stake — and how **messaging redundancy** (sending each message over several independent cascades) trades reliability against anonymity, amplifying both by the very same factor.
This report measures the Blend network end to end: how fast a message propagates, how much of the network an adversary observes, how often a message is deanonymized and how quickly a node can be linked to one, how reliably messages are delivered when nodes go dark or whole regions fail, and what cover traffic buys. **Peering degree** is the first axis and the one that ties the others together, because it trades off several of these at once: the headline is a single tension: **raising the peering degree improves propagation speed, eclipse resistance, and churn resilience, but *worsens* observation and sender deanonymization.** The anonymity axis has its own, degree-independent control — the **blend-path length** — so the two knobs separate cleanly: set the degree for the transport goals, set the path length for the anonymity goal. Two further questions follow from the deanonymization rates: *how long* an adversary needs to link an emitter to its messages and to learn its stake — which scales inversely with the node's own stake — and how **messaging redundancy** (sending each message over several independent cascades) trades reliability against anonymity, amplifying both by the very same factor.
## Headline
@ -392,7 +392,7 @@ Because `s_max` is expressed against `D̂`, an estimator that runs low tightens
<a id="s6"></a>
## 6. Reproducibility
The simulator, configs, and analytic checks live in [`tools/simulators/blend/pd`](../../../tools/simulators/blend/pd). From that directory: `make install`, then `make sweep` runs the main grid (`configs/default.yaml`: N up to 10⁵, degree 316, 15 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/<timestamp>_default/`, writing three tables — `propagation.parquet`, `adversary.parquet`, and `deanon.parquet` — and rendering the figures. `make sweep-fullscale` extends the exact metrics to 10⁶ nodes. The messaging-redundancy study (§3.8) and the linkability figures come from `configs/redundancy.yaml` (`python -m pd.sweep --config configs/redundancy.yaml`), which sweeps `redundancy` ∈ {1, 2, 3, 4} alongside the churn and adversary grids; the churn-threshold study (§3.5, Fig 20) comes from `configs/percolation.yaml`, which walks the unresponsive fraction to 0.9 so each degree's collapse can be located against `u_c = 1 1/(degree 1)`; and the correlated-outage study (§3.9, Fig 22) from `configs/correlated-churn.yaml` (`make correlated-churn`), which partitions the network into failure domains and runs both churn modes on the same topologies. `make sweep-fullscale` produces the 10⁶ scaling check described in §5; and the cover-traffic study (§3.10, Figs 2324) comes from `configs/cover-traffic.yaml`, which sweeps the emission rate over three decades against three release delays and pairs each timeline with the epoch-scale emission budget. Round counts in all three configs are set for statistical resolution, not speed — see the sampling-error note in §5. `make verify` runs the analytic anchors (d-regularity; `observed ≈ 1 (1 f)^degree`; `eclipsed ≈ f^degree`; delivery `≈ (1 u)^blend_hops`; both deanonymization rates against a direct Monte-Carlo of the same draw; and — check 6 — `deanon_R` / `delivery_R = 1 (1 x)^R` for R independent cascades and the time-to-link geometric law), and `make test` the unit suite (`test_linkability.py` covers the time-to-link and stake formulae). The time-to-link and stake-inference curves are computed by `pd.linkability` from these exact rates.
The simulator, configs, and analytic checks live in [`tools/simulators/blend`](../../tools/simulators/blend). From that directory: `make install`, then `make sweep` runs the main grid (`configs/default.yaml`: N up to 10⁵, degree 316, 15 blend hops, `f_adv` up to 0.5, unresponsive fractions to 0.5, all three placement modes, 8 topology seeds) into `runs/<timestamp>_default/`, writing three tables — `propagation.parquet`, `adversary.parquet`, and `deanon.parquet` — and rendering the figures. `make sweep-fullscale` extends the exact metrics to 10⁶ nodes. The messaging-redundancy study (§3.8) and the linkability figures come from `configs/redundancy.yaml` (`python -m blend.sweep --config configs/redundancy.yaml`), which sweeps `redundancy` ∈ {1, 2, 3, 4} alongside the churn and adversary grids; the churn-threshold study (§3.5, Fig 20) comes from `configs/percolation.yaml`, which walks the unresponsive fraction to 0.9 so each degree's collapse can be located against `u_c = 1 1/(degree 1)`; and the correlated-outage study (§3.9, Fig 22) from `configs/correlated-churn.yaml` (`make correlated-churn`), which partitions the network into failure domains and runs both churn modes on the same topologies. `make sweep-fullscale` produces the 10⁶ scaling check described in §5; and the cover-traffic study (§3.10, Figs 2324) comes from `configs/cover-traffic.yaml`, which sweeps the emission rate over three decades against three release delays and pairs each timeline with the epoch-scale emission budget. Round counts in all three configs are set for statistical resolution, not speed — see the sampling-error note in §5. `make verify` runs the analytic anchors (d-regularity; `observed ≈ 1 (1 f)^degree`; `eclipsed ≈ f^degree`; delivery `≈ (1 u)^blend_hops`; both deanonymization rates against a direct Monte-Carlo of the same draw; and — check 6 — `deanon_R` / `delivery_R = 1 (1 x)^R` for R independent cascades and the time-to-link geometric law), and `make test` the unit suite (`test_linkability.py` covers the time-to-link and stake formulae). The time-to-link and stake-inference curves are computed by `blend.linkability` from these exact rates.
The figures of record for this report are the copies checked in under [`report-figures/`](report-figures); the simulator does not commit its own generated figures. To regenerate: run the sweeps above, then copy `runs/<…>/figures/*.png` into `report-figures/`.

View File

@ -17,7 +17,7 @@ Each run directory holds the three tables the simulator writes: `propagation.par
| `cover-traffic/` | `configs/cover-traffic.yaml` | 900 s timeline × 4 seeds | §3.10 — blending, mixing, and the emission-quota stake ceiling. Carries a fourth table, `traffic.parquet` |
The linkability results (§3.6§3.7) and both deanonymization rates are closed forms over these
tables rather than separate measurements, so they have no run of their own — `pd.linkability`
tables rather than separate measurements, so they have no run of their own — `blend.linkability`
derives them and `make verify` checks them against Monte-Carlo.
## Regenerating the report's numbers
@ -33,7 +33,7 @@ runs instead.
## Regenerating the data itself
From [`tools/simulators/blend/pd`](../../../../tools/simulators/blend/pd): `make sweep`,
From [`tools/simulators/blend`](../../../tools/simulators/blend): `make sweep`,
`make redundancy`, `make percolation`, `make correlated-churn`, `make sweep-fullscale`. Results
land in that simulator's `runs/<timestamp>_<label>/`. Note that the seed streams depend on the
configuration, so re-running reproduces the *statistics*, not bit-identical numbers, unless the

View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View File

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View File

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

View File

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View File

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

View File

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 222 KiB

View File

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

View File

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

View File

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

View File

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -21,28 +21,28 @@ $(STAMP): pyproject.toml
install: $(STAMP)
smoke: $(STAMP) ## fast end-to-end (seconds): tiny N, few rounds/seeds
$(PY) -m pd.sweep --config configs/smoke.yaml
$(PY) -m blend.sweep --config configs/smoke.yaml
sweep: $(STAMP)
$(PY) -m pd.sweep --config configs/default.yaml
$(PY) -m blend.sweep --config configs/default.yaml
sweep-fullscale: $(STAMP)
$(PY) -m pd.sweep --config configs/fullscale.yaml
$(PY) -m blend.sweep --config configs/fullscale.yaml
redundancy: $(STAMP) ## messaging redundancy R=1..4 (delivery vs deanonymization, time-to-link)
$(PY) -m pd.sweep --config configs/redundancy.yaml
$(PY) -m blend.sweep --config configs/redundancy.yaml
percolation: $(STAMP) ## churn threshold: coverage collapse at u_c = 1 - 1/(degree-1)
$(PY) -m pd.sweep --config configs/percolation.yaml
$(PY) -m blend.sweep --config configs/percolation.yaml
correlated-churn: $(STAMP) ## correlated AS/region outages vs uniform churn, matched fractions
$(PY) -m pd.sweep --config configs/correlated-churn.yaml
$(PY) -m blend.sweep --config configs/correlated-churn.yaml
figures: $(STAMP) ## make figures RUN=runs/<dir>
$(PY) -m pd.plotting.make_figures --run $(RUN)
$(PY) -m blend.plotting.make_figures --run $(RUN)
verify: $(STAMP)
$(PY) -m pd.verify
$(PY) -m blend.verify
test: $(STAMP)
$(PY) -m pytest

View File

@ -1,6 +1,9 @@
# pd — peering-degree Monte-Carlo graph simulator
# blend — a Monte-Carlo simulator for the Blend network
Quantifies how a node's **peering degree** trades off, in the Blend network:
Measures the Blend network on a seeded peer graph: propagation, adversary exposure,
deanonymization, reliability under churn, messaging redundancy and cover traffic.
Peering degree is the primary study axis and the one that ties the rest together —
it trades off, simultaneously:
- **propagation speed** — the full delay (ms) of a message: a random sender routes it along a
`blend_hops`-relay Blend path (each relay a free-running timed-release mix node) and the last
@ -62,14 +65,14 @@ adversary metrics are exact at every N).
random node set. Clustered failure leaves the survivors fully connected (`frac_reached_live`
stays ~1) while stranding the dead domains (`frac_reached` falls); see `configs/correlated-churn.yaml`.
Regions are failure and peering domains only — link latency does not depend on them.
- **Linkability over time** (`pd.linkability`): given the rates above and an emission cadence (one
- **Linkability over time** (`blend.linkability`): given the rates above and an emission cadence (one
node emits per 30 s slot, chosen ∝ stake), the module derives the *time to link* an emitter
(`≈ 30 s·ln(1/(1α))/(stake·q)`, inverse in stake) and the *time to learn its stake* to a threshold
from the count of attributable observations. See `configs/redundancy.yaml` and the report.
## Quick start
```
make install # or reuse a sibling venv: PYTHONPATH=src <python> -m pd.sweep ...
make install # or reuse a sibling venv: PYTHONPATH=src <python> -m blend.sweep ...
make smoke # fast end-to-end (every code path + 19 of 21 figure builders)
make verify # analytic checks (closed forms + graph invariants)
make test # unit tests
@ -93,7 +96,7 @@ unresponsive fraction (with the `u_c = 1-1/(degree-1)` threshold), the deanonymi
time-to-link / stake-inference / redundancy curves derived from them by `linkability`.
## Layout
`src/pd/`: `graph` (matching-union CSR d-regular), `propagation` (Blend cascade), `mixclock`
`src/blend/`: `graph` (matching-union CSR d-regular), `propagation` (Blend cascade), `mixclock`
(release-clock residual), `adversary` (exact observation/eclipse + deanonymization + placement),
`config`/`engine`/`sweep`/`metrics`, `plotting`. `configs/` sweeps, `tests/`, `scripts/` shims.
Reports of record live outside the sim at `reports/blend/pd/`.
Reports of record live outside the sim at `reports/blend/`.

View File

@ -3,7 +3,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pd"
name = "blend"
version = "0.1.0"
description = "Peering-degree Monte-Carlo graph simulator for the Blend network"
requires-python = ">=3.11"
@ -22,12 +22,12 @@ dependencies = [
dev = ["pytest", "pytest-xdist", "ruff", "mypy"]
[project.scripts]
pd-sweep = "pd.sweep:main"
pd-verify = "pd.verify:main"
pd-figures = "pd.plotting.make_figures:main"
pd-sweep = "blend.sweep:main"
pd-verify = "blend.verify:main"
pd-figures = "blend.plotting.make_figures:main"
[tool.hatch.build.targets.wheel]
packages = ["src/pd"]
packages = ["src/blend"]
[tool.ruff]
line-length = 100

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Shim: add src/ to sys.path, then run pd.plotting.make_figures:main."""
"""Shim: add src/ to sys.path, then run blend.plotting.make_figures:main."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from pd.plotting.make_figures import main # noqa: E402
from blend.plotting.make_figures import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Shim: add src/ to sys.path, then run pd.sweep:main."""
"""Shim: add src/ to sys.path, then run blend.sweep:main."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from pd.sweep import main # noqa: E402
from blend.sweep import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3
"""Shim: add src/ to sys.path, then run pd.verify:main."""
"""Shim: add src/ to sys.path, then run blend.verify:main."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from pd.verify import main # noqa: E402
from blend.verify import main # noqa: E402
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -6,7 +6,7 @@ sampled single-source distance matrices (``S x N`` or ``(blend_hops+1) x N``), w
would exceed its budget, so an under-sized config fails with a clear message instead of freezing
the machine.
Budget (``budget_bytes``): ``PD_BYTES_BUDGET`` > 0 -> that many bytes (the sweep sets this to
Budget (``budget_bytes``): ``BLEND_BYTES_BUDGET`` > 0 -> that many bytes (the sweep sets this to
each worker's RAM share); otherwise ``DEFAULT_BUDGET_FRAC`` of physical RAM.
"""
@ -38,7 +38,7 @@ def total_ram_bytes() -> int:
def budget_bytes() -> int:
"""Per-process byte budget for a single big array (see module docstring)."""
try:
explicit = int(os.environ.get("PD_BYTES_BUDGET", "0"))
explicit = int(os.environ.get("BLEND_BYTES_BUDGET", "0"))
except ValueError:
explicit = 0
if explicit > 0:

View File

@ -2,9 +2,9 @@ from itertools import combinations
import numpy as np
from pd.adversary import _greedy_coverage, adversary_metrics, place_adversary
from pd.config import SimConfig
from pd.graph import Graph, build_graph
from blend.adversary import _greedy_coverage, adversary_metrics, place_adversary
from blend.config import SimConfig
from blend.graph import Graph, build_graph
def _cycle4():

View File

@ -2,7 +2,7 @@ import dataclasses
import pytest
from pd.config import SimConfig, SweepConfig
from blend.config import SimConfig, SweepConfig
def test_key_covers_every_field():

View File

@ -6,10 +6,10 @@ uniformly blind to who is adversarial, so both rates are exact (no sampling in p
import numpy as np
from pd.adversary import adversary_metrics, deanon_metrics, place_adversary
from pd.config import SimConfig
from pd.engine import run_graph_cell
from pd.graph import build_graph
from blend.adversary import adversary_metrics, deanon_metrics, place_adversary
from blend.config import SimConfig
from blend.engine import run_graph_cell
from blend.graph import build_graph
def test_deanon_rate_hand_computed():

View File

@ -1,8 +1,8 @@
import numpy as np
import pytest
from pd.config import SimConfig
from pd.graph import build_graph, build_regular_edges
from blend.config import SimConfig
from blend.graph import build_graph, build_regular_edges
@pytest.mark.parametrize("n,degree", [(10, 1), (10, 2), (10, 3), (100, 4), (100, 7),

View File

@ -4,7 +4,7 @@ import math
import numpy as np
from pd.linkability import (
from blend.linkability import (
capture_prob,
obs_for_precision,
redundant,

View File

@ -1,6 +1,6 @@
import numpy as np
from pd.mixclock import mean_residual_ms, mix_wait
from blend.mixclock import mean_residual_ms, mix_wait
def test_zero_max_delay_is_zero():

View File

@ -1,9 +1,9 @@
import numpy as np
from pd.config import SimConfig
from pd.graph import Graph, build_graph
from pd.propagation import assign_responsive, blend_round, propagation_metrics
from pd.rng import responsive_seedseq, round_seedseq
from blend.config import SimConfig
from blend.graph import Graph, build_graph
from blend.propagation import assign_responsive, blend_round, propagation_metrics
from blend.rng import responsive_seedseq, round_seedseq
def _k4(p):
@ -206,7 +206,7 @@ def test_redundant_cascades_flood_the_same_component():
def test_regional_churn_drops_whole_regions_and_matches_the_uniform_count():
"""Correlated churn kills failure domains, not scattered nodes -- at the same total count."""
from pd.graph import region_of
from blend.graph import region_of
n, n_regions, u = 1000, 10, 0.3
rng = np.random.default_rng(0)
mask = assign_responsive(n, u, rng, "regional", n_regions)
@ -220,7 +220,7 @@ def test_regional_churn_drops_whole_regions_and_matches_the_uniform_count():
def test_uniform_churn_scatters_across_all_regions():
from pd.graph import region_of
from blend.graph import region_of
n, n_regions, u = 1000, 10, 0.3
mask = assign_responsive(n, u, np.random.default_rng(0), "uniform", n_regions)
region = region_of(n, n_regions)
@ -229,7 +229,7 @@ def test_uniform_churn_scatters_across_all_regions():
def test_region_locality_keeps_peers_inside_the_region_and_stays_d_regular():
from pd.graph import build_graph, region_of
from blend.graph import build_graph, region_of
n, n_regions, degree = 2000, 10, 8
region = region_of(n, n_regions)
for locality, want in ((0.0, 0.1), (0.5, 0.5), (1.0, 1.0)):
@ -244,7 +244,7 @@ def test_region_locality_keeps_peers_inside_the_region_and_stays_d_regular():
def test_regional_churn_leaves_survivors_better_connected():
"""The point of the correlated model: clustered failure removes whole neighbourhoods and
leaves the rest intact, so surviving nodes keep more live peers than under scattered failure."""
from pd.graph import build_graph
from blend.graph import build_graph
n, n_regions, degree, u = 4000, 20, 8, 0.4
cfg = SimConfig(n_nodes=n, degree=degree, n_regions=n_regions, region_locality=0.75,
graph_seed=0)

View File

@ -4,7 +4,7 @@ import math
import numpy as np
from pd.quota import (
from blend.quota import (
alpha_max,
assign_stake,
emission_quota_per_slot,

View File

@ -1,5 +1,5 @@
from pd.config import SimConfig
from pd.rng import (
from blend.config import SimConfig
from blend.rng import (
graph_seedseq,
responsive_seedseq,
rng_for,

View File

@ -2,9 +2,9 @@
import numpy as np
from pd.config import SimConfig
from pd.graph import build_graph
from pd.traffic import ReleaseClock, simulate_window, traffic_metrics
from blend.config import SimConfig
from blend.graph import build_graph
from blend.traffic import ReleaseClock, simulate_window, traffic_metrics
def _win(n_nodes=2000, degree=8, hops=3, M=3, mult=1.0, slots=600, seed=0):