Skip to content

Count the packets that left in a different order, and let throughput be read in Mbit/s - #176

Merged
donislawdev merged 5 commits into
masterfrom
feat/reordering-counter-and-rate-units
Sep 4, 2026
Merged

Count the packets that left in a different order, and let throughput be read in Mbit/s#176
donislawdev merged 5 commits into
masterfrom
feat/reordering-counter-and-rate-units

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Two asks, and the first one turned out to be half built already

Reordering as its own knob. It exists: a latency spike is "delay X% of packets by Y ms", it
reorders traffic without smearing every other packet's delay the way jitter does, and the release
heap is keyed by release time so packets genuinely come out in a different order. Nothing said so.
It sat in a card called "Latency (ping)" under a tooltip about momentary lag, and the Control page
search matches names rather than tooltip bodies - so a person looking for reordering found nothing.

So this adds the two things that genuinely did not exist rather than a second field doing what
--spike-prob already does:

  • a Reordered counter, because "20% got +50 ms" is NOT "20% arrived out of order" - whether
    anything overtakes anything depends on the gap between packets, which is a property of the
    traffic. Without the number, a run where reordering never happened reads exactly like a run the
    application coped with. Same hole loss_bursts was added to close for runs of loss.
  • the name, in the place the search actually reads: the card is now "Latency (ping) and packet
    order". Measured before and after: order and kolejnosc went from 0 hits to 5 in both
    languages (the section plus all four fields in it).

Speed units. A limit is typed in KB/s and a link is sold in Mbit/s. The switch is DISPLAY only,
and that is forced rather than chosen: KB/s is what a saved config file, the throughput schedule,
the shipped scenarios, --down/--up and the NDJSON down_kbps/up_kbps fields all carry, and
several of those are frozen contracts. It converts on the way to the screen and never on the way to
a file, and a grey (= 8.39 Mbit/s) appears beside the field you type into.

1024 KB/s is 8.39 Mbit/s, not 8. K is 1024 here, a megabit is a decimal million, and the two
do not cancel. The factor is derived in the open and the comfortable wrong answer is asserted
against, because that version looks more right than this one.

Measured, not asserted

  • A new rig counts the reordering from the other end - numbered UDP datagrams one way over
    loopback, arrival order read from the socket, so the engine's number and the application's come
    from different ends of the same event. Two runs, elevated, real driver: control 0 out of order
    and 0 lost both times, and the two agreed exactly in all six comparisons (788/788, 826/826,
    1974/1974, 1989/1989, 0/0, 0/0). The rig itself is outside git, so only its effect is visible
    here.
  • Its too_sparse case is the one worth having: a spike smaller than the gap between datagrams
    cannot make anything overtake anything, so a configured spike reports zero on both sides.
    That sentence is now in the tooltip, the README and the counter's docstring, and nothing else
    measured it.
  • Ten mutation-registry entries, all caught, so "these tests catch it" is repeated by CI rather
    than claimed here. One SURVIVED the first run and that was information about the TEST: the
    arrival counter never reset, so a restart assertion could not fail in either direction. Fixed at
    the source - arrival numbering is now per session, alongside the mark it is judged against.
  • Real Tk at 1366x768 and 150% DPI, all three languages: 0 clipped labels. The CI render check
    measures buttons and checkboxes only, so the new labels needed their own pass; the converted
    readout asks for 122 px and gets 122.

Shape

damage.py is carved out of engine.py: adding the counter pushed that module past the file-size
crowd band (817 logic lines against 816.2), and the ratchet is answered by moving code out. The
five names moved read a stats dict and touch no thread, handle or packet. The unit preference
needed a CHOICE kind in the Pref registry - the language combobox beside it is hand-rendered
and is an explicitly named exception, so copying that would have copied the exception rather than
the rule.

The NDJSON sample schema is deliberately untouched; new information goes down the text channel,
as the F17 ADR settled. The stats CSV gains packets_reordered, and an existing file is rotated to
a dated backup by the header check that already exists for this.

Verification

1373 passed, ruff and mypy clean, GUI smoke OK, CI render check OK in en/pl/zh - and the crash
log is empty after a full run, so no fake drifted from a real interface and no guarded path
went quiet.

Not done, and deliberate: the rig's baseline reference is not recorded, because baseline.py
refuses to record off origin/master - a reference names the commit that produced it and a squash
merge throws the branch commit away. It wants recording on master after this lands.

🤖 Generated with Claude Code

donislawdev and others added 5 commits September 4, 2026 01:23
Jitter and the latency spike change the order packets go out in, and
nothing measured whether that actually happened. It is not derivable from
the settings: whether a delayed packet is overtaken depends on the gap
between packets, so at two packets a second a 50 ms spike reorders
nothing. Without the number, a run where reordering never occurred reads
exactly like a run the application survived - the hole loss_bursts was
added to close for runs of loss.

