Files
LlamaCasty/ytLive.Tests/StepDownPolicyTests.cs
T
gramps 93719a286d feat(step-down): the bitrate floor is the tier's YouTube minimum
Step-down used a flat 3 Mbps (4 for vertical) as its floor — a number that had
nothing to do with the tier being streamed. With CBR holding the target, that
floor is a real picture-quality floor, so it has to follow YouTube's published
H.264 table (support.google.com/youtube/answer/2853702): 6 at 1080p60, 5 at
1080p30, 3 at 720p.

Models/RateLadder is that table as pure policy. Vertical is not a separate row:
1080x1920 and 1920x1080 are the same pixel count, so the frame rate alone picks
the band. StepDownPolicy takes the floor as an optional constructor argument and
refuses one above the starting rate, so an unusual start cannot begin under its
own floor. Omitted, the historical 3/4 floor still applies, which keeps the
existing policy tests meaningful and lets a caller opt in.

One consequence worth knowing: the three-step budget can stop above the floor
(12 → 9.6 → 7.7 → 6.2 from a 20% loss), so the floor is a limit, not a target.

669 pass, 0 warnings.
2026-11-12 08:05:55 -08:00

297 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using Xunit;
using ytLive.Models;
namespace ytLive.Tests;
/// <summary>
/// TASK 33 — the step-down ladders as pure policy. The two rules that shaped the design and are easy
/// to undo by "tidying" later:
///
/// <list type="number">
/// <item><b>Two levers, never mixed.</b> 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.</item>
/// <item><b>One-way by default.</b> Climbing is a constructor flag; the ceiling is carried either way
/// so recovery is a policy change, not a rewrite.</item>
/// </list>
/// </summary>
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);
/// <summary>Feeds windows every 5 s until the policy changes QUALITY; the capture-load shed is a
/// pre-step (no budget, nothing the viewer sees) so it is skipped here, and tested on its own.</summary>
private static StepDownDecision Run(StepDownPolicy policy, StepDownSample sample, ref double t, int maxWindows = 24)
{
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 && last.Action != StepAction.ShedCaptureCadence) return last;
}
return last;
}
[Fact]
public void MachineStarvation_ShedsOurOwnCaptureCostBeforeLoweringQuality()
{
var policy = new StepDownPolicy(vertical: false, startFps: 60, startBitrateMbps: 8.0);
var t = 0.0;
StepDownDecision first = default;
for (var i = 0; i < 6 && !first.Acted; i++)
{
t += 5;
first = policy.Observe(Machine(), TimeSpan.FromSeconds(t));
}
Assert.Equal(StepAction.ShedCaptureCadence, first.Action);
Assert.Equal(60, policy.Fps);
Assert.Equal(0, policy.StepsUsed); // shedding is not a quality step
Assert.Equal(1920, policy.Width);
}
[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 UploadStep_StopsAtTheTiersYoutubeMinimumWhenOneIsGiven()
{
// 1080p60: YouTube's floor is 6 Mbps, not the historical 3. From 8 the ladder reaches it in two
// steps (8 → 6.4 → 6.0) and must then hold rather than cut into the minimum.
var floor = RateLadder.For(1920, 1080, 60).Minimum;
Assert.Equal(6.0, floor);
var policy = new StepDownPolicy(1920, 1080, 60, 8.0, floorBitrateMbps: floor);
var t = 0.0;
for (var i = 0; i < 12; i++)
{
t += 40;
policy.Observe(Upload(encoderDrops: 60), TimeSpan.FromSeconds(t)); // 20% loss
}
Assert.Equal(6.0, policy.BitrateMbps);
}
[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);
}
}