Skip to content

feat: lose packets in runs, not one at a time (burst loss) - #170

Merged
donislawdev merged 4 commits into
masterfrom
feat/burst-loss
Sep 2, 2026
Merged

feat: lose packets in runs, not one at a time (burst loss)#170
donislawdev merged 4 commits into
masterfrom
feat/burst-loss

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

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 an
independent 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 netem exposes as loss gemodel, but not
the same interface: netem asks for p, r, 1-h, 1-k, and the two numbers a tester
can 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 p disagrees with the notation of
their 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 packets
on 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 <= 1 needs
mean_burst >= loss/(1-loss), so 90% loss cannot arrive in runs of 5. The run
then 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 on max-complexity, so the whole
question moved into a helper and step 8 kept its single if. Measured 27 before
and 27 after.

Cost, measured

  • The pass-through path pays nothing - self.loss > 0 short-circuits before
    the call, so a session with no loss configured is untouched.
  • A loss-armed session pays at most ~7% of decide(), which is ~140 ns
    against the ~70 us end-to-end budget per packet. For scale, switching the whole
    connection log off is worth 1.012x.
  • Hot-path retention is unchanged at the documented floor: 13 blocks and 608
    bytes per 5000 packets
    , in every configuration, armed or not.

What it costs elsewhere, deliberately

--los no longer resolves. allow_abbrev is on by an earlier decision, so a
second option starting with loss makes the three-letter prefix ambiguous.
Measured on the real parser: --loss survives because argparse prefers an exact
match, --loss-b reaches the new flag, --los now exits 2. Pinned by a test so
nobody spends an afternoon on it as a bug.

Also in here

  • Field.parameter_of names the impairment a field only shapes. It replaces
    the 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.
  • A "Loss runs" counter on the Statistics tab, in the stats CSV and in the
    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.
  • The card title said "Impairments (%)" while the new field is measured in
    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

  • 1318 tests green (full suite, run once locally through preflight), plus ruff,
    mypy and the code-shape ratchets.
  • Five mutation-registry entries, all five caught: the two chains folded into
    one, set_params leaving the chain stale, a clamp claiming the number it was
    asked for, a session starting inside the previous run, and the counter never
    counting.
  • Real Tk render at 1366x768 in all three languages.
  • Round trips checked end to end: a config saves and reloads with the field, one
    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.
  • The engine numbers converge, and the first reading was a trap worth recording:
    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

  • No real-driver rig run: those run at a release, not per chunk.
  • Presets are untouched. Setting a run length on the wireless presets is a claim
    about the real world and needs a source, so it is a separate decision.
  • Time-based runs (a run measured in milliseconds) were considered and rejected:
    that is flap with a random phase, and the division of labour stays as it is.

🤖 Generated with Claude Code

donislawdev and others added 4 commits September 1, 2026 21:06
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>
@donislawdev
donislawdev merged commit 3bbca79 into master Sep 2, 2026
14 checks passed
@donislawdev
donislawdev deleted the feat/burst-loss branch September 2, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant