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); } }