ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
167 lines
7.4 KiB
C#
167 lines
7.4 KiB
C#
using Fleet.Core;
|
||
using MoShiro.Protocol;
|
||
using MoShiro.TestSupport;
|
||
|
||
namespace MoShiro.Backend.Analysis.Tests;
|
||
|
||
public sealed class ModelAndTestEvaluatorTests
|
||
{
|
||
private const long T0 = 1_700_000_000_000;
|
||
private static readonly NodeId s_agent = new("agt_test");
|
||
|
||
private static MetricView View(params (string Id, double Value)[] values) =>
|
||
new(values.ToDictionary(v => v.Id, v => v.Value));
|
||
|
||
[Fact]
|
||
public void Registry_ExportIds_AreUnique_AndPresetsResolve()
|
||
{
|
||
Assert.Equal(MetricRegistry.AllExports().Count(), MetricRegistry.AllExports().Select(e => e.Export.Id).Distinct().Count());
|
||
foreach (PresetDef preset in MetricRegistry.Presets)
|
||
{
|
||
foreach (string id in preset.MetricIds)
|
||
{
|
||
Assert.True(
|
||
MetricRegistry.TryFindExport(id, out _, out _) || id.StartsWith("score.", StringComparison.Ordinal),
|
||
$"preset {preset.Id} references unknown metric {id}");
|
||
}
|
||
}
|
||
|
||
Assert.All(MetricRegistry.All, m => Assert.NotEmpty(m.Fingerprint));
|
||
}
|
||
|
||
[Fact]
|
||
public void Fingerprint_ChangesWhenTheOpChanges()
|
||
{
|
||
var a = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 100), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
|
||
var b = Metric.Counter("x", ["udp_echo.msg"], Ops.Gap(GapOutput.Count, "rate_hz", 200), "x", "x", new MetricExport("x", Stat.Count, Unit.Count));
|
||
Assert.NotEqual(a.Fingerprint, b.Fingerprint);
|
||
}
|
||
|
||
[Fact]
|
||
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
|
||
{
|
||
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value;
|
||
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value;
|
||
Assert.True(good > 95);
|
||
Assert.True(bad < 10);
|
||
}
|
||
|
||
[Fact]
|
||
public void Models_WithNoData_ReturnNullScore()
|
||
{
|
||
foreach (IServiceModel model in ModelRegistry.All)
|
||
{
|
||
Assert.Null(model.Score(View()).Score);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void McModel_WithoutTcpData_EstimatesFromUdp()
|
||
{
|
||
ModelScore score = new McModel().Score(View(("rtt.p95", 60), ("loss.total", 2), ("loss.run_max", 2)));
|
||
Assert.True(score.Estimated);
|
||
Assert.NotNull(score.Score);
|
||
Assert.Contains("estimated_from_udp", score.Caveats);
|
||
Assert.False(new McModel().Score(View(("tcp.rtt.p95", 60), ("tcp.stall.per_min", 0))).Estimated);
|
||
}
|
||
|
||
[Fact]
|
||
public void Hy2Model_TestResultsDominate_AndNoTestIsEstimated()
|
||
{
|
||
ModelScore withTest = new Hy2Model().Score(View(("hy2.sustainable_mbps", 100), ("hy2.effective_mbps", 95), ("loss.total", 0.2)));
|
||
ModelScore without = new Hy2Model().Score(View(("loss.total", 0.2), ("rtt.p95", 50)));
|
||
Assert.False(withTest.Estimated);
|
||
Assert.True(without.Estimated);
|
||
Assert.Contains("no_load_test", without.Caveats);
|
||
Assert.True(withTest.Score > 90);
|
||
}
|
||
|
||
[Fact]
|
||
public void Models_AreMonotonic_WorseMetricsNeverRaiseTheScore()
|
||
{
|
||
// 对每个模型的每个所需指标:在“好”和“差”之间扫描,分数不能随指标变差而升高。
|
||
var ranges = new Dictionary<string, double[]>
|
||
{
|
||
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
|
||
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60],
|
||
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
|
||
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
|
||
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
|
||
["hy2.effective_mbps"] = [100, 50, 25, 10, 0], ["hy2.qos_onset_mbps"] = [100, 50, 25, 10],
|
||
};
|
||
|
||
foreach (IServiceModel model in ModelRegistry.All)
|
||
{
|
||
foreach (string need in model.Needs.Where(ranges.ContainsKey))
|
||
{
|
||
var baseline = ranges.Where(r => model.Needs.Contains(r.Key)).ToDictionary(r => r.Key, r => r.Value[0]);
|
||
double previous = double.MaxValue;
|
||
foreach (double value in ranges[need])
|
||
{
|
||
var values = new Dictionary<string, double>(baseline) { [need] = value };
|
||
double score = model.Score(new MetricView(values)).Score ?? 0;
|
||
Assert.True(score <= previous + 1e-9, $"{model.Id}: {need}={value} raised the score from {previous} to {score}");
|
||
previous = score;
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
[Fact]
|
||
public void TestEvaluator_Hy2Ladder_APhaseSplitIntoSeveralChunksIsOneStep()
|
||
{
|
||
// 真实的 Probe 把一个阶段切成几块(一块最多 50000 条记录):阶梯里的一步是一个阶段,不是一个块
|
||
RawChunk[] chunks =
|
||
[
|
||
ChunkFactory.Train(s_agent, T0, 10, 0),
|
||
ChunkFactory.Train(s_agent, T0 + 10_000, 50, 1, seconds: 5, lossEvery: 5),
|
||
ChunkFactory.Train(s_agent, T0 + 15_000, 50, 1, seconds: 5, lossEvery: 5),
|
||
];
|
||
|
||
TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks);
|
||
|
||
Assert.Equal(2, result.Steps.Count);
|
||
Assert.Equal(new double[] { 10, 50 }, result.Series["step_mbps"]);
|
||
Assert.InRange(result.Steps[1].LossPct, 19, 21);
|
||
Assert.InRange(result.Steps[1].DeliveredMbps, 38, 42); // 50 Mbit/s × (1 − 20% 丢包),按整个阶段的时长算
|
||
}
|
||
|
||
[Fact]
|
||
public void TestEvaluator_Hy2Ladder_FindsSustainableRateAndOnset()
|
||
{
|
||
// 10/25 Mbps 无丢包;50 Mbps 起被限速,丢包 20%、40%
|
||
RawChunk[] chunks =
|
||
[
|
||
ChunkFactory.Train(s_agent, T0, 10, 0),
|
||
ChunkFactory.Train(s_agent, T0 + 10_000, 25, 1),
|
||
ChunkFactory.Train(s_agent, T0 + 20_000, 50, 2, lossEvery: 5),
|
||
ChunkFactory.Train(s_agent, T0 + 30_000, 100, 3, lossEvery: 2),
|
||
];
|
||
|
||
TestResult result = TestEvaluator.Evaluate("hy2_ladder", chunks);
|
||
|
||
Assert.Equal(4, result.Steps.Count);
|
||
Assert.Equal(25, result.Metrics["hy2.sustainable_mbps"]);
|
||
Assert.Equal(50, result.Metrics["hy2.qos_onset_mbps"]);
|
||
Assert.InRange(result.Steps[2].LossPct, 19, 21);
|
||
Assert.InRange(result.Steps[3].LossPct, 49, 51);
|
||
Assert.InRange(result.Steps[0].DeliveredMbps, 9.5, 10.5);
|
||
Assert.Equal(new double[] { 10, 25, 50, 100 }, result.Series["step_mbps"]);
|
||
Assert.Contains("port_differs_from_service", result.Caveats);
|
||
|
||
// 模型能直接消费测试结果
|
||
ModelScore score = new Hy2Model().Score(new MetricView(result.Metrics));
|
||
Assert.False(score.Estimated);
|
||
Assert.InRange(score.Score!.Value, 20, 80);
|
||
}
|
||
|
||
[Fact]
|
||
public void TestEvaluator_PartialChunks_AddCaveat()
|
||
{
|
||
RawChunk chunk = ChunkFactory.Train(s_agent, T0, 10, 0);
|
||
chunk.Header.Partial = true;
|
||
Assert.Contains("partial_data", TestEvaluator.Evaluate("hy2_ladder", [chunk]).Caveats);
|
||
}
|
||
}
|