A sweep across the adaptive-bitrate path (client controller, its pump feed, and the host's clamp/apply side) turned up eleven defects. Most share one shape: a single window, or a single refusal, taught the controller something it then treated as permanent.
Wire format and ABI are untouched. No capability negotiation, old clients and old hosts unaffected.
The headline
An Automatic session could be pinned at the 20 Mbps default for the rest of its life by a hiccup at startup.
The host latched cadence_degraded for as long as it was escalated (adaptive capture depth / pipelined retrieve), regardless of whether encode was still missing deadlines — and while degraded it refuses bitrate climbs. The client can't tell that refusal apart from an encoder's real ceiling: both arrive as a short BitrateChanged, and two identical ones latch a cap. Escalation needs ~20 net behind-frames, which a startup hitch supplies while slow start has barely begun. Escaping the cap then cost +12.5% per ~60 s — north of twenty minutes to cross the gap to a probe-measured 300 Mbps ceiling, which a user experiences as "Automatic is broken".
Three changes fix it: escalation alone no longer flags the host (an escalated session is still judged strictly — any net behind-frame keeps it flagged — but escalating exists so cadence can be held, and once it is, refusing climbs refuses the thing that worked); the client re-probes a learned cap after 12 s with exponential backoff instead of a flat minute; and a climb granted in full drops the cap outright, since that is the host's own word that the limit is gone.
Regression test: 20 Mbps pinned session under a 300 Mbps ceiling now reaches 150 Mbps in 22 windows (~16 s), against ~17 minutes before. Verified to fail against the old behaviour rather than pass vacuously.
Everything else
Defect
Fix
All three rolling baselines (OWD, decode, encode) armed off one sample — and on_ack deliberately clears the encode one after every self-inflicted decrease, re-opening that hole each time. A calm re-seed window followed by ordinary motion read as 4 ms of "congestion" → backoff → clear → ratchet toward the floor on a healthy link.
BASELINE_MIN_WINDOWS guard, via one shared score_baseline (the three copies had drifted apart)
A mode switch rebased only the encode baseline. Decode and OWD are just as mode-scoped — 4K120 decodes slower and puts bigger frames on the wire than 1080p60 — so the old floor was one the new mode cleared on its first window: ~30 s of every window scoring bad, i.e. a backoff every other window. A switch up in mode cratered the rate instead of raising it.
Decode + OWD baselines and proven_kbps now go with it
proven_kbps — never decays, permanent authority over every later climb — was raised by any window without a decode rise, including SEVERE ones. The windows that overstate delivered throughput are exactly the damaged ones: a stall's backlog draining at once, a flush's queue, the FEC surge answering a loss burst.
Gated on the full !bad verdict
Delivered throughput was measured in wire bytes (headers, FEC parity, probe filler, audio), but compared against the encoder's target. The figure rose with the redundancy the host adds in answer to loss: at 25% FEC the utilization gate passed with the encoder emitting ~55% of target. Weakest exactly on the lossy links it exists for.
New media_bytes_received counts data-shard payload at the reassembler's routing decision — the same place the probe counters are already stamped
The host could change the session rate without telling the client: build_pipeline re-resolves an Automatic rate whenever the source delivers a size the session didn't negotiate (the mirrored-panel case), and the encoder's own clamp can land below what the control task already acked. A 1080p client mirroring a 4K panel believed 20 Mbps while the host encoded 60 — and its first "climb" from that stale base asked for 40, a re-target downward paying an encoder rebuild.
Both paths push the applied rate to the control task, which sends the existing 9-byte BitrateChanged — it already means precisely this, and clients already handle one arriving unprompted
PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, so the one lever an Automatic session gives the operator did nothing when the session already started above it
Binds at construction; a session above its ceiling steps down to it once (asked once per distinct target — a host answering higher has said it can't go there)
The decode cap latched at the rate that choked, authorizing a climb straight back into the failure; and a bare jump-to-live flush could teach a "decoder knee" from what was a network event
Latches just under the choke rate (inside the ±1/8 band the evidence already required); a flush only counts where the decode signal is absent and can't speak for itself
A SetBitrate dropped by a full control queue counted toward MAX_UNACKED — three retired the controller for the session, while logging that an "older host" was at fault
The pump tells the controller what actually happened
Module doc claimed slow start "DOUBLES" each clean window; the proven-headroom cap always binds first, making it ×1.5
Doc corrected (the real path was already covered by slow_start_steps_stay_within_proven_headroom)
Two things the work itself surfaced
The cap-escape fix was incomplete as first written. Backing the re-probe clock off to 12 s still only lifted the cap 12.5% per probe, so a fully recovered host still handed back its real ceiling over ~4 minutes. Hence the "granted climb disproves the cap" commit.
Two of these fixes collided. Once the host announces an upward re-target, the client learns a rate above its stale negotiated ceiling — and the new step-down would have dragged the host straight back off the rate it had just chosen. An ack now raises the ceiling to meet it, still clamped by the operator's cap.
Deliberately not changed
probing (slow start) is still not re-armed on a mode switch. For H.26x the rate doesn't move across a switch, so there's nothing to re-race toward, and re-arming a doubling climb after every switch didn't look justified by the evidence. Easy to revisit.
Verification
cargo fmt --all --check clean.
punktfunk-core --features quic --lib: 283 passed (278 on main; 6 new ABR tests, incl. the end-to-end escape regression).
Linux amd64 container (host doesn't build on macOS): cargo clippy --all-targets -- -D warnings green for punktfunk-core (quic) andpunktfunk-host.
punktfunk-host native::stream::tests::17/17, including the two new ones (an_escalated_but_caught_up_encoder_stops_refusing_climbs, adopting_a_rebuilt_rate_tells_the_client).
Rebased onto current main (926e2ccb) and the full gate re-run green after the rebase. No file overlap with the 9 commits that landed meanwhile.
Not verified on glass. These are behavioural changes to a live control loop; an A/B on a real session (Automatic, fat LAN, plus one constrained-link run) is worth doing before this is trusted in a release.
A sweep across the adaptive-bitrate path (client controller, its pump feed, and the host's clamp/apply side) turned up eleven defects. Most share one shape: **a single window, or a single refusal, taught the controller something it then treated as permanent.**
Wire format and ABI are untouched. No capability negotiation, old clients and old hosts unaffected.
## The headline
An Automatic session could be pinned at the 20 Mbps default for the rest of its life by a hiccup at startup.
The host latched `cadence_degraded` for as long as it was *escalated* (adaptive capture depth / pipelined retrieve), regardless of whether encode was still missing deadlines — and while degraded it refuses bitrate climbs. The client can't tell that refusal apart from an encoder's real ceiling: both arrive as a short `BitrateChanged`, and two identical ones latch a cap. Escalation needs ~20 net behind-frames, which a startup hitch supplies while slow start has barely begun. Escaping the cap then cost +12.5% per ~60 s — north of twenty minutes to cross the gap to a probe-measured 300 Mbps ceiling, which a user experiences as "Automatic is broken".
Three changes fix it: escalation alone no longer flags the host (an escalated session is still judged strictly — *any* net behind-frame keeps it flagged — but escalating exists so cadence *can* be held, and once it is, refusing climbs refuses the thing that worked); the client re-probes a learned cap after 12 s with exponential backoff instead of a flat minute; and a climb **granted in full drops the cap outright**, since that is the host's own word that the limit is gone.
Regression test: 20 Mbps pinned session under a 300 Mbps ceiling now reaches 150 Mbps in **22 windows (~16 s)**, against ~17 minutes before. Verified to fail against the old behaviour rather than pass vacuously.
## Everything else
| Defect | Fix |
|---|---|
| All three rolling baselines (OWD, decode, encode) armed off **one sample** — and `on_ack` deliberately clears the encode one after every self-inflicted decrease, re-opening that hole each time. A calm re-seed window followed by ordinary motion read as 4 ms of "congestion" → backoff → clear → ratchet toward the floor on a healthy link. | `BASELINE_MIN_WINDOWS` guard, via one shared `score_baseline` (the three copies had drifted apart) |
| A mode switch rebased **only** the encode baseline. Decode and OWD are just as mode-scoped — 4K120 decodes slower and puts bigger frames on the wire than 1080p60 — so the old floor was one the new mode cleared on its first window: ~30 s of every window scoring bad, i.e. a backoff every other window. A switch *up* in mode cratered the rate instead of raising it. | Decode + OWD baselines and `proven_kbps` now go with it |
| `proven_kbps` — never decays, permanent authority over every later climb — was raised by any window without a decode rise, **including SEVERE ones**. The windows that overstate delivered throughput are exactly the damaged ones: a stall's backlog draining at once, a flush's queue, the FEC surge answering a loss burst. | Gated on the full `!bad` verdict |
| Delivered throughput was measured in **wire bytes** (headers, FEC parity, probe filler, audio), but compared against the *encoder's* target. The figure rose with the redundancy the host adds in answer to loss: at 25% FEC the utilization gate passed with the encoder emitting ~55% of target. Weakest exactly on the lossy links it exists for. | New `media_bytes_received` counts data-shard payload at the reassembler's routing decision — the same place the probe counters are already stamped |
| The host could change the session rate **without telling the client**: `build_pipeline` re-resolves an Automatic rate whenever the source delivers a size the session didn't negotiate (the mirrored-panel case), and the encoder's own clamp can land below what the control task already acked. A 1080p client mirroring a 4K panel believed 20 Mbps while the host encoded 60 — and its first "climb" from that stale base asked for 40, a re-target *downward* paying an encoder rebuild. | Both paths push the applied rate to the control task, which sends the existing 9-byte `BitrateChanged` — it already means precisely this, and clients already handle one arriving unprompted |
| `PUNKTFUNK_ABR_MAX_MBPS` bound only probe-learned ceilings, so the one lever an Automatic session gives the operator did nothing when the session already started above it | Binds at construction; a session above its ceiling steps down to it once (asked once per distinct target — a host answering higher has said it can't go there) |
| The decode cap latched **at** the rate that choked, authorizing a climb straight back into the failure; and a bare jump-to-live flush could teach a "decoder knee" from what was a network event | Latches just under the choke rate (inside the ±1/8 band the evidence already required); a flush only counts where the decode signal is absent and can't speak for itself |
| A `SetBitrate` dropped by a full control queue counted toward `MAX_UNACKED` — three retired the controller for the session, while logging that an "older host" was at fault | The pump tells the controller what actually happened |
| Module doc claimed slow start "DOUBLES" each clean window; the proven-headroom cap always binds first, making it ×1.5 | Doc corrected (the real path was already covered by `slow_start_steps_stay_within_proven_headroom`) |
## Two things the work itself surfaced
- **The cap-escape fix was incomplete as first written.** Backing the re-probe clock off to 12 s still only *lifted* the cap 12.5% per probe, so a fully recovered host still handed back its real ceiling over ~4 minutes. Hence the "granted climb disproves the cap" commit.
- **Two of these fixes collided.** Once the host announces an upward re-target, the client learns a rate above its stale negotiated ceiling — and the new step-down would have dragged the host straight back off the rate it had just chosen. An ack now raises the ceiling to meet it, still clamped by the operator's cap.
## Deliberately not changed
`probing` (slow start) is still not re-armed on a mode switch. For H.26x the rate doesn't move across a switch, so there's nothing to re-race toward, and re-arming a doubling climb after every switch didn't look justified by the evidence. Easy to revisit.
## Verification
- `cargo fmt --all --check` clean.
- `punktfunk-core --features quic --lib`: **283 passed** (278 on main; 6 new ABR tests, incl. the end-to-end escape regression).
- Linux amd64 container (host doesn't build on macOS): `cargo clippy --all-targets -- -D warnings` green for `punktfunk-core` (quic) **and** `punktfunk-host`.
- `punktfunk-host native::stream::tests::` **17/17**, including the two new ones (`an_escalated_but_caught_up_encoder_stops_refusing_climbs`, `adopting_a_rebuilt_rate_tells_the_client`).
- Rebased onto current `main` (`926e2ccb`) and the full gate re-run green after the rebase. No file overlap with the 9 commits that landed meanwhile.
**Not verified on glass.** These are behavioural changes to a live control loop; an A/B on a real session (Automatic, fat LAN, plus one constrained-link run) is worth doing before this is trusted in a release.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Six defects found by a sweep of the Automatic-bitrate path, all of them the same
shape: a single window, or a single refusal, taught the controller something it
then treated as permanent.
- Rolling baselines (OWD, client decode, host encode) armed off ONE sample. The
baseline is a rolling minimum, so one window IS the floor — and `on_ack`
deliberately clears the encode baseline after every decrease we ourselves
asked for, re-opening that hole each time. A calm re-seed window followed by
ordinary motion read as 4 ms of "congestion", backed off, cleared again, and
ratcheted toward the floor on a link that was never the problem. All three now
need BASELINE_MIN_WINDOWS of evidence before they may fire, via one shared
`score_baseline` (the three copies had already drifted apart).
- A mode switch rebased only the encode baseline. Decode and OWD are just as
mode-scoped: 4K120 decodes slower and puts bigger frames on the wire than
1080p60, so the old floor was one the new mode cleared on its first window —
~30 s of every window scoring bad, i.e. a backoff every other window. A switch
UP in mode cratered the rate instead of raising it. `proven_kbps` goes with
them; throughput the old mode's decoder digested is not evidence about this one.
- `proven_kbps` — never decayed, and permanent authority over how far every
later climb may step — was raised by any window without a decode rise,
including ones scored SEVERE. The windows that overstate delivered throughput
are exactly the damaged ones: a stall's backlog draining at once, a flush's
queue, the FEC surge answering a loss burst. Now only clean windows raise it.
- A learned cap escaped at +12.5 % per ~60 s. The host cannot distinguish a
durable encoder ceiling from a climb refused while it is transiently behind
cadence, and the latter routinely latches during slow start at the 20 Mbps
default — from which crossing the gap to a probe-measured ceiling took upwards
of twenty minutes. Re-probe after 12 s instead, doubling the interval each time
the lift is immediately re-learned: a transient is out in one interval, a real
ceiling settles into a slow poll.
- The decode cap latched AT the rate that choked, authorizing a climb straight
back into the failure, and a bare jump-to-live flush could teach a "decoder
knee" from what was a network event. It now latches just under the choke rate
(inside the ±1/8 band the evidence already required) and only credits a flush
where the decode signal is absent and cannot speak for itself.
- PUNKTFUNK_ABR_MAX_MBPS bound only probe-learned ceilings, not the negotiated
start rate — so the one knob an Automatic session gives the operator did
nothing when the session already started above it. It now binds at
construction, and a session sitting above its ceiling steps down to it (no
congestion signal will ever find that: the link is fine, the cap is policy).
Also: a SetBitrate dropped by a full control queue counted toward MAX_UNACKED,
so three of them retired the controller for the session while logging that an
"older host" was at fault. The pump now tells the controller what happened.
Wire format and ABI untouched. 34 abr tests green (3 new).
The controller's two throughput-driven gates both compare "what the pipeline
carried" against the ENCODER's target: the utilization gate asks whether a clean
window actually tested that target (a calm menu proves nothing), and the
never-decaying proven mark bounds how far every later climb may step.
Both were fed `bytes_received`, which counts every accepted datagram — headers,
FEC parity, probe filler, audio. So the figure rose with the redundancy the host
adds in ANSWER to loss: at 25 % FEC the gate passed with the encoder emitting
~55 % of target, and the proven mark inherited the same inflation permanently.
The signal was weakest exactly on the lossy links it exists for.
Count data-shard payload separately at the reassembler's routing decision — the
same place, and for the same reason, the probe counters are already stamped —
and feed the ABR that. First time both gates are dimensionally honest: a media
rate compared against a media target.
Two host-side halves of the same sweep.
**The cadence latch.** `cadence_degraded` — which makes the control task refuse
bitrate CLIMBS — was latched true for as long as the session was escalated
(adaptive capture depth or pipelined retrieve), independently of whether encode
was still missing deadlines. The client cannot tell that refusal apart from an
encoder's real ceiling: both arrive as a short `BitrateChanged`, and two
identical ones latch a cap. Escalation needs ~20 net behind-frames, which a
startup hitch supplies while the ABR is still in slow start at the 20 Mbps
default — so one transient pinned the whole session there, long after the
escalation had bought back the headroom it was for, and escaping cost +12.5 %
per 60 s. An escalated session is still judged strictly (ANY net behind-frame
keeps it flagged, where an unescalated one gets the full bucket), but being
escalated no longer flags it by itself: escalating exists so cadence CAN be
held, and once it is, refusing climbs refuses the thing that worked. The rule
moves into `encode_behind_cadence` so it is stateable and testable.
**The silent re-target.** `adopt_built_bitrate` publishes the rate a rebuilt
pipeline actually opened at — `build_pipeline` re-resolves an Automatic rate
whenever the source delivers a size the session did not negotiate, the
mirrored-panel case — and the encoder's own clamp can land below what the
control task already acked. Neither reached the client, whose controller keeps
its own copy of that number as its climb base. A 1080p client mirroring a 4K
panel therefore believed 20 Mbps while the host encoded 60, and its first climb
computed from the stale base asked for 40: a re-target DOWNWARD, paying an
encoder rebuild to get there. Both paths now push the applied rate to the
control task, which sends `BitrateChanged` — the existing 9-byte message, which
already means precisely this and which clients already handle arriving
unprompted. No wire-format change, no capability negotiation, old clients
unaffected.
2 host tests added.
Completing the cap-escape fix. Backing the re-probe clock off to 12 s got the
client asking again quickly, but each ask only LIFTED the cap by +12.5 % — so
even a host that had fully recovered still granted the session its real ceiling
one small step at a time, ~4 minutes from the 20 Mbps default to a 300 Mbps
link. The crawl was never the point; re-learning was.
A request granted IN FULL at or above the cap is the host's own word that the
limit is gone. Drop the cap outright at that point instead of nudging it. A
standing limit is unaffected — it answers the same re-probe with another short
ack, which re-latches it and doubles its clock, exactly as before.
Adds the end-to-end regression the sweep was really about: a session pinned at
20 Mbps by a transient cadence refusal, under a probe-measured 300 Mbps ceiling,
now reaches 150 Mbps in 22 windows (~16 s) where it used to need ~17 minutes.
Interaction between two fixes in this series. The host now tells the client when
a rebuild re-resolves an Automatic rate, and that rate can legitimately sit ABOVE
the client's climb ceiling — the ceiling is the negotiated start rate until the
capacity probe raises it, while the host's re-resolve answers "what do these
pixels actually need" (a 1080p session mirroring a 4K panel resolves ~3× higher).
Left alone, the client would learn the new rate, notice it was above a stale
ceiling, and step the host straight back down off the rate it had just chosen
for itself. So an ack raises the ceiling to meet it. `set_ceiling` only ever
raises and still clamps to PUNKTFUNK_ABR_MAX_MBPS, which is the one limit that
should bind here. No effect on ordinary acks: a climb is never requested above
the effective ceiling to begin with.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
A sweep across the adaptive-bitrate path (client controller, its pump feed, and the host's clamp/apply side) turned up eleven defects. Most share one shape: a single window, or a single refusal, taught the controller something it then treated as permanent.
Wire format and ABI are untouched. No capability negotiation, old clients and old hosts unaffected.
The headline
An Automatic session could be pinned at the 20 Mbps default for the rest of its life by a hiccup at startup.
The host latched
cadence_degradedfor as long as it was escalated (adaptive capture depth / pipelined retrieve), regardless of whether encode was still missing deadlines — and while degraded it refuses bitrate climbs. The client can't tell that refusal apart from an encoder's real ceiling: both arrive as a shortBitrateChanged, and two identical ones latch a cap. Escalation needs ~20 net behind-frames, which a startup hitch supplies while slow start has barely begun. Escaping the cap then cost +12.5% per ~60 s — north of twenty minutes to cross the gap to a probe-measured 300 Mbps ceiling, which a user experiences as "Automatic is broken".Three changes fix it: escalation alone no longer flags the host (an escalated session is still judged strictly — any net behind-frame keeps it flagged — but escalating exists so cadence can be held, and once it is, refusing climbs refuses the thing that worked); the client re-probes a learned cap after 12 s with exponential backoff instead of a flat minute; and a climb granted in full drops the cap outright, since that is the host's own word that the limit is gone.
Regression test: 20 Mbps pinned session under a 300 Mbps ceiling now reaches 150 Mbps in 22 windows (~16 s), against ~17 minutes before. Verified to fail against the old behaviour rather than pass vacuously.
Everything else
on_ackdeliberately clears the encode one after every self-inflicted decrease, re-opening that hole each time. A calm re-seed window followed by ordinary motion read as 4 ms of "congestion" → backoff → clear → ratchet toward the floor on a healthy link.BASELINE_MIN_WINDOWSguard, via one sharedscore_baseline(the three copies had drifted apart)proven_kbpsnow go with itproven_kbps— never decays, permanent authority over every later climb — was raised by any window without a decode rise, including SEVERE ones. The windows that overstate delivered throughput are exactly the damaged ones: a stall's backlog draining at once, a flush's queue, the FEC surge answering a loss burst.!badverdictmedia_bytes_receivedcounts data-shard payload at the reassembler's routing decision — the same place the probe counters are already stampedbuild_pipelinere-resolves an Automatic rate whenever the source delivers a size the session didn't negotiate (the mirrored-panel case), and the encoder's own clamp can land below what the control task already acked. A 1080p client mirroring a 4K panel believed 20 Mbps while the host encoded 60 — and its first "climb" from that stale base asked for 40, a re-target downward paying an encoder rebuild.BitrateChanged— it already means precisely this, and clients already handle one arriving unpromptedPUNKTFUNK_ABR_MAX_MBPSbound only probe-learned ceilings, so the one lever an Automatic session gives the operator did nothing when the session already started above itSetBitratedropped by a full control queue counted towardMAX_UNACKED— three retired the controller for the session, while logging that an "older host" was at faultslow_start_steps_stay_within_proven_headroom)Two things the work itself surfaced
Deliberately not changed
probing(slow start) is still not re-armed on a mode switch. For H.26x the rate doesn't move across a switch, so there's nothing to re-race toward, and re-arming a doubling climb after every switch didn't look justified by the evidence. Easy to revisit.Verification
cargo fmt --all --checkclean.punktfunk-core --features quic --lib: 283 passed (278 on main; 6 new ABR tests, incl. the end-to-end escape regression).cargo clippy --all-targets -- -D warningsgreen forpunktfunk-core(quic) andpunktfunk-host.punktfunk-host native::stream::tests::17/17, including the two new ones (an_escalated_but_caught_up_encoder_stops_refusing_climbs,adopting_a_rebuilt_rate_tells_the_client).main(926e2ccb) and the full gate re-run green after the rebase. No file overlap with the 9 commits that landed meanwhile.Not verified on glass. These are behavioural changes to a live control loop; an A/B on a real session (Automatic, fat LAN, plus one constrained-link run) is worth doing before this is trusted in a release.
🤖 Generated with Claude Code