namespace ytLive.Models;
/// One finished broadcast, as evidence about a tier's rate.
/// The quality tier's display name, e.g. "1080p60".
/// The probed encoder, e.g. "h264_nvenc" — history against one encoder is not
/// evidence about another, so a GPU or driver change cannot import stale conclusions.
/// The rate the session started at (the creator's or the tier default).
/// The rate the session ended at after any step-down.
/// How many quality steps the session spent. Zero means the rate held.
public readonly record struct SessionRateResult(
string Tier, string Encoder, double RatedMbps, double SettledMbps, int Steps)
{
public bool Stepped => Steps > 0;
}
///
/// What the app has learned about a connection, as pure policy — the only thing allowed to suggest a
/// different starting rate, and it never applies one itself.
///
/// High-water mark is the highest rate that finished a session with zero steps, not
/// the highest number ever seen: one easy session (a menu screen, a quiet game) must not set a target
/// the machine cannot hold, which would make every later session look like a regression.
///
/// Asymmetric on purpose. Raising needs a 10% improvement across five clean sessions, so
/// fixing nothing is slow; lowering needs only repeated step-downs, because fixing damage should not
/// wait. A creator may always override either way — see MainViewModel.RatedMbps.
///
public static class RateHistory
{
/// Sessions considered. Below this there is no evidence, only noise.
public const int Window = 5;
/// How far above the current rate an upward proposal must sit to be worth asking.
public const double RaiseThreshold = 0.10;
/// Step-downs in the window that make a downward proposal worth asking.
public const int LowerAfterSteps = 3;
/// A suggested starting rate, and which way it moves.
public readonly record struct Proposal(double Mbps, bool Raise);
///
/// The suggestion for one tier, or null when the evidence does not warrant one.
/// null considers every encoder; callers pass the current one so a hardware change starts clean.
///
public static Proposal? Suggest(
IEnumerable history, string tier, string? encoder, double currentMbps)
{
var window = history
.Where(entry => entry.Tier == tier && (encoder is null || entry.Encoder == encoder))
.TakeLast(Window)
.ToList();
if (window.Count < Window) return null;
if (window.Count(entry => entry.Stepped) >= LowerAfterSteps)
return new Proposal(Math.Round(Median(window.Select(entry => entry.SettledMbps)), 1), Raise: false);
var highWater = window.Max(entry => entry.Stepped ? 0 : entry.RatedMbps);
return highWater >= currentMbps * (1 + RaiseThreshold)
? new Proposal(Math.Round(highWater, 0), Raise: true)
: null;
}
private static double Median(IEnumerable values)
{
var sorted = values.OrderBy(value => value).ToArray();
if (sorted.Length == 0) return 0;
var middle = sorted.Length / 2;
return sorted.Length % 2 == 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
}
// ─── Storage form: one line per session, unit-separated fields ───
private const char Field = '\u001F';
public static string Format(SessionRateResult entry)
=> string.Join(Field, entry.Tier, entry.Encoder,
entry.RatedMbps.ToString("0.#"), entry.SettledMbps.ToString("0.#"), entry.Steps);
public static SessionRateResult? Parse(string? line)
{
if (string.IsNullOrWhiteSpace(line)) return null;
var parts = line.Split(Field);
if (parts.Length != 5 || !double.TryParse(parts[2], out var rated)
|| !double.TryParse(parts[3], out var settled) || !int.TryParse(parts[4], out var steps))
return null;
return new SessionRateResult(parts[0], parts[1], rated, settled, steps);
}
}