feat: lose packets in runs, not one at a time (burst loss) - #170
Merged
Conversation
Real links do not lose packets independently. A microwave oven, a lift or a handover takes the link away for a stretch, and 5% of loss spread evenly is something TCP shrugs off while 5% arriving in three runs of twenty collapses the window and drives the reconnect path - which is the code a tester is usually trying to exercise. Step 8 of decide() can now draw from a two-state Markov chain instead of an independent per-packet coin. The model is Gilbert's burst-noise channel in its Simple Gilbert form (the good state loses nothing, the bad state loses everything), which is what tc netem exposes as "loss gemodel". The inputs are the two numbers a tester can actually answer - the average loss and the average run length in packets - and the transition probabilities are derived from them. - core.burst_loss_params(loss, mean_burst) -> (p, r, achievable) or None. Pure, module level, and the only place the arithmetic lives. - BeanCore.set_loss_burst / _recompute_burst / _loses; BeanEngine delegates. - decide() gained no branch: the whole question moved into _loses() and step 8 kept its single "if", because decide sits exactly on max-complexity (measured 27 before, 27 after) and the rule is to move code out. - One chain per DIRECTION, keyed like the token bucket in step 11. Measured: a single shared chain delivers half the configured run length under the default two-way filter, and the error follows the traffic mix rather than being a constant anyone could correct for. - _recompute_burst is called from set_params too. p depends on the loss as much as on the run length, so one owner would leave the ORDER of two setter calls deciding correctness. - Only a real parameter change restarts the chain, so a scenario stepping an unrelated field does not cut runs in flight. - Impossible pairs are clamped and say what they deliver: p <= 1 needs mean_burst >= loss/(1-loss), so 90% loss cannot arrive in runs of five. Measured rather than assumed: decide() reproduces the bare chain bit for bit over 2 million packets on four seeds and converges on the stationary rate; hot-path retention stays at the documented floor of 13 blocks and 608 bytes per 5000 packets in every configuration; the pass-through path pays nothing because step 8 short-circuits before the call. Nothing is reachable from the GUI or the CLI yet - the settings field, the flag and the documentation follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One registry entry in the "Impairments" card, so the widget, the label, the live validation, the profile scope, the settings surface and the CLI flag all follow from it. "Loss" still decides how much is lost in total, the new field decides whether it arrives evenly or in runs. - fields.py: `loss_burst`, plus `Field.parameter_of` and the `PARAMETER_KEYS` view over it. The default is 0 and not 1 because `off_value()` for a NUMBER is 0: with a default of 1 the registry's own `is_active()` would have called the field ACTIVE while it was switched off, and a profile written by an older release would zero-fill to a value that is not the neutral one. - The card went from three columns to two so the loss pair shares a row. - settings.py says two things out loud at apply time, both through `core.burst_loss_params` rather than repeating its arithmetic: what a clamped pair will really deliver, and roughly how many packets pass between runs. The second exists because a long run length can put runs far enough apart that a short session never sees one, and settings that look reasonable while nothing happens read exactly like a broken tool. - summary.py asks the same function instead of comparing against a threshold, so the preview strip cannot claim runs the engine is not producing. - repro.py carries `--loss-burst` into the reproduction command. - Both READMEs, all three language files, and a "?" help sheet rather than a longer tooltip. Two things this cost, both deliberate: - `--los` no longer resolves. `allow_abbrev` is on, so a second option starting with "loss" makes the three-letter prefix ambiguous. Measured: `--loss` survives because argparse prefers an exact match and `--loss-b` reaches the new flag, but `--los` now exits 2. Pinned by a test so nobody spends an afternoon on it as a bug. - The card title said "Impairments (%)" and the new field is in packets, so the suffix is gone in all three languages and both READMEs. Found by looking at a real render, which is the only thing that could have found it. `Field.parameter_of` replaces the hand-written half of the pass-through sweep: a parameter shapes an impairment instead of arming one, and the new test turns every one of them up to its registry maximum with the rest of the form at its defaults and requires every packet through untouched. That is strictly stronger than the two field names it used to keep in a list. Verified on real Tk at 1366x768 in all three languages, plus the settings round trips: a config saves and reloads with the field, one written before it existed still loads, a scenario step sets it through the same validation as the form, and a preset resets it like every other profile field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`drop_loss` cannot say whether the burst model fired. 0.1% loss in runs of 1000 is one run per million packets, so a legal configuration can look reasonable and produce nothing at all - which reads exactly like a broken tool. The run count separates "the session was too short" from "this is not working". - BeanCore counts a run where it begins, since only decide() can see a good-to-bad transition, and BeanEngine.stats_snapshot merges it in: the one place that can reach both. The read takes no core lock, for the same reason SocketWatcher.pid_for takes none - one int, a lock the capture thread holds per packet, and a snapshot allowed to be a moment old. - Surfaces: a "Loss runs" tile beside "Dropped", the loss_runs column in the stats CSV, and metrics.loss_runs in the reproduction report. Documented in both READMEs. Measured end to end, and the first reading was a trap worth recording: a six-second run produced 32 runs and reported 12.27% against a configured 8%, with a mean run of 21 against 15. Repeated at forty seconds on three seeds, at about 190 runs each, it gives 8.58 / 7.51 / 7.48% and mean runs of 16.07 / 14.77 / 14.03. It converges - the first number was the sample size, and the run count is exactly what makes that visible instead of alarming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five entries, five behaviours, each run and each caught: - the two per-direction chains collapsed into one shared flag - set_params no longer re-deriving the chain from the loss - an impossible pair claiming to deliver the number it was asked for - a session starting inside the previous session's run - the run counter never counting "This test guards that" is a claim like any other, and the registry is where it stops being one. Also trims the user-facing changelog entry back under the hundred-word ceiling the release guard sets - the detail belongs in the internal log, which is where it now lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Real links do not lose packets independently. A microwave oven, a lift or a
handover takes the link away for a stretch, and 5% of loss spread evenly is
something TCP shrugs off while 5% arriving in three runs of twenty collapses the
window and drives the reconnect path - which is usually the code a tester is
trying to exercise.
Step 8 of
decide()can now draw from a two-state Markov chain instead of anindependent per-packet coin. One new setting, "Losses in a row" /
--loss-burst,says how many packets are lost in a row on average. "Loss" still decides how much
is lost in total.
The model, and where it comes from
Gilbert's two-state burst-noise channel (Gilbert 1960, extended by Elliott 1963),
in its Simple Gilbert form - the good state loses nothing, the bad state loses
everything. It is the same model
tc netemexposes asloss gemodel, but notthe same interface: netem asks for
p, r, 1-h, 1-k, and the two numbers a testercan actually answer are the average loss and the average run length, so those are
the inputs and the transition probabilities are derived from them.
The reparametrisation through the average run length follows Hasslinger and
Hohlfeld, MMB 2008. Their printed formula for
pdisagrees with the notation oftheir own figure, so it was re-derived here and then measured rather than
trusted:
decide()reproduces the bare chain bit for bit over 2 million packetson four seeds and converges on the stationary rate (4.9765% against 5.0000%).
Three decisions worth reading
One chain per direction, not one shared. Measured: a shared chain delivers
half the configured run length under the default two-way filter (10.4 at a 50/50
mix), and the error follows the traffic mix (11.9 at 90/10), so there is not even
a constant a reader could correct for. Per direction it is 19.8 both ways.
Impossible pairs are clamped and say so.
p <= 1needsmean_burst >= loss/(1-loss), so 90% loss cannot arrive in runs of 5. The runthen reports what it will really deliver instead of quietly missing the number on
screen. Below 50% loss this can never fire.
decide()gained no branch. It sits exactly onmax-complexity, so the wholequestion moved into a helper and step 8 kept its single
if. Measured 27 beforeand 27 after.
Cost, measured
self.loss > 0short-circuits beforethe call, so a session with no loss configured is untouched.
decide(), which is ~140 nsagainst the ~70 us end-to-end budget per packet. For scale, switching the whole
connection log off is worth 1.012x.
bytes per 5000 packets, in every configuration, armed or not.
What it costs elsewhere, deliberately
--losno longer resolves.allow_abbrevis on by an earlier decision, so asecond option starting with
lossmakes the three-letter prefix ambiguous.Measured on the real parser:
--losssurvives because argparse prefers an exactmatch,
--loss-breaches the new flag,--losnow exits 2. Pinned by a test sonobody spends an afternoon on it as a bug.
Also in here
Field.parameter_ofnames the impairment a field only shapes. It replacesthe hand-written half of the pass-through sweep with a stronger property: every
parameter turned up to its registry maximum, with the rest of the form at its
defaults, must still pass every packet through untouched.
repro report. It answers the question the drop count cannot: 0.1% loss in runs
of 1000 is one run per million packets, so a legal configuration can look
reasonable and produce nothing, which reads exactly like a broken tool.
packets. Found by looking at a real render, which is the only thing that could
have found it. Dropped in all three languages and both READMEs.
Verification
mypy and the code-shape ratchets.
one,
set_paramsleaving the chain stale, a clamp claiming the number it wasasked for, a session starting inside the previous run, and the counter never
counting.
written before it existed still loads, a scenario step goes through the same
validation as the form, and a preset resets it like every other profile field.
a six-second run (32 runs) reported 12.27% against a configured 8%, while forty
seconds on three seeds (about 190 runs each) gives 8.58 / 7.51 / 7.48%. The
sample size, not the model - and the run counter is exactly what makes that
visible.
Not done, said out loud
about the real world and needs a source, so it is a separate decision.
that is
flapwith a random phase, and the division of labour stays as it is.🤖 Generated with Claude Code