Files
LlamaCasty/Models/StepDownPolicy.cs
T
gramps bc62bd81ec feat(33): StepDownPolicy — two ladders, one budget, recovery behind a flag
Pure policy, no wiring yet. 12 unit tests, 584 green overall, 0 warnings.

- **Machine starvation** buys less work: 60 → 30 fps first, then 1080 → 720. The vertical tier has no
  resolution step — its floor *is* its only geometry.
- **Upload congestion** buys fewer bits: `current − max(0.5, current × lossFraction)`, floored at
  3.0 Mbps (16:9) / 4.0 (vertical). Resolution is deliberately not a bandwidth lever: 1080p30 and
  720p60 cost the same, so dropping pixels at an unchanged bitrate relieves nothing.
- **Budgets** as ruled: three steps for a 1920-wide start, one for 720 and vertical. 15 s of evidence,
  30 s cooldown, ambiguous evidence does nothing at all.
- **Recovery** is `allowClimb`, default off (the one-way ruling): the machine carries its ceiling from
  construction, climbs one step after 180 s of clean windows, and a climb followed by a relapse inside
  120 s freezes the session at the low value — one flap pair is proof enough.

Inputs are the poster's own counters (`PumpStalls`, `PumpDrops`, `AvgRenderMs`, `EncoderDrops`), not a
throughput reading: `StreamHealth.CurrentBitrate` is ffmpeg's *encoded output* rate and sits at target
even while the socket is gagging, so it cannot size a step.
2026-11-09 09:30:28 -08:00

268 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;
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,
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;
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>
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;
/// <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");
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);
}
}