From a9ee9f05f3e293e72c9c7b7c7c85703ad6d547ff Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 2 Sep 2026 07:48:47 +0200 Subject: [PATCH 1/4] feat(presets): let the wireless profiles lose in runs, the wired ones evenly The split is the MECHANISM, not taste. Radio loss comes from fading, interference and handovers, which take the channel away for a stretch. Wired loss is queue overflow, and tail drop on one flow is close to independent. So weak_wifi, cafe, metro, inflight, 3g, roaming, satellite, good_wifi and terrible carry a run length, and dsl, modem56k, bufferbloat, distant, perfect, 5g and lte do not. Loss rates are unchanged. New source [WIFI-BLL]: da Silva and Pedroso, Sensors 22(22):8592, 2022, doi:10.3390/s22228592 - a real 802.11b/g/n network over 24 600 minutes, with mean burst loss lengths of 3.00 packets in the good channel state, 4.66 in the worse intermediate and 5.67 in the bad one. The three Wi-Fi presets take those per-state means. Deliberately NOT the 5.37 the same paper reports for the whole trace. That mean carries a standard deviation of 31.68 and a longest burst of 8853, so it is dragged upward by rare enormous bursts, and our chain is geometric and memoryless: it cannot produce "mostly short, occasionally 8853", so fitting its mean to 5.37 would deliver far more medium bursts than the measurement ever saw. Fitting a heavy-tailed mean with a memoryless model looks exactly like diligence, which is why the reasoning sits next to the table. New source [E-MODEL] (ITU-T G.107) as a sanity anchor only: a burst ratio of 1 is random loss and the standard is cautious above 2, so single-digit run lengths are the realistic range and the run of twenty that flattens a TCP window is a case a tester dials in deliberately. leo is left alone on the strength of a source, which is the strongest reason on the list: [STAR-CON] measured the 15-second reconfiguration queueing packets rather than dropping them, which is why that preset carries a spike and no outage. Bursting it would contradict its own citation. metro and inflight already model their handover as a flap, so their run lengths describe the fading between those outages instead. Checked before touching a value: a reproduction command emits explicit numbers and never --preset, so commands saved by an earlier release are immune to this; the highest preset loss is 10%, so no preset can reach the clamp; and the derived gap lands between 114 and 400 packets, i.e. a short session sees several runs. Accepted live through the CLI, and the first reading was the sampling trap again - cafe measured 2.29% against its 3% over 51 runs, and 3.45 / 3.07 / 3.50% over about 200. The site's preset table gained a column: without it the loss column means two different things from row to row, and a reader comparing 2% against 2% cannot see that one of them arrives in runs. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++ README.md | 9 ++- README.pl.md | 4 +- beantester/presets.py | 104 +++++++++++++++++++++++++++++----- tests/test_presets_filters.py | 39 +++++++++++++ tools/build_site.py | 11 +++- 6 files changed, 155 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b758893..265cbfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol which is usually what you meant to test. Set 0, the default, to spread it evenly as before. Profiles remember it, and the log says how often to expect a run. +- **The wireless presets now lose in runs.** Weak WiFi, Cafe, Train/metro, In-flight Wi-Fi, 3G, + Roaming and Satellite pick a run length to match the way a radio link really fails, so picking + one of them now stalls a transfer the way the real thing does instead of sprinkling single + losses. The wired ones - DSL and 56k modem - keep their loss evenly spread, because there it + is a full queue rather than a radio going away. Loss rates are unchanged, and the Wi-Fi + figures come from a published measurement. + - **A "Loss runs" counter** on the Statistics tab, in the stats CSV and in the reproduction report, says how many runs of lost packets a session actually produced. Zero there, with a run length set, means the session was too short to see one rather than the setting doing diff --git a/README.md b/README.md index 532d3d9..380df58 100644 --- a/README.md +++ b/README.md @@ -362,9 +362,14 @@ Terrible network - plus your own (saved under a name). The program **always star network"** (nothing is impaired until you set something). Built-in presets cannot be deleted - the "Delete" button is disabled for them. +The wireless ones also lose **in runs** rather than one packet at a time, because that is what a +radio link does: fading, interference and handovers take the channel away for a stretch. The +wired ones (DSL, 56k modem) keep their loss evenly spread, because there the loss is a full queue +rather than a radio going away. + Their numbers come from published measurements wherever measurements exist (Ookla® Speedtest® -medians for satellite and mobile, peer-reviewed studies for Starlink's 15-second reconfiguration -and for in-flight Wi-Fi). Every figure is attributed in the comment block at the top of +medians for satellite and mobile, peer-reviewed studies for Starlink's 15-second reconfiguration, +for in-flight Wi-Fi and for how many Wi-Fi packets are lost in a row). Every figure is attributed in the comment block at the top of `beantester/presets.py`, with the authors, the venue and a DOI, so you can check it rather than trust it. Where no such figure exists - "weak Wi-Fi" is not a measurable quantity - the comment next to the value says so instead of inventing a source. A few of them are worth a sentence: diff --git a/README.pl.md b/README.pl.md index 1cd03e8..b011d71 100644 --- a/README.pl.md +++ b/README.pl.md @@ -277,7 +277,9 @@ w środku normalnego ruchu. **Profile** - gotowe presety **posortowane od najlepszego (góra) do najgorszego (dół)**: Idealna sieć, Dobre WiFi, Sieć 5G, DSL domowy (VDSL), Sieć LTE/4G, Satelita niskoorbitalny, Odległy serwer (inny kontynent), Słabe WiFi, Kawiarnia (zatłoczone WiFi), Zapchane łącze domowe (bufferbloat), Pociąg / metro (tunele), Sieć 3G, Roaming zagraniczny, Satelita geostacjonarny, Wi-Fi w samolocie, Modem 56k, Fatalna sieć - oraz Twoje własne (zapis pod nazwą). Program **startuje zawsze na „Idealnej sieci”** (nic nie jest psute, dopóki sam czegoś nie ustawisz). Presetów wbudowanych nie da się usunąć - przycisk „Usuń” jest wtedy nieaktywny. -Ich liczby pochodzą z opublikowanych pomiarów wszędzie tam, gdzie pomiary istnieją (mediany Ookla® Speedtest® dla satelity i sieci komórkowych, recenzowane prace naukowe o 15-sekundowym przełączaniu Starlinka i o Wi-Fi w samolocie). Każda liczba jest przypisana do źródła w komentarzu na górze `beantester/presets.py` - z autorami, miejscem publikacji i numerem DOI - żebyś mógł ją sprawdzić, a nie tylko nam uwierzyć. Tam, gdzie takiej liczby nie ma - „słabe Wi-Fi" nie jest wielkością mierzalną - komentarz przy wartości mówi to wprost, zamiast wymyślać źródło. +Te bezprzewodowe gubią też pakiety **seriami**, a nie po jednym, bo tak zachowuje się łącze radiowe: zaniki, zakłócenia i przełączenia zabierają kanał na moment. Te przewodowe (DSL, modem 56k) mają stratę rozłożoną równomiernie, bo tam bierze się ona z zapchanej kolejki, a nie ze znikającego radia. + +Ich liczby pochodzą z opublikowanych pomiarów wszędzie tam, gdzie pomiary istnieją (mediany Ookla® Speedtest® dla satelity i sieci komórkowych, recenzowane prace naukowe o 15-sekundowym przełączaniu Starlinka, o Wi-Fi w samolocie i o tym, ile pakietów Wi-Fi ginie pod rząd). Każda liczba jest przypisana do źródła w komentarzu na górze `beantester/presets.py` - z autorami, miejscem publikacji i numerem DOI - żebyś mógł ją sprawdzić, a nie tylko nam uwierzyć. Tam, gdzie takiej liczby nie ma - „słabe Wi-Fi" nie jest wielkością mierzalną - komentarz przy wartości mówi to wprost, zamiast wymyślać źródło. - **Satelita niskoorbitalny** - wzorowany na Starlinku. W stanie ustalonym jest dobry (około 40 ms pingu, 100 Mb/s). Wyróżnia go przełączanie satelity co 15 sekund, które na chwilę wstrzymuje transmisję i objawia się okazjonalnym skokiem pingu, a nie stratą pakietów. - **Odległy serwer (inny kontynent)** - szybkie łącze, w którym nic nie jest zepsute, tylko jest daleko (~120 ms pingu). To ten preset obnaża gadatliwe protokoły i kod pisany przy założeniu, że serwer stoi obok. diff --git a/beantester/presets.py b/beantester/presets.py index eae0b1e..382004f 100644 --- a/beantester/presets.py +++ b/beantester/presets.py @@ -64,12 +64,63 @@ # 200-2000 ms, "most home links affected" - are a typical range # assembled from general reporting, NOT figures from that paper. # Treat them as a shaped default, not as a measurement. +# [WIFI-BLL] da Silva and Pedroso, "Packet Loss Characterization Using Cross +# Layer Information and HMM for Wi-Fi Networks", Sensors 22(22):8592, +# 2022, doi:10.3390/s22228592. A real 802.11b/g/n network, 24 600 +# minutes of traffic: mean BURST LOSS LENGTH (consecutive packets +# lost) 3.00 in the good channel state, 3.03 and 4.66 in the two +# intermediate ones, 5.67 in the bad one, and 5.37 over the whole +# trace. 🔴 That last figure carries a standard deviation of 31.68 +# and a maximum burst of 8853, i.e. it is heavy-tailed - see the +# note on `loss_burst` below before copying it anywhere. +# [E-MODEL] ITU-T Rec. G.107, the E-model. Defines Burst Ratio as the average +# length of observed loss bursts over the length expected under +# random loss, so BurstR = 1 means independent loss and BurstR > 1 +# means bursty, and it cautions against using the algorithm above +# BurstR = 2.0 pending further verification (allowing higher when +# loss is under 2%). Used here only as a sanity anchor for what +# counts as ordinary burstiness, not as a source for any number. # [3GPP-RTT] GENERAL KNOWLEDGE, deliberately not dressed as a citation: UMTS # round trips of roughly 100-200 ms, HSPA 80-150 ms, real # throughput 0.384-2 Mbit/s. Widely reported engineering ranges; no # single document is being leaned on, and inventing a specification # number to make it look sourced would be worse than saying this. # +# HOW `loss_burst` WAS CHOSEN, and why it is not the number the paper prints +# ----------------------------------------------------------------------------- +# `loss_burst` is the average number of packets lost IN A ROW (0 = the loss is +# spread evenly). It shapes `loss`, it does not add to it. +# +# 🔴 The obvious move - take [WIFI-BLL]'s overall mean of 5.37 and write it down - +# is wrong, and the reason is worth keeping. That mean comes from a heavy-tailed +# distribution (sd 31.68, longest burst 8853), so it is dragged upward by rare +# enormous bursts. Our chain is GEOMETRIC and memoryless: it cannot produce +# "mostly short, occasionally 8853", so fitting its mean to 5.37 would deliver far +# more MEDIUM bursts than the measurement ever saw. The three Wi-Fi presets +# therefore take the paper's PER-STATE means instead, which describe a channel in +# one condition rather than a mixture of all of them - good state 3.00, the worse +# intermediate 4.66, bad state 5.67. +# +# Sanity check on the other side: under [E-MODEL], a burst ratio of 1 is random +# loss and the standard is cautious above 2. So single-digit run lengths are the +# realistic range for a working link, and the run of twenty that flattens a TCP +# window is a case a tester dials in deliberately - not something a preset should +# claim a real network does. +# +# Where it is left at 0, that is a decision and not an omission: +# * `perfect`, `distant`, `bufferbloat` lose nothing, so a run length there +# would be a knob that cannot move; +# * `dsl` and `modem56k` are wired, where loss is queue overflow rather than a +# radio going away, and tail drop on one flow is close to independent; +# * `leo` is left alone on the strength of a SOURCE, which is the strongest +# reason on this list: [STAR-CON] measured the 15-second reconfiguration +# QUEUEING packets rather than dropping them, which is why this preset carries +# a latency spike and not an outage. Bursting it would contradict its own +# citation; +# * `5g` and `lte` lose 0.1-0.3%, little enough that a run length would be an +# unsourced number a tester would rarely see act. +# ----------------------------------------------------------------------------- + # 🔴 LICENSING, before somebody improves this: the figures above are FACTS with # attribution, which is exactly what is allowed - facts carry no copyright and a # handful of them is not a substantial part of any database. That stops being @@ -85,7 +136,10 @@ "presets.perfect": dict(loss=0, corrupt=0, dup=0, lat=0, jit=0, down=0, up=0), # JUDGEMENT: "good" is not a measurable quantity. Ping 30 ms to a server on # the internet, a whisper of loss, and the link is not the bottleneck. - "presets.good_wifi": dict(loss=0.1, corrupt=0, dup=0, lat=15, jit=5, down=0, up=0), + # `loss_burst` is the exception and is SOURCED: the good-channel state in + # [WIFI-BLL] loses 3.00 packets in a row on average. + "presets.good_wifi": dict(loss=0.1, corrupt=0, dup=0, lat=15, jit=5, down=0, up=0, + loss_burst=3), # 168 Mbit/s down is mid-band 5G. Upload was 8192 (67 Mbit/s), which no # market reaches: 5G upload runs 50-120% above 4G, so ~21 Mbit/s. [OOKLA25] "presets.5g": dict(loss=0.1, corrupt=0, dup=0, lat=18, jit=8, down=20480, up=2560), @@ -107,8 +161,13 @@ # throughput collapses and jitter dominates. The old 2048/1024 and 1024/384 # made "weak" and "cafe" faster than most home DSL, which is the one thing # they must not be. - "presets.weak_wifi": dict(loss=2, corrupt=0.2, dup=0.5, lat=80, jit=40, down=512, up=256), - "presets.cafe": dict(loss=3, corrupt=0.3, dup=1, lat=120, jit=90, down=256, up=96), + # The two run lengths ARE sourced: [WIFI-BLL] measures 4.66 packets in a row + # in the worse intermediate channel state and 5.67 in the bad one, so a + # degraded link takes the first and a crowded one the second. + "presets.weak_wifi": dict(loss=2, corrupt=0.2, dup=0.5, lat=80, jit=40, down=512, up=256, + loss_burst=5), + "presets.cafe": dict(loss=3, corrupt=0.3, dup=1, lat=120, jit=90, down=256, up=96, + loss_burst=6), # Idle it is a good link; under load the queue is the impairment. 2000 ms of # buffer on a 1 Mbit/s uplink is the classic "the video call dies when # somebody starts a backup" [BLOAT]. NOTE: the buffer only bites once the @@ -120,31 +179,48 @@ # cluster near stations. JUDGEMENT: the 10% duty cycle (3 s out of every 30) # is a choice, not a measurement - no study gives a canonical figure. It is # the only preset that takes the link fully down long enough to make an - # application RECONNECT rather than merely degrade. + # application RECONNECT rather than merely degrade. The run length is + # JUDGEMENT too: it describes the fading BETWEEN those handovers, which is + # why it sits a little above the bad Wi-Fi state rather than modelling the + # handover itself - the flap already does that. "presets.metro": dict(loss=2, corrupt=0, dup=0, lat=50, jit=40, down=1024, up=256, - spike_prob=5, spike_ms=400, flap_period=30, flap_down=10), + spike_prob=5, spike_ms=400, flap_period=30, flap_down=10, + loss_burst=6), # [3GPP-RTT]. The old 384/128 was UMTS R99's kbit/s pair in a KB/s field, # which made "3G" deliver 3.1 Mbit/s - HSPA+, not the experience anybody - # picks this preset to reproduce. - "presets.3g": dict(loss=1, corrupt=0, dup=0, lat=90, jit=60, down=96, up=32), + # picks this preset to reproduce. The run length is JUDGEMENT: a radio link + # fades in clusters, but no source gives a run length for UMTS. + "presets.3g": dict(loss=1, corrupt=0, dup=0, lat=90, jit=60, down=96, up=32, + loss_burst=4), # JUDGEMENT: roaming varies by operator and agreement more than by # technology. The shape is what matters - your traffic goes home first. - "presets.roaming": dict(loss=1.5, corrupt=0, dup=0, lat=200, jit=80, down=256, up=64), + # The run length is JUDGEMENT for the same reason as `3g`. + "presets.roaming": dict(loss=1.5, corrupt=0, dup=0, lat=200, jit=80, down=256, up=64, + loss_burst=4), # Geostationary: SLOW in latency, not in bandwidth - the opposite of what # "satellite" suggests. 680 ms ping and 25 Mbit/s, between what HughesNet - # delivers (8-20) and Viasat (25-60). [OOKLA25] - "presets.satellite": dict(loss=1, corrupt=0, dup=0, lat=340, jit=100, down=3072, up=384), + # delivers (8-20) and Viasat (25-60). [OOKLA25]. The run length is + # JUDGEMENT: rain fade clusters losses, but [OOKLA25] reports rates, not + # run lengths. + "presets.satellite": dict(loss=1, corrupt=0, dup=0, lat=340, jit=100, down=3072, up=384, + loss_burst=4), # [MILEHIGH]. 7% loss is the measured MEDIAN for satellite in-flight, not a # worst case. Per-user throughput is provider policy, not technology: one # operator throttled every user to 100 kbit/s. The flap models losing the - # beam. Aircraft on newer LEO kit behave like `presets.leo` instead. + # beam. Aircraft on newer LEO kit behave like `presets.leo` instead. The run + # length is JUDGEMENT: [MILEHIGH] measures the loss RATE, not how it arrives, + # and this is the worst-behaved link in the table that still claims to be a + # real one. "presets.inflight": dict(loss=7, corrupt=0, dup=0, lat=375, jit=150, down=64, up=24, - flap_period=60, flap_down=3), + flap_period=60, flap_down=3, loss_burst=8), # V.90 in practice: ~41 kbit/s down, ~33 up. These two were always right. "presets.modem56k": dict(loss=0.5, corrupt=0, dup=0, lat=100, jit=30, down=5, up=4), # Not a real network and not meant to be one: the everything-at-once case. - # Its old 256/128 made the WORST preset faster than the 3G one. - "presets.terrible": dict(loss=10, corrupt=2, dup=2, lat=300, jit=150, down=32, up=16), + # Its old 256/128 made the WORST preset faster than the 3G one. The run + # length needs no source for the same reason the rest of this row does not: + # it is meant to be fatal, not realistic. + "presets.terrible": dict(loss=10, corrupt=2, dup=2, lat=300, jit=150, down=32, up=16, + loss_burst=15), } diff --git a/tests/test_presets_filters.py b/tests/test_presets_filters.py index 9d99296..5311c93 100644 --- a/tests/test_presets_filters.py +++ b/tests/test_presets_filters.py @@ -77,6 +77,45 @@ def test_preset_order_best_to_worst(): check("presets: perfect before terrible", idx["presets.perfect"] < idx["presets.terrible"]) +def test_no_preset_sets_a_run_length_it_cannot_use(): + """A run length shapes the loss, so without loss it is a knob that cannot move. + + Silent in every other way: the preset would look configured, the summary + would say nothing, and the field would sit there describing a link the + program never produces. It is the same class as a `spike_ms` with no + `spike_prob`, and cheap enough to make mechanical. + """ + from beantester.presets import PRESETS, preset_to_settings + for key in PRESETS: + settings = preset_to_settings(key) + if settings["loss_burst"]: + check(f"{key} sets a run length and has loss for it to shape", + settings["loss"] > 0, + f"(run={settings['loss_burst']}, loss={settings['loss']})") + + +def test_the_wireless_presets_lose_in_runs_and_the_wired_ones_do_not(): + """The mechanism split, pinned so a later tidy-up cannot quietly erase it. + + Loss on a radio link comes from fading, interference and handovers, which + take the channel away for a stretch. Loss on a wired link is queue overflow, + and tail drop on one flow is close to independent. That distinction is the + whole reason only some of these presets carry a run length, and nothing else + in the tree records it. + """ + from beantester.presets import preset_to_settings + for key in ("presets.weak_wifi", "presets.cafe", "presets.metro", + "presets.inflight", "presets.3g", "presets.roaming", + "presets.satellite"): + check(f"{key} loses in runs", preset_to_settings(key)["loss_burst"] > 0, + f"(run={preset_to_settings(key)['loss_burst']})") + for key in ("presets.dsl", "presets.modem56k", "presets.bufferbloat", + "presets.distant", "presets.perfect"): + check(f"{key} keeps its loss independent", + preset_to_settings(key)["loss_burst"] == 0, + f"(run={preset_to_settings(key)['loss_burst']})") + + def test_resolve_preset_variants(): import beantester as n check("presets: canonical id resolves", n.resolve_preset("presets.3g") == "presets.3g") diff --git a/tools/build_site.py b/tools/build_site.py index 9cf03b0..bc89f8f 100644 --- a/tools/build_site.py +++ b/tools/build_site.py @@ -765,12 +765,19 @@ def preset_table(app, texts): program. Column heads come from the program's own field labels, so they match the window a reader is looking at. """ - heads = [texts["table.preset"], app["app.fields.loss"], app["app.fields.latency"], + heads = [texts["table.preset"], app["app.fields.loss"], app["app.fields.loss_burst"], + app["app.fields.latency"], app["app.fields.jitter"], app["app.fields.download"], app["app.fields.upload"]] rows = ["%s" % "".join("%s" % html.escape(h, quote=True) for h in heads)] for key, values in presets.PRESETS.items(): + # The run length sits next to the loss it shapes, and it is here rather + # than left out with the other advanced fields for one reason: without it + # the loss column means two different things from row to row, and a reader + # comparing "2%" against "2%" has no way to see that one of them arrives + # in runs. A column that changes meaning silently is worse than a wider table. cells = [html.escape(app["app." + key], quote=True), - _cell(values.get("loss"), "%"), _cell(values.get("lat"), "ms"), + _cell(values.get("loss"), "%"), _cell(values.get("loss_burst")), + _cell(values.get("lat"), "ms"), _cell(values.get("jit"), "ms"), _cell(values.get("down"), "KB/s"), _cell(values.get("up"), "KB/s")] rows.append("%s" % "".join("%s" % c for c in cells)) From ab2628a6e402b22f9d70d9f3fb9de3cefe5b03da Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 2 Sep 2026 07:57:02 +0200 Subject: [PATCH 2/4] docs(presets): record the licence check for the two new sources Checked at the source rather than assumed, and written down so nobody checks it twice. [WIFI-BLL] is open access under CC BY 4.0 with copyright held by the authors, which makes it clean twice over: individual figures are facts and carry no copyright at all, and even if they did, CC BY permits reuse with attribution, which is given with authors, venue and DOI. No wording is reproduced and the underlying trace is not ingested, which is the line that matters. [E-MODEL] is an ITU-T Recommendation and ITU reserves its rights in the text. None of that text is here: one definition in our own words and one threshold, both attributed, used only as a sanity anchor rather than as the source of any value. A definition and a number are not the copyrighted expression. Neither belongs in THIRD-PARTY-NOTICES.md or licenses/ - those carry what the build ships, and a citation in a comment ships no third-party work, which is why the five older sources have no entry either. Co-Authored-By: Claude Opus 5 --- beantester/presets.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/beantester/presets.py b/beantester/presets.py index 382004f..777fb1b 100644 --- a/beantester/presets.py +++ b/beantester/presets.py @@ -130,6 +130,25 @@ # Ookla, Speedtest, Starlink, HughesNet and Viasat are trademarks of their # respective owners, named here only to identify whose measurements these are. # This project is not affiliated with, endorsed by or sponsored by any of them. +# +# CHECKED AND CLEAN, so nobody has to check it twice (2026-09-01, at the source +# rather than from memory): +# * [WIFI-BLL] is open access under **CC BY 4.0**, copyright the authors +# ("© 2022 by the authors. Licensee MDPI"), verified on the article page. +# Two independent reasons it is fine here: individual figures are facts and +# carry no copyright at all, and even if they did, CC BY permits reuse with +# attribution - which is given, with authors, venue and DOI. No wording is +# reproduced, and the underlying trace is NOT ingested, which is the line +# that matters (see the Ookla note above). +# * [E-MODEL] is an ITU-T Recommendation and ITU reserves its rights in the +# TEXT. Nothing of that text is here: one definition in our own words and one +# threshold, both attributed, and it is used only as a sanity anchor rather +# than as the source of any value in the table. A definition and a number are +# not the copyrighted expression. +# * Neither belongs in `THIRD-PARTY-NOTICES.md` or `licenses/`. Those carry +# what the BUILD ships - libraries, DLLs, fonts, icons (convention 35). A +# bibliographic citation in a comment ships no third-party work, which is why +# the five older sources here have no entry either. # --------------------------------------------------------------------------- # PRESETS = { # ordered best -> worst (top = best network, bottom = worst) From cc54ddfa2e1f7c5ca19b6d4a75f136b82d0addad Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 2 Sep 2026 07:57:16 +0200 Subject: [PATCH 3/4] feat(scenarios): give the radio scenarios bursty loss, and add one about shape The split follows the presets and for the same reason: cafe-wifi and mobile-lte-to-3g are radio stories, where loss comes from fading, interference and handovers and therefore arrives in runs. congested-vpn, overloaded-game-server, failing-dns, upload-drop-midway and blocked-endpoint are queue and path stories, where tail drop on one flow is close to independent, so their loss stays evenly spread. The cafe timeline takes its run lengths from the same measurement the preset does, walking the good, degraded and bad channel states as the room fills up. New scenario same-loss-in-runs.json holds 5% loss for the whole run and changes nothing else, delivering it evenly and then in runs of 5, 15 and 40 packets. It is the only file in the set where the loss RATE is constant, so whatever breaks is the shape rather than the amount - which is the question this impairment exists to ask and the one a rate alone cannot. Edited line by line rather than through a JSON re-dump: these files are hand-laid timelines with one step per line, and a re-dump spreads every step over six lines for no gain. Accepted live: each step announces its own run length and its own expected distance between runs, and the two steps that ask for evenly spread loss correctly announce nothing. Co-Authored-By: Claude Opus 5 --- README.md | 1 + README.pl.md | 1 + scenarios/cafe-wifi.json | 10 +++++----- scenarios/mobile-lte-to-3g.json | 6 +++--- scenarios/same-loss-in-runs.json | 10 ++++++++++ 5 files changed, 20 insertions(+), 8 deletions(-) create mode 100644 scenarios/same-loss-in-runs.json diff --git a/README.md b/README.md index 380df58..cb0512a 100644 --- a/README.md +++ b/README.md @@ -1044,6 +1044,7 @@ All of them loop except `upload-drop-midway.json`, so you can start one and leav | `congested-vpn.json` | A VPN whose **upload** collapses while download stays fine (512 to 160 KB/s), with latency spikes, an MTU of 1400 and occasional resets. | | `failing-dns.json` | Aimed at **UDP port 53 only**: name resolution degrades to 60% loss and 1.5 s of ping, goes **100% dead for 13 s**, then comes back. Everything else on the machine keeps working, which is what makes it a DNS test rather than an outage test. | | `overloaded-game-server.json` | A server sagging under load: ping, jitter, loss and duplication all climb together, with latency spikes up to 800 ms at 45% of packets. | +| `same-loss-in-runs.json` | The same 5% loss for the whole run, arriving four different ways: evenly spread, then in runs of 5, 15 and 40 packets. Nothing else changes, so whatever breaks is the SHAPE of the loss and not how much of it there is. The one for finding out whether your reconnect path works. | | `upload-drop-midway.json` | **Does not loop** - a one-shot: an upload that starts healthy, degrades, is **cut to zero mid-transfer** with a TCP reset, then partially recovers. For testing resumable uploads and progress bars that lie. | | `blocked-endpoint.json` | One backend (`203.0.113.0/24`) is **blocked** at 20 s while everything else keeps working, then unblocked. For testing timeouts, retries and fallbacks against a single dependency. | diff --git a/README.pl.md b/README.pl.md index b011d71..c188761 100644 --- a/README.pl.md +++ b/README.pl.md @@ -890,6 +890,7 @@ Wszystkie chodzą w pętli poza `upload-drop-midway.json`, więc można je włą | `congested-vpn.json` | VPN, w którym pada **upload**, a pobieranie trzyma się dobrze (512 → 160 KB/s), ze skokami latencji, MTU 1400 i sporadycznymi resetami. | | `failing-dns.json` | Wycelowany **wyłącznie w UDP port 53**: rozwiązywanie nazw degraduje się do 60% strat i 1,5 s pingu, na 13 s **pada w 100%**, po czym wraca. Reszta ruchu działa normalnie - i to właśnie czyni z tego test DNS-u, a nie test awarii. | | `overloaded-game-server.json` | Serwer uginający się pod obciążeniem: ping, jitter, straty i duplikacja rosną razem, ze skokami latencji do 800 ms na 45% pakietów. | +| `same-loss-in-runs.json` | Te same 5% strat przez cały przebieg, przychodzące na cztery sposoby: równomiernie, a potem seriami po 5, 15 i 40 pakietów. Nic innego się nie zmienia, więc to, co pęknie, jest winą KSZTAŁTU straty, a nie jej wielkości. Ten do sprawdzenia, czy ścieżka ponownego łączenia działa. | | `upload-drop-midway.json` | **Bez pętli** - jednorazowy: upload zaczyna zdrowo, degraduje się, zostaje **ucięty do zera w połowie transferu** z resetem TCP, potem częściowo wraca. Do testowania wznawialnych wysyłek i pasków postępu, które kłamią. | | `blocked-endpoint.json` | Jeden backend (`203.0.113.0/24`) zostaje **zablokowany** w 20 s, reszta działa dalej, po czym blokada znika. Do testowania timeoutów, ponowień i fallbacków wobec pojedynczej zależności. | diff --git a/scenarios/cafe-wifi.json b/scenarios/cafe-wifi.json index 5a27347..809462d 100644 --- a/scenarios/cafe-wifi.json +++ b/scenarios/cafe-wifi.json @@ -1,10 +1,10 @@ { "loop": true, "steps": [ - { "at": 0, "settings": { "latency": 20, "jitter": 15, "loss": 1, "down": 1024, "up": 256 } }, - { "at": 20, "settings": { "latency": 45, "jitter": 45, "loss": 3, "down": 512, "up": 160 } }, - { "at": 40, "settings": { "latency": 120, "jitter": 70, "loss": 6, "down": 256, "up": 96, "flap_period": 12, "flap_down": 20 } }, - { "at": 65, "settings": { "latency": 35, "jitter": 30, "loss": 2, "down": 512, "up": 160, "flap_period": 0 } }, - { "at": 85, "settings": { "latency": 20, "jitter": 15, "loss": 1, "down": 1024, "up": 256 } } + { "at": 0, "settings": { "latency": 20, "jitter": 15, "loss": 1, "loss_burst": 3, "down": 1024, "up": 256 } }, + { "at": 20, "settings": { "latency": 45, "jitter": 45, "loss": 3, "loss_burst": 5, "down": 512, "up": 160 } }, + { "at": 40, "settings": { "latency": 120, "jitter": 70, "loss": 6, "loss_burst": 6, "down": 256, "up": 96, "flap_period": 12, "flap_down": 20 } }, + { "at": 65, "settings": { "latency": 35, "jitter": 30, "loss": 2, "loss_burst": 5, "down": 512, "up": 160, "flap_period": 0 } }, + { "at": 85, "settings": { "latency": 20, "jitter": 15, "loss": 1, "loss_burst": 3, "down": 1024, "up": 256 } } ] } diff --git a/scenarios/mobile-lte-to-3g.json b/scenarios/mobile-lte-to-3g.json index f77fb98..7fa8ef4 100644 --- a/scenarios/mobile-lte-to-3g.json +++ b/scenarios/mobile-lte-to-3g.json @@ -2,11 +2,11 @@ "loop": true, "steps": [ { "at": 0, "settings": { "latency": 30, "jitter": 20, "loss": 0, "down": 4096, "up": 1280 } }, - { "at": 25, "settings": { "latency": 60, "jitter": 60, "loss": 2, "down": 512, "up": 128 } }, - { "at": 45, "settings": { "latency": 120, "jitter": 120, "loss": 8, "down": 96, "up": 32 } }, + { "at": 25, "settings": { "latency": 60, "jitter": 60, "loss": 2, "loss_burst": 4, "down": 512, "up": 128 } }, + { "at": 45, "settings": { "latency": 120, "jitter": 120, "loss": 8, "loss_burst": 8, "down": 96, "up": 32 } }, { "at": 60, "action": "reset_tcp" }, { "at": 60, "settings": { "loss": 100 } }, - { "at": 68, "settings": { "loss": 6, "latency": 100, "jitter": 100, "down": 160, "up": 48 } }, + { "at": 68, "settings": { "loss": 6, "loss_burst": 6, "latency": 100, "jitter": 100, "down": 160, "up": 48 } }, { "at": 85, "settings": { "latency": 30, "jitter": 20, "loss": 0, "down": 4096, "up": 1280 } } ] } diff --git a/scenarios/same-loss-in-runs.json b/scenarios/same-loss-in-runs.json new file mode 100644 index 0000000..d33d460 --- /dev/null +++ b/scenarios/same-loss-in-runs.json @@ -0,0 +1,10 @@ +{ + "loop": true, + "steps": [ + { "at": 0, "settings": { "latency": 30, "jitter": 10, "loss": 5, "loss_burst": 0, "down": 1024, "up": 256 } }, + { "at": 25, "settings": { "loss": 5, "loss_burst": 5 } }, + { "at": 50, "settings": { "loss": 5, "loss_burst": 15 } }, + { "at": 75, "settings": { "loss": 5, "loss_burst": 40 } }, + { "at": 100, "settings": { "loss": 5, "loss_burst": 0 } } + ] +} From e9f87de50ea70a37d3c24255b02970521014c5db Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 2 Sep 2026 08:00:00 +0200 Subject: [PATCH 4/4] docs(core): close the licence question on the burst-loss sources Two of the three names in that docstring are the kind an audit stops on, so the answer is written where a reader will look for it rather than left to be worked out again. The MMB 2008 paper is not open access. What is taken from it is an idea - that the average run length is the intuitive way into the model - plus an observation about its printed formula. Ideas and facts carry no copyright, the formula here was worked out in this repository, and none of their wording is reproduced. tc netem is named, not used. It ships under GPL-2.0 as part of iproute2, which convention 35 forbids as a dependency, and nothing here depends on it: no line of it was read into this file. It is named so a reader knows the model has a widely deployed implementation to compare against. Also restates the ITU-T anchor in our own words. ITU reserves its rights in the text of a Recommendation, and while a definition and a threshold are facts rather than that text, the previous wording sat closer to theirs than it needed to. Verified mechanically as well as by reading: no dependency, no licence file and no notices entry changed anywhere in this work, and a scan of the tracked tree for distinctive wording from every source read finds only two hits, both deliberate - the model's own name, and the CC BY notice quoted because that licence asks for exactly that attribution. Co-Authored-By: Claude Opus 5 --- beantester/core.py | 15 +++++++++++++++ beantester/presets.py | 11 +++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/beantester/core.py b/beantester/core.py index 252702a..8170914 100644 --- a/beantester/core.py +++ b/beantester/core.py @@ -65,6 +65,21 @@ def burst_loss_params(loss, mean_burst): the delivered loss and the delivered mean run length both land on the request inside the sampling noise, from 0.5% upward. + On the licensing of all that, checked rather than assumed (2026-09-01), because + two of the three names above are the kind an audit stops on: + + * the MMB 2008 paper is NOT open access. What is used from it is an IDEA - that + the average run length is the intuitive way in - together with an observation + about its printed formula. Ideas and facts carry no copyright, the formula in + this function was worked out here, and no wording of theirs is reproduced. + * ``tc netem`` is named, not used. It ships under GPL-2.0 as part of iproute2, + which convention 35 forbids as a DEPENDENCY - and nothing here depends on it. + No line of it was read into this file. It is named because a reader deserves + to know the model has a widely deployed implementation to compare against. + * Gilbert 1960 and Elliott 1963 are named as the model's origin, which is + attribution rather than reproduction. "Burst-noise channel" is the model's + own term, not a quotation. + 🔴 Not every request is possible, and that is what ``achievable`` is for. ``p <= 1`` needs ``mean_burst >= loss / (1 - loss)``, so 90% loss cannot arrive in runs of 5 - runs that short leave too little room between them. diff --git a/beantester/presets.py b/beantester/presets.py index 777fb1b..42fbae9 100644 --- a/beantester/presets.py +++ b/beantester/presets.py @@ -76,10 +76,13 @@ # [E-MODEL] ITU-T Rec. G.107, the E-model. Defines Burst Ratio as the average # length of observed loss bursts over the length expected under # random loss, so BurstR = 1 means independent loss and BurstR > 1 -# means bursty, and it cautions against using the algorithm above -# BurstR = 2.0 pending further verification (allowing higher when -# loss is under 2%). Used here only as a sanity anchor for what -# counts as ordinary burstiness, not as a source for any number. +# means bursty. The Recommendation puts the tested range of its own +# algorithm at a burst ratio of 2, and allows more than that only +# where loss stays under 2%. Restated rather than quoted: ITU +# reserves its rights in the TEXT of a Recommendation, and a +# definition and a threshold are facts rather than that text. Used +# here as a sanity anchor for what counts as ordinary burstiness, +# never as the source of a number in the table. # [3GPP-RTT] GENERAL KNOWLEDGE, deliberately not dressed as a citation: UMTS # round trips of roughly 100-200 ms, HSPA 80-150 ms, real # throughput 0.384-2 Mbit/s. Widely reported engineering ranges; no