feat(quality): learn the starting rate from real sessions, on the creator's permission

Each finished broadcast is filed locally — tier, probed encoder, rated, settled, steps
spent — and Models/RateHistory turns that into at most a suggestion. The app never
applies one: it asks before the next stream, "Use it" sets the rate, "Keep mine"
remembers that exact number so the same question does not return until the evidence
moves. Nothing leaves the machine.

Two rules keep it from nagging. The high-water mark is the highest rate that
*finished* a session with zero steps, not the highest seen, so one easy session (a
menu screen, a quiet game) cannot set a target the machine cannot hold. And it is
asymmetric: raising needs 10% above the current rate across five clean sessions of the
same tier and encoder, lowering needs three step-downs in five — fixing damage should
not wait for a percentage, and risking it should.

The encoder key is real, not decorative: StreamHealth.VideoEncoder is now filled from
the probe (FfmpegEncoder), so history recorded against NVENC is not applied to a
machine that later runs QSV. At startup, before anything has streamed, the last
session on that tier is the honest guess.

672 pass, 0 warnings.
This commit is contained in:
2026-11-12 08:24:12 -08:00
parent f2cab5ca0f
commit b0f8263414
12 changed files with 407 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// The rate the app suggests next session, from real sessions — and the two rules that keep it from
/// becoming a nuisance: five sessions of evidence per tier and encoder, a 10% gap before asking for
/// more, and a lower bar for backing off.
/// </summary>
public class RateHistoryTests
{
private static SessionRateResult Session(
double rated, double settled = -1, int steps = 0, string tier = "1080p60", string encoder = "h264_nvenc")
=> new(tier, encoder, rated, settled < 0 ? rated : settled, steps);
[Fact]
public void Suggestions_need_five_sessions_of_the_same_tier_and_encoder_and_persist_with_them()
{
// Four sessions is not evidence.
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 4).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12));
// Five clean sessions holding 15 against a 12 target: worth asking, and the ask is 10% up.
var raised = RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_nvenc", 12);
Assert.NotNull(raised);
Assert.True(raised!.Value.Raise);
Assert.Equal(15, raised.Value.Mbps);
// The same evidence means nothing below the threshold (12 → 12.6 is under 10%).
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(13)), "1080p60", "h264_nvenc", 12));
// A different encoder is a different machine: no suggestion.
Assert.Null(RateHistory.Suggest(
Enumerable.Range(0, 5).Select(_ => Session(15)), "1080p60", "h264_qsv", 12));
// Three step-downs out of five asks to back off to the settled median, faster than it asks to
// climb — fixing damage should not wait for a 10% gap.
var lowered = RateHistory.Suggest(
new[]
{
Session(12, 9.6, 1), Session(12, 8.0, 2), Session(12), Session(12, 7.2, 1), Session(12),
},
"1080p60", "h264_nvenc", 12);
Assert.NotNull(lowered);
Assert.False(lowered!.Value.Raise);
Assert.Equal(9.6, lowered.Value.Mbps); // median of 9.6, 8.0, 12, 7.2, 12
// And the storage form survives a round trip through the settings row, which is where the
// evidence actually lives between runs.
var db = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-history-{Guid.NewGuid():N}.db");
using (var store = new LayoutStore(db))
{
foreach (var entry in Enumerable.Range(0, 5).Select(_ => Session(15)))
store.AppendRateHistory(entry);
var reloaded = store.LoadRateHistory();
Assert.Equal(5, reloaded.Count);
Assert.Equal(Session(15), reloaded[0]);
Assert.Equal(15, RateHistory.Suggest(reloaded, "1080p60", "h264_nvenc", 12)!.Value.Mbps);
}
try { File.Delete(db); } catch (IOException) { }
}
}
+62
View File
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
@@ -58,4 +59,65 @@ public class RatedBitrateTests
try { File.Delete(tempDb); } catch (IOException) { }
}
}
/// <summary>
/// The suggestion the creator is asked about before a stream, and the refusal that stops it coming
/// back: raising is proposed only from five clean sessions above the 10% gap, applied only on a
/// click, and "keep mine" suppresses that exact number for the tier.
/// </summary>
[Fact]
public void A_suggested_rate_is_offered_between_sessions_and_only_a_click_applies_it()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"llamacasty-rate-proposal-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var seed = new LayoutStore(tempDb))
{
for (var i = 0; i < 5; i++)
seed.AppendRateHistory(new SessionRateResult("1080p60", "h264_nvenc", 15.0, 15.0, 0));
Assert.Equal(5, seed.LoadRateHistory().Count);
}
var vm = new MainViewModel();
vm.SelectedQuality = vm.QualityOptions.Single(tier => tier.Display == "1080p60");
var historyCount = new LayoutStore(tempDb).LoadRateHistory().Count;
// Offered, not applied: the rate is still the shipped default until the creator agrees.
Assert.True(vm.HasRateProposal, $"history={historyCount}");
Assert.Contains("15", vm.RateProposalText);
Assert.Equal(12.0, vm.RatedMbps);
vm.AcceptRateProposalCommand.Execute(null);
Assert.Equal(15.0, vm.RatedMbps);
Assert.False(vm.HasRateProposal);
// Honest evidence now says 15 is what this line holds, so the same question stops being
// asked: 15 is no longer 10% above the rate in use.
var second = new MainViewModel();
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
Assert.False(second.HasRateProposal);
// Back to 12 by hand: the evidence qualifies again, and this time the creator says no.
second.RatedMbps = 12;
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p30");
second.SelectedQuality = second.QualityOptions.Single(tier => tier.Display == "1080p60");
Assert.True(second.HasRateProposal);
second.DismissRateProposalCommand.Execute(null);
Assert.False(second.HasRateProposal);
Assert.Equal(12.0, second.RatedMbps);
// Refusal is remembered against that number, so a fresh run stays quiet.
var third = new MainViewModel();
third.SelectedQuality = third.QualityOptions.Single(tier => tier.Display == "1080p60");
Assert.False(third.HasRateProposal);
}
finally
{
MainViewModel.LayoutPathOverride = null;
try { File.Delete(tempDb); } catch (IOException) { }
}
}
}