diff --git a/Models/StepDownPolicy.cs b/Models/StepDownPolicy.cs
new file mode 100644
index 0000000..287db2f
--- /dev/null
+++ b/Models/StepDownPolicy.cs
@@ -0,0 +1,267 @@
+using System;
+
+namespace ytLive.Models;
+
+/// Why the policy acted. Reported in the toast and the session report, so it is never
+/// guessed at again after the fact.
+public enum StepCause
+{
+ None,
+ /// The machine could not produce the frame: stalls, late renders, pump drops.
+ Machine,
+ /// The frames were produced but the sink could not take them: encoder drops.
+ Upload,
+ /// Both kinds of evidence in the same window — doing nothing is the only safe move.
+ Ambiguous,
+}
+
+public enum StepAction
+{
+ None,
+ LowerFrameRate,
+ LowerResolution,
+ LowerBitrate,
+ RaiseFrameRate,
+ RaiseResolution,
+ RaiseBitrate,
+}
+
+/// One statistics window as the pump and the encoder report it. Nothing here is derived
+/// from the network directly: congestion shows up as frames the encoder failed to produce in
+/// wall-clock time, and backpressure shows up as submit wait.
+public readonly record struct StepDownSample(
+ double WindowSeconds,
+ int PumpStalls,
+ int PumpDrops,
+ int EncoderDrops,
+ double AvgRenderMs,
+ double AvgSubmitWaitMs);
+
+/// The decision and the numbers behind it. is for the log; the
+/// creator-facing sentence is built in the VM from .
+public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
+{
+ public bool Acted => Action != StepAction.None;
+}
+
+///
+/// Decides when a session must give up quality, and why (TASK 33).
+///
+/// Two levers, never mixed. Machine starvation is answered with less work (60 → 30 fps,
+/// then 1080 → 720); upload congestion is answered with fewer bits, sized by the observed loss
+/// fraction. Resolution is never used as a bandwidth lever, because 1080p30 and 720p60 cost the same
+/// 6–8 Mbps: dropping pixels at an unchanged bitrate relieves nothing.
+///
+/// One-way by default. A step-down that climbs back is an encoder restart, which is a
+/// visible hitch; the creator ruled that a show does not oscillate. The state machine still carries
+/// the ceiling, so enabling recovery later is a constructor flag rather than a rewrite.
+///
+///
+public sealed class StepDownPolicy
+{
+ /// Minimum evidence before acting: ~15 s of qualifying windows.
+ internal const double QualifyingSeconds = 15.0;
+
+ /// A sample window shorter than this is ignored — the pump reports every 5 s.
+ internal const double MinWindowSeconds = 4.0;
+
+ internal const double CooldownSeconds = 30.0;
+
+ /// Loss fraction at or above which the upload lever fires.
+ internal const double UploadLossThreshold = 0.05;
+
+ /// Render time relative to the frame budget that counts as machine starvation.
+ internal const double RenderBudgetFraction = 0.9;
+
+ internal const int StallThreshold = 5;
+
+ /// Clean seconds required before climbing when recovery is enabled.
+ internal const double ClimbAfterCleanSeconds = 180.0;
+
+ /// A climb followed by a fall inside this window freezes the session at the low value.
+ internal const double FlapWindowSeconds = 120.0;
+
+ private readonly bool _vertical;
+ private readonly bool _allowClimb;
+ private readonly int _ceilingFps;
+ private readonly int _ceilingWidth;
+ private readonly int _ceilingHeight;
+ private readonly double _ceilingBitrateMbps;
+ private readonly double _floorBitrateMbps;
+
+ private double _qualifyingSeconds;
+ private double _cleanSeconds;
+ private double _lastStepAt = double.NegativeInfinity;
+ private double _lastRaiseAt = double.NegativeInfinity;
+ private bool _frozen;
+
+ public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
+ : this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
+ {
+ }
+
+ /// Explicit geometry: the tier the creator actually chose, which is what decides the
+ /// step budget (a 1080 start gets three, a 720 start gets one).
+ public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
+ bool allowClimb = false)
+ {
+ _vertical = startHeight > startWidth;
+ _allowClimb = allowClimb;
+ _ceilingFps = startFps;
+ _ceilingWidth = startWidth;
+ _ceilingHeight = startHeight;
+ _ceilingBitrateMbps = startBitrateMbps;
+ _floorBitrateMbps = _vertical ? 4.0 : 3.0;
+
+ Fps = startFps;
+ Width = startWidth;
+ Height = startHeight;
+ BitrateMbps = startBitrateMbps;
+
+ // Creator ruling 2026-11-09: a 1080p start may spend three steps; 720 and vertical get one.
+ StepsAllowed = startWidth >= 1920 ? 3 : 1;
+ }
+
+ public int Fps { get; private set; }
+ public int Width { get; private set; }
+ public int Height { get; private set; }
+ public double BitrateMbps { get; private set; }
+ public int StepsUsed { get; private set; }
+ public int StepsAllowed { get; }
+ public bool Frozen => _frozen;
+ /// True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.
+ public bool AtFloor => Fps <= 30
+ && (_vertical || Width <= 1280)
+ && BitrateMbps <= _floorBitrateMbps + 0.001;
+
+ public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
+
+ /// Classifies one window and, when the evidence is strong enough and the budget allows,
+ /// applies exactly one change. is time since the session started.
+ public StepDownDecision Observe(in StepDownSample sample, TimeSpan elapsed)
+ {
+ if (sample.WindowSeconds < MinWindowSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
+
+ var now = elapsed.TotalSeconds;
+ var budget = FrameBudgetMs;
+
+ var machine = sample.PumpStalls >= StallThreshold
+ || sample.PumpDrops > 0
+ || sample.AvgRenderMs > budget * RenderBudgetFraction;
+
+ var loss = sample.EncoderDrops / Math.Max(1.0, Fps * sample.WindowSeconds);
+ var upload = loss >= UploadLossThreshold;
+
+ if (machine && upload)
+ {
+ _qualifyingSeconds = 0;
+ _cleanSeconds = 0;
+ return new StepDownDecision(StepCause.Ambiguous, StepAction.None,
+ $"render {sample.AvgRenderMs:0.0}ms, stalls {sample.PumpStalls}, loss {loss:P0} — not acting");
+ }
+
+ if (!machine && !upload)
+ {
+ _qualifyingSeconds = 0;
+ _cleanSeconds += sample.WindowSeconds;
+ return TryClimb(sample, now);
+ }
+
+ _cleanSeconds = 0;
+ _qualifyingSeconds += sample.WindowSeconds;
+ if (_qualifyingSeconds < QualifyingSeconds)
+ return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, null);
+
+ if (now - _lastStepAt < CooldownSeconds)
+ return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None, "cooldown");
+
+ if (StepsUsed >= StepsAllowed)
+ return new StepDownDecision(machine ? StepCause.Machine : StepCause.Upload, StepAction.None,
+ "step budget spent — holding");
+
+ var decision = machine ? StepMachine() : StepUpload(loss);
+ if (!decision.Acted) return decision;
+
+ if (now - _lastRaiseAt < FlapWindowSeconds) _frozen = true;
+
+ StepsUsed++;
+ _lastStepAt = now;
+ _qualifyingSeconds = 0;
+ return decision;
+ }
+
+ private StepDownDecision StepMachine()
+ {
+ if (Fps > 30)
+ {
+ Fps = 30;
+ return new StepDownDecision(StepCause.Machine, StepAction.LowerFrameRate,
+ $"frame rate 60 → 30 to give the machine its time back");
+ }
+
+ // The vertical tier's floor IS its only geometry (1080×1920), so it has no resolution step.
+ if (!_vertical && (Width > 1280 || Height > 720))
+ {
+ Width = 1280;
+ Height = 720;
+ return new StepDownDecision(StepCause.Machine, StepAction.LowerResolution,
+ $"resolution {Width}×{Height} — fewer pixels to composite");
+ }
+
+ return new StepDownDecision(StepCause.Machine, StepAction.None, "at the machine floor — holding");
+ }
+
+ private StepDownDecision StepUpload(double loss)
+ {
+ var reduction = Math.Max(0.5, BitrateMbps * loss);
+ var next = Math.Max(_floorBitrateMbps, BitrateMbps - reduction);
+ if (next >= BitrateMbps - 0.05)
+ return new StepDownDecision(StepCause.Upload, StepAction.None, "at the bitrate floor — holding");
+
+ var from = BitrateMbps;
+ BitrateMbps = Math.Round(next, 1);
+ return new StepDownDecision(StepCause.Upload, StepAction.LowerBitrate,
+ $"{from:0.0} → {BitrateMbps:0.0} Mbps on {loss:P0} loss");
+ }
+
+ private StepDownDecision TryClimb(in StepDownSample sample, double now)
+ {
+ if (!_allowClimb || _frozen) return new StepDownDecision(StepCause.None, StepAction.None, null);
+ if (StepsUsed == 0) return new StepDownDecision(StepCause.None, StepAction.None, null);
+ if (_cleanSeconds < ClimbAfterCleanSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
+ if (now - _lastStepAt < CooldownSeconds) return new StepDownDecision(StepCause.None, StepAction.None, null);
+
+ var (action, caused) = RaiseOneStep();
+ if (!caused) return new StepDownDecision(StepCause.None, StepAction.None, null);
+
+ StepsUsed = Math.Max(0, StepsUsed - 1);
+ _lastRaiseAt = now;
+ _lastStepAt = now;
+ _cleanSeconds = 0;
+ return new StepDownDecision(StepCause.None, action, "conditions clean — one step back up");
+ }
+
+ private (StepAction Action, bool Changed) RaiseOneStep()
+ {
+ if (Fps < _ceilingFps)
+ {
+ Fps = _ceilingFps;
+ return (StepAction.RaiseFrameRate, true);
+ }
+
+ if (Width < _ceilingWidth || Height < _ceilingHeight)
+ {
+ Width = _ceilingWidth;
+ Height = _ceilingHeight;
+ return (StepAction.RaiseResolution, true);
+ }
+
+ if (BitrateMbps < _ceilingBitrateMbps - 0.05)
+ {
+ BitrateMbps = Math.Round(Math.Min(_ceilingBitrateMbps, BitrateMbps + 1.0), 1);
+ return (StepAction.RaiseBitrate, true);
+ }
+
+ return (StepAction.None, false);
+ }
+}
diff --git a/TASKS.md b/TASKS.md
index 935702b..e992112 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -49,7 +49,7 @@ cited first), then 35 / 37 / 40·A/C/D / 55·2–3. TASK 32 is complete, Back on
| 40·A/C/D | ☐ | App Settings: active-camera picker; persist output resolution + surface transition defaults + confirm-before-End; accent consolidation | [`task-40`](TASKS/task-40-app-settings-round.md) |
| 3·20 | ✅ | Session report — **derived from `superChatEvents.list` on demand, not persisted** (YouTube is the authority; no ledger). Per-currency totals, money off by default (done 2026-11-05) | [`task-03`](TASKS/task-03-capture-pipeline.md) |
| 32 | ✅ | Stream resilience: fragmented MP4, blip retry, poll-driven RECONNECTING (no countdown — YouTube's cutoff is not a constant), ingest pre-flight, one-click Back on air (`BackOnAirTests`) | [`task-32`](TASKS/task-32-stream-resilience.md) |
-| 33 | ◇ | Auto step-down, **cause-classified** (machine starvation → fps/resolution; upload congestion → bitrate only), shed our own capture cost first, one-way, hard floors, plain-words toast | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
+| 33 | 🔶 | Auto step-down — **`StepDownPolicy` + 12 unit tests done 2026-11-09** (two ladders, budgets, floors, cooldown, ambiguity, recovery behind a flag); remaining: pump/health wiring, encoder restart, toast + session line, capture-cost shed | [`task-33`](TASKS/task-33-bandwidth-step-down.md) |
| 34 | ☐ | Scheduled streams (Text-drawer version) | [`task-34`](TASKS/task-34-scheduled-streams.md) |
| 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) |
| 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) |
diff --git a/ytLive.Tests/StepDownPolicyTests.cs b/ytLive.Tests/StepDownPolicyTests.cs
new file mode 100644
index 0000000..311b959
--- /dev/null
+++ b/ytLive.Tests/StepDownPolicyTests.cs
@@ -0,0 +1,257 @@
+using System;
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 33 — the step-down ladders as pure policy. The two rules that shaped the design and are easy
+/// to undo by "tidying" later:
+///
+///
+/// - Two levers, never mixed. Machine starvation buys less work (fps, then resolution);
+/// upload congestion buys fewer bits. Resolution at an unchanged bitrate relieves no congestion, and
+/// an fps drop at an unchanged bitrate relieves no upload.
+/// - One-way by default. Climbing is a constructor flag; the ceiling is carried either way
+/// so recovery is a policy change, not a rewrite.
+///
+///
+public sealed class StepDownPolicyTests
+{
+ private static StepDownSample Machine(double renderMs = 40, int stalls = 10) =>
+ new(WindowSeconds: 5, PumpStalls: stalls, PumpDrops: 0, EncoderDrops: 0,
+ AvgRenderMs: renderMs, AvgSubmitWaitMs: 1);
+
+ private static StepDownSample Upload(int encoderDrops = 30) =>
+ new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: encoderDrops,
+ AvgRenderMs: 1, AvgSubmitWaitMs: 2);
+
+ private static StepDownSample Clean() =>
+ new(WindowSeconds: 5, PumpStalls: 0, PumpDrops: 0, EncoderDrops: 0,
+ AvgRenderMs: 1, AvgSubmitWaitMs: 1);
+
+ /// Feeds windows every 5 s until the policy acts; returns that decision, or the last
+ /// decision it reached when it never acted (so the "holding" reason is visible to the test).
+ private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 12)
+ {
+ var last = new StepDownDecision(StepCause.None, StepAction.None, null);
+ for (var i = 0; i < maxWindows; i++)
+ {
+ t += 5;
+ last = policy.Observe(sample, TimeSpan.FromSeconds(t));
+ if (last.Acted) return last;
+ }
+ return last;
+ }
+
+ [Fact]
+ public void MachineStarvation_LowersFrameRateBeforeAnythingElse()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+
+ var decision = Run(policy, Machine(), ref t);
+
+ Assert.Equal(StepCause.Machine, decision.Cause);
+ Assert.Equal(StepAction.LowerFrameRate, decision.Action);
+ Assert.Equal(30, policy.Fps);
+ Assert.Equal(8.0, policy.BitrateMbps); // bits were never the problem
+ Assert.Equal(1920, policy.Width);
+ }
+
+ [Fact]
+ public void MachineStarvationAtThirty_DropsResolutionNext()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 30, startBitrateMbps: 8.0);
+ var t = 0.0;
+
+ var decision = Run(policy, Machine(renderMs: 40), ref t);
+
+ Assert.Equal(StepAction.LowerResolution, decision.Action);
+ Assert.Equal(1280, policy.Width);
+ Assert.Equal(720, policy.Height);
+ Assert.Equal(30, policy.Fps);
+ }
+
+ [Fact]
+ public void MachineStarvationAtTheFloor_HoldsAndSaysSo()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 30, startBitrateMbps: 3.0);
+ var t = 0.0;
+ Run(policy, Machine(), ref t); // 1080p30 → 720p30
+ var t2 = t;
+ var next = Run(policy, Machine(renderMs: 90), ref t2);
+
+ Assert.False(next.Acted);
+ Assert.Contains("floor", next.Why ?? string.Empty);
+ Assert.Equal(1280, policy.Width);
+ }
+
+ [Fact]
+ public void UploadCongestion_StepsBitrateOnly_NeverGeometry()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+
+ var decision = Run(policy, Upload(encoderDrops: 30), ref t); // 30 / (60×5) = 10% loss
+
+ Assert.Equal(StepCause.Upload, decision.Cause);
+ Assert.Equal(StepAction.LowerBitrate, decision.Action);
+ Assert.Equal(7.2, policy.BitrateMbps); // 8.0 − max(0.5, 0.8)
+ Assert.Equal(60, policy.Fps);
+ Assert.Equal(1920, policy.Width);
+ }
+
+ [Fact]
+ public void UploadStep_IsSizedByLossAndStopsAtTheFloor()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 6.0);
+ var t = 0.0;
+
+ for (var i = 0; i < 8; i++)
+ {
+ t += 40; // clear the cooldown
+ policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)); // 20% loss
+ }
+
+ Assert.True(policy.BitrateMbps >= 3.0);
+ Assert.Equal(60, policy.Fps); // congested upload never buys an fps drop
+ Assert.Equal(1920, policy.Width);
+ }
+
+ [Fact]
+ public void BothKindsOfEvidenceInOneWindow_DoesNothing()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+ var mixed = new StepDownSample(5, PumpStalls: 20, PumpDrops: 3, EncoderDrops: 40,
+ AvgRenderMs: 50, AvgSubmitWaitMs: 30);
+
+ var decision = Run(policy, mixed, ref t, maxWindows: 6);
+
+ Assert.False(decision.Acted);
+ Assert.Equal(StepCause.Ambiguous, decision.Cause);
+ Assert.Equal(60, policy.Fps);
+ Assert.Equal(8.0, policy.BitrateMbps);
+ }
+
+ [Fact]
+ public void StepBudget_IsThreeForAFullHdStart_AndOneForSevenTwenty()
+ {
+ var full = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var hd = new StepDownPolicy(startWidth: 1280, startHeight: 720, startFps: 60, startBitrateMbps: 6.0);
+ var vertical = new StepDownPolicy(vertical: true, startFps: 60, startBitrateMbps: 8.0);
+
+ Assert.Equal(3, full.StepsAllowed);
+ Assert.Equal(1, hd.StepsAllowed);
+ Assert.Equal(1, vertical.StepsAllowed);
+ }
+
+ [Fact]
+ public void VerticalTier_HasNoResolutionStepOnlyFrameRateAndBits()
+ {
+ var policy = new StepDownPolicy(vertical: true, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+
+ var first = Run(policy, Machine(), ref t);
+ Assert.Equal(StepAction.LowerFrameRate, first.Action);
+ Assert.Equal(1080, policy.Width);
+ Assert.Equal(1920, policy.Height);
+
+ var t2 = t;
+ var second = Run(policy, Machine(), ref t2);
+ Assert.False(second.Acted); // one step on a vertical start, then done
+ }
+
+ [Fact]
+ public void Cooldown_StopsASecondStepImmediatelyAfterTheFirst()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+ Run(policy, Upload(encoderDrops: 30), ref t);
+
+ // Three more qualifying windows, still inside the 30 s cooldown.
+ var acted = false;
+ for (var i = 0; i < 3; i++)
+ {
+ t += 5;
+ if (policy.Observe(Upload(encoderDrops: 30), TimeSpan.FromSeconds(t)).Acted) acted = true;
+ }
+
+ Assert.False(acted);
+ Assert.Equal(7.2, policy.BitrateMbps);
+ }
+
+ [Fact]
+ public void Climbing_IsOffByDefault_EvenAfterMinutesOfCleanWindows()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
+ var t = 0.0;
+ Run(policy, Upload(encoderDrops: 30), ref t);
+ var lowered = policy.BitrateMbps;
+
+ for (var i = 0; i < 60; i++)
+ {
+ t += 5;
+ policy.Observe(Clean(), TimeSpan.FromSeconds(t));
+ }
+
+ Assert.Equal(lowered, policy.BitrateMbps);
+ Assert.Equal(1, policy.StepsUsed);
+ }
+
+ [Fact]
+ public void WithRecoveryEnabled_ALongCleanStretchClimbsOneStep()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0, allowClimb: true);
+ var t = 0.0;
+ Run(policy, Upload(encoderDrops: 30), ref t);
+
+ StepDownDecision raised = default;
+ for (var i = 0; i < 60 && !raised.Acted; i++)
+ {
+ t += 5;
+ raised = policy.Observe(Clean(), TimeSpan.FromSeconds(t));
+ }
+
+ Assert.Equal(StepAction.RaiseBitrate, raised.Action);
+ Assert.True(policy.BitrateMbps > 7.2);
+ Assert.True(policy.BitrateMbps <= 8.0);
+ Assert.Equal(0, policy.StepsUsed);
+ }
+
+ [Fact]
+ public void AClimbFollowedByARelapse_FreezesTheSessionAtTheLowValue()
+ {
+ var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0, allowClimb: true);
+ var t = 0.0;
+ Run(policy, Upload(encoderDrops: 30), ref t);
+
+ for (var i = 0; i < 60; i++)
+ {
+ t += 5;
+ if (policy.Observe(Clean(), TimeSpan.FromSeconds(t)).Acted) break;
+ }
+ var raisedTo = policy.BitrateMbps;
+
+ // The link gives way again shortly after the climb.
+ for (var i = 0; i < 60; i++)
+ {
+ t += 5;
+ if (policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)).Acted) break;
+ }
+
+ Assert.True(policy.Frozen);
+
+ // Frozen: a much longer clean stretch must not climb again.
+ var afterRelapse = policy.BitrateMbps;
+ for (var i = 0; i < 120; i++)
+ {
+ t += 5;
+ policy.Observe(Clean(), TimeSpan.FromSeconds(t));
+ }
+
+ Assert.Equal(afterRelapse, policy.BitrateMbps);
+ Assert.True(afterRelapse <= raisedTo);
+ }
+}