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.
287 lines
12 KiB
C#
287 lines
12 KiB
C#
using System;
|
||
|
||
namespace ytLive.Models;
|
||
|
||
/// <summary>Why the policy acted. Reported in the toast and the session report, so it is never
|
||
/// guessed at again after the fact.</summary>
|
||
public enum StepCause
|
||
{
|
||
None,
|
||
/// <summary>The machine could not produce the frame: stalls, late renders, pump drops.</summary>
|
||
Machine,
|
||
/// <summary>The frames were produced but the sink could not take them: encoder drops.</summary>
|
||
Upload,
|
||
/// <summary>Both kinds of evidence in the same window — doing nothing is the only safe move.</summary>
|
||
Ambiguous,
|
||
}
|
||
|
||
public enum StepAction
|
||
{
|
||
None,
|
||
/// <summary>Cheapest first: halve LlamaCasty's own capture cost, change nothing the viewer sees.</summary>
|
||
ShedCaptureCadence,
|
||
LowerFrameRate,
|
||
LowerResolution,
|
||
LowerBitrate,
|
||
RaiseFrameRate,
|
||
RaiseResolution,
|
||
RaiseBitrate,
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
public readonly record struct StepDownSample(
|
||
double WindowSeconds,
|
||
int PumpStalls,
|
||
int PumpDrops,
|
||
int EncoderDrops,
|
||
double AvgRenderMs,
|
||
double AvgSubmitWaitMs);
|
||
|
||
/// <summary>The decision and the numbers behind it. <paramref name="Why"/> is for the log; the
|
||
/// creator-facing sentence is built in the VM from <see cref="Cause"/>.</summary>
|
||
public readonly record struct StepDownDecision(StepCause Cause, StepAction Action, string? Why)
|
||
{
|
||
public bool Acted => Action != StepAction.None;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Decides when a session must give up quality, and why (TASK 33).
|
||
///
|
||
/// <para><b>Two levers, never mixed.</b> 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.</para>
|
||
///
|
||
/// <para><b>One-way by default.</b> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class StepDownPolicy
|
||
{
|
||
/// <summary>Minimum evidence before acting: ~15 s of qualifying windows.</summary>
|
||
internal const double QualifyingSeconds = 15.0;
|
||
|
||
/// <summary>A sample window shorter than this is ignored — the pump reports every 5 s.</summary>
|
||
internal const double MinWindowSeconds = 4.0;
|
||
|
||
internal const double CooldownSeconds = 30.0;
|
||
|
||
/// <summary>Loss fraction at or above which the upload lever fires.</summary>
|
||
internal const double UploadLossThreshold = 0.05;
|
||
|
||
/// <summary>Render time relative to the frame budget that counts as machine starvation.</summary>
|
||
internal const double RenderBudgetFraction = 0.9;
|
||
|
||
internal const int StallThreshold = 5;
|
||
|
||
/// <summary>Clean seconds required before climbing when recovery is enabled.</summary>
|
||
internal const double ClimbAfterCleanSeconds = 180.0;
|
||
|
||
/// <summary>A climb followed by a fall inside this window freezes the session at the low value.</summary>
|
||
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;
|
||
private bool _shedCapture;
|
||
|
||
public StepDownPolicy(bool vertical, int startFps, double startBitrateMbps, bool allowClimb = false)
|
||
: this(vertical ? 1080 : 1920, vertical ? 1920 : 1080, startFps, startBitrateMbps, allowClimb)
|
||
{
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
/// <param name="floorBitrateMbps">The lowest rate a step-down may reach — the tier's YouTube
|
||
/// minimum (<see cref="RateLadder"/>). Never below the starting rate, so an unusual start cannot
|
||
/// begin already under its floor. Omitted, the historical 3/4 Mbps floor applies.</param>
|
||
public StepDownPolicy(int startWidth, int startHeight, int startFps, double startBitrateMbps,
|
||
bool allowClimb = false, double? floorBitrateMbps = null)
|
||
{
|
||
_vertical = startHeight > startWidth;
|
||
_allowClimb = allowClimb;
|
||
_ceilingFps = startFps;
|
||
_ceilingWidth = startWidth;
|
||
_ceilingHeight = startHeight;
|
||
_ceilingBitrateMbps = startBitrateMbps;
|
||
_floorBitrateMbps = Math.Min(
|
||
floorBitrateMbps ?? (_vertical ? 4.0 : 3.0),
|
||
startBitrateMbps);
|
||
|
||
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;
|
||
/// <summary>True when no lever has anything left: 30 fps, the floor geometry, the floor bitrate.</summary>
|
||
public bool AtFloor => Fps <= 30
|
||
&& (_vertical || Width <= 1280)
|
||
&& BitrateMbps <= _floorBitrateMbps + 0.001;
|
||
|
||
public double FrameBudgetMs => 1000.0 / Math.Max(1, Fps);
|
||
|
||
/// <summary>Classifies one window and, when the evidence is strong enough and the budget allows,
|
||
/// applies exactly one change. <paramref name="elapsed"/> is time since the session started.</summary>
|
||
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");
|
||
|
||
// Machine starvation: shed OUR cost before the viewer's tier is touched. Costs no step and is
|
||
// not a quality change — if it does not fix the starvation, the fps ladder runs next window.
|
||
if (machine && !_shedCapture)
|
||
{
|
||
_shedCapture = true;
|
||
_qualifyingSeconds = 0;
|
||
_lastStepAt = now;
|
||
return new StepDownDecision(StepCause.Machine, StepAction.ShedCaptureCadence,
|
||
"halving capture cost before lowering quality");
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|