Skip to content

feat: asymmetry - separate impairment values for uploads and downloads - #180

Merged
donislawdev merged 6 commits into
masterfrom
feat/asymmetric-impairments
Sep 5, 2026
Merged

feat: asymmetry - separate impairment values for uploads and downloads#180
donislawdev merged 6 commits into
masterfrom
feat/asymmetric-impairments

Conversation

@donislawdev

Copy link
Copy Markdown
Owner

Until now only throughput was per direction. Latency, jitter, latency spikes,
loss, corruption and duplication were one number applied both ways, so a link
that is fast down and slow up - which is most consumer and mobile links - could
not be described at all.

What this adds

A new "Asymmetry" card with a tick box, and seven upload values behind it:
latency, jitter, spike chance, spike size, loss, corruption and duplication.
On the command line: --asym, then --loss-up, --corrupt-up, --dup-up,
--latency-up, --jitter-up, --spike-prob-up and --spike-ms-up. Profiles
store all of it, the reproduction command carries it, and the session summary
says what the upload half is doing.

A switch, not an empty box

The obvious design - leave the upload box empty to mean "same as download" - was
rejected, and so was the other obvious one.

Separate fields defaulting to zero silently change what an existing profile
means.
A profile file names only the keys it carries and the store fills the
rest from each field's default, so a profile saying "latency 200" would load
without any error as 200 ms down and 0 ms up. The existing legacy-file guard
could not have seen it: it flags a field zero-filled against a NON-ZERO default,
and every upload value's default is zero. Reproduced as a mutation:
down=0.07, up=0.0 for a stored 7% loss.

A sentinel is invisible where it matters. An empty numeric box means zero
everywhere else in this program, and in a reproduction command the inheritance
would not appear at all.

So the switch decides, and ticking it copies the download values across. The
second set of boxes is never blank, switching it on changes nothing until you
edit something, and an existing profile keeps its meaning by construction rather
than by a migration somebody has to remember.

The measured cost, in full

The upload flags make 18 currently-working abbreviations ambiguous: --lat,
--jit, --j, --cor, --spike-p and the longer forms of each. This was
measured on the real parser before the names were chosen, and it is the same
cost --loss-burst was allowed to charge for --los.

Every FULL flag survives, because argparse prefers an exact match over a prefix
one - so no stored reproduction command and no documented example changes. Only
a hand-typed abbreviation needs writing out.

--up-latency would have cost one abbreviation instead of eighteen and was
refused on purpose: every other modifier in this parser reads
noun-then-modifier (--loss-burst, --spike-prob, --rst-cooldown,
--flap-down, --nat-timeout), and --up- would also read as belonging to
--up, the upload speed limit.

Safety

--asym --loss-up 50 cuts half of everything the machine sends. With no target
and no deadline that is a machine-wide impairment, and the blast-radius warning
looks at the download loss, which is zero - so the upload fields declare
impairs and would otherwise have started in silence. They also declare the new
Field.live_when, without which a value left behind after the switch goes back
off would warn about a run that touches nothing. Both halves are load-bearing
and each was proven by mutation.

A one-way traffic filter under asymmetry is now said out loud: out and in
are applied in the driver, so the other direction never reaches the tool and
half the values would describe traffic the session cannot see.

Verification

  • Full suite green: 1394 passed.
  • 73 mutations run against the changed files, 73 caught, none survived. Seven
    are new and cover this change; two existing registry entries had gone stale on
    this branch and the registry caught both.
  • A release rig proves it reaches a real application over a real network:
    the switch honoured (0.00% both ways), up_only 49.91% up and 0.00% down,
    down_only 0.03% up and 50.79% down - the pair that catches a swapped
    mapping - and round trips of +401.9 ms symmetric against +221.8 ms and
    +221.3 ms for the two asymmetric shapes.
  • decide() gains no branch and sits where it did on the complexity ceiling.
    Against the packet-path benchmark on this tree (4750 ns/packet, impairing
    mix, spread 6.6%), the per-direction read costs about 39 ns/packet, which is
    under what that benchmark can resolve. Retention per packet is unchanged.

Deliberately not in this change

  • Presets stay symmetric. Home and mobile links being asymmetric is common
    knowledge, but no number goes into a preset without a measurement or a source.
  • Drop counters stay aggregate. With 10% down and 40% up you still see one
    "Dropped" figure, so the asymmetry cannot be read off the screen directly
    (delivered bytes are already per direction). That is a separate change.

🤖 Generated with Claude Code

donislawdev and others added 6 commits September 5, 2026 13:05
`decide()` reads the seven values from step 8 down (loss, corruption,
duplication, latency, jitter, spike probability and spike length) from one
value set indexed by `is_outbound`, the way `_bucket` and `_loss_bad` are
already indexed. Nothing above the core uses it yet: with the switch off both
directions get the same object, so the packet path cannot read whether
asymmetry is on and there is no state there to get wrong.

The shape was measured rather than picked. Against bench_decide.py on this
tree (4750 ns/packet, impairing mix, spread 6.6%) one index plus seven
attribute reads costs ~39 ns/packet, while indexing seven separate
per-direction tuples costs ~172 ns. Both sit under the spread the rig can
resolve. `decide()` gains no branch, which matters because it sits exactly on
the mccabe ceiling of 27 pinned in pyproject.toml.

The burst-loss chain moves with the values, and so does its reset. `p` is
derived from the loss, so two directions losing different amounts need two
chains. While they shared one pair of probabilities, re-deriving it reset both
runs, so raising the upload loss ended the run the download direction was in
the middle of: a direction nobody touched, losing its run because the other
one moved. The run length itself stays symmetric on purpose.

