diff --git a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
index d0f28d1..cabd503 100644
--- a/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
+++ b/backend/agent/src/MoShiro.Agent/DataPlane/Registries.cs
@@ -217,7 +217,7 @@ public sealed class StreamInfo
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
- "udp_echo" or "tcp_echo" => parameters.GetInt("rate_hz"),
+ "udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kind, parameters),
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
diff --git a/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs b/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
index afa65cf..60e28e0 100644
--- a/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
+++ b/backend/shared/MoShiro.Measurement/Blocks/BlockBase.cs
@@ -1,4 +1,5 @@
using System.Net;
+using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
@@ -41,6 +42,76 @@ public abstract class BlockBase : IBlock
Env.Fragments!.Ship(Spec.KindId, Spec.Scope, streams, parameters ?? Spec.Params.Values, Env.Clock.UtcMsAt(first), (int)((last - first) / 1000), Spec.Port);
}
+ ///
+ /// 按自适应的节奏发包:下一个包的时刻按绝对时间排(不累积误差),每秒评估一次状态,状态变化记进日志(供审计)。
+ /// 落后了就从现在重新排,不补发。
+ ///
+ protected async Task PaceAsync(AdaptiveRate rate, Func sendOne, CancellationToken ct)
+ {
+ long due = Env.Clock.NowUs();
+ long lastEvaluation = due;
+ while (!ct.IsCancellationRequested)
+ {
+ due += (long)(rate.NextDelay().TotalMilliseconds * 1000);
+ long wait = due - Env.Clock.NowUs();
+ if (wait > 0)
+ {
+ await Task.Delay(TimeSpan.FromMicroseconds(wait), Env.Time, ct);
+ }
+ else
+ {
+ due = Env.Clock.NowUs();
+ }
+
+ await sendOne();
+ long now = Env.Clock.NowUs();
+ if (now - lastEvaluation >= 1_000_000 || rate.State == ProbeState.Dead)
+ {
+ lastEvaluation = now;
+ if (rate.Evaluate(now) is { } change)
+ {
+ LogTransition(change);
+ }
+ }
+ }
+ }
+
+ /// 读这个任务的自适应频率参数(udp_echo 和 tcp_echo 共用)。
+ protected AdaptiveRate? CreateAdaptiveRate()
+ {
+ if (!Spec.Bool("adaptive"))
+ {
+ return null;
+ }
+
+ double max = Math.Max(Spec.Int("rate_max_hz"), Spec.Int("rate_min_hz"));
+ return new AdaptiveRate(new AdaptiveRateOptions
+ {
+ MinHz = Spec.Int("rate_min_hz"),
+ MaxHz = max,
+ TauPct = Spec.Number("tau_pct"),
+ RttTolPct = Spec.Number("rtt_tol_pct"),
+ Prior = Env.LossPrior,
+ });
+ }
+
+ private void LogTransition(ProbeTransition change)
+ {
+ var fields = new (string, object?)[]
+ {
+ ("stream", Spec.StreamId), ("kind", Spec.KindId), ("from", change.From.ToString().ToLowerInvariant()), ("to", change.To.ToString().ToLowerInvariant()), ("hz", Math.Round(change.Hz, 1)),
+ ("reason", change.Reason), ("loss_pct", Math.Round(change.LossPct, 3)), ("sent", change.Sent), ("lost", change.Lost),
+ };
+ if (change.To == ProbeState.Dead)
+ {
+ Env.Log.Warn("probe rate changed", fields);
+ }
+ else
+ {
+ Env.Log.Info("probe rate changed", fields);
+ }
+ }
+
/// 什么也不做、一直等到取消(监听方里不需要自己运行逻辑的积木)。
protected async Task WaitUntilCancelledAsync(CancellationToken ct)
{
diff --git a/backend/shared/MoShiro.Measurement/Blocks/Echo.cs b/backend/shared/MoShiro.Measurement/Blocks/Echo.cs
index 3627608..f8fa36a 100644
--- a/backend/shared/MoShiro.Measurement/Blocks/Echo.cs
+++ b/backend/shared/MoShiro.Measurement/Blocks/Echo.cs
@@ -23,6 +23,7 @@ internal sealed class EchoRows
private readonly DriverLog _log;
private readonly int _size;
+ private readonly int _w;
private readonly int _t1;
private readonly int _t4;
private readonly long _graceUs;
@@ -36,26 +37,36 @@ internal sealed class EchoRows
StreamSpec spec = kind.FindStream("msg")!;
_log = new DriverLog(spec, Side.Dialer, capacity);
_size = _log.Store.ColumnIndex("size");
+ _w = _log.Store.ColumnIndex("w");
_t1 = _log.Store.ColumnIndex("t1");
_t4 = _log.Store.ColumnIndex("t4");
}
public long Count => _log.Count - Interlocked.Read(ref _discarded);
- public uint Add(int size, long t1Us)
+ /// :这个包代表多少个基础网格间隔(频率变化时分析按它累计,丢包率和分位数仍是按时间平均的)。
+ public uint Add(int size, long t1Us, int weight = 1)
{
long key = _log.Store.Append();
_log.Store.Set(key, _size, size);
+ _log.Store.Set(key, _w, weight);
_log.Store.Set(key, _t1, t1Us);
return (uint)key;
}
- public void Reply(uint seq, long t4Us)
+ public bool Reply(uint seq, long t4Us) => Reply(seq, t4Us, out _);
+
+ /// 回填 t4;第一次回复才算, 是这个包的发出时间。
+ public bool Reply(uint seq, long t4Us, out long t1Us)
{
- if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4))
+ t1Us = 0;
+ if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4) && _log.Store.TryGet(seq, _t1, out t1Us))
{
_log.Store.Set(seq, _t4, t4Us);
+ return true;
}
+
+ return false;
}
public bool Discard(uint seq)
@@ -83,6 +94,7 @@ public sealed class DatagramEchoDialer : BlockBase
private readonly EchoRows _rows;
private readonly object _gate = new();
private long _nextSendFailureWarningUs;
+ private AdaptiveRate? _rate;
public DatagramEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
@@ -100,29 +112,50 @@ public sealed class DatagramEchoDialer : BlockBase
IPEndPoint target = PeerEndpoint(Spec.Port);
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
+ _rate = CreateAdaptiveRate();
+
+ void SendOne()
+ {
+ long t1 = Env.Clock.NowUs();
+ lock (_gate)
+ {
+ uint seq = _rows.Add(size, t1, _rate?.Weight ?? 1);
+ MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
+ if (datagrams.Send(session, target, packet))
+ {
+ _rate?.OnSend(t1);
+ return;
+ }
+
+ _rows.Discard(seq);
+ if (t1 >= _nextSendFailureWarningUs)
+ {
+ Env.Log.Warn("QUIC echo datagram was not queued; omitted from loss samples",
+ ("stream", Spec.StreamId), ("seq", seq), ("bytes", size));
+ _nextSendFailureWarningUs = t1 + 5_000_000;
+ }
+ }
+ }
+
datagrams.Register(Spec.StreamId, OnPacket);
try
{
- using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
- while (await timer.WaitForNextTickAsync(ct))
+ if (_rate is null)
{
- long t1 = Env.Clock.NowUs();
- lock (_gate)
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
+ while (await timer.WaitForNextTickAsync(ct))
{
- uint seq = _rows.Add(size, t1);
- MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
- if (!datagrams.Send(session, target, packet))
- {
- _rows.Discard(seq);
- if (t1 >= _nextSendFailureWarningUs)
- {
- Env.Log.Warn("QUIC echo datagram was not queued; omitted from loss samples",
- ("stream", Spec.StreamId), ("seq", seq), ("bytes", size));
- _nextSendFailureWarningUs = t1 + 5_000_000;
- }
- }
+ SendOne();
}
}
+ else
+ {
+ await PaceAsync(_rate, () =>
+ {
+ SendOne();
+ return ValueTask.CompletedTask;
+ }, ct);
+ }
}
catch (OperationCanceledException)
{
@@ -157,7 +190,10 @@ public sealed class DatagramEchoDialer : BlockBase
{
lock (_gate)
{
- _rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
+ if (_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs, out long t1))
+ {
+ _rate?.OnReply(t1, delivery.ReceiveUs, (delivery.ReceiveUs - t1) / 1000.0);
+ }
}
}
}
@@ -211,6 +247,7 @@ public sealed class TcpEchoDialer : BlockBase
{
private readonly EchoRows _rows;
private readonly TcpInfoRecorder _info;
+ private AdaptiveRate? _rate;
public TcpEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
@@ -224,12 +261,14 @@ public sealed class TcpEchoDialer : BlockBase
public override async Task RunAsync(CancellationToken ct)
{
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
+ _rate = CreateAdaptiveRate();
while (!ct.IsCancellationRequested)
{
try
{
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
backoff.Reset();
+ _rate?.Reconnected(Env.Clock.NowUs());
await ExchangeAsync(link, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -300,16 +339,29 @@ public sealed class TcpEchoDialer : BlockBase
int min = (int)Spec.Int("min");
int max = (int)Spec.Int("max");
byte[] body = new byte[Math.Max(max, min)];
- using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
- while (await timer.WaitForNextTickAsync(ct))
+
+ async ValueTask SendOneAsync()
{
int size = Math.Max(TcpWire.MinEchoBody, max > min ? Random.Shared.Next(min, max + 1) : min);
long t1 = Env.Clock.NowUs();
- uint seq = _rows.Add(size, t1);
+ uint seq = _rows.Add(size, t1, _rate?.Weight ?? 1);
body.AsSpan(0, size).Clear();
TcpWire.WriteEchoBody(body, seq, t1);
await TcpWire.WriteMessageAsync(stream, body.AsMemory(0, size), ct);
await stream.FlushAsync(ct);
+ _rate?.OnSend(t1);
+ }
+
+ if (_rate is not null)
+ {
+ await PaceAsync(_rate, SendOneAsync, ct);
+ return;
+ }
+
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
+ while (await timer.WaitForNextTickAsync(ct))
+ {
+ await SendOneAsync();
}
}
@@ -324,9 +376,9 @@ public sealed class TcpEchoDialer : BlockBase
}
long now = Env.Clock.NowUs();
- if (TcpWire.TryReadEchoBody(reply, out uint seq, out _))
+ if (TcpWire.TryReadEchoBody(reply, out uint seq, out _) && _rows.Reply(seq, now, out long t1))
{
- _rows.Reply(seq, now);
+ _rate?.OnReply(t1, now, (now - t1) / 1000.0);
}
}
}
diff --git a/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs b/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs
new file mode 100644
index 0000000..b7eb0bc
--- /dev/null
+++ b/backend/shared/MoShiro.Measurement/Core/AdaptiveRate.cs
@@ -0,0 +1,449 @@
+namespace MoShiro.Measurement;
+
+/// 探测节奏的状态。 是线路几乎全丢:不再按正常频率白白发包,指数退避着探测,等到有回包再恢复。
+public enum ProbeState : byte
+{
+ Quiet = 0,
+ Watch = 1,
+ Active = 2,
+ Dead = 3,
+}
+
+/// 自适应探测频率的参数。频率范围、精度目标和各状态的保持时间都在这里。
+public sealed record AdaptiveRateOptions
+{
+ /// 平稳时的最低频率(Hz)。
+ public double MinHz { get; init; } = 2;
+
+ ///
+ /// 最高频率(Hz),也是权重的参考网格:每个探测包的权重是它代表的时间除以 1/MaxHz,
+ /// 分析时丢包率和 RTT 分位数按权重累计,所以频率怎么变,得到的仍是按时间平均的值,不会偏向探测更密的时段。
+ ///
+ public double MaxHz { get; init; } = 10;
+
+ /// 平稳时 RTT 分位数(p99)的 95% 置信区间半宽容差:取 max(下限毫秒, 百分比 × p99)。样本多到超出这个精度才会降频。
+ public double RttTolPct { get; init; } = 2;
+
+ public double RttTolFloorMs { get; init; } = 0.3;
+
+ /// 平稳线路要能证明的丢包上界(百分比,95% 置信)。三法则:零丢包需要 3 / τ 个样本。
+ public double TauPct { get; init; } = 0.2;
+
+ /// 证明窗口(秒):平稳频率 = 3 / (τ × 窗口)。
+ public double CertifySeconds { get; init; } = 300;
+
+ /// 历史上一直平稳的时段可以拉长证明窗口,同样的精度用更少的包。
+ public double QuietCertifySeconds { get; init; } = 900;
+
+ /// 回包等多久算丢(秒)。
+ public double GraceSeconds { get; init; } = 3;
+
+ /// 刚启动没有历史:先按 Watch 频率跑这么久(秒)。
+ public double WarmupSeconds { get; init; } = 300;
+
+ /// 一次丢包事件之后,Watch 至少保持多久(秒)。
+ public double WatchHoldSeconds { get; init; } = 1800;
+
+ /// 进入 Active 之后,没有新的丢包事件要保持多久才降到 Watch(秒)。
+ public double ActiveHoldSeconds { get; init; } = 900;
+
+ /// 证明窗口内有这么多个不同的秒出现丢包,就算持续丢包,升到 Active。
+ public int ActiveLossCount { get; init; } = 3;
+
+ /// 判断线路全丢的窗口(秒)和最少样本数、丢包比例。
+ public double DeadWindowSeconds { get; init; } = 10;
+
+ public int DeadMinSent { get; init; } = 10;
+
+ public double DeadLossRatio { get; init; } = 0.95;
+
+ /// Dead 状态的探测间隔:从 1 秒起翻倍,最长这么久(秒),带 ±20% 抖动。
+ public double DeadMaxIntervalSeconds { get; init; } = 30;
+
+ /// RTT 比长期水平高出这个倍数(再加下面的绝对值)持续几秒,就当作链路在变差,至少升到 Watch。
+ public double RttFactor { get; init; } = 1.5;
+
+ public double RttFloorMs { get; init; } = 5;
+
+ /// 先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。
+ public Func? Prior { get; init; }
+
+ /// 先验里的"丢包高发"阈值:达到它在到来之前 提前预热。
+ public double PriorWatchAt { get; init; } = 0.15;
+
+ public double PriorActiveAt { get; init; } = 0.5;
+
+ public double PriorLeadSeconds { get; init; } = 1800;
+}
+
+/// 状态或频率变了: 是给日志和审计看的固定短语。
+public sealed record ProbeTransition(ProbeState From, ProbeState To, double Hz, string Reason, double LossPct, int Sent, int Lost);
+
+///
+/// 自适应探测频率:按"要证明的精度"决定频率,丢包多了自动升频,线路全丢就指数退避。
+/// 平稳线路只需要能证明丢包低于 τ 的样本数(三法则 N = 3/τ),有丢包的线路用满频率保精度和丢包的细节(连续丢包、乱序)。
+/// 状态靠丢包事件的频率升降,带滞回,不会在阈值附近来回跳;时间画像可以让高发时段提前预热。
+/// 纯逻辑:时间由调用方传入,不依赖时钟,方便测试。
+///
+public sealed class AdaptiveRate
+{
+ private const long UsPerSecond = 1_000_000;
+ private const int Buckets = 1200; // 20 分钟,够覆盖最长的统计窗口
+
+ private readonly AdaptiveRateOptions _options;
+ private readonly Random _random;
+ private readonly object _gate = new();
+ private readonly long[] _second = new long[Buckets];
+ private readonly int[] _sent = new int[Buckets];
+ private readonly int[] _replied = new int[Buckets];
+
+ private long _startUs = -1;
+ private long _lastLossUs = long.MinValue;
+ private long _activeUntilUs = long.MinValue;
+ private long _lastReplyUs = long.MinValue;
+ private long _deadSinceUs = long.MinValue;
+ private int _deadProbes;
+ private readonly Queue<(long Second, double Ms)> _rtts = new();
+ private long _lastGuardSecond = long.MinValue;
+ private double _guardHz = double.NaN;
+ private TimeSpan _lastDelay = TimeSpan.Zero;
+ private double _rttFast = double.NaN;
+ private double _rttSlow = double.NaN;
+ private int _rttHighSeconds;
+ private long _lastRttSecond = long.MinValue;
+
+ public AdaptiveRate(AdaptiveRateOptions options, Random? random = null)
+ {
+ _options = options;
+ _random = random ?? Random.Shared;
+ State = ProbeState.Watch;
+ Hz = _options.MaxHz;
+ }
+
+ public ProbeState State { get; private set; }
+
+ /// 当前的探测频率(Hz)。Dead 时是退避后的近似频率。
+ public double Hz { get; private set; }
+
+ /// 发出一个探测包。
+ public void OnSend(long nowUs)
+ {
+ lock (_gate)
+ {
+ if (_startUs < 0)
+ {
+ _startUs = nowUs;
+ }
+
+ long second = nowUs / UsPerSecond;
+ int slot = Slot(second);
+ if (_second[slot] != second)
+ {
+ _second[slot] = second;
+ _sent[slot] = 0;
+ _replied[slot] = 0;
+ }
+
+ _sent[slot]++;
+ if (State == ProbeState.Dead)
+ {
+ _deadProbes++;
+ }
+ }
+ }
+
+ /// 收到回包: 是它发出的时间, 是往返时延。
+ public void OnReply(long sentUs, long nowUs, double rttMs)
+ {
+ lock (_gate)
+ {
+ long second = sentUs / UsPerSecond;
+ int slot = Slot(second);
+ if (_second[slot] == second && _replied[slot] < _sent[slot])
+ {
+ _replied[slot]++;
+ }
+
+ if (double.IsNaN(_rttSlow))
+ {
+ _rttSlow = _rttFast = rttMs;
+ }
+ else
+ {
+ // 时间常数:快的 5 秒,慢的 15 分钟(按时间而不是按包数,频率变了也一样)
+ double dt = Math.Max(0, (nowUs - Math.Max(_lastReplyUs, 0)) / (double)UsPerSecond);
+ _rttFast += (1 - Math.Exp(-dt / 5)) * (rttMs - _rttFast);
+ _rttSlow += (1 - Math.Exp(-dt / 900)) * (rttMs - _rttSlow);
+ }
+
+ _lastReplyUs = nowUs;
+ _rtts.Enqueue((nowUs / UsPerSecond, rttMs));
+ while (_rtts.Count > 6000)
+ {
+ _rtts.Dequeue();
+ }
+ }
+ }
+
+ /// 连接(重新)建立了:之前线路不通退避着的话,马上恢复探测,不用等退避间隔。
+ public void Reconnected(long nowUs)
+ {
+ lock (_gate)
+ {
+ if (State == ProbeState.Dead)
+ {
+ _lastReplyUs = nowUs;
+ }
+ }
+ }
+
+ /// 距离下一个探测包还有多久(带抖动,平均值等于频率的倒数)。
+ public TimeSpan NextDelay()
+ {
+ lock (_gate)
+ {
+ if (State == ProbeState.Dead)
+ {
+ double seconds = Math.Min(_options.DeadMaxIntervalSeconds, Math.Pow(2, Math.Min(_deadProbes, 10)));
+ return _lastDelay = TimeSpan.FromSeconds(seconds * (0.8 + 0.4 * _random.NextDouble()));
+ }
+
+ // 平稳时间隔精确(不加抖动):发包时刻规整,列块里的时间戳差分更小;抖动只用在退避里避免多条线路同步重试
+ return _lastDelay = TimeSpan.FromSeconds(1.0 / Hz);
+ }
+ }
+
+ /// 刚刚等过的那个间隔所代表的权重(以 1/MaxHz 为单位):下一个发出的包用它。平稳时是 MaxHz/Hz,退避时是几十到几百。
+ public int Weight
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return (int)Math.Clamp(Math.Round(_lastDelay.TotalSeconds * _options.MaxHz), 1, 65535);
+ }
+ }
+ }
+
+ /// 重新评估状态,大约每秒调用一次;状态或频率变了就返回变化。
+ public ProbeTransition? Evaluate(long nowUs)
+ {
+ lock (_gate)
+ {
+ if (_startUs < 0)
+ {
+ return null;
+ }
+
+ long finalSecond = nowUs / UsPerSecond - (long)Math.Ceiling(_options.GraceSeconds);
+ (int sent, int replied, int _) shortWindow = Window(finalSecond, (int)_options.DeadWindowSeconds);
+ (int sent, int replied, int lossSeconds) certify = Window(finalSecond, (int)_options.CertifySeconds);
+ int lostCertify = certify.sent - certify.replied;
+ double lossPct = certify.sent == 0 ? 0 : 100.0 * lostCertify / certify.sent;
+
+ // 最近一次有丢包的时间:用最近 CertifySeconds 里最晚一个有丢包的整秒
+ for (long second = finalSecond; second > finalSecond - (long)_options.CertifySeconds; second--)
+ {
+ int slot = Slot(second);
+ if (_second[slot] == second && _sent[slot] > _replied[slot])
+ {
+ _lastLossUs = Math.Max(_lastLossUs, second * UsPerSecond);
+ break;
+ }
+ }
+
+ UpdateRttTrend(nowUs);
+
+ ProbeState next;
+ string reason;
+ if (State == ProbeState.Dead)
+ {
+ if (_lastReplyUs > _deadSinceUs)
+ {
+ next = ProbeState.Watch;
+ reason = "reply received";
+ _deadProbes = 0;
+ _lastLossUs = nowUs;
+ }
+ else
+ {
+ return null;
+ }
+ }
+ else if (shortWindow.sent >= _options.DeadMinSent && shortWindow.replied <= shortWindow.sent * (1 - _options.DeadLossRatio))
+ {
+ next = ProbeState.Dead;
+ reason = "line unreachable";
+ _deadSinceUs = nowUs;
+ _deadProbes = 0;
+ }
+ else
+ {
+ (next, reason) = Decide(nowUs, certify.lossSeconds, lostCertify, lossPct);
+ }
+
+ UpdateGuard(nowUs);
+ double hz = next switch
+ {
+ ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
+ ProbeState.Watch or ProbeState.Active => _options.MaxHz,
+ _ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
+ };
+ hz = Math.Min(_options.MaxHz, hz);
+
+ if (next == State && Math.Abs(hz - Hz) / Hz < 0.05)
+ {
+ return null;
+ }
+
+ ProbeTransition result = new(State, next, hz, reason, lossPct, certify.sent, lostCertify);
+ State = next;
+ Hz = hz;
+ return result;
+ }
+ }
+
+ private (ProbeState State, string Reason) Decide(long nowUs, int lossSeconds, int lostCertify, double lossPct)
+ {
+ ProbeState state = ProbeState.Quiet;
+ string reason = "quiet";
+
+ double sinceLoss = _lastLossUs == long.MinValue ? double.PositiveInfinity : (nowUs - _lastLossUs) / (double)UsPerSecond;
+ if (lossSeconds >= _options.ActiveLossCount || (lossPct >= _options.TauPct * 2 && lossSeconds >= 2))
+ {
+ _activeUntilUs = nowUs + (long)(_options.ActiveHoldSeconds * UsPerSecond);
+ }
+
+ if (nowUs < _activeUntilUs)
+ {
+ return (ProbeState.Active, "sustained loss");
+ }
+
+ if (sinceLoss <= _options.WatchHoldSeconds)
+ {
+ state = ProbeState.Watch;
+ reason = "recent loss";
+ }
+
+ if (_rttHighSeconds >= 3)
+ {
+ state = ProbeState.Watch;
+ reason = "rtt above baseline";
+ }
+
+ double? prior = _options.Prior?.Invoke(nowUs);
+ double? ahead = _options.Prior?.Invoke(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond));
+ double expected = Math.Max(prior ?? 0, ahead ?? 0);
+ if (expected >= _options.PriorActiveAt)
+ {
+ return (ProbeState.Active, "expected peak");
+ }
+
+ if (expected >= _options.PriorWatchAt && state == ProbeState.Quiet)
+ {
+ state = ProbeState.Watch;
+ reason = "expected loss window";
+ }
+
+ if (state == ProbeState.Quiet && _options.Prior is null && nowUs - _startUs < (long)(_options.WarmupSeconds * UsPerSecond))
+ {
+ state = ProbeState.Watch;
+ reason = "warm-up";
+ }
+
+ return (state, reason);
+ }
+
+ /// 历史上这个时段(现在和将要到来的一段时间)都平稳时,证明窗口可以更长,频率更低。
+ private double QuietWindowSeconds(long nowUs)
+ {
+ if (_options.Prior is null)
+ {
+ return _options.CertifySeconds;
+ }
+
+ double now = _options.Prior(nowUs) ?? 1;
+ double ahead = _options.Prior(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond)) ?? 1;
+ return Math.Max(now, ahead) <= 0.02 ? _options.QuietCertifySeconds : _options.CertifySeconds;
+ }
+
+ /// 频率取 MaxHz/k(k 为整数):每个包的权重正好是整数 k,分析时没有舍入误差。
+ private double Quantize(double hz)
+ {
+ double k = Math.Max(1, Math.Floor(_options.MaxHz / Math.Max(hz, 1e-9)));
+ return _options.MaxHz / k;
+ }
+
+ ///
+ /// 分位数精度保护:用最近一个证明窗口里的 RTT 样本估计 p99 的 95% 置信区间(次序统计量),
+ /// 半宽超过容差就升频,远小于容差才降频(单步最多减半),使平稳线路的 RTT 分位数始终保持在设定的精度内。
+ ///
+ private void UpdateGuard(long nowUs)
+ {
+ long second = nowUs / UsPerSecond;
+ if (_lastGuardSecond != long.MinValue && second - _lastGuardSecond < 10)
+ {
+ return;
+ }
+
+ _lastGuardSecond = second;
+ long from = second - (long)_options.CertifySeconds;
+ double[] window = [.. _rtts.Where(r => r.Second >= from).Select(r => r.Ms).Order()];
+ if (window.Length < 200)
+ {
+ _guardHz = double.NaN; // 样本不够估计精度:按最高频率
+ return;
+ }
+
+ int n = window.Length;
+ double rank = n * 0.99;
+ double half = 1.96 * Math.Sqrt(n * 0.99 * 0.01);
+ int lo = Math.Clamp((int)Math.Floor(rank - half), 0, n - 1);
+ int hi = Math.Clamp((int)Math.Ceiling(rank + half), 0, n - 1);
+ double halfWidth = (window[hi] - window[lo]) / 2;
+ double tolerance = Math.Max(_options.RttTolFloorMs, _options.RttTolPct / 100.0 * window[Math.Min(n - 1, (int)rank)]);
+ double ratio = Math.Clamp(halfWidth / tolerance, 0.7, 2.0); // 单步变化有限,避免来回跳
+ double current = Hz;
+ _guardHz = Math.Clamp(current * ratio * ratio, _options.MinHz, _options.MaxHz);
+ }
+
+ private double QuietHz(double windowSeconds) => Math.Min(_options.MaxHz, Math.Max(_options.MinHz, 3.0 / (_options.TauPct / 100.0 * windowSeconds)));
+
+ /// 窗口内发出、回来的包数,以及有丢包的整秒数(一次连续丢包只算一个"事件",不会因为一次短暂中断就判成持续丢包)。
+ private (int Sent, int Replied, int LossSeconds) Window(long finalSecond, int seconds)
+ {
+ int sent = 0;
+ int replied = 0;
+ int lossSeconds = 0;
+ for (long second = finalSecond; second > finalSecond - seconds; second--)
+ {
+ int slot = Slot(second);
+ if (_second[slot] == second)
+ {
+ sent += _sent[slot];
+ replied += _replied[slot];
+ if (_sent[slot] > _replied[slot])
+ {
+ lossSeconds++;
+ }
+ }
+ }
+
+ return (sent, replied, lossSeconds);
+ }
+
+ private void UpdateRttTrend(long nowUs)
+ {
+ long second = nowUs / UsPerSecond;
+ if (second == _lastRttSecond || double.IsNaN(_rttFast) || double.IsNaN(_rttSlow))
+ {
+ return;
+ }
+
+ _lastRttSecond = second;
+ bool high = _rttFast > _rttSlow * _options.RttFactor + _options.RttFloorMs;
+ _rttHighSeconds = high ? _rttHighSeconds + 1 : 0;
+ }
+
+ private static int Slot(long second) => (int)(((second % Buckets) + Buckets) % Buckets);
+}
diff --git a/backend/shared/MoShiro.Measurement/Core/Contracts.cs b/backend/shared/MoShiro.Measurement/Core/Contracts.cs
index 177d503..dd1fd9d 100644
--- a/backend/shared/MoShiro.Measurement/Core/Contracts.cs
+++ b/backend/shared/MoShiro.Measurement/Core/Contracts.cs
@@ -186,6 +186,8 @@ public sealed class BlockSpec
public long Int(string name) => Params.GetInt(name);
+ public double Number(string name) => Params.GetNumber(name);
+
public bool Bool(string name) => Params.GetBool(name);
/// 参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。
@@ -221,6 +223,9 @@ public sealed class BlockEnv
public QuicDatagramTransport? Datagrams { get; init; }
+ /// 拨号方:这条线路在每个时刻(单调时钟,微秒)丢包的历史概率(0 到 1),自适应探测用它在高发时段提前升频;没有历史为 null。
+ public Func? LossPrior { get; init; }
+
/// 监听方的准入策略(限速等);拨号方为 null。
public IAdmission? Admission { get; init; }
@@ -257,6 +262,7 @@ public sealed class BlockEnv
Log = Log,
Session = () => session,
Datagrams = Datagrams,
+ LossPrior = LossPrior,
Admission = Admission,
Counters = Counters,
TcpListen = TcpListen,
diff --git a/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs b/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
index 934e6a9..06bd324 100644
--- a/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
+++ b/backend/shared/MoShiro.Measurement/Execution/BudgetGuard.cs
@@ -148,7 +148,7 @@ public sealed class BudgetGuard
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
- "udp_echo" or "tcp_echo" => values.GetInt("rate_hz") * 2,
+ "udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kindId, values) * 2,
"icmp_echo" => values.GetInt("rate_hz") * 2,
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"connect" => values.GetInt("per_minute") * 6 / 60,
diff --git a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
index c0984f1..f20d420 100644
--- a/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
+++ b/backend/shared/MoShiro.Protocol/Catalog/Kinds/Kinds.cs
@@ -8,10 +8,14 @@ public static class Kinds
{
private static readonly string[] s_directions = ["fwd", "rev", "bidir"];
+ /// 这个任务可能达到的最高探测频率(Hz):自适应时是 rate_max_hz,否则是固定的 rate_hz。预算、限速和准入都按它算。
+ public static long PeakRateHz(string kindId, ParamValues values) =>
+ (kindId is "udp_echo" or "tcp_echo") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
+
private static readonly StreamSpec s_echoMsg = new(
"msg",
[
- Col.Seq("seq"), Col.U16("size"),
+ Col.Seq("seq"), Col.U16("size"), Col.U16("w"),
Col.TsDialer("t1"), Col.TsListener("t2", nullable: true), Col.TsListener("t3", nullable: true), Col.TsDialer("t4", nullable: true),
],
Side.Dialer,
@@ -33,12 +37,17 @@ public static class Kinds
Scope.Line,
new BlockSet("quic_datagram", "fixed", "fixed", "echo", []),
[
- P.Int("rate_hz", 10, 1, 200, "Hz"),
+ P.Bool("adaptive", true, "Adapt the probe rate to the loss rate: low when the line is quiet, up to rate_max_hz when it loses packets, exponential back-off when it is unreachable."),
+ P.Int("rate_hz", 10, 1, 200, "Hz", "Fixed rate, used when adaptive is false."),
+ P.Int("rate_min_hz", 2, 1, 200, "Hz"),
+ P.Int("rate_max_hz", 10, 1, 200, "Hz", "Highest rate, and the reference grid for sample weights."),
+ P.Number("tau_pct", 0.2, 0.01, 5, "%", "Loss rate a quiet line has to be able to prove below, at 95% confidence."),
+ P.Number("rtt_tol_pct", 2, 0.1, 20, "%", "Half-width of the 95% confidence interval of the RTT p99 a quiet line has to keep, as a share of p99 (never below 0.3 ms)."),
P.Int("size", 64, 44, 1400, "B"),
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
- p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
+ p => Bandwidth.UdpEcho(PeakRateHz("udp_echo", p), p.GetInt("size")));
public static readonly KindSpec UdpTrain = new(
"udp_train",
@@ -66,15 +75,20 @@ public static class Kinds
Scope.Line,
new BlockSet("tcp", "fixed", "uniform", "echo", ["tcp_info"]),
[
- P.Int("rate_hz", 20, 1, 200, "Hz"),
+ P.Bool("adaptive", true, "Adapt the echo rate: as low as the loss bound and the RTT percentile precision allow when the line is quiet, the maximum when it is not, back-off when it stalls."),
+ P.Int("rate_hz", 20, 1, 200, "Hz", "Fixed rate, used when adaptive is false."),
+ P.Int("rate_min_hz", 2, 1, 200, "Hz"),
+ P.Int("rate_max_hz", 20, 1, 200, "Hz", "Highest rate, and the reference grid for sample weights."),
+ P.Number("tau_pct", 0.2, 0.01, 5, "%", "Share of retransmitted segments a quiet line has to be able to prove below, at 95% confidence."),
+ P.Number("rtt_tol_pct", 2, 0.1, 20, "%", "Half-width of the 95% confidence interval of the RTT p99 a quiet line has to keep, as a share of p99 (never below 0.3 ms)."),
P.Int("min", 32, 16, 1400, "B"),
P.Int("max", 64, 16, 1400, "B"),
- P.Int("tcp_info_ms", 100, 10, 1000, "ms"),
+ P.Int("tcp_info_ms", 1000, 10, 1000, "ms"),
P.Int("port", 7444, 1, 65535),
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
- p => Bandwidth.TcpEcho(p.GetInt("rate_hz"), p.GetInt("max")));
+ p => Bandwidth.TcpEcho(PeakRateHz("tcp_echo", p), p.GetInt("max")));
public static readonly KindSpec TcpBulk = new(
"tcp_bulk",
diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/AdaptiveRateTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/AdaptiveRateTests.cs
new file mode 100644
index 0000000..c908403
--- /dev/null
+++ b/backend/shared/tests/MoShiro.Measurement.Tests/AdaptiveRateTests.cs
@@ -0,0 +1,175 @@
+using MoShiro.Measurement;
+
+namespace MoShiro.Measurement.Tests;
+
+public sealed class AdaptiveRateTests
+{
+ private const long Second = 1_000_000;
+
+ /// 按当前频率发包、按 决定回不回,每秒评估一次;返回结束时的秒数。
+ private static long Run(AdaptiveRate rate, long fromSecond, int seconds, Func lost, double rttMs = 20, List? log = null, bool stopAtDead = false)
+ {
+ long second = fromSecond;
+ for (int i = 0; i < seconds; i++, second++)
+ {
+ int probes = Math.Max(1, (int)Math.Round(rate.Hz));
+ if (rate.State == ProbeState.Dead)
+ {
+ probes = i % 4 == 0 ? 1 : 0; // 退避:几秒才发一个
+ }
+
+ for (int k = 0; k < probes; k++)
+ {
+ long sent = second * Second + k * Second / probes;
+ rate.OnSend(sent);
+ if (!lost(second))
+ {
+ rate.OnReply(sent, sent + (long)(rttMs * 1000), rttMs);
+ }
+ }
+
+ if (rate.Evaluate((second + 1) * Second) is { } change)
+ {
+ log?.Add(change);
+ if (stopAtDead && change.To == ProbeState.Dead)
+ {
+ return second + 1;
+ }
+ }
+ }
+
+ return second;
+ }
+
+ [Fact]
+ public void QuietLine_AfterWarmUp_ProbesJustEnoughToProveTheLossBound()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions());
+ long at = Run(rate, 0, 600, _ => false);
+ Assert.Equal(ProbeState.Quiet, rate.State);
+ Assert.Equal(5, rate.Hz, 0.01); // 3 / (0.2% × 300 s)
+ Assert.True(at > 0);
+ }
+
+ [Fact]
+ public void SustainedLoss_RaisesToTheMaximum_AndComesBackOnlyAfterTheHoldTimes()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions());
+ long at = Run(rate, 0, 600, _ => false);
+ at = Run(rate, at, 60, second => second % 10 == 0); // 每 10 秒丢一整秒的包
+ Assert.Equal(ProbeState.Active, rate.State);
+ Assert.Equal(10, rate.Hz);
+
+ at = Run(rate, at, 800, _ => false); // 不到 15 分钟:还在 Active
+ Assert.Equal(ProbeState.Active, rate.State);
+ at = Run(rate, at, 400, _ => false); // 超过 15 分钟:降到 Watch
+ Assert.Equal(ProbeState.Watch, rate.State);
+ Run(rate, at, 1900, _ => false); // 最后一次丢包 30 分钟之后:回到 Quiet
+ Assert.Equal(ProbeState.Quiet, rate.State);
+ }
+
+ [Fact]
+ public void AnIsolatedLoss_OnlyRaisesToWatch()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions());
+ long at = Run(rate, 0, 600, _ => false);
+ var log = new List();
+ Run(rate, at, 120, second => second == at + 10, log: log);
+ Assert.Equal(ProbeState.Watch, rate.State);
+ Assert.DoesNotContain(log, change => change.To == ProbeState.Active);
+ }
+
+ [Fact]
+ public void Unreachable_BacksOffExponentially_AndRecoversOnTheFirstReply()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions(), new Random(1));
+ long at = Run(rate, 0, 600, _ => false);
+ at = Run(rate, at, 60, _ => true, stopAtDead: true);
+ Assert.Equal(ProbeState.Dead, rate.State);
+
+ var delays = new List();
+ long now = at * Second;
+ for (int i = 0; i < 8; i++)
+ {
+ double delay = rate.NextDelay().TotalSeconds;
+ delays.Add(delay);
+ now += (long)(delay * Second);
+ rate.OnSend(now);
+ }
+
+ Assert.InRange(delays[0], 0.8, 1.2); // 从 1 秒开始
+ Assert.True(delays[3] > delays[0] * 4, string.Join(",", delays)); // 翻倍增长(有抖动)
+ Assert.All(delays, delay => Assert.InRange(delay, 0.8, 36.0)); // 最长 30 秒,抖动 ±20%
+
+ rate.OnReply(now, now + 30_000, 30);
+ Assert.Equal(ProbeState.Watch, rate.Evaluate(now + 2 * Second)!.To);
+ }
+
+ [Fact]
+ public void QuietLineWithTightRtt_MayDropBelowTheGrid_ButAJitteryOneStaysAtTheMaximum()
+ {
+ var tight = new AdaptiveRate(new AdaptiveRateOptions());
+ Run(tight, 0, 1200, _ => false, rttMs: 20); // RTT 恒定:p99 的置信区间几乎为零,降到丢包下界 5 Hz
+ Assert.Equal(ProbeState.Quiet, tight.State);
+ Assert.Equal(5, tight.Hz, 0.01);
+
+ var jittery = new AdaptiveRate(new AdaptiveRateOptions());
+ var random = new Random(7);
+ long second = 0;
+ for (int i = 0; i < 1200; i++, second++)
+ {
+ int probes = Math.Max(1, (int)Math.Round(jittery.Hz));
+ for (int k = 0; k < probes; k++)
+ {
+ long sent = second * Second + k * Second / probes;
+ double rtt = 20 + random.NextDouble() * random.NextDouble() * 60; // 长尾 20 到 80 ms
+ jittery.OnSend(sent);
+ jittery.OnReply(sent, sent + (long)(rtt * 1000), rtt);
+ }
+
+ jittery.Evaluate((second + 1) * Second);
+ }
+
+ Assert.Equal(10, jittery.Hz, 0.01); // 分位数精度保护:抖动大,样本不能少
+ }
+
+ [Fact]
+ public void Weight_IsTheTimeAProbeStandsFor_InUnitsOfTheGrid()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions());
+ Run(rate, 0, 1200, _ => false);
+ Assert.Equal(5, rate.Hz, 0.01);
+ rate.NextDelay();
+ Assert.Equal(2, rate.Weight); // 5 Hz 的一个包代表 2 个 10 Hz 网格间隔
+ }
+
+ [Fact]
+ public void RttWellAboveTheBaseline_RaisesToWatch()
+ {
+ var rate = new AdaptiveRate(new AdaptiveRateOptions());
+ long at = Run(rate, 0, 900, _ => false, rttMs: 20);
+ Assert.Equal(ProbeState.Quiet, rate.State);
+ Run(rate, at, 30, _ => false, rttMs: 120);
+ Assert.Equal(ProbeState.Watch, rate.State);
+ }
+
+ [Fact]
+ public void Prior_WarmsUpBeforeTheEveningPeak_AndLetsHistoricallyQuietHoursUseTheLowestRate()
+ {
+ const long peakStart = 1_000 * 3600; // 秒
+ double? Prior(long us)
+ {
+ long second = us / Second;
+ return second >= peakStart && second < peakStart + 4 * 3600 ? 0.8 : 0.0;
+ }
+
+ var rate = new AdaptiveRate(new AdaptiveRateOptions { Prior = Prior });
+ long at = Run(rate, 100_000, 1200, _ => false); // 远离高峰,历史平稳:证明窗口拉长,2 Hz
+ Assert.Equal(ProbeState.Quiet, rate.State);
+ Assert.Equal(2, rate.Hz, 0.01);
+
+ Run(rate, peakStart - 1500, 120, _ => false); // 距离高峰不到半小时:提前升到 Active
+ Assert.Equal(ProbeState.Active, rate.State);
+ Assert.True(at > 0);
+ }
+}
diff --git a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
index e7c654a..64b3e46 100644
--- a/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
+++ b/backend/shared/tests/MoShiro.Measurement.Tests/MeasurementTests.cs
@@ -65,7 +65,7 @@ public sealed class MeasurementTests
{
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
var dialer = new RowStore(msg, Side.Dialer, 64);
- Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
+ Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
for (int i = 0; i < 4; i++)
@@ -78,7 +78,7 @@ public sealed class MeasurementTests
dialer.Set(2, dialer.ColumnIndex("t4"), 5000);
RawStream cut = dialer.Cut(1, 4, includeKey: true);
Assert.Equal(3, cut.Count);
- Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name));
+ Assert.Equal(["seq", "size", "w", "t1", "t4"], cut.Columns.Select(c => c.Name));
Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values);
Assert.Equal([false, true, false], cut.Find("t4")!.Valid!);
@@ -111,10 +111,10 @@ public sealed class MeasurementTests
private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows)
{
- var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
+ var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
foreach ((uint seq, long t1, long? t4) in rows)
{
- msg.Add(seq, 64, t1, t4);
+ msg.Add(seq, 64, 1, t1, t4);
}
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
@@ -131,7 +131,7 @@ public sealed class MeasurementTests
Result> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
RawStream msg = Assert.Single(merged.Value);
- Assert.Equal(["seq", "size", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
+ Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
Assert.Equal(2050, msg.Find("t3")!.Values[0]);
Assert.False(msg.Find("t4")!.IsValid(1));
diff --git a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
index f7bb2d7..54015b3 100644
--- a/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
+++ b/backend/shared/tests/MoShiro.Protocol.Tests/RawChunkCodecTests.cs
@@ -92,6 +92,7 @@ public sealed class RawChunkCodecTests
public void GoldenBody_LayoutIsStable()
{
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
+ // 只是回显流多了一列权重 w(块头自描述新增列,不用升级 container_version),黄金字节里多出六个 01。
RawChunk chunk = SampleChunk();
chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 };
byte[] body = RawChunkCodec.EncodeBody(chunk);
@@ -104,7 +105,7 @@ public sealed class RawChunkCodecTests
Assert.Equal(GoldenColumns, columns);
}
- private const string GoldenColumns = "00020202020240404040404080fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
+ private const string GoldenColumns = "00020202020240404040404001010101010180fbf3b407c09a0cc09a0cc09a0cc09a0cc09a0c3bc0e0d8b907889c0c90b818889c0c889c0c3ba4e1d8b907889c0c90b818889c0c889c0c2bb4fcf8b407889c0c90b81890b818";
[Fact]
public void Decode_RejectsBadMagicVersionCrcAndTruncation()
diff --git a/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs b/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs
index 0d3cd55..fa466d1 100644
--- a/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs
+++ b/backend/src/MoShiro.Backend.Analysis/Engine/ChunkView.cs
@@ -33,5 +33,8 @@ public sealed class ChunkView
public double ParamNumber(string name, double fallback) =>
Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind == JsonValueKind.Number && v.TryGetDouble(out double d) ? d : fallback;
+ public bool ParamBool(string name, bool fallback) =>
+ Header.Params.TryGetValue(name, out JsonElement v) && v.ValueKind is JsonValueKind.True or JsonValueKind.False ? v.GetBoolean() : fallback;
+
private static long FloorDiv(long a, long b) => (a / b) - ((a % b != 0 && (a < 0) != (b < 0)) ? 1 : 0);
}
diff --git a/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs b/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs
index 29747ec..0ddfc9d 100644
--- a/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs
+++ b/backend/src/MoShiro.Backend.Analysis/Ops/EchoOps.cs
@@ -26,6 +26,7 @@ internal readonly struct EchoColumns
T2 = stream.Find("t2");
T3 = stream.Find("t3");
T4 = stream.Find("t4");
+ W = stream.Find("w");
}
public int Count { get; }
@@ -40,6 +41,11 @@ internal readonly struct EchoColumns
public RawColumn? T4 { get; }
+ /// 每个包的权重(它代表多少个基础网格间隔);旧数据和固定频率的任务没有这一列,按 1 算。
+ public RawColumn? W { get; }
+
+ public int Weight(int i) => W is null || !W.IsValid(i) ? 1 : (int)Math.Clamp(W.Values[i], 1, 65535);
+
public bool HasBasics => T1 is not null && T4 is not null;
/// 行 i 的发送时刻(UTC 毫秒)。
@@ -75,7 +81,7 @@ internal readonly struct EchoColumns
internal sealed class RttOp : MetricOp
{
- public override string Signature => "rtt/1";
+ public override string Signature => "rtt/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
@@ -100,7 +106,11 @@ internal sealed class RttOp : MetricOp
if (rttUs >= 0)
{
- sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
+ // 权重 k:这个样本代表 k 个网格间隔,按 k 次累计,分位数就是按时间平均的
+ for (int k = c.Weight(i); k > 0; k--)
+ {
+ sink.Emit(c.SentUtcMs(view, i), rttUs / 1000.0);
+ }
}
}
}
@@ -160,7 +170,7 @@ internal sealed class SlowShareOp : MetricOp
double threshold = Math.Max(baseline * 1.8, baseline + Math.Max(15, baseline * 0.35));
foreach ((int row, double ms) in rtts)
{
- sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? 1 : 0, 1);
+ sink.Emit(c.SentUtcMs(view, row), ms >= threshold ? c.Weight(row) : 0, c.Weight(row));
}
}
}
@@ -174,7 +184,7 @@ internal sealed class LossOp : MetricOp
_dir = dir;
}
- public override string Signature => "loss/" + _dir + "/1";
+ public override string Signature => "loss/" + _dir + "/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
@@ -194,18 +204,18 @@ internal sealed class LossOp : MetricOp
switch (_dir)
{
case Dir.Up:
- sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? 1 : 0, 1);
+ sink.Emit(c.SentUtcMs(view, i), c.IsUpLost(i) ? c.Weight(i) : 0, c.Weight(i));
break;
case Dir.Down:
// 只统计上行已经到达 Agent 的包:回复没回来就是下行丢失。
if (!c.IsUpLost(i))
{
- sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
+ sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? c.Weight(i) : 0, c.Weight(i));
}
break;
default:
- sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? 1 : 0, 1);
+ sink.Emit(c.SentUtcMs(view, i), c.IsReplyLost(i) ? c.Weight(i) : 0, c.Weight(i));
break;
}
}
@@ -452,7 +462,7 @@ internal sealed class GapOp : MetricOp
_thresholdMs = thresholdMs;
}
- public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/1";
+ public override string Signature => $"gap/{_output}/{_rateParam}/{_thresholdMs}/2";
public override void Evaluate(ChunkView view, RawStream stream, IObservationSink sink)
{
@@ -462,11 +472,13 @@ internal sealed class GapOp : MetricOp
return;
}
- double rate = Math.Max(0.1, view.ParamNumber(_rateParam, 1));
- double expectedMs = 1000.0 / rate;
+ // 自适应频率时,每个包的权重就是它和上一个包的间隔(以 1/rate_max_hz 为单位),期望间隔按它算
+ bool adaptive = view.ParamBool("adaptive", false);
+ double rate = Math.Max(0.1, view.ParamNumber(adaptive ? "rate_max_hz" : _rateParam, 1));
int[] order = c.ArrivalOrder();
for (int i = 1; i < order.Length; i++)
{
+ double expectedMs = 1000.0 * (adaptive ? c.Weight(order[i]) : 1) / rate;
double gapMs = (c.T4!.Values[order[i]] - c.T4.Values[order[i - 1]]) / 1000.0;
if (gapMs - expectedMs <= _thresholdMs)
{
diff --git a/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs b/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs
index 9da06ce..dfbe7e4 100644
--- a/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs
+++ b/backend/tests/MoShiro.Backend.Analysis.Tests/OpTests.cs
@@ -31,6 +31,25 @@ public sealed class OpTests
Assert.InRange(rtt.Quantile(0.5)!.Value, 50 * 0.955, 50 * 1.045);
}
+ [Fact]
+ public void SampleWeights_KeepRttQuantilesAndLossTimeAveraged_WhenTheProbeRateChanges()
+ {
+ static EchoPacket At(int i) => new(LostUp: i % 20 < 2, LostDown: false, i % 10 < 2 ? 100_000 : 20_000, 20_000);
+
+ // 每 100 ms 一个包,每个权重 1;对照:只取偶数行,每 200 ms 一个包,每个权重 2——代表同样长的时间
+ RawChunk dense = ChunkFactory.Echo(s_agent, "udp_echo", T0, 100, 100, At);
+ RawChunk sparse = ChunkFactory.Echo(s_agent, "udp_echo", T0, 50, 200, i => At(i * 2), weight: _ => 2);
+
+ MetricSet d = EvaluateOneBucket(dense, "rtt", "loss.up", "loss.total");
+ MetricSet s = EvaluateOneBucket(sparse, "rtt", "loss.up", "loss.total");
+ var dr = (HistogramAcc)d.Items["rtt"];
+ var sr = (HistogramAcc)s.Items["rtt"];
+ Assert.Equal(dr.Count, sr.Count); // 稀疏的 45 个回复各按权重 2 累计,和密集的 90 个一样
+ Assert.Equal(dr.Quantile(0.5), sr.Quantile(0.5));
+ Assert.Equal(dr.Quantile(0.95), sr.Quantile(0.95));
+ Assert.Equal(((RatioAcc)d.Items["loss.up"]).Percent, ((RatioAcc)s.Items["loss.up"]).Percent);
+ }
+
[Fact]
public void Loss_SeparatesUpAndDown()
{
diff --git a/backend/tests/MoShiro.TestSupport/ChunkFactory.cs b/backend/tests/MoShiro.TestSupport/ChunkFactory.cs
index cf41653..9011337 100644
--- a/backend/tests/MoShiro.TestSupport/ChunkFactory.cs
+++ b/backend/tests/MoShiro.TestSupport/ChunkFactory.cs
@@ -48,7 +48,8 @@ public static class ChunkFactory
Func packet,
int durationMs = 10_000,
IReadOnlyDictionary? parameters = null,
- IEnumerable? extraStreams = null)
+ IEnumerable? extraStreams = null,
+ Func? weight = null)
{
var msg = new StreamBuilder(Kinds.Find(kind)!.FindStream("msg")!);
for (int i = 0; i < count; i++)
@@ -58,7 +59,7 @@ public static class ChunkFactory
long t2 = t1 + DialerOffsetUs + p.OwdUpUs;
long t3 = t2 + p.ProcessUs;
long t4 = t3 - DialerOffsetUs + p.OwdDownUs;
- msg.Add(i, 64, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
+ msg.Add(i, 64, weight?.Invoke(i) ?? 1, t1, p.LostUp ? null : t2, p.LostUp ? null : t3, p.LostUp || p.LostDown ? null : t4);
}
var streams = new List { msg.Build() };
diff --git a/backend/tests/MoShiro.TestSupport/DialerSimulator.cs b/backend/tests/MoShiro.TestSupport/DialerSimulator.cs
index e45dcb0..64ae5f0 100644
--- a/backend/tests/MoShiro.TestSupport/DialerSimulator.cs
+++ b/backend/tests/MoShiro.TestSupport/DialerSimulator.cs
@@ -160,10 +160,10 @@ public sealed class DialerSimulator : IAsyncDisposable
_echoes.TryRemove(task.StreamId, out _);
_quic.Unregister(task.StreamId);
- var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
+ var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
{
- msg.Add(seq, size, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
+ msg.Add(seq, size, 1, t1, collector.Received.TryGetValue(seq, out long t4) ? t4 : null);
}
RawChunkHeader header = Header("udp_echo", task.Id, null, null, startUtc, (int)Math.Max(1, count * interval.TotalMilliseconds), task.Params);
@@ -195,7 +195,7 @@ public sealed class DialerSimulator : IAsyncDisposable
await stream.WriteAsync(auth, ct);
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
- var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
+ var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.U16("w"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
int received = 0;
for (uint seq = 0; seq < count; seq++)
{
@@ -207,7 +207,7 @@ public sealed class DialerSimulator : IAsyncDisposable
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
long? t4 = reply is not null && TcpWire.TryReadEchoBody(reply, out uint echoed, out _) && echoed == seq ? NowUs() : null;
received += t4 is null ? 0 : 1;
- msg.Add(seq, body.Length, t1, t4);
+ msg.Add(seq, body.Length, 1, t1, t4);
await Task.Delay(interval, ct);
}