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); + } +}