Files
LlamaCasty/Models/StepDownPolicy.cs
T
gramps 0fa3006da7 feat(33): shed our own capture cost first; the task is done
Last slice. 590 green, 0 warnings.

- **Shed before you charge the viewer.** On machine evidence the policy's first act is
  `ShedCaptureCadence`: `ScreenCaptureFrameSource` widens its conversion interval 10 ms → 33 ms (≈100
  → 30 conversions/s) through a new `ScreenCaptureManager.ShedLoad`. The output tier, the declared fps
  and the viewer's picture are untouched — we stop paying for readbacks we were throwing away. It
  costs no step budget; if the starvation persists, the fps ladder runs on the next clean window.
- The shed is reset on every stop path, so a cheap session cannot leave a later one capped.
- **The session line is a Logs-tab line, not a session-report line.** The session report is the money
  summary (Super Chats, per currency); quality chatter there would bury the numbers it exists for. The
  cause-tagged line already lands in the log at the moment of the step.

TASK 33 is complete: two ladders, budgets, floors, ambiguity, one-way with recovery behind a flag, the
relaunch, the copy, and the cost shed. 18 tests pin the ladders, the copy and the restart.
2026-11-09 13:05:44 -08:00

282 lines
11 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,
/// <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>
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");
// 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);
}
}