- engine: read the arrival sequence the heap entry already carried and
  compare it against a per-direction high-water mark, after send() rather
  than before, so a packet the driver refused cannot make the next one
  look overtaken by one that never reached the stack
- counter surfaces on the Statistics tile and session panel, in the stats
  CSV as packets_reordered, and in the repro report; the NDJSON sample
  schema is left alone (frozen contract)
- carve damage.py out of engine.py: adding the counter pushed that module
  past the file-size crowd band (817 logic lines against 816.2), and the
  ratchet is answered by moving code out, not by raising the number. The
  five names moved read a stats dict and touch no thread, handle or packet
- tests/test_reordering.py: an overtaken packet counts, kept order does
  not, the directions are judged separately, a refused send does not move
  the mark, and a restarted session does not inherit the old one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…that could not fail

Five entries in the mutation registry, so "these tests catch it" is a claim
CI repeats instead of a sentence in a changelog.

The fifth mutant SURVIVED the first run, and that was information about the
test: the arrival counter was built in __init__ and never reset, so a second
session's numbers were always higher than a stale high-water mark and the
restart assertion could not fail in either direction. Fixed at the source
rather than in the test - arrival numbering is now per session and resets
alongside the mark it is judged against, since the mark only means anything
against numbers from the same numbering. stop() clears the heap, so a reused
number cannot meet a queued entry that still holds it as a tie-breaker.

- repoint "engine: the Internet-only drop loses its own counter" at damage.py:
  the carve-out moved its pattern, so it reported SKIP, and a skip reads like
  a pass. Re-run from its new home: caught
- reordering mutations, re-run after the fix: 5 of 5 caught

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The knob for out-of-order delivery already existed - a latency spike is
"delay X% of packets by Y ms", which reorders traffic without smearing
every other packet's delay the way jitter does. Nothing said so. It sat in
a card called "Latency (ping)" under a tooltip about momentary lag, and
the Control page search matches names rather than tooltip bodies, so a
person looking for reordering found nothing.

- rename the card to "Latency (ping) and packet order" in all three
  languages. The section title is folded into the haystack of every field
  inside it, so one string makes the card and all four of its fields
  findable. MEASURED: "order" and "kolejnosc" went from 0 hits to 5
- rewrite tips.spike so its second sentence carries the consequence that
  matters instead of restating the first
- README: a paragraph on testing out-of-order delivery, and what a zero in
  the new Reordered figure does and does not mean
- guard the discoverability itself, since a later reword would take it away
  silently

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A speed limit is typed in KB/s and a link is sold in Mbit/s, and the tool
made you do that conversion in your head - in the one place where getting
it wrong is invisible, because a wrong limit still runs.

The switch is DISPLAY only, and that is forced rather than chosen: KB/s is
what a saved config file, the throughput schedule, the shipped scenarios,
--down/--up and the NDJSON down_kbps/up_kbps fields all carry, and several
of those are frozen contracts. So it converts on the way to the screen and
never on the way to a file.

- new CHOICE kind in the Pref registry, rendered as a readonly combobox.
  The language box beside it is hand-rendered and is a named exception, so
  copying that would have copied the exception rather than the rule
- gui/rates.py gains the unit table and the conversion; the rate fields are
  a VIEW over the field registry (unit == "KB/s"), not a list of names
- wired into both Statistics tiles and their captions, the copied text, the
  session peak and average, the chart axis and caption, and a live grey
  readout beside Download and Upload. The chart converts labels only: the
  series and the ceiling stay in KB/s
- the readout is pushed from set_pref rather than polled - the dropdown is
  in a different window from the label it moves
- 1024 KB/s is 8.39 Mbit/s and not 8: K is 1024 here, a megabit is a
  decimal million, and the two do not cancel. The factor is derived in the
  open and the comfortable wrong answer is asserted against, because that
  version looks more right than this one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eiling

The reaction to the speed-unit preference was in App.set_pref, which put
five logic lines into the one module that sits ON the file-size ratchet
with zero headroom by construction - it went 1166 to 1171 and the full
suite said so. The answer to a ceiling here is to move code out, not to
raise the number, and this project already has the right home for it:
pages.pref_changed broadcasts to whichever page cares, and its own
docstring gives this exact reason for existing.

- move the refresh to ControlPage.on_pref_changed, beside the one that
  shows and hides the search bar
- point the test at the production path (set_pref then pref_changed)
  rather than calling the form directly: it was green through the move
  while the wiring was broken, which is the half worth guarding
- one more mutation entry for that wiring, caught
- annotate the three new rates.py helpers - that module is on the mypy
  strictness ratchet, so an unannotated def there is a red job
- trim the user-facing changelog entry under the 100-word ceiling

Verified: 1373 passed, ruff and mypy clean, GUI smoke OK, and the crash
log is EMPTY after a full run - no fake drifted from a real interface and
no guarded path went silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit 76c6f87 into master Sep 4, 2026
14 checks passed
@donislawdev
donislawdev deleted the feat/reordering-counter-and-rate-units branch September 4, 2026 00:19
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