Both mutations were run, not assumed. Restoring the shared reset fails the new
guard on "the download run survives a change to the upload", and deriving both
chains from the download loss fails it on "with the probability its own new
loss implies".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One switch (`asym`) and seven upload values reach the field registry, the
settings model, the parser, the reproduction command and the session summary.
They live in a card of their own rather than beside each field they mirror:
the latency and impairment cards pair their fields on purpose, "Loss" next to
the run length that shapes it, and interleaving an upload value into those
rows would break every one of those pairs.

The upload fields declare both `impairs` and the new `Field.live_when`, and
each half alone is a real defect. Without `impairs`, `--asym --loss-up 50`
cuts half of everything this machine sends, with no target and no deadline,
and starts in silence, because the blast-radius warning looks at the download
loss and that is zero. Without `live_when`, a value left behind after the
switch goes back off warns about a run that touches nothing, which is not a
corner case but the ordinary path of trying the feature and changing your
mind. Both mutations were run against the new guards and both fail them.

The flag names cost 18 working abbreviations (`--lat`, `--jit`, `--j`,
`--cor`, `--spike-p` and longer forms), measured before the names were chosen,
the same cost `--loss-burst` was allowed to charge for `--los`. Every full
flag survives, so no stored reproduction command and no documented example
moves. `--up-latency` would have cost one prefix and was refused: every other
modifier in this parser reads noun-then-modifier, and `--up-` would also read
as belonging to `--up`, the upload speed limit.

Two ratchets took the routine door rather than moving. `settings_to_cli`
crossed into the complexity crowd band, so its seven identical boolean
branches became a table. The upload half of the summary went into its own
function instead of into `settings_summary`, which sits one step below the
ceiling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A profile file names only the keys it carries and the store fills the rest
from each field's default, so a profile written before asymmetry loads
whatever the design is. The question was never whether it loads, it is what
it then means, and the existing legacy guard could not see this class: it
flags a field zero-filled against a non-zero default, and every upload value
defaults to zero.

The new guard checks that the stored numbers ARRIVE and that both directions
then carry them. Mutation run: reading the upload values regardless of the
switch, which is the rejected design where absent simply means zero, turns a
stored 7% loss into 7% down and 0% up and fails it.

A second guard covers `asym` being the first bool in the profile scope, where
everything is stored through float(). Mutation run: taking the switch out of
the profile scope fails it.

The fixture is built from the keys that existed before the change rather than
read from the stored corpus, so it keeps describing the pre-asymmetry shape
however that corpus is regenerated later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ticking the switch copies each upload field from the download field it
mirrors, which is the whole reason the card needs no new rule explained: the
second set of boxes is never blank, so nobody has to work out what an empty
one would have meant. It describes the same link it described a moment ago,
and only an edit changes anything. Unticking leaves the values alone, since
they are inert either way and wiping them would punish somebody comparing
against a symmetric run.

While the switch is off those fields are disabled and their labels greyed,
through the same `apply_overrides` that already did this for a field another
field had taken over. An editable box that changes nothing is a lie about what
the tool is doing, whichever of the two reasons makes it dead.

The pairs come from the registry (`Field.mirror_of`), so a value added to the
card later cannot be left out of the copy, and a new guard checks that every
gated field names a switch and a mirror that both exist and agree on kind,
bounds and unit.

This also fixes a defect it exposed, which is a class rather than one field:
both loops that fill the form from a preset or profile called number_string on
every value, which held only while every profile field was a number. The
switch went in as the string "0", which real tkinter coerces back to False by
luck, and a profile storing it on would have arrived as "1" and worked for the
same wrong reason. The conversion now dispatches on the field kind. Mutation
run: restoring the uniform call fails the new guard.

Both size ratchets fired and both took the routine door. app.py went five
lines over its ceiling, and lowering that ceiling was not available - the
crowd band is 70% of it and engine.py sits one line under the band - so the
helper moved to fields.py beside off_value and the existing import absorbed
it. `_on_switch` crossed the nesting crowd count, so the copy moved out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The outbound-only and inbound-only traffic filters are applied in the driver,
so the other direction never reaches this process. With asymmetry on, half the
values the user just typed then describe traffic the session cannot see, the
summary still lists them, and the counters read as the tool ignoring its own
form. It is said once per apply rather than refused, which is the answer the
two LAN switches and the address-family pair already give: impairing one
direction on purpose is a legitimate thing to ask for.

This also moves seven mutation proofs out of prose and into the registry.
Leaving them as sentences would be exactly the unguarded claim that file
exists to prevent, and the registry test caught two of its own entries going
stale on this branch: the repro entry aimed at an `if` that became a table row
when the complexity ratchet fired, and the burst-loss entry named
`_recompute_burst`, which became `_recompute` when the value sets went per
direction. Both are re-aimed at the same statements.

Run on this branch with `--changed origin/master`: 73 mutations, 73 caught,
none survived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two problems the full suite found that the per-chunk runs could not. The entry
ran to 143 words against the 100-word cap in
test_no_user_facing_entry_grows_into_an_essay: this file carries the effect for
a tester, and the reasoning belongs in CHANGELOG-INTERNAL.md, where it already
is.

The Changed heading had also been inserted in the middle of the Added list,
which quietly moved two entries from the previous session (the refused-block
checkbox and the connections_refused column) under a heading that describes
neither. Both are additions and are back under Added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@donislawdev
donislawdev merged commit c180436 into master Sep 5, 2026
14 checks passed
@donislawdev
donislawdev deleted the feat/asymmetric-impairments branch September 5, 2026 15:50
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